1package telegram
2
3import (
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
19type roundTripFunc func(*http.Request) (*http.Response, error)
20
21func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
22 return f(req)
23}
24
25func 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
33func testClient(transport http.RoundTripper) *Client {
34 return &Client{
35 token: "test",
36 http: &http.Client{Transport: transport},
37 }
38}
39
40func 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
67func 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
78func TestAdapterCommandsAreCaseInsensitive(t *testing.T) {
79 cfg := Config{AllowedUserID: 42}
80 adapter := NewAdapter(nil, &cfg, func(Config) error { return nil })
81 var got bot.Command
82 var called bool
83 adapter.handleUpdate(context.Background(), Update{Message: &Message{
84 MessageID: 1,
85 From: &User{ID: 42},
86 Chat: Chat{ID: 7, Type: "private"},
87 Text: "/STOP",
88 }}, func(bot.InboundMessage) {
89 t.Fatal("uppercase slash command was forwarded as a prompt")
90 }, func(command bot.Command, _ bot.InboundMessage) {
91 got = command
92 called = true
93 })
94 if !called || got != bot.CmdStop {
95 t.Fatalf("command = %v, called = %v, want CmdStop", got, called)
96 }
97}
98
99func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) {
100 ctx, cancel := context.WithCancel(context.Background())
101 defer cancel()
102
103 var (
104 mu sync.Mutex
105 calls []string
106 )
107 client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
108 endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
109 mu.Lock()
110 calls = append(calls, endpoint)
111 mu.Unlock()
112 switch endpoint {
113 case "getMe":
114 return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
115 case "deleteWebhook":
116 return telegramResponse(`{"ok":true,"result":true}`), nil
117 case "getUpdates":
118 cancel()
119 return telegramResponse(`{"ok":true,"result":[]}`), nil
120 default:
121 t.Fatalf("unexpected endpoint %q", endpoint)
122 return nil, errors.New("unreachable")
123 }
124 }))
125 cfg := Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"}
126 adapter := NewAdapter(client, &cfg, func(Config) error { return nil })
127
128 err := adapter.Run(ctx, func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {})
129 if !errors.Is(err, context.Canceled) {
130 t.Fatalf("Run() error = %v, want context canceled", err)
131 }
132 mu.Lock()
133 defer mu.Unlock()
134 want := []string{"getMe", "deleteWebhook", "getUpdates"}
135 if strings.Join(calls, ",") != strings.Join(want, ",") {
136 t.Fatalf("API calls = %v, want %v", calls, want)
137 }
138}
139
140func TestBridgeStartDeletesWebhookBeforePolling(t *testing.T) {
141 pollStarted := make(chan struct{})
142 var pollOnce sync.Once
143 var (
144 mu sync.Mutex
145 calls []string
146 )
147 client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
148 endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
149 mu.Lock()
150 calls = append(calls, endpoint)
151 mu.Unlock()
152 switch endpoint {
153 case "getMe":
154 return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
155 case "deleteWebhook":
156 return telegramResponse(`{"ok":true,"result":true}`), nil
157 case "getUpdates":
158 pollOnce.Do(func() { close(pollStarted) })
159 <-req.Context().Done()
160 return nil, req.Context().Err()
161 default:
162 t.Fatalf("unexpected endpoint %q", endpoint)
163 return nil, errors.New("unreachable")
164 }
165 }))
166 bridge := &Bridge{
167 Client: client,
168 Config: Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"},
169 Save: func(Config) error { return nil },
170 Host: testBridgeHost{},
171 }
172
173 if err := bridge.Start(context.Background()); err != nil {
174 t.Fatalf("Start() error = %v", err)
175 }
176 select {
177 case <-pollStarted:
178 case <-time.After(2 * time.Second):
179 t.Fatal("timed out waiting for Telegram polling to start")
180 }
181 bridge.Stop()
182
183 mu.Lock()
184 defer mu.Unlock()
185 want := []string{"getMe", "deleteWebhook", "getUpdates"}
186 if strings.Join(calls, ",") != strings.Join(want, ",") {
187 t.Fatalf("API calls = %v, want %v", calls, want)
188 }
189}
190
191type testBridgeHost struct{}
192
193func (testBridgeHost) SubmitOrQueue(string, []provider.ImageBlock) {}
194func (testBridgeHost) CancelTurn() {}
195func (testBridgeHost) Status() string { return "" }
196func (testBridgeHost) Notify(string, string) {}
197
198func TestAdapterRunFailsBeforePollingWhenWebhookRemovalFails(t *testing.T) {
199 var calls bytes.Buffer
200 client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
201 endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
202 calls.WriteString(endpoint + "\n")
203 if endpoint == "getMe" {
204 return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
205 }
206 return telegramResponse(`{"ok":false,"description":"webhook removal denied","result":false}`), nil
207 }))
208 cfg := Config{BotToken: "test"}
209 adapter := NewAdapter(client, &cfg, func(Config) error { return nil })
210
211 err := adapter.Run(context.Background(), func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {})
212 if err == nil || !strings.Contains(err.Error(), "remove telegram webhook before polling") {
213 t.Fatalf("Run() error = %v, want actionable webhook error", err)
214 }
215 if got, want := calls.String(), "getMe\ndeleteWebhook\n"; got != want {
216 t.Fatalf("API calls = %q, want %q", got, want)
217 }
218}