179 lines
6.2 KiB
Go
179 lines
6.2 KiB
Go
|
|
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")
|
||
|
|
}
|