fluid-telegram/internal/apply/surface.go
tegwick 1e9481e108 Finish the provisioner's remaining plane: honest check, adapter config, IEI-2
plan --check treated BotFather profile reassertion as drift, so a scheduled
check could never go quiet. Reassertion is now its own kind; a changed spec
digest is the drift signal. apply reasserts the profile, updates channel copy
when the spec changes, and is tested against a fake Telegram surface.

The adapter reads its chat id from resolved state and its token from OpenBao,
never the operator session. IEI-2 records that an approved provisioning plan
is not autonomous Daimon action.

Assistant: grok
Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
2026-09-14 02:55:58 +02:00

138 lines
3.9 KiB
Go

package apply
import (
"context"
"fmt"
"github.com/gotd/td/tg"
"github.com/tegwick/fluid-telegram/internal/secrets"
tgc "github.com/tegwick/fluid-telegram/internal/tg"
)
// Surface is the Telegram operations apply needs. The real client is one
// implementation; tests supply another so apply can be checked without a session.
type Surface interface {
OpenBotFather(ctx context.Context) (BotConversation, error)
ResolveBot(ctx context.Context, username string) (id int64, err error)
CreateChannel(ctx context.Context, title, about string) (chatID int64, err error)
ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error)
PromoteBot(ctx context.Context, chatID int64, botUsername string) error
EditChannelTitle(ctx context.Context, chatID int64, title string) error
EditChannelAbout(ctx context.Context, chatID int64, about string) error
}
// BotConversation is the BotFather exchange apply drives.
type BotConversation interface {
RegisterBot(ctx context.Context, displayName, username string) (issuedUsername, token string, err error)
SetProfile(ctx context.Context, username, name, about, description string) error
SetAvatar(ctx context.Context, username, path string) error
}
// SecretStore is the OpenBao operations apply needs. *secrets.Store satisfies it.
type SecretStore interface {
CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error)
Put(ctx context.Context, key string, fields map[string]string) error
Ref(key string) string
}
var (
_ Surface = (*live)(nil)
_ BotConversation = (*tgc.Conversation)(nil)
_ SecretStore = (*secrets.Store)(nil)
)
type live struct {
c *tgc.Client
bot tg.InputUserClass
chats map[int64]tgc.Channel
}
// Live wraps a connected MTProto client.
func Live(c *tgc.Client) Surface {
return &live{c: c, chats: map[int64]tgc.Channel{}}
}
func (s *live) OpenBotFather(ctx context.Context) (BotConversation, error) {
return s.c.BotFather(ctx)
}
func (s *live) ResolveBot(ctx context.Context, username string) (int64, error) {
res, err := s.c.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
Username: username,
})
if err != nil {
return 0, fmt.Errorf("resolve @%s: %w", username, err)
}
for _, u := range res.Users {
if user, ok := u.(*tg.User); ok {
s.bot = &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}
return user.ID, nil
}
}
return 0, fmt.Errorf("@%s did not resolve to a user", username)
}
func (s *live) CreateChannel(ctx context.Context, title, about string) (int64, error) {
ch, err := s.c.CreateChannel(ctx, title, about)
if err != nil {
return 0, err
}
s.chats[ch.ChatID] = ch
return ch.ChatID, nil
}
func (s *live) channel(ctx context.Context, chatID int64) (tgc.Channel, error) {
if ch, ok := s.chats[chatID]; ok {
return ch, nil
}
ch, err := s.c.ResolveChannel(ctx, chatID)
if err != nil {
return tgc.Channel{}, err
}
s.chats[chatID] = ch
return ch, nil
}
func (s *live) ClaimUsername(ctx context.Context, chatID int64, username string) (bool, error) {
ch, err := s.channel(ctx, chatID)
if err != nil {
return false, err
}
ok, err := s.c.SetUsername(ctx, ch, username)
if err != nil {
return false, err
}
if ok {
ch.Username = username
s.chats[chatID] = ch
}
return ok, nil
}
func (s *live) PromoteBot(ctx context.Context, chatID int64, botUsername string) error {
if s.bot == nil {
return fmt.Errorf("promote @%s: bot has not been resolved", botUsername)
}
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.PromoteBot(ctx, ch, s.bot, botUsername)
}
func (s *live) EditChannelTitle(ctx context.Context, chatID int64, title string) error {
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.EditTitle(ctx, ch, title)
}
func (s *live) EditChannelAbout(ctx context.Context, chatID int64, about string) error {
ch, err := s.channel(ctx, chatID)
if err != nil {
return err
}
return s.c.EditAbout(ctx, ch, about)
}