agent-messenger 2.12.0 → 2.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/README.md +11 -1
- package/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/CONTRIBUTING.md +12 -0
- package/README.md +30 -4
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +22 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +93 -7
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +43 -72
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/telegrambot/cli.d.ts +5 -0
- package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/cli.js +29 -0
- package/dist/src/platforms/telegrambot/cli.js.map +1 -0
- package/dist/src/platforms/telegrambot/client.d.ts +85 -0
- package/dist/src/platforms/telegrambot/client.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/client.js +282 -0
- package/dist/src/platforms/telegrambot/client.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/auth.d.ts +31 -0
- package/dist/src/platforms/telegrambot/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/auth.js +173 -0
- package/dist/src/platforms/telegrambot/commands/auth.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/chat.d.ts +25 -0
- package/dist/src/platforms/telegrambot/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/chat.js +69 -0
- package/dist/src/platforms/telegrambot/commands/chat.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/index.d.ts +6 -0
- package/dist/src/platforms/telegrambot/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/index.js +6 -0
- package/dist/src/platforms/telegrambot/commands/index.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/message.d.ts +39 -0
- package/dist/src/platforms/telegrambot/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/message.js +145 -0
- package/dist/src/platforms/telegrambot/commands/message.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/reaction.d.ts +16 -0
- package/dist/src/platforms/telegrambot/commands/reaction.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/reaction.js +49 -0
- package/dist/src/platforms/telegrambot/commands/reaction.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/shared.d.ts +12 -0
- package/dist/src/platforms/telegrambot/commands/shared.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/shared.js +21 -0
- package/dist/src/platforms/telegrambot/commands/shared.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/whoami.d.ts +17 -0
- package/dist/src/platforms/telegrambot/commands/whoami.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/whoami.js +30 -0
- package/dist/src/platforms/telegrambot/commands/whoami.js.map +1 -0
- package/dist/src/platforms/telegrambot/credential-manager.d.ts +17 -0
- package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/credential-manager.js +113 -0
- package/dist/src/platforms/telegrambot/credential-manager.js.map +1 -0
- package/dist/src/platforms/telegrambot/index.d.ts +7 -0
- package/dist/src/platforms/telegrambot/index.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/index.js +5 -0
- package/dist/src/platforms/telegrambot/index.js.map +1 -0
- package/dist/src/platforms/telegrambot/listener.d.ts +30 -0
- package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/listener.js +186 -0
- package/dist/src/platforms/telegrambot/listener.js.map +1 -0
- package/dist/src/platforms/telegrambot/types.d.ts +256 -0
- package/dist/src/platforms/telegrambot/types.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/types.js +96 -0
- package/dist/src/platforms/telegrambot/types.js.map +1 -0
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/cli/telegrambot.mdx +149 -0
- package/docs/content/docs/index.mdx +10 -9
- package/docs/content/docs/quick-start.mdx +2 -0
- package/docs/content/docs/sdk/meta.json +1 -0
- package/docs/content/docs/sdk/telegrambot.mdx +216 -0
- package/e2e/config.ts +24 -0
- package/e2e/helpers.ts +1 -0
- package/e2e/telegrambot.e2e.test.ts +185 -0
- package/examples/telegrambot-listen.ts +54 -0
- package/package.json +10 -2
- package/scripts/postbuild.ts +1 -0
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +12 -5
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +357 -0
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +3 -0
- package/src/platforms/kakaotalk/client.ts +108 -9
- package/src/platforms/kakaotalk/listener.test.ts +155 -149
- package/src/platforms/kakaotalk/listener.ts +46 -80
- package/src/platforms/telegrambot/cli.ts +34 -0
- package/src/platforms/telegrambot/client.test.ts +454 -0
- package/src/platforms/telegrambot/client.ts +404 -0
- package/src/platforms/telegrambot/commands/auth.test.ts +244 -0
- package/src/platforms/telegrambot/commands/auth.ts +220 -0
- package/src/platforms/telegrambot/commands/chat.ts +96 -0
- package/src/platforms/telegrambot/commands/index.ts +5 -0
- package/src/platforms/telegrambot/commands/message.ts +235 -0
- package/src/platforms/telegrambot/commands/reaction.ts +70 -0
- package/src/platforms/telegrambot/commands/shared.ts +32 -0
- package/src/platforms/telegrambot/commands/whoami.ts +45 -0
- package/src/platforms/telegrambot/credential-manager.test.ts +196 -0
- package/src/platforms/telegrambot/credential-manager.ts +141 -0
- package/src/platforms/telegrambot/index.ts +44 -0
- package/src/platforms/telegrambot/listener.test.ts +398 -0
- package/src/platforms/telegrambot/listener.ts +198 -0
- package/src/platforms/telegrambot/types.test.ts +128 -0
- package/src/platforms/telegrambot/types.ts +282 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { TelegramBotClient } from './client'
|
|
4
|
+
import { TelegramBotListener } from './listener'
|
|
5
|
+
import type { TelegramBotUser, TelegramUpdate } from './types'
|
|
6
|
+
import { TelegramBotError } from './types'
|
|
7
|
+
|
|
8
|
+
interface FakeClient {
|
|
9
|
+
deleteWebhook: () => Promise<boolean>
|
|
10
|
+
getMe: () => Promise<TelegramBotUser>
|
|
11
|
+
getUpdates: (options?: unknown, signal?: AbortSignal) => Promise<TelegramUpdate[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ME: TelegramBotUser = {
|
|
15
|
+
id: 1,
|
|
16
|
+
is_bot: true,
|
|
17
|
+
first_name: 'Test Bot',
|
|
18
|
+
username: 'testbot',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeFakeClient(overrides: Partial<FakeClient> = {}): TelegramBotClient {
|
|
22
|
+
const base: FakeClient = {
|
|
23
|
+
deleteWebhook: async () => true,
|
|
24
|
+
getMe: async () => ME,
|
|
25
|
+
getUpdates: async () => [],
|
|
26
|
+
...overrides,
|
|
27
|
+
}
|
|
28
|
+
return base as unknown as TelegramBotClient
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function flush(ms = 5): Promise<void> {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function pendingWithAbort(signal?: AbortSignal): Promise<never> {
|
|
36
|
+
return new Promise<never>((_resolve, reject) => {
|
|
37
|
+
if (signal?.aborted) {
|
|
38
|
+
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
signal?.addEventListener('abort', () => reject(Object.assign(new Error('Aborted'), { name: 'AbortError' })), {
|
|
42
|
+
once: true,
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('TelegramBotListener', () => {
|
|
48
|
+
let listener: TelegramBotListener | null = null
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
listener = null
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
listener?.stop()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('emits "connected" with bot user after start()', async () => {
|
|
59
|
+
let connectedUser: TelegramBotUser | null = null
|
|
60
|
+
const client = makeFakeClient({
|
|
61
|
+
getUpdates: (_options, signal) => pendingWithAbort(signal),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
listener = new TelegramBotListener(client)
|
|
65
|
+
listener.on('connected', ({ user }) => {
|
|
66
|
+
connectedUser = user
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
await listener.start()
|
|
70
|
+
await flush()
|
|
71
|
+
|
|
72
|
+
expect(connectedUser).not.toBeNull()
|
|
73
|
+
expect(connectedUser!.username).toBe('testbot')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('deletes webhook before polling and forwards dropPendingUpdates', async () => {
|
|
77
|
+
let deleteWebhookCalls: Array<{ drop_pending_updates?: boolean } | undefined> = []
|
|
78
|
+
const client = {
|
|
79
|
+
deleteWebhook: async (opts?: { drop_pending_updates?: boolean }) => {
|
|
80
|
+
deleteWebhookCalls.push(opts)
|
|
81
|
+
return true
|
|
82
|
+
},
|
|
83
|
+
getMe: async () => ME,
|
|
84
|
+
getUpdates: (_options: unknown, signal?: AbortSignal) => pendingWithAbort(signal),
|
|
85
|
+
} as unknown as TelegramBotClient
|
|
86
|
+
|
|
87
|
+
listener = new TelegramBotListener(client, { dropPendingUpdates: true })
|
|
88
|
+
await listener.start()
|
|
89
|
+
await flush()
|
|
90
|
+
|
|
91
|
+
expect(deleteWebhookCalls).toHaveLength(1)
|
|
92
|
+
expect(deleteWebhookCalls[0]).toEqual({ drop_pending_updates: true })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('dispatches message updates', async () => {
|
|
96
|
+
const messages: string[] = []
|
|
97
|
+
let pollCount = 0
|
|
98
|
+
|
|
99
|
+
const client = makeFakeClient({
|
|
100
|
+
getUpdates: (_options, signal) => {
|
|
101
|
+
pollCount++
|
|
102
|
+
if (pollCount === 1) {
|
|
103
|
+
return Promise.resolve([
|
|
104
|
+
{
|
|
105
|
+
update_id: 100,
|
|
106
|
+
message: {
|
|
107
|
+
message_id: 1,
|
|
108
|
+
date: 1,
|
|
109
|
+
chat: { id: 99, type: 'private' as const, first_name: 'Alice' },
|
|
110
|
+
text: 'hello',
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
])
|
|
114
|
+
}
|
|
115
|
+
return pendingWithAbort(signal)
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
listener = new TelegramBotListener(client)
|
|
120
|
+
listener.on('message', (msg) => {
|
|
121
|
+
if (msg.text) messages.push(msg.text)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await listener.start()
|
|
125
|
+
await flush(20)
|
|
126
|
+
|
|
127
|
+
expect(messages).toEqual(['hello'])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('advances offset to update_id + 1 after processing', async () => {
|
|
131
|
+
const offsetsSeen: Array<number | undefined> = []
|
|
132
|
+
let pollCount = 0
|
|
133
|
+
|
|
134
|
+
const client = makeFakeClient({
|
|
135
|
+
getUpdates: (options, signal) => {
|
|
136
|
+
pollCount++
|
|
137
|
+
const offset = (options as { offset?: number } | undefined)?.offset
|
|
138
|
+
offsetsSeen.push(offset)
|
|
139
|
+
if (pollCount === 1) {
|
|
140
|
+
return Promise.resolve([
|
|
141
|
+
{
|
|
142
|
+
update_id: 100,
|
|
143
|
+
message: {
|
|
144
|
+
message_id: 1,
|
|
145
|
+
date: 1,
|
|
146
|
+
chat: { id: 99, type: 'private' as const, first_name: 'A' },
|
|
147
|
+
text: 'a',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
update_id: 101,
|
|
152
|
+
message: {
|
|
153
|
+
message_id: 2,
|
|
154
|
+
date: 2,
|
|
155
|
+
chat: { id: 99, type: 'private' as const, first_name: 'A' },
|
|
156
|
+
text: 'b',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
])
|
|
160
|
+
}
|
|
161
|
+
return pendingWithAbort(signal)
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
listener = new TelegramBotListener(client)
|
|
166
|
+
await listener.start()
|
|
167
|
+
await flush(20)
|
|
168
|
+
|
|
169
|
+
expect(offsetsSeen[0]).toBe(0)
|
|
170
|
+
expect(offsetsSeen[1]).toBe(102)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('emits telegram_update for every update (catch-all)', async () => {
|
|
174
|
+
let count = 0
|
|
175
|
+
let pollCount = 0
|
|
176
|
+
|
|
177
|
+
const client = makeFakeClient({
|
|
178
|
+
getUpdates: async () => {
|
|
179
|
+
pollCount++
|
|
180
|
+
if (pollCount === 1) {
|
|
181
|
+
return [{ update_id: 1, callback_query: { id: 'q', from: ME, chat_instance: 'ci', data: 'click' } }]
|
|
182
|
+
}
|
|
183
|
+
return new Promise(() => {})
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
listener = new TelegramBotListener(client)
|
|
188
|
+
listener.on('telegram_update', () => {
|
|
189
|
+
count++
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
await listener.start()
|
|
193
|
+
await flush(20)
|
|
194
|
+
|
|
195
|
+
expect(count).toBe(1)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('stops on fatal Unauthorized error', async () => {
|
|
199
|
+
let errorCaught: Error | null = null
|
|
200
|
+
const client = makeFakeClient({
|
|
201
|
+
getUpdates: async () => {
|
|
202
|
+
throw new TelegramBotError('Unauthorized', 'unauthorized')
|
|
203
|
+
},
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
listener = new TelegramBotListener(client)
|
|
207
|
+
listener.on('error', (err) => {
|
|
208
|
+
errorCaught = err
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
await listener.start()
|
|
212
|
+
await flush(20)
|
|
213
|
+
|
|
214
|
+
expect(errorCaught).not.toBeNull()
|
|
215
|
+
expect((errorCaught as unknown as TelegramBotError).code).toBe('unauthorized')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('emits disconnected on transient errors', async () => {
|
|
219
|
+
let disconnectedCount = 0
|
|
220
|
+
let pollCount = 0
|
|
221
|
+
|
|
222
|
+
const client = makeFakeClient({
|
|
223
|
+
getUpdates: (_options, signal) => {
|
|
224
|
+
pollCount++
|
|
225
|
+
if (pollCount === 1) {
|
|
226
|
+
return Promise.reject(new Error('Network error'))
|
|
227
|
+
}
|
|
228
|
+
return pendingWithAbort(signal)
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
listener = new TelegramBotListener(client)
|
|
233
|
+
listener.on('disconnected', () => {
|
|
234
|
+
disconnectedCount++
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
await listener.start()
|
|
238
|
+
await flush(50)
|
|
239
|
+
|
|
240
|
+
expect(disconnectedCount).toBeGreaterThanOrEqual(1)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('stop() aborts in-flight getUpdates and halts polling', async () => {
|
|
244
|
+
let pollStarts = 0
|
|
245
|
+
let aborted = false
|
|
246
|
+
|
|
247
|
+
const client = makeFakeClient({
|
|
248
|
+
getUpdates: (_options, signal) => {
|
|
249
|
+
pollStarts++
|
|
250
|
+
return new Promise<never>((_resolve, reject) => {
|
|
251
|
+
signal?.addEventListener(
|
|
252
|
+
'abort',
|
|
253
|
+
() => {
|
|
254
|
+
aborted = true
|
|
255
|
+
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
|
256
|
+
},
|
|
257
|
+
{ once: true },
|
|
258
|
+
)
|
|
259
|
+
})
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
listener = new TelegramBotListener(client)
|
|
264
|
+
await listener.start()
|
|
265
|
+
await flush()
|
|
266
|
+
|
|
267
|
+
expect(pollStarts).toBe(1)
|
|
268
|
+
|
|
269
|
+
listener.stop()
|
|
270
|
+
await flush(20)
|
|
271
|
+
|
|
272
|
+
expect(aborted).toBe(true)
|
|
273
|
+
expect(pollStarts).toBe(1)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('on/off/once chain returns this', () => {
|
|
277
|
+
const client = makeFakeClient()
|
|
278
|
+
listener = new TelegramBotListener(client)
|
|
279
|
+
const fn = (): void => {}
|
|
280
|
+
expect(listener.on('message', fn)).toBe(listener)
|
|
281
|
+
expect(listener.off('message', fn)).toBe(listener)
|
|
282
|
+
expect(listener.once('message', fn)).toBe(listener)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('emits chat_member and my_chat_member as distinct events', async () => {
|
|
286
|
+
const myChatMemberEvents: unknown[] = []
|
|
287
|
+
const chatMemberEvents: unknown[] = []
|
|
288
|
+
let pollCount = 0
|
|
289
|
+
|
|
290
|
+
const memberPayload = {
|
|
291
|
+
chat: { id: 1, type: 'private' as const, first_name: 'A' },
|
|
292
|
+
from: ME,
|
|
293
|
+
date: 1,
|
|
294
|
+
old_chat_member: { user: ME, status: 'member' as const },
|
|
295
|
+
new_chat_member: { user: ME, status: 'administrator' as const },
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const client = makeFakeClient({
|
|
299
|
+
getUpdates: (_options, signal) => {
|
|
300
|
+
pollCount++
|
|
301
|
+
if (pollCount === 1) {
|
|
302
|
+
return Promise.resolve([
|
|
303
|
+
{ update_id: 1, my_chat_member: memberPayload },
|
|
304
|
+
{ update_id: 2, chat_member: memberPayload },
|
|
305
|
+
])
|
|
306
|
+
}
|
|
307
|
+
return pendingWithAbort(signal)
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
listener = new TelegramBotListener(client)
|
|
312
|
+
listener.on('my_chat_member', (e) => myChatMemberEvents.push(e))
|
|
313
|
+
listener.on('chat_member', (e) => chatMemberEvents.push(e))
|
|
314
|
+
|
|
315
|
+
await listener.start()
|
|
316
|
+
await flush(20)
|
|
317
|
+
|
|
318
|
+
expect(myChatMemberEvents).toHaveLength(1)
|
|
319
|
+
expect(chatMemberEvents).toHaveLength(1)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('user handler exception does not stop polling and surfaces via error event', async () => {
|
|
323
|
+
let messageCount = 0
|
|
324
|
+
let errorCount = 0
|
|
325
|
+
let pollCount = 0
|
|
326
|
+
|
|
327
|
+
const client = makeFakeClient({
|
|
328
|
+
getUpdates: (_options, signal) => {
|
|
329
|
+
pollCount++
|
|
330
|
+
if (pollCount === 1) {
|
|
331
|
+
return Promise.resolve([
|
|
332
|
+
{
|
|
333
|
+
update_id: 1,
|
|
334
|
+
message: {
|
|
335
|
+
message_id: 1,
|
|
336
|
+
date: 1,
|
|
337
|
+
chat: { id: 1, type: 'private' as const, first_name: 'A' },
|
|
338
|
+
text: 'first',
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
update_id: 2,
|
|
343
|
+
message: {
|
|
344
|
+
message_id: 2,
|
|
345
|
+
date: 2,
|
|
346
|
+
chat: { id: 1, type: 'private' as const, first_name: 'A' },
|
|
347
|
+
text: 'second',
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
])
|
|
351
|
+
}
|
|
352
|
+
return pendingWithAbort(signal)
|
|
353
|
+
},
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
listener = new TelegramBotListener(client)
|
|
357
|
+
listener.on('message', (msg) => {
|
|
358
|
+
messageCount++
|
|
359
|
+
if (msg.text === 'first') {
|
|
360
|
+
throw new Error('handler boom')
|
|
361
|
+
}
|
|
362
|
+
})
|
|
363
|
+
listener.on('error', () => {
|
|
364
|
+
errorCount++
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
await listener.start()
|
|
368
|
+
await flush(20)
|
|
369
|
+
|
|
370
|
+
expect(messageCount).toBe(2)
|
|
371
|
+
expect(errorCount).toBeGreaterThanOrEqual(1)
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('propagates AbortError from getUpdates through the listener cleanly', async () => {
|
|
375
|
+
let aborted = false
|
|
376
|
+
const client = makeFakeClient({
|
|
377
|
+
getUpdates: (_options, signal) =>
|
|
378
|
+
new Promise<never>((_resolve, reject) => {
|
|
379
|
+
signal?.addEventListener(
|
|
380
|
+
'abort',
|
|
381
|
+
() => {
|
|
382
|
+
aborted = true
|
|
383
|
+
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
|
384
|
+
},
|
|
385
|
+
{ once: true },
|
|
386
|
+
)
|
|
387
|
+
}),
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
listener = new TelegramBotListener(client)
|
|
391
|
+
await listener.start()
|
|
392
|
+
await flush(20)
|
|
393
|
+
|
|
394
|
+
listener.stop()
|
|
395
|
+
await flush(20)
|
|
396
|
+
expect(aborted).toBe(true)
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { EventEmitter } from 'events'
|
|
2
|
+
|
|
3
|
+
import type { TelegramBotClient } from './client'
|
|
4
|
+
import type { TelegramBotListenerEventMap, TelegramBotListenerOptions, TelegramBotUser, TelegramUpdate } from './types'
|
|
5
|
+
import { TelegramBotError } from './types'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_SECONDS = 30
|
|
8
|
+
const DEFAULT_LIMIT = 100
|
|
9
|
+
const FETCH_TIMEOUT_GRACE_MS = 10_000
|
|
10
|
+
const RECONNECT_BASE_DELAY = 1_000
|
|
11
|
+
const RECONNECT_MAX_DELAY = 30_000
|
|
12
|
+
const FATAL_ERROR_CODES = new Set(['unauthorized', 'conflict'])
|
|
13
|
+
|
|
14
|
+
type EventKey = keyof TelegramBotListenerEventMap
|
|
15
|
+
|
|
16
|
+
export class TelegramBotListener {
|
|
17
|
+
private client: TelegramBotClient
|
|
18
|
+
private timeoutSeconds: number
|
|
19
|
+
private limit: number
|
|
20
|
+
private allowedUpdates: string[] | undefined
|
|
21
|
+
private dropPendingUpdates: boolean
|
|
22
|
+
private running = false
|
|
23
|
+
private offset = 0
|
|
24
|
+
private reconnectAttempts = 0
|
|
25
|
+
private emitter = new EventEmitter()
|
|
26
|
+
private abortController: AbortController | null = null
|
|
27
|
+
private cachedUser: TelegramBotUser | null = null
|
|
28
|
+
private generation = 0
|
|
29
|
+
|
|
30
|
+
constructor(client: TelegramBotClient, options?: TelegramBotListenerOptions) {
|
|
31
|
+
this.client = client
|
|
32
|
+
this.timeoutSeconds = options?.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS
|
|
33
|
+
this.limit = options?.limit ?? DEFAULT_LIMIT
|
|
34
|
+
this.allowedUpdates = options?.allowedUpdates
|
|
35
|
+
this.dropPendingUpdates = options?.dropPendingUpdates ?? false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start(): Promise<void> {
|
|
39
|
+
if (this.running) return
|
|
40
|
+
this.running = true
|
|
41
|
+
this.reconnectAttempts = 0
|
|
42
|
+
this.generation++
|
|
43
|
+
const generation = this.generation
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await this.client.deleteWebhook({ drop_pending_updates: this.dropPendingUpdates })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (!this.isCurrent(generation)) return
|
|
49
|
+
this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
|
|
50
|
+
this.running = false
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!this.isCurrent(generation)) return
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
this.cachedUser = await this.client.getMe()
|
|
58
|
+
if (!this.isCurrent(generation)) return
|
|
59
|
+
this.emitter.emit('connected', { user: this.cachedUser })
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!this.isCurrent(generation)) return
|
|
62
|
+
this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
|
|
63
|
+
this.running = false
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
void this.pollLoop(generation)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
stop(): void {
|
|
71
|
+
this.running = false
|
|
72
|
+
this.generation++
|
|
73
|
+
if (this.abortController) {
|
|
74
|
+
this.abortController.abort()
|
|
75
|
+
this.abortController = null
|
|
76
|
+
}
|
|
77
|
+
this.cachedUser = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
on<K extends EventKey>(event: K, listener: (...args: TelegramBotListenerEventMap[K]) => void): this {
|
|
81
|
+
this.emitter.on(event, listener as (...args: unknown[]) => void)
|
|
82
|
+
return this
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
off<K extends EventKey>(event: K, listener: (...args: TelegramBotListenerEventMap[K]) => void): this {
|
|
86
|
+
this.emitter.off(event, listener as (...args: unknown[]) => void)
|
|
87
|
+
return this
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
once<K extends EventKey>(event: K, listener: (...args: TelegramBotListenerEventMap[K]) => void): this {
|
|
91
|
+
this.emitter.once(event, listener as (...args: unknown[]) => void)
|
|
92
|
+
return this
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private isCurrent(generation: number): boolean {
|
|
96
|
+
return generation === this.generation && this.running
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async pollLoop(generation: number): Promise<void> {
|
|
100
|
+
let firstPoll = true
|
|
101
|
+
|
|
102
|
+
while (this.isCurrent(generation)) {
|
|
103
|
+
const pollController = new AbortController()
|
|
104
|
+
this.abortController = pollController
|
|
105
|
+
|
|
106
|
+
// Server-side getUpdates uses `timeout` (seconds); add grace so the HTTP fetch can't hang
|
|
107
|
+
// past the long-poll deadline if the underlying socket stalls.
|
|
108
|
+
const fetchTimeout = setTimeout(
|
|
109
|
+
() => pollController.abort(),
|
|
110
|
+
(this.timeoutSeconds + FETCH_TIMEOUT_GRACE_MS / 1000) * 1000,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
let updates: TelegramUpdate[]
|
|
114
|
+
try {
|
|
115
|
+
updates = await this.client.getUpdates(
|
|
116
|
+
{
|
|
117
|
+
offset: this.offset,
|
|
118
|
+
limit: this.limit,
|
|
119
|
+
timeout: this.timeoutSeconds,
|
|
120
|
+
allowed_updates: firstPoll ? this.allowedUpdates : undefined,
|
|
121
|
+
},
|
|
122
|
+
pollController.signal,
|
|
123
|
+
)
|
|
124
|
+
firstPoll = false
|
|
125
|
+
this.reconnectAttempts = 0
|
|
126
|
+
} catch (error) {
|
|
127
|
+
clearTimeout(fetchTimeout)
|
|
128
|
+
if (pollController.signal.aborted && !this.isCurrent(generation)) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (!this.isCurrent(generation)) return
|
|
132
|
+
|
|
133
|
+
if (error instanceof TelegramBotError && FATAL_ERROR_CODES.has(error.code)) {
|
|
134
|
+
this.emitter.emit('error', error)
|
|
135
|
+
this.running = false
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.emitter.emit('disconnected')
|
|
140
|
+
await this.backoff(generation)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
clearTimeout(fetchTimeout)
|
|
144
|
+
|
|
145
|
+
if (!this.isCurrent(generation)) return
|
|
146
|
+
|
|
147
|
+
for (const update of updates) {
|
|
148
|
+
if (!this.isCurrent(generation)) return
|
|
149
|
+
// Advance offset BEFORE dispatching so a thrown user handler doesn't cause redelivery.
|
|
150
|
+
this.offset = update.update_id + 1
|
|
151
|
+
this.dispatch(update)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private dispatch(update: TelegramUpdate): void {
|
|
157
|
+
if (update.message) this.safeEmit('message', update.message)
|
|
158
|
+
if (update.edited_message) this.safeEmit('edited_message', update.edited_message)
|
|
159
|
+
if (update.channel_post) this.safeEmit('channel_post', update.channel_post)
|
|
160
|
+
if (update.edited_channel_post) this.safeEmit('edited_channel_post', update.edited_channel_post)
|
|
161
|
+
if (update.callback_query) this.safeEmit('callback_query', update.callback_query)
|
|
162
|
+
if (update.inline_query) this.safeEmit('inline_query', update.inline_query)
|
|
163
|
+
if (update.my_chat_member) this.safeEmit('my_chat_member', update.my_chat_member)
|
|
164
|
+
if (update.chat_member) this.safeEmit('chat_member', update.chat_member)
|
|
165
|
+
this.safeEmit('telegram_update', update)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private safeEmit<K extends EventKey>(event: K, ...args: TelegramBotListenerEventMap[K]): void {
|
|
169
|
+
try {
|
|
170
|
+
this.emitter.emit(event, ...args)
|
|
171
|
+
} catch (handlerError) {
|
|
172
|
+
const err = handlerError instanceof Error ? handlerError : new Error(String(handlerError))
|
|
173
|
+
try {
|
|
174
|
+
this.emitter.emit('error', err)
|
|
175
|
+
} catch {
|
|
176
|
+
// Swallow secondary errors from error handlers to keep the poll loop alive.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private async backoff(generation: number): Promise<void> {
|
|
182
|
+
const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts, RECONNECT_MAX_DELAY)
|
|
183
|
+
this.reconnectAttempts++
|
|
184
|
+
await new Promise<void>((resolve) => {
|
|
185
|
+
const timer = setTimeout(() => resolve(), delay)
|
|
186
|
+
const onAbort = (): void => {
|
|
187
|
+
clearTimeout(timer)
|
|
188
|
+
resolve()
|
|
189
|
+
}
|
|
190
|
+
if (this.isCurrent(generation) && this.abortController) {
|
|
191
|
+
this.abortController.signal.addEventListener('abort', onAbort, { once: true })
|
|
192
|
+
} else {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
resolve()
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
}
|