opencode-telegram-mirror 0.3.0
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/AGENTS.md +196 -0
- package/README.md +230 -0
- package/bun.lock +67 -0
- package/package.json +27 -0
- package/src/config.ts +99 -0
- package/src/database.ts +120 -0
- package/src/diff-service.ts +176 -0
- package/src/log.ts +23 -0
- package/src/main.ts +1182 -0
- package/src/message-formatting.ts +202 -0
- package/src/opencode.ts +306 -0
- package/src/permission-handler.ts +242 -0
- package/src/question-handler.ts +391 -0
- package/src/system-message.ts +73 -0
- package/src/telegram.ts +705 -0
- package/test/fixtures/commands-test.json +157 -0
- package/test/fixtures/sample-updates.json +9098 -0
- package/test/mock-server.ts +271 -0
- package/test/run-test.ts +160 -0
- package/tsconfig.json +26 -0
package/src/telegram.ts
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API client for sending/receiving messages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Result, TaggedError } from "better-result"
|
|
6
|
+
import type { LogFn } from "./log"
|
|
7
|
+
|
|
8
|
+
export interface TelegramConfig {
|
|
9
|
+
botToken: string
|
|
10
|
+
chatId: string // Channel, group, or DM chat ID
|
|
11
|
+
threadId?: number // Optional thread/topic ID for forum groups
|
|
12
|
+
log?: LogFn // Optional logger function
|
|
13
|
+
baseUrl?: string // Optional custom base URL (for testing)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TelegramPhotoSize {
|
|
17
|
+
file_id: string
|
|
18
|
+
file_unique_id: string
|
|
19
|
+
width: number
|
|
20
|
+
height: number
|
|
21
|
+
file_size?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TelegramMessage {
|
|
25
|
+
message_id: number
|
|
26
|
+
from?: {
|
|
27
|
+
id: number
|
|
28
|
+
first_name: string
|
|
29
|
+
username?: string
|
|
30
|
+
is_bot?: boolean
|
|
31
|
+
}
|
|
32
|
+
chat: {
|
|
33
|
+
id: number
|
|
34
|
+
type: string
|
|
35
|
+
}
|
|
36
|
+
message_thread_id?: number
|
|
37
|
+
date: number
|
|
38
|
+
text?: string
|
|
39
|
+
caption?: string
|
|
40
|
+
photo?: TelegramPhotoSize[]
|
|
41
|
+
reply_to_message?: TelegramMessage
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CallbackQuery {
|
|
45
|
+
id: string
|
|
46
|
+
from: {
|
|
47
|
+
id: number
|
|
48
|
+
first_name: string
|
|
49
|
+
username?: string
|
|
50
|
+
}
|
|
51
|
+
message?: TelegramMessage
|
|
52
|
+
data?: string // Callback data from inline button
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface TelegramUpdate {
|
|
56
|
+
update_id: number
|
|
57
|
+
message?: TelegramMessage
|
|
58
|
+
callback_query?: CallbackQuery
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface GetUpdatesResponse {
|
|
62
|
+
ok: boolean
|
|
63
|
+
result: TelegramUpdate[]
|
|
64
|
+
error_code?: number
|
|
65
|
+
description?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SendMessageResponse {
|
|
69
|
+
ok: boolean
|
|
70
|
+
result: TelegramMessage
|
|
71
|
+
error_code?: number
|
|
72
|
+
description?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Custom error for fatal Telegram errors (chat not found, etc.)
|
|
76
|
+
export class TelegramFatalError extends TaggedError("TelegramFatalError")<{
|
|
77
|
+
message: string
|
|
78
|
+
code: number
|
|
79
|
+
cause?: unknown
|
|
80
|
+
}>() {}
|
|
81
|
+
|
|
82
|
+
export class TelegramApiError extends TaggedError("TelegramApiError")<{
|
|
83
|
+
message: string
|
|
84
|
+
cause: unknown
|
|
85
|
+
}>() {
|
|
86
|
+
constructor(args: { cause: unknown }) {
|
|
87
|
+
const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause)
|
|
88
|
+
super({ ...args, message: `Telegram API error: ${causeMessage}` })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type TelegramResult<T> = Result<T, TelegramFatalError | TelegramApiError>
|
|
93
|
+
|
|
94
|
+
export interface InlineKeyboardButton {
|
|
95
|
+
text: string
|
|
96
|
+
callback_data?: string
|
|
97
|
+
url?: string
|
|
98
|
+
web_app?: { url: string }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface InlineKeyboardMarkup {
|
|
102
|
+
inline_keyboard: InlineKeyboardButton[][]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class TelegramClient {
|
|
106
|
+
private baseUrl: string
|
|
107
|
+
private chatId: string
|
|
108
|
+
private threadId?: number
|
|
109
|
+
private lastUpdateId = 0
|
|
110
|
+
private log: LogFn
|
|
111
|
+
|
|
112
|
+
constructor(config: TelegramConfig) {
|
|
113
|
+
this.baseUrl = config.baseUrl ?? `https://api.telegram.org/bot${config.botToken}`
|
|
114
|
+
this.chatId = config.chatId
|
|
115
|
+
this.threadId = config.threadId
|
|
116
|
+
this.log = config.log ?? (() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Send a message to the configured chat/thread
|
|
121
|
+
*/
|
|
122
|
+
async sendMessage(
|
|
123
|
+
text: string,
|
|
124
|
+
options?: {
|
|
125
|
+
replyMarkup?: InlineKeyboardMarkup
|
|
126
|
+
replyToMessageId?: number
|
|
127
|
+
}
|
|
128
|
+
): Promise<TelegramResult<TelegramMessage | null>> {
|
|
129
|
+
// Telegram has a 4096 character limit per message
|
|
130
|
+
const maxLength = 4096
|
|
131
|
+
const chunks = this.splitMessage(text, maxLength)
|
|
132
|
+
|
|
133
|
+
this.log("debug", "Preparing to send message", {
|
|
134
|
+
textLength: text.length,
|
|
135
|
+
chunks: chunks.length,
|
|
136
|
+
chatId: this.chatId,
|
|
137
|
+
threadId: this.threadId,
|
|
138
|
+
hasReplyMarkup: !!options?.replyMarkup,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
let lastMessage: TelegramMessage | null = null
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
144
|
+
const chunk = chunks[i]
|
|
145
|
+
const isLastChunk = i === chunks.length - 1
|
|
146
|
+
|
|
147
|
+
const params: Record<string, unknown> = {
|
|
148
|
+
chat_id: this.chatId,
|
|
149
|
+
text: chunk,
|
|
150
|
+
parse_mode: "Markdown",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.threadId) {
|
|
154
|
+
params.message_thread_id = this.threadId
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (options?.replyToMessageId) {
|
|
158
|
+
params.reply_to_message_id = options.replyToMessageId
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Only add reply markup to the last chunk
|
|
162
|
+
if (isLastChunk && options?.replyMarkup) {
|
|
163
|
+
params.reply_markup = options.replyMarkup
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
this.log("debug", "Sending chunk to Telegram API", {
|
|
167
|
+
chunkIndex: i,
|
|
168
|
+
chunkLength: chunk.length,
|
|
169
|
+
isLastChunk,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const response = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
174
|
+
method: "POST",
|
|
175
|
+
headers: { "Content-Type": "application/json" },
|
|
176
|
+
body: JSON.stringify(params),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const data = (await response.json()) as SendMessageResponse
|
|
180
|
+
|
|
181
|
+
this.log("debug", "Telegram API response", {
|
|
182
|
+
ok: data.ok,
|
|
183
|
+
messageId: data.result?.message_id,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
if (!data.ok) {
|
|
187
|
+
// Check for fatal errors (chat not found, etc.)
|
|
188
|
+
if (data.error_code === 400 && data.description?.includes("chat not found")) {
|
|
189
|
+
this.log("error", "Chat not found - stopping", { chatId: this.chatId, response: data })
|
|
190
|
+
return Result.err(
|
|
191
|
+
new TelegramFatalError({ message: `Chat not found: ${this.chatId}`, code: 400 })
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.log("warn", "Markdown failed, retrying without parse_mode", { response: data, text: chunk })
|
|
196
|
+
// Retry without markdown if it fails (markdown can be finicky)
|
|
197
|
+
params.parse_mode = undefined
|
|
198
|
+
const retryResponse = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify(params),
|
|
202
|
+
})
|
|
203
|
+
const retryData = (await retryResponse.json()) as SendMessageResponse
|
|
204
|
+
if (retryData.ok) {
|
|
205
|
+
lastMessage = retryData.result
|
|
206
|
+
this.log("info", "Message sent successfully (plain text)", {
|
|
207
|
+
messageId: retryData.result.message_id,
|
|
208
|
+
})
|
|
209
|
+
} else {
|
|
210
|
+
// Check for fatal errors on retry too
|
|
211
|
+
if (retryData.error_code === 400 && retryData.description?.includes("chat not found")) {
|
|
212
|
+
this.log("error", "Chat not found - stopping", { chatId: this.chatId, response: retryData })
|
|
213
|
+
return Result.err(
|
|
214
|
+
new TelegramFatalError({ message: `Chat not found: ${this.chatId}`, code: 400 })
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
this.log("error", "Failed to send message", { response: retryData })
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
lastMessage = data.result
|
|
221
|
+
this.log("info", "Message sent successfully", { messageId: data.result.message_id })
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
this.log("error", "Error sending message", { error: String(error) })
|
|
225
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (lastMessage) {
|
|
230
|
+
return Result.ok(lastMessage)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Result.err(
|
|
234
|
+
new TelegramApiError({
|
|
235
|
+
cause: new Error("Failed to send Telegram message"),
|
|
236
|
+
})
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Edit an existing message's text and/or reply markup
|
|
242
|
+
*/
|
|
243
|
+
async editMessage(
|
|
244
|
+
messageId: number,
|
|
245
|
+
text: string,
|
|
246
|
+
options?: { replyMarkup?: InlineKeyboardMarkup }
|
|
247
|
+
): Promise<TelegramResult<boolean>> {
|
|
248
|
+
this.log("debug", "Editing message", { messageId, textLength: text.length })
|
|
249
|
+
|
|
250
|
+
const params: Record<string, unknown> = {
|
|
251
|
+
chat_id: this.chatId,
|
|
252
|
+
message_id: messageId,
|
|
253
|
+
text,
|
|
254
|
+
parse_mode: "Markdown",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (options?.replyMarkup) {
|
|
258
|
+
params.reply_markup = options.replyMarkup
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetch(`${this.baseUrl}/editMessageText`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
body: JSON.stringify(params),
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const data = (await response.json()) as { ok: boolean }
|
|
269
|
+
|
|
270
|
+
if (!data.ok) {
|
|
271
|
+
this.log("warn", "Edit with markdown failed, retrying plain", { messageId })
|
|
272
|
+
// Retry without markdown
|
|
273
|
+
params.parse_mode = undefined
|
|
274
|
+
const retryResponse = await fetch(`${this.baseUrl}/editMessageText`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: { "Content-Type": "application/json" },
|
|
277
|
+
body: JSON.stringify(params),
|
|
278
|
+
})
|
|
279
|
+
const retryData = (await retryResponse.json()) as { ok: boolean }
|
|
280
|
+
this.log("debug", "Edit retry result", { messageId, ok: retryData.ok })
|
|
281
|
+
return Result.ok(retryData.ok)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
this.log("debug", "Message edited successfully", { messageId })
|
|
285
|
+
return Result.ok(true)
|
|
286
|
+
} catch (error) {
|
|
287
|
+
this.log("error", "Error editing message", { messageId, error: String(error) })
|
|
288
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Update a forum topic name
|
|
294
|
+
*/
|
|
295
|
+
async editForumTopic(threadId: number, name: string): Promise<TelegramResult<boolean>> {
|
|
296
|
+
this.log("debug", "Editing forum topic", { threadId, nameLength: name.length })
|
|
297
|
+
|
|
298
|
+
const params: Record<string, unknown> = {
|
|
299
|
+
chat_id: this.chatId,
|
|
300
|
+
message_thread_id: threadId,
|
|
301
|
+
name,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const response = await fetch(`${this.baseUrl}/editForumTopic`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "Content-Type": "application/json" },
|
|
308
|
+
body: JSON.stringify(params),
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
const data = (await response.json()) as {
|
|
312
|
+
ok: boolean
|
|
313
|
+
result?: boolean
|
|
314
|
+
error_code?: number
|
|
315
|
+
description?: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!data.ok) {
|
|
319
|
+
this.log("error", "Failed to edit forum topic", { threadId, response: data })
|
|
320
|
+
return Result.err(new TelegramApiError({ cause: data.description || "Edit forum topic failed" }))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.log("info", "Forum topic edited", { threadId })
|
|
324
|
+
return Result.ok(true)
|
|
325
|
+
} catch (error) {
|
|
326
|
+
this.log("error", "Error editing forum topic", { threadId, error: String(error) })
|
|
327
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Answer a callback query (acknowledge button press)
|
|
333
|
+
*/
|
|
334
|
+
async answerCallbackQuery(
|
|
335
|
+
callbackQueryId: string,
|
|
336
|
+
options?: { text?: string; showAlert?: boolean }
|
|
337
|
+
): Promise<TelegramResult<boolean>> {
|
|
338
|
+
this.log("debug", "Answering callback query", { callbackQueryId, text: options?.text })
|
|
339
|
+
|
|
340
|
+
const params: Record<string, unknown> = {
|
|
341
|
+
callback_query_id: callbackQueryId,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (options?.text) {
|
|
345
|
+
params.text = options.text
|
|
346
|
+
}
|
|
347
|
+
if (options?.showAlert) {
|
|
348
|
+
params.show_alert = options.showAlert
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const response = await fetch(`${this.baseUrl}/answerCallbackQuery`, {
|
|
353
|
+
method: "POST",
|
|
354
|
+
headers: { "Content-Type": "application/json" },
|
|
355
|
+
body: JSON.stringify(params),
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
const data = (await response.json()) as { ok: boolean }
|
|
359
|
+
this.log("debug", "Callback query answered", { ok: data.ok })
|
|
360
|
+
return Result.ok(data.ok)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
this.log("error", "Error answering callback query", { error: String(error) })
|
|
363
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get new updates (messages and callback queries) using long polling
|
|
369
|
+
*/
|
|
370
|
+
async getUpdates(timeout = 30): Promise<TelegramResult<TelegramUpdate[]>> {
|
|
371
|
+
try {
|
|
372
|
+
const params = new URLSearchParams({
|
|
373
|
+
offset: String(this.lastUpdateId + 1),
|
|
374
|
+
timeout: String(timeout),
|
|
375
|
+
allowed_updates: JSON.stringify(["message", "callback_query"]),
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
const response = await fetch(`${this.baseUrl}/getUpdates?${params}`)
|
|
379
|
+
const data = (await response.json()) as GetUpdatesResponse
|
|
380
|
+
|
|
381
|
+
if (!data.ok) {
|
|
382
|
+
this.log("error", "Failed to get updates from Telegram API", { response: data })
|
|
383
|
+
|
|
384
|
+
if (data.error_code === 409) {
|
|
385
|
+
return Result.err(
|
|
386
|
+
new TelegramFatalError({
|
|
387
|
+
message: data.description || "Conflict",
|
|
388
|
+
code: 409,
|
|
389
|
+
})
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
if (data.error_code === 401) {
|
|
393
|
+
return Result.err(
|
|
394
|
+
new TelegramFatalError({
|
|
395
|
+
message: data.description || "Unauthorized",
|
|
396
|
+
code: 401,
|
|
397
|
+
})
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return Result.err(new TelegramApiError({ cause: data.description || "Unknown error" }))
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const updates: TelegramUpdate[] = []
|
|
405
|
+
|
|
406
|
+
for (const update of data.result) {
|
|
407
|
+
this.lastUpdateId = update.update_id
|
|
408
|
+
|
|
409
|
+
// Filter messages to our target chat/thread
|
|
410
|
+
if (update.message) {
|
|
411
|
+
const msg = update.message
|
|
412
|
+
const chatMatches = String(msg.chat.id) === this.chatId
|
|
413
|
+
const threadMatches = this.threadId
|
|
414
|
+
? msg.message_thread_id === this.threadId
|
|
415
|
+
: true
|
|
416
|
+
|
|
417
|
+
if (chatMatches && threadMatches) {
|
|
418
|
+
updates.push(update)
|
|
419
|
+
this.log("info", "Message matched filter", {
|
|
420
|
+
updateId: update.update_id,
|
|
421
|
+
from: msg.from?.username || msg.from?.first_name,
|
|
422
|
+
preview: msg.text?.slice(0, 50),
|
|
423
|
+
})
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Include callback queries from our chat
|
|
428
|
+
if (update.callback_query?.message) {
|
|
429
|
+
const chatMatches =
|
|
430
|
+
String(update.callback_query.message.chat.id) === this.chatId
|
|
431
|
+
|
|
432
|
+
this.log("debug", "Processing callback query", {
|
|
433
|
+
updateId: update.update_id,
|
|
434
|
+
callbackData: update.callback_query.data,
|
|
435
|
+
chatMatches,
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
if (chatMatches) {
|
|
439
|
+
updates.push(update)
|
|
440
|
+
this.log("info", "Callback query matched", {
|
|
441
|
+
updateId: update.update_id,
|
|
442
|
+
data: update.callback_query.data,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.log("debug", "Polling complete", {
|
|
449
|
+
totalReceived: data.result.length,
|
|
450
|
+
matchedUpdates: updates.length,
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
return Result.ok(updates)
|
|
454
|
+
} catch (error) {
|
|
455
|
+
this.log("error", "Error getting updates", { error: String(error) })
|
|
456
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Split a long message into chunks that fit Telegram's limit
|
|
462
|
+
*/
|
|
463
|
+
private splitMessage(text: string, maxLength: number): string[] {
|
|
464
|
+
if (text.length <= maxLength) {
|
|
465
|
+
return [text]
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const chunks: string[] = []
|
|
469
|
+
let remaining = text
|
|
470
|
+
|
|
471
|
+
while (remaining.length > 0) {
|
|
472
|
+
if (remaining.length <= maxLength) {
|
|
473
|
+
chunks.push(remaining)
|
|
474
|
+
break
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Try to split at a newline
|
|
478
|
+
let splitIndex = remaining.lastIndexOf("\n", maxLength)
|
|
479
|
+
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
|
480
|
+
// Try to split at a space
|
|
481
|
+
splitIndex = remaining.lastIndexOf(" ", maxLength)
|
|
482
|
+
}
|
|
483
|
+
if (splitIndex === -1 || splitIndex < maxLength / 2) {
|
|
484
|
+
// Force split at maxLength
|
|
485
|
+
splitIndex = maxLength
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
chunks.push(remaining.slice(0, splitIndex))
|
|
489
|
+
remaining = remaining.slice(splitIndex).trimStart()
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return chunks
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Send typing indicator to show the bot is working
|
|
497
|
+
* Returns a stop function to cancel the typing indicator
|
|
498
|
+
*/
|
|
499
|
+
startTyping(intervalMs = 4000): () => void {
|
|
500
|
+
// Send immediately
|
|
501
|
+
this.sendTypingAction()
|
|
502
|
+
|
|
503
|
+
// Telegram typing indicator lasts ~5 seconds, so refresh every 4 seconds
|
|
504
|
+
const interval = setInterval(() => {
|
|
505
|
+
this.sendTypingAction()
|
|
506
|
+
}, intervalMs)
|
|
507
|
+
|
|
508
|
+
return () => {
|
|
509
|
+
clearInterval(interval)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Send a single typing action
|
|
515
|
+
*/
|
|
516
|
+
async sendTypingAction(): Promise<TelegramResult<void>> {
|
|
517
|
+
const params: Record<string, unknown> = {
|
|
518
|
+
chat_id: this.chatId,
|
|
519
|
+
action: "typing",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (this.threadId) {
|
|
523
|
+
params.message_thread_id = this.threadId
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
try {
|
|
527
|
+
await fetch(`${this.baseUrl}/sendChatAction`, {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers: { "Content-Type": "application/json" },
|
|
530
|
+
body: JSON.stringify(params),
|
|
531
|
+
})
|
|
532
|
+
return Result.ok(undefined)
|
|
533
|
+
} catch (error) {
|
|
534
|
+
this.log("debug", "Failed to send typing action", { error: String(error) })
|
|
535
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Get bot info to verify the token is valid
|
|
541
|
+
*/
|
|
542
|
+
async getMe(): Promise<TelegramResult<{ id: number; username: string }>> {
|
|
543
|
+
const result = await Result.tryPromise({
|
|
544
|
+
try: async () => {
|
|
545
|
+
const response = await fetch(`${this.baseUrl}/getMe`)
|
|
546
|
+
return (await response.json()) as { ok: boolean; result?: { id: number; username: string } }
|
|
547
|
+
},
|
|
548
|
+
catch: (error) => new TelegramApiError({ cause: error }),
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
if (result.status === "error") {
|
|
552
|
+
return Result.err(result.error)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (!result.value.ok || !result.value.result) {
|
|
556
|
+
return Result.err(new TelegramApiError({ cause: "Invalid bot response" }))
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return Result.ok(result.value.result)
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Set bot commands (menu button)
|
|
564
|
+
*/
|
|
565
|
+
async setMyCommands(
|
|
566
|
+
commands: Array<{ command: string; description: string }>
|
|
567
|
+
): Promise<TelegramResult<boolean>> {
|
|
568
|
+
this.log("debug", "Setting bot commands", { count: commands.length })
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const response = await fetch(`${this.baseUrl}/setMyCommands`, {
|
|
572
|
+
method: "POST",
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
574
|
+
body: JSON.stringify({ commands }),
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
const data = (await response.json()) as { ok: boolean; description?: string }
|
|
578
|
+
|
|
579
|
+
if (!data.ok) {
|
|
580
|
+
this.log("error", "Failed to set bot commands", { response: data })
|
|
581
|
+
return Result.err(new TelegramApiError({ cause: data.description || "Set commands failed" }))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
this.log("info", "Bot commands set", { commands: commands.map((c) => c.command) })
|
|
585
|
+
return Result.ok(true)
|
|
586
|
+
} catch (error) {
|
|
587
|
+
this.log("error", "Error setting bot commands", { error: String(error) })
|
|
588
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Get a file's download URL from Telegram
|
|
594
|
+
* @param fileId The file_id from a photo/document/etc
|
|
595
|
+
* @returns The file URL, or null on failure
|
|
596
|
+
*/
|
|
597
|
+
async getFileUrl(fileId: string): Promise<TelegramResult<string>> {
|
|
598
|
+
this.log("debug", "Getting file info", { fileId })
|
|
599
|
+
|
|
600
|
+
const result = await Result.tryPromise({
|
|
601
|
+
try: async () => {
|
|
602
|
+
const response = await fetch(`${this.baseUrl}/getFile?file_id=${encodeURIComponent(fileId)}`)
|
|
603
|
+
return (await response.json()) as {
|
|
604
|
+
ok: boolean
|
|
605
|
+
result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string }
|
|
606
|
+
description?: string
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
catch: (error) => new TelegramApiError({ cause: error }),
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
if (result.status === "error") {
|
|
613
|
+
this.log("error", "Error getting file URL", { fileId, error: result.error.message })
|
|
614
|
+
return Result.err(result.error)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (!result.value.ok || !result.value.result?.file_path) {
|
|
618
|
+
this.log("error", "Failed to get file info", { response: result.value })
|
|
619
|
+
return Result.err(new TelegramApiError({ cause: result.value.description || "Invalid file info" }))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Construct the download URL
|
|
623
|
+
// Format: https://api.telegram.org/file/bot<token>/<file_path>
|
|
624
|
+
const downloadUrl = `${this.baseUrl.replace("/bot", "/file/bot")}/${result.value.result.file_path}`
|
|
625
|
+
this.log("debug", "Got file URL", { fileId, filePath: result.value.result.file_path })
|
|
626
|
+
return Result.ok(downloadUrl)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Download a file from Telegram and return it as a base64 data URL
|
|
631
|
+
* @param fileId The file_id from a photo/document/etc
|
|
632
|
+
* @param mimeType The MIME type for the data URL (e.g., "image/jpeg")
|
|
633
|
+
* @returns The base64 data URL, or null on failure
|
|
634
|
+
*/
|
|
635
|
+
async downloadFileAsDataUrl(
|
|
636
|
+
fileId: string,
|
|
637
|
+
mimeType: string
|
|
638
|
+
): Promise<TelegramResult<string>> {
|
|
639
|
+
const fileUrlResult = await this.getFileUrl(fileId)
|
|
640
|
+
if (fileUrlResult.status === "error") {
|
|
641
|
+
return Result.err(fileUrlResult.error)
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const fileUrl = fileUrlResult.value
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
this.log("debug", "Downloading file", { fileUrl })
|
|
648
|
+
const response = await fetch(fileUrl)
|
|
649
|
+
if (!response.ok) {
|
|
650
|
+
this.log("error", "Failed to download file", { status: response.status })
|
|
651
|
+
return Result.err(new TelegramApiError({ cause: `Download failed: ${response.status}` }))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const buffer = await response.arrayBuffer()
|
|
655
|
+
const base64 = Buffer.from(buffer).toString("base64")
|
|
656
|
+
const dataUrl = `data:${mimeType};base64,${base64}`
|
|
657
|
+
this.log("debug", "File downloaded", { size: buffer.byteLength })
|
|
658
|
+
return Result.ok(dataUrl)
|
|
659
|
+
} catch (error) {
|
|
660
|
+
this.log("error", "Error downloading file", { error: String(error) })
|
|
661
|
+
return Result.err(new TelegramApiError({ cause: error }))
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Build inline keyboard from options
|
|
667
|
+
*/
|
|
668
|
+
static buildInlineKeyboard(
|
|
669
|
+
options: Array<{ label: string; callbackData: string }>,
|
|
670
|
+
options2?: { columns?: number; addOther?: boolean; otherCallbackData?: string }
|
|
671
|
+
): InlineKeyboardMarkup {
|
|
672
|
+
const columns = options2?.columns ?? 2
|
|
673
|
+
const keyboard: InlineKeyboardButton[][] = []
|
|
674
|
+
let currentRow: InlineKeyboardButton[] = []
|
|
675
|
+
|
|
676
|
+
for (const opt of options) {
|
|
677
|
+
currentRow.push({
|
|
678
|
+
text: opt.label,
|
|
679
|
+
callback_data: opt.callbackData,
|
|
680
|
+
})
|
|
681
|
+
|
|
682
|
+
if (currentRow.length >= columns) {
|
|
683
|
+
keyboard.push(currentRow)
|
|
684
|
+
currentRow = []
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Add remaining buttons
|
|
689
|
+
if (currentRow.length > 0) {
|
|
690
|
+
keyboard.push(currentRow)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Add "Other" button for freetext input
|
|
694
|
+
if (options2?.addOther) {
|
|
695
|
+
keyboard.push([
|
|
696
|
+
{
|
|
697
|
+
text: "Other (type reply)",
|
|
698
|
+
callback_data: options2.otherCallbackData ?? "other",
|
|
699
|
+
},
|
|
700
|
+
])
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return { inline_keyboard: keyboard }
|
|
704
|
+
}
|
|
705
|
+
}
|