package tg import ( "context" "fmt" "strings" "github.com/gotd/td/tg" "github.com/tegwick/fluid-telegram/internal/spec" ) // Channel is what provisioning needs to remember about a created channel. type Channel struct { ChatID int64 AccessHash int64 Username string } // CreateChannel creates a broadcast channel. There is deliberately no // counterpart that deletes one: deleting destroys its subscribers and post // history irreversibly, and no specification is trusted with that. func (c *Client) CreateChannel(ctx context.Context, title, about string) (Channel, error) { pause() upd, err := c.api.ChannelsCreateChannel(ctx, &tg.ChannelsCreateChannelRequest{ Broadcast: true, // a channel, not a supergroup Title: title, About: about, }) if err != nil { return Channel{}, fmt.Errorf("create channel %q: %w", title, err) } ch, err := firstChannel(upd) if err != nil { return Channel{}, fmt.Errorf("create channel %q: %w", title, err) } return Channel{ChatID: ch.ID, AccessHash: ch.AccessHash, Username: ch.Username}, nil } // SetUsername claims a public username, returning false if it is taken. A // collision is an ordinary outcome the caller walks its candidates through, not // an error: a plan promises the attempt, never the result. func (c *Client) SetUsername(ctx context.Context, ch Channel, username string) (bool, error) { pause() ok, err := c.api.ChannelsUpdateUsername(ctx, &tg.ChannelsUpdateUsernameRequest{ Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, Username: username, }) if err != nil { if isUsernameUnavailable(err) { return false, nil } return false, fmt.Errorf("claim username @%s: %w", username, err) } return ok, nil } // PromoteBot grants the bot post_messages on a channel, and nothing else. // // The rights are built here rather than taken from the spec. The spec may state // them for a reader's benefit, but which rights this system may hold is not the // spec's decision -- InterfaceEvolutionIntent.md 7 forbids a wider one, and a // right that is never exercised is still a right that was granted. func (c *Client) PromoteBot(ctx context.Context, ch Channel, bot tg.InputUserClass, botUsername string) error { pause() _, err := c.api.ChannelsEditAdmin(ctx, &tg.ChannelsEditAdminRequest{ Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, UserID: bot, AdminRights: tg.ChatAdminRights{PostMessages: true}, Rank: "", }) if err != nil { return fmt.Errorf("grant post_messages to @%s: %w", botUsername, err) } return nil } // AdminRights reports the rights the given bot actually holds, in the // vocabulary the spec uses, so drift can be compared without translating. func (c *Client) AdminRights(ctx context.Context, ch Channel, botID int64) ([]string, error) { parts, err := c.api.ChannelsGetParticipant(ctx, &tg.ChannelsGetParticipantRequest{ Channel: &tg.InputChannel{ChannelID: ch.ChatID, AccessHash: ch.AccessHash}, Participant: &tg.InputPeerUser{UserID: botID}, }) if err != nil { return nil, fmt.Errorf("read participant rights: %w", err) } admin, ok := parts.Participant.(*tg.ChannelParticipantAdmin) if !ok { // Not an administrator at all. An empty set, not an error: a demoted bot // is exactly the drift the plan is looking for. return nil, nil } return rightsNames(admin.AdminRights), nil } // rightsNames lists granted rights using the spec's names. Only post_messages // has a spec name; anything else is reported raw so a plan can show what was // actually found rather than silently dropping it. func rightsNames(r tg.ChatAdminRights) []string { var out []string if r.PostMessages { out = append(out, spec.PostMessages) } for name, granted := range map[string]bool{ "change_info": r.ChangeInfo, "edit_messages": r.EditMessages, "delete_messages": r.DeleteMessages, "ban_users": r.BanUsers, "invite_users": r.InviteUsers, "pin_messages": r.PinMessages, "add_admins": r.AddAdmins, "manage_call": r.ManageCall, "post_stories": r.PostStories, "edit_stories": r.EditStories, "delete_stories": r.DeleteStories, "manage_topics": r.ManageTopics, } { if granted { out = append(out, name) } } return out } func firstChannel(upd tg.UpdatesClass) (*tg.Channel, error) { u, ok := upd.(*tg.Updates) if !ok { return nil, fmt.Errorf("unexpected updates type %T", upd) } for _, chat := range u.Chats { if ch, ok := chat.(*tg.Channel); ok { return ch, nil } } return nil, fmt.Errorf("telegram accepted the request but returned no channel") } func isUsernameUnavailable(err error) bool { s := strings.ToUpper(err.Error()) return strings.Contains(s, "USERNAME_OCCUPIED") || strings.Contains(s, "USERNAME_INVALID") || strings.Contains(s, "USERNAME_PURCHASE_AVAILABLE") } // ResolveChannel recovers a usable channel handle from a chat id. Access hashes // are per-account and not persisted, so they are re-resolved on each run rather // than stored -- a stale hash fails in ways that look like a missing channel. func (c *Client) ResolveChannel(ctx context.Context, chatID int64) (Channel, error) { res, err := c.api.ChannelsGetChannels(ctx, []tg.InputChannelClass{ &tg.InputChannel{ChannelID: chatID}, }) if err != nil { return Channel{}, fmt.Errorf("resolve channel %d: %w", chatID, err) } chats, ok := res.(*tg.MessagesChats) if !ok { return Channel{}, fmt.Errorf("resolve channel %d: unexpected type %T", chatID, res) } for _, ch := range chats.Chats { if c2, ok := ch.(*tg.Channel); ok && c2.ID == chatID { return Channel{ChatID: c2.ID, AccessHash: c2.AccessHash, Username: c2.Username}, nil } } return Channel{}, fmt.Errorf("channel %d was not returned by telegram", chatID) } // ChannelUsername reports the username a channel currently carries. func (c *Client) ChannelUsername(ctx context.Context, ch Channel) (string, error) { fresh, err := c.ResolveChannel(ctx, ch.ChatID) if err != nil { return "", err } return fresh.Username, nil } func resolveReq(username string) *tg.ContactsResolveUsernameRequest { return &tg.ContactsResolveUsernameRequest{Username: username} } func isNotFound(err error) bool { s := strings.ToUpper(err.Error()) return strings.Contains(s, "USERNAME_NOT_OCCUPIED") || strings.Contains(s, "USERNAME_INVALID") }