feat: sync bot setup and webhook diagnostics

This commit is contained in:
A 2026-07-22 00:51:52 +08:00
parent f53579416e
commit d9875b5caa
11 changed files with 521 additions and 29 deletions

View file

@ -37,6 +37,7 @@ const (
botFatherCmdSetLogin = "setlogin"
botFatherCmdLoginInfo = "logininfo"
botFatherCmdResetLogin = "resetloginsecret"
botFatherCmdDone = "done"
botFatherStepName = "name"
botFatherStepUsername = "username"
@ -45,6 +46,8 @@ const (
botFatherDraftBotID = "bot_id"
botFatherDraftBotUsername = "bot_username"
maxTelegramLoginCommandsPerMessage = 32
)
const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots.
@ -67,6 +70,7 @@ You can control me by sending these commands:
/setlogin - configure Telegram Login allowed URLs and signing
/logininfo - show a bot's Telegram Login configuration
/resetloginsecret - rotate a bot's OIDC Client Secret
/done - finish the active Telegram Login configuration
/cancel - cancel the current operation
/help - show this message`
@ -176,7 +180,7 @@ func (s *Service) botReplyRandomID() int64 {
// 必须作为原始内容透传给状态机,否则 /setcommands 的 /empty 永不可达、且首行
// 带斜杠的命令列表会被截成命令名 "start" 静默销毁整个流程。
var botFatherGlobalCommands = map[string]bool{
"start": true, "help": true, "cancel": true,
"start": true, "help": true, "cancel": true, botFatherCmdDone: true,
botFatherCmdNewBot: true, "mybots": true,
botFatherCmdToken: true, botFatherCmdRevoke: true,
botFatherCmdSetName: true, botFatherCmdSetDescription: true, botFatherCmdSetAbout: true,
@ -288,7 +292,7 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
_ = s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID)
return botReply{Text: botFatherHelpText}
case "cancel":
_, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
@ -300,7 +304,12 @@ func (s *Service) handleBotFatherCommand(ctx context.Context, userID int64, cmd
s.log.Error("botfather: delete chat state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if state.Command == botFatherCmdSetLogin && state.Step == botFatherStepValue {
return botReply{Text: "Telegram Login configuration closed. Changes that were already applied have been kept."}
}
return botReply{Text: "The command has been cancelled. Anything else I can do for you? Send /help for a list of commands."}
case botFatherCmdDone:
return s.finishTelegramLoginConfiguration(ctx, userID)
case botFatherCmdNewBot:
count, err := s.bots.CountBotsByOwner(ctx, userID)
if err != nil {
@ -577,7 +586,7 @@ func (s *Service) handleSetValue(ctx context.Context, state domain.BotChatState,
case botFatherCmdSetPrivacy:
reply, err = s.applyToggle(ctx, botID, text, false)
case botFatherCmdSetLogin:
reply, err = s.applyTelegramLoginConfiguration(ctx, botID, username, text)
return s.handleTelegramLoginConfigurationInput(ctx, state, botID, username, text)
default:
s.clearState(ctx, state.UserID)
return internalReply()
@ -655,7 +664,7 @@ func (s *Service) applySetInlineGeo(ctx context.Context, botID int64, text strin
}
func telegramLoginConfigurationPrompt(username string) string {
return fmt.Sprintf(`Send one configuration command for @%s:
return fmt.Sprintf(`Configure Telegram Login for @%s. Send commands one at a time or paste up to %d commands on separate lines:
add origin https://example.com
add redirect https://example.com/auth/callback
@ -668,7 +677,117 @@ algorithm RS256|ES256|EdDSA|ES256K
enable
disable
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Run /logininfo to inspect the result or /cancel to stop.`, username)
Origins authorize the JS SDK and legacy login_url buttons. Redirects are exact OIDC callbacks. Changes apply immediately. Send /done to finish, or /cancel to close this session without undoing changes already applied.`, username, maxTelegramLoginCommandsPerMessage)
}
func telegramLoginConfigurationContinuePrompt(username string) string {
return fmt.Sprintf("Still configuring @%s. Send another command, paste multiple commands on separate lines, or send /done to finish.", username)
}
func (s *Service) finishTelegramLoginConfiguration(ctx context.Context, userID int64) botReply {
state, found, err := s.bots.GetBotChatState(ctx, domain.BotFatherUserID, userID)
if err != nil {
s.log.Error("botfather: get telegram login state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
if !found || state.Command != botFatherCmdSetLogin || state.Step != botFatherStepValue {
return botReply{Text: "There is no active Telegram Login configuration to finish. Send /setlogin to start one."}
}
botID, _ := strconv.ParseInt(state.Draft[botFatherDraftBotID], 10, 64)
username := state.Draft[botFatherDraftBotUsername]
if botID == 0 || username == "" {
s.clearState(ctx, userID)
return botReply{Text: "Something went wrong, I forgot which bot we were editing. Send /setlogin to start again."}
}
owns, err := s.OwnsBot(ctx, userID, botID)
if err != nil {
s.log.Error("botfather: verify telegram login owner", zap.Int64("user_id", userID), zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
}
if !owns {
s.clearState(ctx, userID)
return botReply{Text: "That bot is no longer available."}
}
if s.telegramLogin == nil {
s.clearState(ctx, userID)
return botReply{Text: "Telegram Login is not enabled on this server."}
}
configuration, configured, err := s.telegramLogin.ClientConfiguration(ctx, botID)
if err != nil {
s.log.Error("botfather: get telegram login configuration", zap.Int64("bot_user_id", botID), zap.Error(err))
return internalReply()
}
if !configured {
s.clearState(ctx, userID)
return botReply{Text: fmt.Sprintf("Telegram Login is not configured for @%s. Send /setlogin to create it.", username)}
}
if err := s.bots.DeleteBotChatState(ctx, domain.BotFatherUserID, userID); err != nil {
s.log.Error("botfather: finish telegram login state", zap.Int64("user_id", userID), zap.Error(err))
return internalReply()
}
return botReply{Text: fmt.Sprintf("Finished configuring Telegram Login for @%s.\n\n%s", username, formatTelegramLoginConfiguration(username, configuration))}
}
func (s *Service) handleTelegramLoginConfigurationInput(
ctx context.Context,
state domain.BotChatState,
botID int64,
username string,
text string,
) botReply {
if strings.EqualFold(strings.TrimSpace(text), "done") {
return s.finishTelegramLoginConfiguration(ctx, state.UserID)
}
lines := make([]string, 0, 4)
for _, raw := range strings.Split(text, "\n") {
if line := strings.TrimSpace(raw); line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return botReply{Text: "Send a Telegram Login configuration command.\n\n" + telegramLoginConfigurationContinuePrompt(username)}
}
if len(lines) > maxTelegramLoginCommandsPerMessage {
return botReply{Text: fmt.Sprintf("Too many commands in one message. Send at most %d lines at a time.\n\n%s", maxTelegramLoginCommandsPerMessage, telegramLoginConfigurationContinuePrompt(username))}
}
applied := make([]string, 0, len(lines))
for i, line := range lines {
reply, err := s.applyTelegramLoginConfiguration(ctx, botID, username, line)
if err != nil {
if len(lines) == 1 {
if reply.Text == "" {
return internalReply()
}
return botReply{Text: reply.Text + "\n\n" + telegramLoginConfigurationContinuePrompt(username)}
}
failure := reply.Text
if failure == "" {
failure = "Something went wrong on my side. Please try that line again later."
}
var out strings.Builder
if len(applied) > 0 {
fmt.Fprintf(&out, "Applied %d command(s) before the error:\n%s\n\n", len(applied), strings.Join(applied, "\n"))
}
fmt.Fprintf(&out, "Stopped at line %d:\n%s\n\n", i+1, failure)
if i+1 < len(lines) {
fmt.Fprintf(&out, "%d later command(s) were not applied.\n\n", len(lines)-i-1)
}
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
return botReply{Text: out.String()}
}
applied = append(applied, fmt.Sprintf("Line %d: %s", i+1, reply.Text))
}
var out strings.Builder
if len(lines) == 1 {
out.WriteString(strings.TrimPrefix(applied[0], "Line 1: "))
} else {
fmt.Fprintf(&out, "Applied all %d commands:\n%s", len(applied), strings.Join(applied, "\n"))
}
out.WriteString("\n\n")
out.WriteString(telegramLoginConfigurationContinuePrompt(username))
return botReply{Text: out.String()}
}
func formatTelegramLoginConfiguration(username string, configuration telegramloginapp.ClientConfiguration) string {
@ -733,7 +852,7 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
if err := s.telegramLogin.SetClientEnabled(ctx, botID, true); err != nil {
return botReply{}, err
}
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s. Use /setlogin for another change or /logininfo to review it.", username)}, nil
return botReply{Text: fmt.Sprintf("Telegram Login is enabled for @%s.", username)}, nil
case "disable":
if err := s.telegramLogin.SetClientEnabled(ctx, botID, false); err != nil {
return botReply{}, err
@ -763,7 +882,7 @@ func (s *Service) applyTelegramLoginConfiguration(ctx context.Context, botID int
if strings.EqualFold(fields[0], "add") {
allowed, err := s.telegramLogin.AddAllowedURL(ctx, botID, kind, fields[2])
if err != nil {
return botReply{Text: "That URL is not allowed. Use an exact HTTPS URL without credentials, fragments or reserved OAuth query fields."}, err
return botReply{Text: "That URL is not allowed. Use an exact HTTP(S) URL permitted by this server without credentials, fragments or reserved OAuth query fields."}, err
}
return botReply{Text: fmt.Sprintf("Success! Added %s for @%s:\n%s", allowed.Kind, username, allowed.NormalizedURL)}, nil
}

View file

@ -8,6 +8,7 @@ import (
"time"
telegramloginapp "telesrv/internal/app/telegramlogin"
"telesrv/internal/domain"
"telesrv/internal/store/memory"
)
@ -32,7 +33,7 @@ func newBotFatherTelegramLoginService(t *testing.T) *telegramloginapp.Service {
}
func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
svc, users, _, messages := newTestService(t)
svc, users, bots, messages := newTestService(t)
svc.telegramLogin = newBotFatherTelegramLoginService(t)
owner := newOwner(t, users, "+1090")
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Login Demo", "login_demo_bot")
@ -55,31 +56,30 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
if reply := sendToBotFather(t, svc, messages, owner, "add origin http://rp.example.test:3000"); !strings.Contains(reply, "Success!") {
t.Fatalf("add origin reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
state, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID)
if err != nil || !found || state.Step != botFatherStepValue || state.Draft[botFatherDraftBotID] != strconv.FormatInt(bot.ID, 10) {
t.Fatalf("state after first command = %+v, found=%v err=%v", state, found, err)
}
if reply := sendToBotFather(t, svc, messages, owner, "add redirect http://192.0.2.26:3000/auth/callback"); !strings.Contains(reply, "Success!") {
t.Fatalf("add redirect reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "algorithm ES256"); !strings.Contains(reply, "ES256") {
t.Fatalf("algorithm reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
if reply := sendToBotFather(t, svc, messages, owner, "add ios dev.bedolaga.demo ABCDE12345 bedolaga://telegram-login Bedolaga iOS Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add iOS app reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "login_demo_bot")
fingerprint := strings.Repeat("A", 64)
if reply := sendToBotFather(t, svc, messages, owner, "add android dev.bedolaga.demo "+fingerprint+" bedolaga://android-login Bedolaga Android Demo"); !strings.Contains(reply, "Registered native app #") {
t.Fatalf("add Android app reply = %q", reply)
}
done := sendToBotFather(t, svc, messages, owner, "/done")
if !strings.Contains(done, "Finished configuring") || !strings.Contains(done, "Signing algorithm: ES256") {
t.Fatalf("/done reply = %q", done)
}
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
t.Fatalf("state after /done: found=%v err=%v", found, err)
}
sendToBotFather(t, svc, messages, owner, "/logininfo")
info := sendToBotFather(t, svc, messages, owner, "login_demo_bot")
@ -98,3 +98,70 @@ func TestBotFatherTelegramLoginConfigurationFlow(t *testing.T) {
t.Fatalf("rotate reply = %q", rotated)
}
}
func TestBotFatherTelegramLoginBatchAndCancelFlow(t *testing.T) {
svc, users, bots, messages := newTestService(t)
svc.telegramLogin = newBotFatherTelegramLoginService(t)
owner := newOwner(t, users, "+1091")
bot, _, err := svc.CreateBot(context.Background(), owner.ID, "Batch Login Demo", "batch_login_bot")
if err != nil {
t.Fatal(err)
}
if reply := sendToBotFather(t, svc, messages, owner, "/done"); !strings.Contains(reply, "no active") {
t.Fatalf("inactive /done reply = %q", reply)
}
sendToBotFather(t, svc, messages, owner, "/setlogin")
sendToBotFather(t, svc, messages, owner, "@batch_login_bot")
tooMany := strings.TrimSuffix(strings.Repeat("enable\n", maxTelegramLoginCommandsPerMessage+1), "\n")
if reply := sendToBotFather(t, svc, messages, owner, tooMany); !strings.Contains(reply, "at most 32 lines") {
t.Fatalf("oversized batch reply = %q", reply)
}
oversizedConfiguration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
if err != nil || !found || len(oversizedConfiguration.AllowedURLs) != 0 || oversizedConfiguration.Client.SigningAlgorithm != "RS256" {
t.Fatalf("configuration after oversized batch = %+v, found=%v err=%v", oversizedConfiguration, found, err)
}
batch := strings.Join([]string{
"add origin http://batch.example.test:3000",
"add redirect http://batch.example.test:3000/auth/telegram/callback",
"algorithm ES256",
"enable",
}, "\n")
if reply := sendToBotFather(t, svc, messages, owner, batch); !strings.Contains(reply, "Applied all 4 commands") || !strings.Contains(reply, "/done") {
t.Fatalf("batch reply = %q", reply)
}
configuration, found, err := svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
if err != nil || !found || !configuration.Client.Enabled || configuration.Client.SigningAlgorithm != "ES256" || len(configuration.AllowedURLs) != 2 {
t.Fatalf("configuration after batch = %+v, found=%v err=%v", configuration, found, err)
}
partial := strings.Join([]string{
"add origin http://second.example.test:3001",
"add redirect not-a-url",
"disable",
}, "\n")
partialReply := sendToBotFather(t, svc, messages, owner, partial)
for _, want := range []string{"Applied 1 command(s) before the error", "Stopped at line 2", "1 later command(s) were not applied", "/done"} {
if !strings.Contains(partialReply, want) {
t.Fatalf("partial batch reply = %q, missing %q", partialReply, want)
}
}
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
if err != nil || !found || !configuration.Client.Enabled || len(configuration.AllowedURLs) != 3 {
t.Fatalf("configuration after partial batch = %+v, found=%v err=%v", configuration, found, err)
}
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || !found {
t.Fatalf("state after partial batch: found=%v err=%v", found, err)
}
if reply := sendToBotFather(t, svc, messages, owner, "/cancel"); !strings.Contains(reply, "already applied have been kept") {
t.Fatalf("/cancel reply = %q", reply)
}
if _, found, err := bots.GetBotChatState(context.Background(), domain.BotFatherUserID, owner.ID); err != nil || found {
t.Fatalf("state after /cancel: found=%v err=%v", found, err)
}
configuration, found, err = svc.telegramLogin.ClientConfiguration(context.Background(), bot.ID)
if err != nil || !found || len(configuration.AllowedURLs) != 3 {
t.Fatalf("configuration after /cancel = %+v, found=%v err=%v", configuration, found, err)
}
}

View file

@ -255,5 +255,5 @@ func (d *webhookDispatcher) fail(ctx context.Context, config domain.BotAPIWebhoo
d.logger.Warn("record bot api webhook failure", zap.Int64("bot_user_id", config.BotUserID), zap.Error(err))
return
}
d.logger.Debug("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
d.logger.Warn("bot api webhook delivery failed", zap.Int64("bot_user_id", config.BotUserID), zap.Duration("retry_in", delay), zap.String("reason", message))
}

View file

@ -10,6 +10,7 @@ import (
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"telesrv/internal/domain"
)
@ -115,7 +116,8 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
webhookFound: true,
}
gateway := &recordingWebhookGateway{fakeBotAPIGateway: base}
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.NewNop(), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
logCore, observedLogs := observer.New(zap.WarnLevel)
d := &webhookDispatcher{control: gateway, gateway: gateway, client: server.Client(), logger: zap.New(logCore), botSem: make(chan struct{}, 1), httpSem: make(chan struct{}, 8)}
d.deliver(context.Background(), base.webhook)
gateway.mu.Lock()
@ -124,4 +126,18 @@ func TestWebhookDispatcherOnlyConfirmsSuccessfulPrefixAndSchedulesRetry(t *testi
if base.webhookConfirmed != 21 || failure != "webhook returned HTTP 503" || !retryAt.After(time.Now()) {
t.Fatalf("confirmed=%d failure=%q retry=%v", base.webhookConfirmed, failure, retryAt)
}
entries := observedLogs.FilterMessage("bot api webhook delivery failed").All()
if len(entries) != 1 {
t.Fatalf("delivery failure warning count = %d, want 1", len(entries))
}
fields := entries[0].ContextMap()
if fields["bot_user_id"] != int64(1001) || fields["reason"] != "webhook returned HTTP 503" {
t.Fatalf("delivery failure warning fields = %#v", fields)
}
if _, ok := fields["url"]; ok {
t.Fatalf("delivery failure warning must not include webhook URL: %#v", fields)
}
if _, ok := fields["secret_token"]; ok {
t.Fatalf("delivery failure warning must not include webhook secret: %#v", fields)
}
}

View file

@ -78,6 +78,7 @@ func botFatherSeedProfile() domain.BotProfile {
{Command: "setlogin", Description: "configure Telegram Login"},
{Command: "logininfo", Description: "show Telegram Login configuration"},
{Command: "resetloginsecret", Description: "rotate an OIDC Client Secret"},
{Command: "done", Description: "finish Telegram Login configuration"},
{Command: "cancel", Description: "cancel the current operation"},
{Command: "help", Description: "show help"},
},

View file

@ -35,6 +35,16 @@ func TestBotStoreRoundTripPostgres(t *testing.T) {
if bfProfile.TokenSecret != "" || len(bfProfile.Commands) == 0 {
t.Fatalf("BotFather profile = %+v, want empty token with seeded commands", bfProfile)
}
hasDone := false
for _, command := range bfProfile.Commands {
if command.Command == "done" {
hasDone = true
break
}
}
if !hasDone {
t.Fatalf("BotFather commands = %+v, want /done for persistent /setlogin sessions", bfProfile.Commands)
}
// 空 phone 查询不得命中任何行。
if _, found, err := users.ByPhone(ctx, ""); err != nil || found {
t.Fatalf("ByPhone('') found=%v err=%v, want not found", found, err)