Completes the provisioner's write path. internal/tg drives BotFather as a conversation rather than pretending it is an endpoint, creates channels, claims usernames with fallbacks, and grants post_messages. internal/apply sequences it: bot before administrator, test channel before public, and state saved after every step that changed the world -- a channel that exists but is unrecorded is worse than one that does not exist, because the next run creates a second. The operator session lives in OpenBao, not on disk. gotd's FileStorage would leave a full-account credential in the working directory, where it outlives the run and can be committed by accident. The bot token goes straight from BotFather's reply to OpenBao and is cleared from memory; if that write fails the error says how to recover by hand and warns against re-running, since a retry creates a second bot. Closes T05: the redaction salt is create-if-absent with no overwrite path, and the test asserts it, because rotating it invalidates every longitudinal comparison with no visible failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa Assistant: claude-code Assistant-Model: opus Assistant-Process: 1361245@bnt-lap001 Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
178 lines
6.2 KiB
Go
178 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")
|
|
}
|