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.
@@ -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
+ }