detached e9bc0c6

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:
867867
868868 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).
869869
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+
870872 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.
871873
872874 ### 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 core874 ### Architecture: protocol-agnostic bot core
M packages/agent/botcmd.go +26 -3
@@ -18,6 +18,7 @@ import (
1818 "github.com/patriceckhart/zot/packages/agent/modes/telegram"
1919 "github.com/patriceckhart/zot/packages/core"
2020 "github.com/patriceckhart/zot/packages/provider"
21+ "golang.org/x/term"
2122 )
2223
2324 // detachChild configures cmd to run in its own process group so tty
@@ -394,7 +395,30 @@ config & state:
394395 `)
395396 }
396397
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.
398422 func telegramBotSetup(_ []string) error {
399423 cfg, err := telegram.LoadConfig(ZotHome())
400424 if err != nil {
@@ -402,8 +426,7 @@ func telegramBotSetup(_ []string) error {
402426 }
403427
404428 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)
407430 if err != nil {
408431 return err
409432 }
@@ -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 tty24 // 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 err431 return err
409 }432 }
M packages/agent/botspec_test.go +45 -1
@@ -1,6 +1,11 @@
11 package agent
22
3-import "testing"
3+import (
4+ "bytes"
5+ "os"
6+ "strings"
7+ "testing"
8+)
49
510 func TestSpecForSubcommand(t *testing.T) {
611 if s := specFor("telegram-bot"); s == nil || s.name != "telegram" {
@@ -13,3 +18,42 @@ func TestSpecForSubcommand(t *testing.T) {
1318 t.Fatal("unknown subcommand must not match")
1419 }
1520 }
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 agent1 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,
3737 if err != nil {
3838 return fmt.Errorf("getMe: %w", err)
3939 }
40+ if err := a.Client.DeleteWebhook(ctx, false); err != nil {
41+ return fmt.Errorf("remove telegram webhook before polling: %w", err)
42+ }
4043 // Keep the stored username/id in sync with the actual bot.
4144 if a.Cfg.BotID != me.ID || a.Cfg.BotUsername != me.Username {
4245 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.ID45 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) {
9696 return &resp.Result, nil
9797 }
9898
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+
99116 // GetUpdates polls for new updates since offset with a long-poll timeout.
100117 func (c *Client) GetUpdates(ctx context.Context, offset int64, timeoutSec int) ([]Update, error) {
101118 q := url.Values{}
@@ -96,6 +96,23 @@ func (c *Client) GetMe(ctx context.Context) (*User, error) {
96 return &resp.Result, nil96 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 {
115115 cancel()
116116 return fmt.Errorf("getMe: %w", err)
117117 }
118+ if err := b.Client.DeleteWebhook(ctx, false); err != nil {
119+ cancel()
120+ return fmt.Errorf("remove telegram webhook before polling: %w", err)
121+ }
118122
119123 b.mu.Lock()
120124 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 = true124 b.running = true

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close