fluid-telegram/internal/tg/channels.go

179 lines
6.2 KiB
Go
Raw Normal View History

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")
}