Fix Telegram polling with active webhooks
- commit
- e9bc0c63c683f6cc389ace48a2c3ee7a6b12765f
- author
- patriceckhart · · 2026-07-20 05:55 UTC
- parent
- b99b1ae
- signature
- unsigned
- refs
- tag: v0.2.93
7 files · +294 -4
M README.md +2 -0
| @@ -867,6 +867,8 @@ Setup flow: | |||
| 867 | 867 | ||
| 868 | 868 | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | |
| 869 | 869 | ||
| 870 | + | Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active. | |
| 871 | + | ||
| 870 | 872 | Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example. | |
| 871 | 873 | ||
| 872 | 874 | ### Architecture: protocol-agnostic bot core | |
| @@ -867,6 +867,8 @@ Setup flow: | |||||
| 867 | 867 | ||||
| 868 | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | 868 | From then on, any DM you send is forwarded to the agent as a user prompt. Attached photos or `image/*` documents are downloaded and passed to vision-capable models. In-bot telegram commands: `/help`, `/status`, `/stop` (cancel the current turn). Config lives in `$ZOT_HOME/bot.json` (mode 0600). | ||
| 869 | 869 | ||||
| 870 | + | Starting either Telegram bridge removes any webhook configured for that bot before long polling begins, while preserving pending updates. Telegram does not allow webhooks and `getUpdates` polling at the same time, so do not share the bot token with another service that expects to keep a webhook active. | |||
| 871 | + | ||||
| 870 | Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example. | 872 | Bot mode respects the usual zot flags: `--provider`, `--model`, `--cwd`, `--reasoning`, `--continue`, `--no-session`, `--no-tools`, and so on. Run `zot tg run -c --model claude-opus-4-1` to resume the latest session on Opus, for example. | ||
| 871 | 873 | ||||
| 872 | ### Architecture: protocol-agnostic bot core | 874 | ### Architecture: protocol-agnostic bot core | ||
M packages/agent/botcmd.go +26 -3
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | |
| 19 | 19 | "github.com/patriceckhart/zot/packages/core" | |
| 20 | 20 | "github.com/patriceckhart/zot/packages/provider" | |
| 21 | + | "golang.org/x/term" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // detachChild configures cmd to run in its own process group so tty | |
| @@ -394,7 +395,30 @@ config & state: | |||
| 394 | 395 | `) | |
| 395 | 396 | } | |
| 396 | 397 | ||
| 397 | - | // telegramBotSetup interactively reads a bot token, verifies it via getMe, and saves it. | |
| 398 | + | var ( | |
| 399 | + | botTokenIsTerminal = term.IsTerminal | |
| 400 | + | botTokenReadPassword = term.ReadPassword | |
| 401 | + | ) | |
| 402 | + | ||
| 403 | + | func readBotToken(in *os.File, out io.Writer) (string, error) { | |
| 404 | + | if botTokenIsTerminal(int(in.Fd())) { | |
| 405 | + | value, err := botTokenReadPassword(int(in.Fd())) | |
| 406 | + | fmt.Fprintln(out) | |
| 407 | + | if err != nil { | |
| 408 | + | return "", fmt.Errorf("read telegram bot token: %w", err) | |
| 409 | + | } | |
| 410 | + | return string(value), nil | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | value, err := bufio.NewReader(in).ReadString('\n') | |
| 414 | + | if err != nil && !errors.Is(err, io.EOF) { | |
| 415 | + | return "", fmt.Errorf("read telegram bot token: %w", err) | |
| 416 | + | } | |
| 417 | + | return value, nil | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | // telegramBotSetup interactively reads a bot token without echoing it on a | |
| 421 | + | // terminal, verifies it via getMe, and saves it. | |
| 398 | 422 | func telegramBotSetup(_ []string) error { | |
| 399 | 423 | cfg, err := telegram.LoadConfig(ZotHome()) | |
| 400 | 424 | if err != nil { | |
| @@ -402,8 +426,7 @@ func telegramBotSetup(_ []string) error { | |||
| 402 | 426 | } | |
| 403 | 427 | ||
| 404 | 428 | fmt.Print("telegram bot token (from @BotFather): ") | |
| 405 | - | reader := bufio.NewReader(os.Stdin) | |
| 406 | - | line, err := reader.ReadString('\n') | |
| 429 | + | line, err := readBotToken(os.Stdin, os.Stdout) | |
| 407 | 430 | if err != nil { | |
| 408 | 431 | return err | |
| 409 | 432 | } | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | 18 | "github.com/patriceckhart/zot/packages/agent/modes/telegram" | ||
| 19 | "github.com/patriceckhart/zot/packages/core" | 19 | "github.com/patriceckhart/zot/packages/core" | ||
| 20 | "github.com/patriceckhart/zot/packages/provider" | 20 | "github.com/patriceckhart/zot/packages/provider" | ||
| 21 | + | "golang.org/x/term" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // detachChild configures cmd to run in its own process group so tty | 24 | // detachChild configures cmd to run in its own process group so tty | ||
| @@ -394,7 +395,30 @@ config & state: | |||||
| 394 | `) | 395 | `) | ||
| 395 | } | 396 | } | ||
| 396 | 397 | ||||
| 397 | - | // telegramBotSetup interactively reads a bot token, verifies it via getMe, and saves it. | 398 | + | var ( |
| 399 | + | botTokenIsTerminal = term.IsTerminal | |||
| 400 | + | botTokenReadPassword = term.ReadPassword | |||
| 401 | + | ) | |||
| 402 | + | ||||
| 403 | + | func readBotToken(in *os.File, out io.Writer) (string, error) { | |||
| 404 | + | if botTokenIsTerminal(int(in.Fd())) { | |||
| 405 | + | value, err := botTokenReadPassword(int(in.Fd())) | |||
| 406 | + | fmt.Fprintln(out) | |||
| 407 | + | if err != nil { | |||
| 408 | + | return "", fmt.Errorf("read telegram bot token: %w", err) | |||
| 409 | + | } | |||
| 410 | + | return string(value), nil | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | value, err := bufio.NewReader(in).ReadString('\n') | |||
| 414 | + | if err != nil && !errors.Is(err, io.EOF) { | |||
| 415 | + | return "", fmt.Errorf("read telegram bot token: %w", err) | |||
| 416 | + | } | |||
| 417 | + | return value, nil | |||
| 418 | + | } | |||
| 419 | + | ||||
| 420 | + | // telegramBotSetup interactively reads a bot token without echoing it on a | |||
| 421 | + | // terminal, verifies it via getMe, and saves it. | |||
| 398 | func telegramBotSetup(_ []string) error { | 422 | func telegramBotSetup(_ []string) error { | ||
| 399 | cfg, err := telegram.LoadConfig(ZotHome()) | 423 | cfg, err := telegram.LoadConfig(ZotHome()) | ||
| 400 | if err != nil { | 424 | if err != nil { | ||
| @@ -402,8 +426,7 @@ func telegramBotSetup(_ []string) error { | |||||
| 402 | } | 426 | } | ||
| 403 | 427 | ||||
| 404 | fmt.Print("telegram bot token (from @BotFather): ") | 428 | fmt.Print("telegram bot token (from @BotFather): ") | ||
| 405 | - | reader := bufio.NewReader(os.Stdin) | 429 | + | line, err := readBotToken(os.Stdin, os.Stdout) |
| 406 | - | line, err := reader.ReadString('\n') | |||
| 407 | if err != nil { | 430 | if err != nil { | ||
| 408 | return err | 431 | return err | ||
| 409 | } | 432 | } | ||
M packages/agent/botspec_test.go +45 -1
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package agent | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 4 | 9 | ||
| 5 | 10 | func TestSpecForSubcommand(t *testing.T) { | |
| 6 | 11 | if s := specFor("telegram-bot"); s == nil || s.name != "telegram" { | |
| @@ -13,3 +18,42 @@ func TestSpecForSubcommand(t *testing.T) { | |||
| 13 | 18 | t.Fatal("unknown subcommand must not match") | |
| 14 | 19 | } | |
| 15 | 20 | } | |
| 21 | + | ||
| 22 | + | func TestReadBotTokenDoesNotEchoTerminalInput(t *testing.T) { | |
| 23 | + | in, err := os.CreateTemp(t.TempDir(), "stdin") | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatal(err) | |
| 26 | + | } | |
| 27 | + | defer in.Close() | |
| 28 | + | ||
| 29 | + | oldIsTerminal := botTokenIsTerminal | |
| 30 | + | oldReadPassword := botTokenReadPassword | |
| 31 | + | t.Cleanup(func() { | |
| 32 | + | botTokenIsTerminal = oldIsTerminal | |
| 33 | + | botTokenReadPassword = oldReadPassword | |
| 34 | + | }) | |
| 35 | + | botTokenIsTerminal = func(fd int) bool { | |
| 36 | + | if fd != int(in.Fd()) { | |
| 37 | + | t.Fatalf("terminal check fd = %d, want %d", fd, in.Fd()) | |
| 38 | + | } | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | botTokenReadPassword = func(fd int) ([]byte, error) { | |
| 42 | + | if fd != int(in.Fd()) { | |
| 43 | + | t.Fatalf("password read fd = %d, want %d", fd, in.Fd()) | |
| 44 | + | } | |
| 45 | + | return []byte("secret-token"), nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | var out bytes.Buffer | |
| 49 | + | got, err := readBotToken(in, &out) | |
| 50 | + | if err != nil { | |
| 51 | + | t.Fatalf("readBotToken() error = %v", err) | |
| 52 | + | } | |
| 53 | + | if got != "secret-token" { | |
| 54 | + | t.Fatalf("readBotToken() = %q, want secret token", got) | |
| 55 | + | } | |
| 56 | + | if strings.Contains(out.String(), got) { | |
| 57 | + | t.Fatalf("terminal output exposed bot token: %q", out.String()) | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -1,6 +1,11 @@ | |||||
| 1 | package agent | 1 | package agent | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "bytes" | |||
| 5 | + | "os" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 4 | 9 | ||||
| 5 | func TestSpecForSubcommand(t *testing.T) { | 10 | func TestSpecForSubcommand(t *testing.T) { | ||
| 6 | if s := specFor("telegram-bot"); s == nil || s.name != "telegram" { | 11 | if s := specFor("telegram-bot"); s == nil || s.name != "telegram" { | ||
| @@ -13,3 +18,42 @@ func TestSpecForSubcommand(t *testing.T) { | |||||
| 13 | t.Fatal("unknown subcommand must not match") | 18 | t.Fatal("unknown subcommand must not match") | ||
| 14 | } | 19 | } | ||
| 15 | } | 20 | } | ||
| 21 | + | ||||
| 22 | + | func TestReadBotTokenDoesNotEchoTerminalInput(t *testing.T) { | |||
| 23 | + | in, err := os.CreateTemp(t.TempDir(), "stdin") | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatal(err) | |||
| 26 | + | } | |||
| 27 | + | defer in.Close() | |||
| 28 | + | ||||
| 29 | + | oldIsTerminal := botTokenIsTerminal | |||
| 30 | + | oldReadPassword := botTokenReadPassword | |||
| 31 | + | t.Cleanup(func() { | |||
| 32 | + | botTokenIsTerminal = oldIsTerminal | |||
| 33 | + | botTokenReadPassword = oldReadPassword | |||
| 34 | + | }) | |||
| 35 | + | botTokenIsTerminal = func(fd int) bool { | |||
| 36 | + | if fd != int(in.Fd()) { | |||
| 37 | + | t.Fatalf("terminal check fd = %d, want %d", fd, in.Fd()) | |||
| 38 | + | } | |||
| 39 | + | return true | |||
| 40 | + | } | |||
| 41 | + | botTokenReadPassword = func(fd int) ([]byte, error) { | |||
| 42 | + | if fd != int(in.Fd()) { | |||
| 43 | + | t.Fatalf("password read fd = %d, want %d", fd, in.Fd()) | |||
| 44 | + | } | |||
| 45 | + | return []byte("secret-token"), nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var out bytes.Buffer | |||
| 49 | + | got, err := readBotToken(in, &out) | |||
| 50 | + | if err != nil { | |||
| 51 | + | t.Fatalf("readBotToken() error = %v", err) | |||
| 52 | + | } | |||
| 53 | + | if got != "secret-token" { | |||
| 54 | + | t.Fatalf("readBotToken() = %q, want secret token", got) | |||
| 55 | + | } | |||
| 56 | + | if strings.Contains(out.String(), got) { | |||
| 57 | + | t.Fatalf("terminal output exposed bot token: %q", out.String()) | |||
| 58 | + | } | |||
| 59 | + | } | |||
M packages/agent/modes/telegram/adapter.go +3 -0
| @@ -37,6 +37,9 @@ func (a *Adapter) Run(ctx context.Context, | |||
| 37 | 37 | if err != nil { | |
| 38 | 38 | return fmt.Errorf("getMe: %w", err) | |
| 39 | 39 | } | |
| 40 | + | if err := a.Client.DeleteWebhook(ctx, false); err != nil { | |
| 41 | + | return fmt.Errorf("remove telegram webhook before polling: %w", err) | |
| 42 | + | } | |
| 40 | 43 | // Keep the stored username/id in sync with the actual bot. | |
| 41 | 44 | if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username { | |
| 42 | 45 | a.Cfg.BotID = me.ID | |
| @@ -37,6 +37,9 @@ func (a *Adapter) Run(ctx context.Context, | |||||
| 37 | if err != nil { | 37 | if err != nil { | ||
| 38 | return fmt.Errorf("getMe: %w", err) | 38 | return fmt.Errorf("getMe: %w", err) | ||
| 39 | } | 39 | } | ||
| 40 | + | if err := a.Client.DeleteWebhook(ctx, false); err != nil { | |||
| 41 | + | return fmt.Errorf("remove telegram webhook before polling: %w", err) | |||
| 42 | + | } | |||
| 40 | // Keep the stored username/id in sync with the actual bot. | 43 | // Keep the stored username/id in sync with the actual bot. | ||
| 41 | if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username { | 44 | if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username { | ||
| 42 | a.Cfg.BotID = me.ID | 45 | a.Cfg.BotID = me.ID | ||
M packages/agent/modes/telegram/api.go +17 -0
| @@ -96,6 +96,23 @@ func (c *Client) GetMe(ctx context.Context) (*User, error) { | |||
| 96 | 96 | return &resp.Result, nil | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // DeleteWebhook removes any webhook so the bot can receive updates through | |
| 100 | + | // long polling. Pending updates are preserved unless dropPendingUpdates is true. | |
| 101 | + | func (c *Client) DeleteWebhook(ctx context.Context, dropPendingUpdates bool) error { | |
| 102 | + | body := map[string]any{"drop_pending_updates": dropPendingUpdates} | |
| 103 | + | var resp apiResponse[bool] | |
| 104 | + | if err := c.call(ctx, "deleteWebhook", body, &resp); err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | if !resp.OK { | |
| 108 | + | return fmt.Errorf("deleteWebhook: %s", resp.Description) | |
| 109 | + | } | |
| 110 | + | if !resp.Result { | |
| 111 | + | return fmt.Errorf("deleteWebhook: telegram did not remove the webhook") | |
| 112 | + | } | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | ||
| 99 | 116 | // GetUpdates polls for new updates since offset with a long-poll timeout. | |
| 100 | 117 | func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) { | |
| 101 | 118 | q := url.Values{} | |
| @@ -96,6 +96,23 @@ func (c *Client) GetMe(ctx context.Context) (*User, error) { | |||||
| 96 | return &resp.Result, nil | 96 | return &resp.Result, nil | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // DeleteWebhook removes any webhook so the bot can receive updates through | |||
| 100 | + | // long polling. Pending updates are preserved unless dropPendingUpdates is true. | |||
| 101 | + | func (c *Client) DeleteWebhook(ctx context.Context, dropPendingUpdates bool) error { | |||
| 102 | + | body := map[string]any{"drop_pending_updates": dropPendingUpdates} | |||
| 103 | + | var resp apiResponse[bool] | |||
| 104 | + | if err := c.call(ctx, "deleteWebhook", body, &resp); err != nil { | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 107 | + | if !resp.OK { | |||
| 108 | + | return fmt.Errorf("deleteWebhook: %s", resp.Description) | |||
| 109 | + | } | |||
| 110 | + | if !resp.Result { | |||
| 111 | + | return fmt.Errorf("deleteWebhook: telegram did not remove the webhook") | |||
| 112 | + | } | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 99 | // GetUpdates polls for new updates since offset with a long-poll timeout. | 116 | // GetUpdates polls for new updates since offset with a long-poll timeout. | ||
| 100 | func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) { | 117 | func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) { | ||
| 101 | q := url.Values{} | 118 | q := url.Values{} | ||
A packages/agent/modes/telegram/api_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | package telegram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |
| 16 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |
| 20 | + | ||
| 21 | + | func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |
| 22 | + | return f(req) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func telegramResponse(body string) *http.Response { | |
| 26 | + | return &http.Response{ | |
| 27 | + | StatusCode: http.StatusOK, | |
| 28 | + | Body: io.NopCloser(strings.NewReader(body)), | |
| 29 | + | Header: make(http.Header), | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func testClient(transport http.RoundTripper) *Client { | |
| 34 | + | return &Client{ | |
| 35 | + | token: "test", | |
| 36 | + | http: &http.Client{Transport: transport}, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestClientDeleteWebhookPreservesPendingUpdates(t *testing.T) { | |
| 41 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |
| 42 | + | if req.Method != http.MethodPost { | |
| 43 | + | t.Fatalf("method = %s, want POST", req.Method) | |
| 44 | + | } | |
| 45 | + | if req.URL.Path != "/bottest/deleteWebhook" { | |
| 46 | + | t.Fatalf("path = %q, want deleteWebhook endpoint", req.URL.Path) | |
| 47 | + | } | |
| 48 | + | body, err := io.ReadAll(req.Body) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | var got map[string]bool | |
| 53 | + | if err := json.Unmarshal(body, &got); err != nil { | |
| 54 | + | t.Fatal(err) | |
| 55 | + | } | |
| 56 | + | if got["drop_pending_updates"] { | |
| 57 | + | t.Fatal("drop_pending_updates = true, want false") | |
| 58 | + | } | |
| 59 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |
| 60 | + | })) | |
| 61 | + | ||
| 62 | + | if err := client.DeleteWebhook(context.Background(), false); err != nil { | |
| 63 | + | t.Fatalf("DeleteWebhook() error = %v", err) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) { | |
| 68 | + | client := testClient(roundTripFunc(func(*http.Request) (*http.Response, error) { | |
| 69 | + | return telegramResponse(`{"ok":false,"description":"not allowed","result":false}`), nil | |
| 70 | + | })) | |
| 71 | + | ||
| 72 | + | err := client.DeleteWebhook(context.Background(), false) | |
| 73 | + | if err == nil || !strings.Contains(err.Error(), "deleteWebhook: not allowed") { | |
| 74 | + | t.Fatalf("DeleteWebhook() error = %v, want Telegram API error", err) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) { | |
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 80 | + | defer cancel() | |
| 81 | + | ||
| 82 | + | var ( | |
| 83 | + | mu sync.Mutex | |
| 84 | + | calls []string | |
| 85 | + | ) | |
| 86 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |
| 87 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |
| 88 | + | mu.Lock() | |
| 89 | + | calls = append(calls, endpoint) | |
| 90 | + | mu.Unlock() | |
| 91 | + | switch endpoint { | |
| 92 | + | case "getMe": | |
| 93 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |
| 94 | + | case "deleteWebhook": | |
| 95 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |
| 96 | + | case "getUpdates": | |
| 97 | + | cancel() | |
| 98 | + | return telegramResponse(`{"ok":true,"result":[]}`), nil | |
| 99 | + | default: | |
| 100 | + | t.Fatalf("unexpected endpoint %q", endpoint) | |
| 101 | + | return nil, errors.New("unreachable") | |
| 102 | + | } | |
| 103 | + | })) | |
| 104 | + | cfg := Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"} | |
| 105 | + | adapter := NewAdapter(client, &cfg, func(Config) error { return nil }) | |
| 106 | + | ||
| 107 | + | err := adapter.Run(ctx, func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {}) | |
| 108 | + | if !errors.Is(err, context.Canceled) { | |
| 109 | + | t.Fatalf("Run() error = %v, want context canceled", err) | |
| 110 | + | } | |
| 111 | + | mu.Lock() | |
| 112 | + | defer mu.Unlock() | |
| 113 | + | want := []string{"getMe", "deleteWebhook", "getUpdates"} | |
| 114 | + | if strings.Join(calls, ",") != strings.Join(want, ",") { | |
| 115 | + | t.Fatalf("API calls = %v, want %v", calls, want) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestBridgeStartDeletesWebhookBeforePolling(t *testing.T) { | |
| 120 | + | pollStarted := make(chan struct{}) | |
| 121 | + | var pollOnce sync.Once | |
| 122 | + | var ( | |
| 123 | + | mu sync.Mutex | |
| 124 | + | calls []string | |
| 125 | + | ) | |
| 126 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |
| 127 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |
| 128 | + | mu.Lock() | |
| 129 | + | calls = append(calls, endpoint) | |
| 130 | + | mu.Unlock() | |
| 131 | + | switch endpoint { | |
| 132 | + | case "getMe": | |
| 133 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |
| 134 | + | case "deleteWebhook": | |
| 135 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |
| 136 | + | case "getUpdates": | |
| 137 | + | pollOnce.Do(func() { close(pollStarted) }) | |
| 138 | + | <-req.Context().Done() | |
| 139 | + | return nil, req.Context().Err() | |
| 140 | + | default: | |
| 141 | + | t.Fatalf("unexpected endpoint %q", endpoint) | |
| 142 | + | return nil, errors.New("unreachable") | |
| 143 | + | } | |
| 144 | + | })) | |
| 145 | + | bridge := &Bridge{ | |
| 146 | + | Client: client, | |
| 147 | + | Config: Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"}, | |
| 148 | + | Save: func(Config) error { return nil }, | |
| 149 | + | Host: testBridgeHost{}, | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | if err := bridge.Start(context.Background()); err != nil { | |
| 153 | + | t.Fatalf("Start() error = %v", err) | |
| 154 | + | } | |
| 155 | + | select { | |
| 156 | + | case <-pollStarted: | |
| 157 | + | case <-time.After(2 * time.Second): | |
| 158 | + | t.Fatal("timed out waiting for Telegram polling to start") | |
| 159 | + | } | |
| 160 | + | bridge.Stop() | |
| 161 | + | ||
| 162 | + | mu.Lock() | |
| 163 | + | defer mu.Unlock() | |
| 164 | + | want := []string{"getMe", "deleteWebhook", "getUpdates"} | |
| 165 | + | if strings.Join(calls, ",") != strings.Join(want, ",") { | |
| 166 | + | t.Fatalf("API calls = %v, want %v", calls, want) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | type testBridgeHost struct{} | |
| 171 | + | ||
| 172 | + | func (testBridgeHost) SubmitOrQueue(string, []provider.ImageBlock) {} | |
| 173 | + | func (testBridgeHost) CancelTurn() {} | |
| 174 | + | func (testBridgeHost) Status() string { return "" } | |
| 175 | + | func (testBridgeHost) Notify(string, string) {} | |
| 176 | + | ||
| 177 | + | func TestAdapterRunFailsBeforePollingWhenWebhookRemovalFails(t *testing.T) { | |
| 178 | + | var calls bytes.Buffer | |
| 179 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |
| 180 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |
| 181 | + | calls.WriteString(endpoint + "\n") | |
| 182 | + | if endpoint == "getMe" { | |
| 183 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |
| 184 | + | } | |
| 185 | + | return telegramResponse(`{"ok":false,"description":"webhook removal denied","result":false}`), nil | |
| 186 | + | })) | |
| 187 | + | cfg := Config{BotToken: "test"} | |
| 188 | + | adapter := NewAdapter(client, &cfg, func(Config) error { return nil }) | |
| 189 | + | ||
| 190 | + | err := adapter.Run(context.Background(), func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {}) | |
| 191 | + | if err == nil || !strings.Contains(err.Error(), "remove telegram webhook before polling") { | |
| 192 | + | t.Fatalf("Run() error = %v, want actionable webhook error", err) | |
| 193 | + | } | |
| 194 | + | if got, want := calls.String(), "getMe\ndeleteWebhook\n"; got != want { | |
| 195 | + | t.Fatalf("API calls = %q, want %q", got, want) | |
| 196 | + | } | |
| 197 | + | } | |
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package telegram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "net/http" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/patriceckhart/zot/packages/agent/modes/bot" | |||
| 16 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | type roundTripFunc func(*http.Request) (*http.Response, error) | |||
| 20 | + | ||||
| 21 | + | func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { | |||
| 22 | + | return f(req) | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func telegramResponse(body string) *http.Response { | |||
| 26 | + | return &http.Response{ | |||
| 27 | + | StatusCode: http.StatusOK, | |||
| 28 | + | Body: io.NopCloser(strings.NewReader(body)), | |||
| 29 | + | Header: make(http.Header), | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func testClient(transport http.RoundTripper) *Client { | |||
| 34 | + | return &Client{ | |||
| 35 | + | token: "test", | |||
| 36 | + | http: &http.Client{Transport: transport}, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestClientDeleteWebhookPreservesPendingUpdates(t *testing.T) { | |||
| 41 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |||
| 42 | + | if req.Method != http.MethodPost { | |||
| 43 | + | t.Fatalf("method = %s, want POST", req.Method) | |||
| 44 | + | } | |||
| 45 | + | if req.URL.Path != "/bottest/deleteWebhook" { | |||
| 46 | + | t.Fatalf("path = %q, want deleteWebhook endpoint", req.URL.Path) | |||
| 47 | + | } | |||
| 48 | + | body, err := io.ReadAll(req.Body) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | var got map[string]bool | |||
| 53 | + | if err := json.Unmarshal(body, &got); err != nil { | |||
| 54 | + | t.Fatal(err) | |||
| 55 | + | } | |||
| 56 | + | if got["drop_pending_updates"] { | |||
| 57 | + | t.Fatal("drop_pending_updates = true, want false") | |||
| 58 | + | } | |||
| 59 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |||
| 60 | + | })) | |||
| 61 | + | ||||
| 62 | + | if err := client.DeleteWebhook(context.Background(), false); err != nil { | |||
| 63 | + | t.Fatalf("DeleteWebhook() error = %v", err) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) { | |||
| 68 | + | client := testClient(roundTripFunc(func(*http.Request) (*http.Response, error) { | |||
| 69 | + | return telegramResponse(`{"ok":false,"description":"not allowed","result":false}`), nil | |||
| 70 | + | })) | |||
| 71 | + | ||||
| 72 | + | err := client.DeleteWebhook(context.Background(), false) | |||
| 73 | + | if err == nil || !strings.Contains(err.Error(), "deleteWebhook: not allowed") { | |||
| 74 | + | t.Fatalf("DeleteWebhook() error = %v, want Telegram API error", err) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) { | |||
| 79 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 80 | + | defer cancel() | |||
| 81 | + | ||||
| 82 | + | var ( | |||
| 83 | + | mu sync.Mutex | |||
| 84 | + | calls []string | |||
| 85 | + | ) | |||
| 86 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |||
| 87 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |||
| 88 | + | mu.Lock() | |||
| 89 | + | calls = append(calls, endpoint) | |||
| 90 | + | mu.Unlock() | |||
| 91 | + | switch endpoint { | |||
| 92 | + | case "getMe": | |||
| 93 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |||
| 94 | + | case "deleteWebhook": | |||
| 95 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |||
| 96 | + | case "getUpdates": | |||
| 97 | + | cancel() | |||
| 98 | + | return telegramResponse(`{"ok":true,"result":[]}`), nil | |||
| 99 | + | default: | |||
| 100 | + | t.Fatalf("unexpected endpoint %q", endpoint) | |||
| 101 | + | return nil, errors.New("unreachable") | |||
| 102 | + | } | |||
| 103 | + | })) | |||
| 104 | + | cfg := Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"} | |||
| 105 | + | adapter := NewAdapter(client, &cfg, func(Config) error { return nil }) | |||
| 106 | + | ||||
| 107 | + | err := adapter.Run(ctx, func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {}) | |||
| 108 | + | if !errors.Is(err, context.Canceled) { | |||
| 109 | + | t.Fatalf("Run() error = %v, want context canceled", err) | |||
| 110 | + | } | |||
| 111 | + | mu.Lock() | |||
| 112 | + | defer mu.Unlock() | |||
| 113 | + | want := []string{"getMe", "deleteWebhook", "getUpdates"} | |||
| 114 | + | if strings.Join(calls, ",") != strings.Join(want, ",") { | |||
| 115 | + | t.Fatalf("API calls = %v, want %v", calls, want) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestBridgeStartDeletesWebhookBeforePolling(t *testing.T) { | |||
| 120 | + | pollStarted := make(chan struct{}) | |||
| 121 | + | var pollOnce sync.Once | |||
| 122 | + | var ( | |||
| 123 | + | mu sync.Mutex | |||
| 124 | + | calls []string | |||
| 125 | + | ) | |||
| 126 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |||
| 127 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |||
| 128 | + | mu.Lock() | |||
| 129 | + | calls = append(calls, endpoint) | |||
| 130 | + | mu.Unlock() | |||
| 131 | + | switch endpoint { | |||
| 132 | + | case "getMe": | |||
| 133 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |||
| 134 | + | case "deleteWebhook": | |||
| 135 | + | return telegramResponse(`{"ok":true,"result":true}`), nil | |||
| 136 | + | case "getUpdates": | |||
| 137 | + | pollOnce.Do(func() { close(pollStarted) }) | |||
| 138 | + | <-req.Context().Done() | |||
| 139 | + | return nil, req.Context().Err() | |||
| 140 | + | default: | |||
| 141 | + | t.Fatalf("unexpected endpoint %q", endpoint) | |||
| 142 | + | return nil, errors.New("unreachable") | |||
| 143 | + | } | |||
| 144 | + | })) | |||
| 145 | + | bridge := &Bridge{ | |||
| 146 | + | Client: client, | |||
| 147 | + | Config: Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"}, | |||
| 148 | + | Save: func(Config) error { return nil }, | |||
| 149 | + | Host: testBridgeHost{}, | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | if err := bridge.Start(context.Background()); err != nil { | |||
| 153 | + | t.Fatalf("Start() error = %v", err) | |||
| 154 | + | } | |||
| 155 | + | select { | |||
| 156 | + | case <-pollStarted: | |||
| 157 | + | case <-time.After(2 * time.Second): | |||
| 158 | + | t.Fatal("timed out waiting for Telegram polling to start") | |||
| 159 | + | } | |||
| 160 | + | bridge.Stop() | |||
| 161 | + | ||||
| 162 | + | mu.Lock() | |||
| 163 | + | defer mu.Unlock() | |||
| 164 | + | want := []string{"getMe", "deleteWebhook", "getUpdates"} | |||
| 165 | + | if strings.Join(calls, ",") != strings.Join(want, ",") { | |||
| 166 | + | t.Fatalf("API calls = %v, want %v", calls, want) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | type testBridgeHost struct{} | |||
| 171 | + | ||||
| 172 | + | func (testBridgeHost) SubmitOrQueue(string, []provider.ImageBlock) {} | |||
| 173 | + | func (testBridgeHost) CancelTurn() {} | |||
| 174 | + | func (testBridgeHost) Status() string { return "" } | |||
| 175 | + | func (testBridgeHost) Notify(string, string) {} | |||
| 176 | + | ||||
| 177 | + | func TestAdapterRunFailsBeforePollingWhenWebhookRemovalFails(t *testing.T) { | |||
| 178 | + | var calls bytes.Buffer | |||
| 179 | + | client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { | |||
| 180 | + | endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:] | |||
| 181 | + | calls.WriteString(endpoint + "\n") | |||
| 182 | + | if endpoint == "getMe" { | |||
| 183 | + | return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil | |||
| 184 | + | } | |||
| 185 | + | return telegramResponse(`{"ok":false,"description":"webhook removal denied","result":false}`), nil | |||
| 186 | + | })) | |||
| 187 | + | cfg := Config{BotToken: "test"} | |||
| 188 | + | adapter := NewAdapter(client, &cfg, func(Config) error { return nil }) | |||
| 189 | + | ||||
| 190 | + | err := adapter.Run(context.Background(), func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {}) | |||
| 191 | + | if err == nil || !strings.Contains(err.Error(), "remove telegram webhook before polling") { | |||
| 192 | + | t.Fatalf("Run() error = %v, want actionable webhook error", err) | |||
| 193 | + | } | |||
| 194 | + | if got, want := calls.String(), "getMe\ndeleteWebhook\n"; got != want { | |||
| 195 | + | t.Fatalf("API calls = %q, want %q", got, want) | |||
| 196 | + | } | |||
| 197 | + | } | |||
M packages/agent/modes/telegram/bridge.go +4 -0
| @@ -115,6 +115,10 @@ func (b *Bridge) Start(parent context.Context) error { | |||
| 115 | 115 | cancel() | |
| 116 | 116 | return fmt.Errorf("getMe: %w", err) | |
| 117 | 117 | } | |
| 118 | + | if err := b.Client.DeleteWebhook(ctx, false); err != nil { | |
| 119 | + | cancel() | |
| 120 | + | return fmt.Errorf("remove telegram webhook before polling: %w", err) | |
| 121 | + | } | |
| 118 | 122 | ||
| 119 | 123 | b.mu.Lock() | |
| 120 | 124 | b.running = true | |
| @@ -115,6 +115,10 @@ func (b *Bridge) Start(parent context.Context) error { | |||||
| 115 | cancel() | 115 | cancel() | ||
| 116 | return fmt.Errorf("getMe: %w", err) | 116 | return fmt.Errorf("getMe: %w", err) | ||
| 117 | } | 117 | } | ||
| 118 | + | if err := b.Client.DeleteWebhook(ctx, false); err != nil { | |||
| 119 | + | cancel() | |||
| 120 | + | return fmt.Errorf("remove telegram webhook before polling: %w", err) | |||
| 121 | + | } | |||
| 118 | 122 | ||||
| 119 | b.mu.Lock() | 123 | b.mu.Lock() | ||
| 120 | b.running = true | 124 | b.running = true | ||