Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
// Package apply executes an approved plan.
|
|
|
|
|
//
|
|
|
|
|
// The ordering is the guarantee: the bot exists before a channel needs an
|
|
|
|
|
// administrator, the test channel exists before the public one, and the token is
|
|
|
|
|
// in OpenBao before anything else can fail. Each step records what it did before
|
|
|
|
|
// the next runs, so an interruption leaves state that describes reality rather
|
|
|
|
|
// than intent.
|
|
|
|
|
package apply
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/gotd/td/tg"
|
|
|
|
|
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/plan"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/secrets"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/spec"
|
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/state"
|
|
|
|
|
tgc "github.com/tegwick/fluid-telegram/internal/tg"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type Options struct {
|
|
|
|
|
Spec *spec.Presence
|
|
|
|
|
SpecDigest string
|
|
|
|
|
State *state.Resolved
|
|
|
|
|
StatePath string
|
|
|
|
|
Store *secrets.Store
|
|
|
|
|
Client *tgc.Client
|
|
|
|
|
Out io.Writer
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Run applies the plan. It saves state after every step that changed the world,
|
|
|
|
|
// because a channel that exists but is not recorded is worse than one that does
|
|
|
|
|
// not exist: the next run creates a second.
|
|
|
|
|
func Run(ctx context.Context, p *plan.Plan, o Options) error {
|
|
|
|
|
if p.Blocked() {
|
|
|
|
|
return fmt.Errorf("refusing to apply a blocked plan")
|
|
|
|
|
}
|
|
|
|
|
if o.State.Channels == nil {
|
|
|
|
|
o.State.Channels = map[string]state.Channel{}
|
|
|
|
|
}
|
|
|
|
|
o.State.Campaign = o.Spec.Campaign
|
|
|
|
|
|
|
|
|
|
if err := ensureSalt(ctx, o); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
botUser, err := ensureBot(ctx, o, p)
|
Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := ensureChannels(ctx, o, botUser); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
o.State.SpecDigest = o.SpecDigest
|
|
|
|
|
o.State.ProvisionedAt = time.Now().UTC()
|
|
|
|
|
return state.Save(o.StatePath, o.State)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ensureSalt generates the redaction salt once, and can never replace one.
|
|
|
|
|
//
|
|
|
|
|
// docs/observation.md: rotating it silently invalidates every longitudinal
|
|
|
|
|
// comparison the interface has made, and does so with no visible failure --
|
|
|
|
|
// the numbers keep arriving and quietly stop meaning what they used to.
|
|
|
|
|
func ensureSalt(ctx context.Context, o Options) error {
|
|
|
|
|
buf := make([]byte, 32)
|
|
|
|
|
if _, err := rand.Read(buf); err != nil {
|
|
|
|
|
return fmt.Errorf("generate redaction salt: %w", err)
|
|
|
|
|
}
|
|
|
|
|
created, err := o.Store.CreateIfAbsent(ctx, secrets.KeyRedactionSalt,
|
|
|
|
|
map[string]string{"salt": hex.EncodeToString(buf)})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if created {
|
|
|
|
|
fmt.Fprintf(o.Out, " created redaction salt at %s (never rotated)\n",
|
|
|
|
|
o.Store.Ref(secrets.KeyRedactionSalt))
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
func ensureBot(ctx context.Context, o Options, p *plan.Plan) (tg.InputUserClass, error) {
|
Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
if o.State.Bot.ID != 0 {
|
|
|
|
|
fmt.Fprintf(o.Out, " ok bot @%s\n", o.State.Bot.Username)
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
user, err := resolveBot(ctx, o, o.State.Bot.Username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if err := ensureAvatar(ctx, o, p, nil); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return user, nil
|
Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conv, err := o.Client.BotFather(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var username, token string
|
|
|
|
|
for _, candidate := range o.Spec.Bot.UsernamePreference {
|
|
|
|
|
username, token, err = conv.RegisterBot(ctx, o.Spec.Bot.Name, candidate)
|
|
|
|
|
if err == nil {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if errors.Is(err, tgc.ErrUsernameTaken) {
|
|
|
|
|
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", candidate)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
if token == "" {
|
|
|
|
|
return nil, fmt.Errorf("every username candidate was taken; add another to "+
|
|
|
|
|
"bot.username_preference in the spec (tried %d)", len(o.Spec.Bot.UsernamePreference))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Straight to OpenBao, before anything else can fail. The token is not
|
|
|
|
|
// printed, not logged, and not returned past this point.
|
|
|
|
|
if err := o.Store.Put(ctx, secrets.KeyBotToken, map[string]string{"token": token}); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("the bot @%s was created but its token could not be stored: %w\n"+
|
|
|
|
|
"recover it from the @BotFather chat and write it to %s by hand; do not "+
|
|
|
|
|
"re-run, which would create a second bot", username, err,
|
|
|
|
|
o.Store.Ref(secrets.KeyBotToken))
|
|
|
|
|
}
|
|
|
|
|
token = ""
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(o.Out, " created bot @%s, token at %s\n", username,
|
|
|
|
|
o.Store.Ref(secrets.KeyBotToken))
|
|
|
|
|
|
|
|
|
|
o.State.Bot.Username = username
|
|
|
|
|
if err := state.Save(o.StatePath, o.State); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := conv.SetProfile(ctx, username, o.Spec.Bot.About, o.Spec.Bot.Description); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintln(o.Out, " set bot about text and description")
|
|
|
|
|
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
if err := ensureAvatar(ctx, o, p, conv); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
user, err := resolveBot(ctx, o, username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return user, state.Save(o.StatePath, o.State)
|
|
|
|
|
}
|
|
|
|
|
|
Implement the avatar and a preflight dry run
The avatar is now applied rather than deferred: BotFather's /setuserpic is a
conversation in which you send a photo, so the file is uploaded and sent as
a message. It is content addressed -- replacing the file is what triggers an
update, and the digest is recorded only after BotFather confirms, so a failed
upload retries rather than being remembered as done. The image is validated
before the conversation starts, because an image rejected halfway leaves the
bot registered without a picture.
Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials,
session presence, salt and the resulting plan, checked in one run that writes
nothing and never contacts Telegram. Every failure it reports is one that
would otherwise surface after a phone number had been spent.
Two bugs it found immediately. The avatar path is documented as repo-relative
but resolved against the spec's own directory, so the real campaign spec
failed to find its own asset. And the OpenBao error named both variables when
only one was missing, sending the reader to check the one already set.
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
2026-09-04 22:12:56 +02:00
|
|
|
// ensureAvatar sends the picture when the plan found one that differs from what
|
|
|
|
|
// is recorded. The digest is written only after BotFather confirms, so a failed
|
|
|
|
|
// upload is retried next run rather than being remembered as done.
|
|
|
|
|
func ensureAvatar(ctx context.Context, o Options, p *plan.Plan, conv *tgc.Conversation) error {
|
|
|
|
|
if p.Avatar == nil || o.State.Bot.AvatarDigest == p.Avatar.Digest {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
if conv == nil {
|
|
|
|
|
var err error
|
|
|
|
|
if conv, err = o.Client.BotFather(ctx); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if err := conv.SetAvatar(ctx, o.State.Bot.Username, p.Avatar.Path); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
o.State.Bot.AvatarDigest = p.Avatar.Digest
|
|
|
|
|
fmt.Fprintf(o.Out, " set bot picture from %s\n", p.Avatar.Path)
|
|
|
|
|
return state.Save(o.StatePath, o.State)
|
|
|
|
|
}
|
|
|
|
|
|
Implement the MTProto client, session bootstrap and apply
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
2026-09-04 21:39:30 +02:00
|
|
|
func resolveBot(ctx context.Context, o Options, username string) (tg.InputUserClass, error) {
|
|
|
|
|
res, err := o.Client.API().ContactsResolveUsername(ctx, &tg.ContactsResolveUsernameRequest{
|
|
|
|
|
Username: username,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("resolve @%s: %w", username, err)
|
|
|
|
|
}
|
|
|
|
|
for _, u := range res.Users {
|
|
|
|
|
if user, ok := u.(*tg.User); ok {
|
|
|
|
|
o.State.Bot.ID = user.ID
|
|
|
|
|
return &tg.InputUser{UserID: user.ID, AccessHash: user.AccessHash}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return nil, fmt.Errorf("@%s did not resolve to a user", username)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func ensureChannels(ctx context.Context, o Options, bot tg.InputUserClass) error {
|
|
|
|
|
// Test first, always. Not a convention a caller may reorder.
|
|
|
|
|
for _, name := range []string{spec.Test, spec.Live} {
|
|
|
|
|
sc, ok := o.Spec.Channels[name]
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if name == spec.Live && !o.State.TestChannelVerified(spec.Test) {
|
|
|
|
|
fmt.Fprintln(o.Out, " held public channel, until the test channel has a checked rendering")
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if _, done := o.State.Channels[name]; done {
|
|
|
|
|
fmt.Fprintf(o.Out, " ok channel %s\n", name)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, err := o.Client.CreateChannel(ctx, sc.Title, sc.Description)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
rec := state.Channel{ChatID: ch.ChatID, AdminRights: []string{spec.PostMessages}}
|
|
|
|
|
o.State.Channels[name] = rec
|
|
|
|
|
if err := state.Save(o.StatePath, o.State); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(o.Out, " created channel %s (%d)\n", name, ch.ChatID)
|
|
|
|
|
|
|
|
|
|
if sc.Visibility == spec.Public {
|
|
|
|
|
claimed := ""
|
|
|
|
|
for _, cand := range sc.UsernamePreference {
|
|
|
|
|
ok, err := o.Client.SetUsername(ctx, ch, cand)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if ok {
|
|
|
|
|
claimed = cand
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(o.Out, " taken @%s, trying the next candidate\n", cand)
|
|
|
|
|
}
|
|
|
|
|
if claimed == "" {
|
|
|
|
|
return fmt.Errorf("channel %s was created but every username candidate was "+
|
|
|
|
|
"taken; claim one by hand or add candidates to the spec, then re-run", name)
|
|
|
|
|
}
|
|
|
|
|
rec.Username = claimed
|
|
|
|
|
o.State.Channels[name] = rec
|
|
|
|
|
if err := state.Save(o.StatePath, o.State); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(o.Out, " claimed @%s\n", claimed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := o.Client.PromoteBot(ctx, ch, bot, o.State.Bot.Username); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(o.Out, " granted post_messages to @%s on %s\n", o.State.Bot.Username, name)
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|