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,242 @@
1
+ /**
2
+ * Permission handler for OpenCode permission.asked events
3
+ * Shows Telegram inline keyboard buttons for Accept/Accept Always/Deny
4
+ */
5
+
6
+ import { TelegramClient, type CallbackQuery } from "./telegram"
7
+ import type { LogFn } from "./log"
8
+
9
+ // Permission request from OpenCode event
10
+ export type PermissionRequest = {
11
+ id: string
12
+ sessionID: string
13
+ permission: string // e.g., "bash", "edit", "webfetch"
14
+ patterns: string[] // e.g., file paths or command patterns
15
+ }
16
+
17
+ type PendingPermissionContext = {
18
+ sessionId: string
19
+ requestId: string
20
+ chatId: number
21
+ threadId: number | null
22
+ permission: string
23
+ patterns: string[]
24
+ messageId: number
25
+ directory: string // Directory for looking up the correct OpenCode server
26
+ }
27
+
28
+ // Store pending permissions by a unique key (chatId:threadId)
29
+ const pendingPermissions = new Map<string, PendingPermissionContext>()
30
+
31
+ function getThreadKey(chatId: number, threadId: number | null): string {
32
+ return `${chatId}:${threadId ?? "main"}`
33
+ }
34
+
35
+ /**
36
+ * Show permission buttons in Telegram
37
+ */
38
+ export async function showPermissionButtons({
39
+ telegram,
40
+ chatId,
41
+ threadId,
42
+ sessionId,
43
+ request,
44
+ directory,
45
+ log,
46
+ }: {
47
+ telegram: TelegramClient
48
+ chatId: number
49
+ threadId: number | null
50
+ sessionId: string
51
+ request: PermissionRequest
52
+ directory: string
53
+ log: LogFn
54
+ }): Promise<void> {
55
+ const threadKey = getThreadKey(chatId, threadId)
56
+
57
+ // Cancel any existing pending permission for this thread
58
+ const existing = pendingPermissions.get(threadKey)
59
+ if (existing) {
60
+ log("info", "Replacing existing pending permission", { threadKey })
61
+ }
62
+
63
+ const patternStr = request.patterns.length > 0
64
+ ? request.patterns.join(", ")
65
+ : ""
66
+
67
+ // Build message text
68
+ let messageText = "*Permission Required*\n\n"
69
+ messageText += `*Type:* \`${request.permission}\`\n`
70
+ if (patternStr) {
71
+ messageText += `*Pattern:* \`${patternStr}\``
72
+ }
73
+
74
+ // Build inline keyboard
75
+ const options = [
76
+ { label: "Accept", callbackData: `p:${threadKey}:once` },
77
+ { label: "Accept Always", callbackData: `p:${threadKey}:always` },
78
+ { label: "Deny", callbackData: `p:${threadKey}:reject` },
79
+ ]
80
+
81
+ const keyboard = TelegramClient.buildInlineKeyboard(options, { columns: 3 })
82
+
83
+ const messageResult = await telegram.sendMessage(messageText, { replyMarkup: keyboard })
84
+
85
+ if (messageResult.status === "error") {
86
+ log("error", "Failed to show permission buttons", {
87
+ threadKey,
88
+ error: messageResult.error.message,
89
+ })
90
+ return
91
+ }
92
+
93
+ if (messageResult.value) {
94
+ const context: PendingPermissionContext = {
95
+ sessionId,
96
+ requestId: request.id,
97
+ chatId,
98
+ threadId,
99
+ permission: request.permission,
100
+ patterns: request.patterns,
101
+ messageId: messageResult.value.message_id,
102
+ directory,
103
+ }
104
+ pendingPermissions.set(threadKey, context)
105
+
106
+ log("info", "Showed permission buttons", {
107
+ threadKey,
108
+ permission: request.permission,
109
+ patterns: request.patterns,
110
+ })
111
+ }
112
+
113
+ }
114
+
115
+ /**
116
+ * Handle callback query from permission button press
117
+ * Returns the reply data to send to OpenCode, including directory for server lookup
118
+ */
119
+ export async function handlePermissionCallback({
120
+ telegram,
121
+ callback,
122
+ log,
123
+ }: {
124
+ telegram: TelegramClient
125
+ callback: CallbackQuery
126
+ log: LogFn
127
+ }): Promise<{ requestId: string; reply: "once" | "always" | "reject"; directory: string } | null> {
128
+ const data = callback.data
129
+ if (!data?.startsWith("p:")) {
130
+ return null
131
+ }
132
+
133
+ // Parse callback data: p:chatId:threadId:reply
134
+ const parts = data.split(":")
135
+ if (parts.length < 4) {
136
+ log("warn", "Invalid permission callback data", { data })
137
+ return null
138
+ }
139
+
140
+ const threadKey = `${parts[1]}:${parts[2]}`
141
+ const reply = parts[3] as "once" | "always" | "reject"
142
+
143
+ const context = pendingPermissions.get(threadKey)
144
+ if (!context) {
145
+ const answerResult = await telegram.answerCallbackQuery(callback.id, {
146
+ text: "This permission request has expired",
147
+ showAlert: true,
148
+ })
149
+ if (answerResult.status === "error") {
150
+ log("error", "Failed to answer permission callback", {
151
+ error: answerResult.error.message,
152
+ })
153
+ }
154
+ return null
155
+ }
156
+
157
+ // Acknowledge the button press
158
+ const ackResult = await telegram.answerCallbackQuery(callback.id)
159
+ if (ackResult.status === "error") {
160
+ log("error", "Failed to acknowledge permission callback", {
161
+ error: ackResult.error.message,
162
+ })
163
+ }
164
+
165
+ // Format result text
166
+ const resultText = (() => {
167
+ switch (reply) {
168
+ case "once":
169
+ return "Permission *accepted*"
170
+ case "always":
171
+ return "Permission *accepted* (auto-approve similar requests)"
172
+ case "reject":
173
+ return "Permission *rejected*"
174
+ }
175
+ })()
176
+
177
+ // Update the message to show the result and remove keyboard
178
+ const patternStr = context.patterns.length > 0
179
+ ? context.patterns.join(", ")
180
+ : ""
181
+
182
+ let updatedText = "*Permission Required*\n\n"
183
+ updatedText += `*Type:* \`${context.permission}\`\n`
184
+ if (patternStr) {
185
+ updatedText += `*Pattern:* \`${patternStr}\`\n\n`
186
+ } else {
187
+ updatedText += "\n"
188
+ }
189
+ updatedText += resultText
190
+
191
+ const editResult = await telegram.editMessage(context.messageId, updatedText)
192
+ if (editResult.status === "error") {
193
+ log("error", "Failed to update permission message", {
194
+ error: editResult.error.message,
195
+ })
196
+ }
197
+
198
+ pendingPermissions.delete(threadKey)
199
+
200
+ log("info", "Permission responded", {
201
+ threadKey,
202
+ requestId: context.requestId,
203
+ reply,
204
+ directory: context.directory,
205
+ })
206
+
207
+ return {
208
+ requestId: context.requestId,
209
+ reply,
210
+ directory: context.directory,
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Auto-reject pending permission for a thread (e.g., when user sends a new message)
216
+ */
217
+ export function cancelPendingPermission(
218
+ chatId: number,
219
+ threadId: number | null
220
+ ): { requestId: string; reply: "reject"; directory: string } | null {
221
+ const threadKey = getThreadKey(chatId, threadId)
222
+ const context = pendingPermissions.get(threadKey)
223
+
224
+ if (!context) {
225
+ return null
226
+ }
227
+
228
+ pendingPermissions.delete(threadKey)
229
+
230
+ return {
231
+ requestId: context.requestId,
232
+ reply: "reject",
233
+ directory: context.directory,
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Check if there's a pending permission for a thread
239
+ */
240
+ export function hasPendingPermission(chatId: number, threadId: number | null): boolean {
241
+ return pendingPermissions.has(getThreadKey(chatId, threadId))
242
+ }
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Question handler for OpenCode question.asked events
3
+ * Shows Telegram inline keyboard buttons for questions and collects responses
4
+ */
5
+
6
+ import { TelegramClient, type CallbackQuery } from "./telegram"
7
+ import type { LogFn } from "./log"
8
+
9
+ // Question input schema matching OpenCode's question tool
10
+ export type QuestionInput = {
11
+ questions: Array<{
12
+ question: string
13
+ header: string // max 12 chars
14
+ options: Array<{
15
+ label: string
16
+ description: string
17
+ }>
18
+ multiple?: boolean
19
+ }>
20
+ }
21
+
22
+ // Question request from OpenCode event
23
+ export type QuestionRequest = {
24
+ id: string
25
+ sessionID: string
26
+ questions: QuestionInput["questions"]
27
+ }
28
+
29
+ type PendingQuestionContext = {
30
+ sessionId: string
31
+ requestId: string
32
+ chatId: number
33
+ threadId: number | null
34
+ questions: QuestionInput["questions"]
35
+ answers: Record<number, string[]> // questionIndex -> selected labels
36
+ totalQuestions: number
37
+ answeredCount: number
38
+ messageIds: number[] // Track sent message IDs for cleanup
39
+ awaitingFreetext: number | null // questionIndex awaiting freetext input, or null
40
+ directory: string // Directory for looking up the correct OpenCode server
41
+ }
42
+
43
+ // Store pending questions by a unique key (chatId:threadId)
44
+ const pendingQuestions = new Map<string, PendingQuestionContext>()
45
+
46
+ function getThreadKey(chatId: number, threadId: number | null): string {
47
+ return `${chatId}:${threadId ?? "main"}`
48
+ }
49
+
50
+ /**
51
+ * Show question buttons in Telegram
52
+ */
53
+ export async function showQuestionButtons({
54
+ telegram,
55
+ chatId,
56
+ threadId,
57
+ sessionId,
58
+ request,
59
+ directory,
60
+ log,
61
+ }: {
62
+ telegram: TelegramClient
63
+ chatId: number
64
+ threadId: number | null
65
+ sessionId: string
66
+ request: QuestionRequest
67
+ directory: string
68
+ log: LogFn
69
+ }): Promise<void> {
70
+ const threadKey = getThreadKey(chatId, threadId)
71
+
72
+ // Cancel any existing pending question for this thread
73
+ const existing = pendingQuestions.get(threadKey)
74
+ if (existing) {
75
+ log("info", "Cancelling existing pending question", { threadKey })
76
+ }
77
+
78
+ const context: PendingQuestionContext = {
79
+ sessionId,
80
+ requestId: request.id,
81
+ chatId,
82
+ threadId,
83
+ questions: request.questions,
84
+ answers: {},
85
+ totalQuestions: request.questions.length,
86
+ answeredCount: 0,
87
+ messageIds: [],
88
+ awaitingFreetext: null,
89
+ directory,
90
+ }
91
+
92
+ pendingQuestions.set(threadKey, context)
93
+
94
+ // Send one message per question with inline keyboard
95
+ for (let i = 0; i < request.questions.length; i++) {
96
+ const q = request.questions[i]
97
+ if (!q) continue
98
+
99
+ // Build inline keyboard - max 8 buttons per row, max 100 buttons total
100
+ const options = [
101
+ ...q.options.slice(0, 7).map((opt, optIdx) => ({
102
+ label: opt.label.slice(0, 64), // Telegram button text limit
103
+ callbackData: `q:${threadKey}:${i}:${optIdx}`,
104
+ })),
105
+ {
106
+ label: "Other",
107
+ callbackData: `q:${threadKey}:${i}:other`,
108
+ },
109
+ ]
110
+
111
+ const keyboard = TelegramClient.buildInlineKeyboard(options, { columns: 2 })
112
+
113
+ const messageResult = await telegram.sendMessage(
114
+ `*${q.header}*\n${q.question}`,
115
+ { replyMarkup: keyboard }
116
+ )
117
+
118
+ if (messageResult.status === "error") {
119
+ log("error", "Failed to send question message", {
120
+ threadKey,
121
+ error: messageResult.error.message,
122
+ })
123
+ continue
124
+ }
125
+
126
+ if (messageResult.value) {
127
+ context.messageIds.push(messageResult.value.message_id)
128
+ }
129
+
130
+ }
131
+
132
+ log("info", "Showed question buttons", {
133
+ threadKey,
134
+ questionCount: request.questions.length,
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Handle callback query from question button press
140
+ * Returns the answer data if all questions are answered, null otherwise
141
+ * Returns { awaitingFreetext: true } if waiting for user to type custom answer
142
+ */
143
+ export async function handleQuestionCallback({
144
+ telegram,
145
+ callback,
146
+ log,
147
+ }: {
148
+ telegram: TelegramClient
149
+ callback: CallbackQuery
150
+ log: LogFn
151
+ }): Promise<{ requestId: string; answers: string[][]; directory: string } | { awaitingFreetext: true } | null> {
152
+ const data = callback.data
153
+ if (!data?.startsWith("q:")) {
154
+ return null
155
+ }
156
+
157
+ // Parse callback data: q:chatId:threadId:questionIndex:optionIndex
158
+ const parts = data.split(":")
159
+ log("debug", "Parsing question callback", { parts, partsLength: parts.length })
160
+
161
+ if (parts.length < 5) {
162
+ log("warn", "Invalid question callback data", { data })
163
+ return null
164
+ }
165
+
166
+ const threadKey = `${parts[1]}:${parts[2]}`
167
+ const questionIndex = Number.parseInt(parts[3] ?? "0", 10)
168
+ const optionValue = parts[4] ?? "0"
169
+
170
+ log("debug", "Looking up pending question", {
171
+ threadKey,
172
+ questionIndex,
173
+ optionValue,
174
+ pendingKeys: Array.from(pendingQuestions.keys())
175
+ })
176
+
177
+ const context = pendingQuestions.get(threadKey)
178
+ if (!context) {
179
+ log("warn", "No pending question for threadKey", { threadKey })
180
+ const answerResult = await telegram.answerCallbackQuery(callback.id, {
181
+ text: "This question has expired",
182
+ showAlert: true,
183
+ })
184
+ if (answerResult.status === "error") {
185
+ log("error", "Failed to answer expired question", {
186
+ error: answerResult.error.message,
187
+ })
188
+ }
189
+ return null
190
+ }
191
+
192
+ const question = context.questions[questionIndex]
193
+ if (!question) {
194
+ log("error", "Question index not found", { questionIndex, threadKey })
195
+ const answerResult = await telegram.answerCallbackQuery(callback.id)
196
+ if (answerResult.status === "error") {
197
+ log("error", "Failed to answer invalid question", {
198
+ error: answerResult.error.message,
199
+ })
200
+ }
201
+ return null
202
+ }
203
+
204
+ // Acknowledge the button press
205
+ const ackResult = await telegram.answerCallbackQuery(callback.id)
206
+ if (ackResult.status === "error") {
207
+ log("error", "Failed to acknowledge question", {
208
+ error: ackResult.error.message,
209
+ })
210
+ }
211
+
212
+ // Handle "Other" - wait for freetext input
213
+ if (optionValue === "other") {
214
+ context.awaitingFreetext = questionIndex
215
+
216
+ // Update the message to prompt for freetext input
217
+ const messageId = context.messageIds[questionIndex]
218
+ if (messageId) {
219
+ const editResult = await telegram.editMessage(
220
+ messageId,
221
+ `*${question.header}*\n${question.question}\n\n_Please type your answer:_`
222
+ )
223
+ if (editResult.status === "error") {
224
+ log("error", "Failed to prompt freetext answer", {
225
+ error: editResult.error.message,
226
+ })
227
+ }
228
+ }
229
+
230
+ log("info", "Awaiting freetext input for question", { threadKey, questionIndex })
231
+ return { awaitingFreetext: true }
232
+ }
233
+
234
+ // Record the selected option answer
235
+ const optIdx = Number.parseInt(optionValue, 10)
236
+ const selectedLabel = question.options[optIdx]?.label ?? `Option ${optIdx + 1}`
237
+ context.answers[questionIndex] = [selectedLabel]
238
+ context.answeredCount++
239
+
240
+ // Update the message to show the selection and remove keyboard
241
+ const messageId = context.messageIds[questionIndex]
242
+ if (messageId) {
243
+ const answeredText = context.answers[questionIndex]?.join(", ") ?? ""
244
+ const editResult = await telegram.editMessage(
245
+ messageId,
246
+ `*${question.header}*\n${question.question}\n\n_${answeredText}_`
247
+ )
248
+ if (editResult.status === "error") {
249
+ log("error", "Failed to update answered question", {
250
+ error: editResult.error.message,
251
+ })
252
+ }
253
+ }
254
+
255
+ // Check if all questions are answered
256
+ if (context.answeredCount >= context.totalQuestions) {
257
+ // Build answers array
258
+ const answers = context.questions.map((_, i) => context.answers[i] ?? [])
259
+
260
+ pendingQuestions.delete(threadKey)
261
+
262
+ log("info", "All questions answered", {
263
+ threadKey,
264
+ requestId: context.requestId,
265
+ directory: context.directory,
266
+ })
267
+
268
+ return {
269
+ requestId: context.requestId,
270
+ answers,
271
+ directory: context.directory,
272
+ }
273
+ }
274
+
275
+ return null
276
+ }
277
+
278
+ /**
279
+ * Check if there's a pending freetext question for a thread
280
+ */
281
+ export function isAwaitingFreetext(chatId: number, threadId: number | null): boolean {
282
+ const threadKey = getThreadKey(chatId, threadId)
283
+ const context = pendingQuestions.get(threadKey)
284
+ return context?.awaitingFreetext !== null && context?.awaitingFreetext !== undefined
285
+ }
286
+
287
+ /**
288
+ * Handle freetext answer for "Other" option
289
+ * Returns the answer data if all questions are answered, null otherwise
290
+ */
291
+ export async function handleFreetextAnswer({
292
+ telegram,
293
+ chatId,
294
+ threadId,
295
+ text,
296
+ log,
297
+ }: {
298
+ telegram: TelegramClient
299
+ chatId: number
300
+ threadId: number | null
301
+ text: string
302
+ log: LogFn
303
+ }): Promise<{ requestId: string; answers: string[][]; directory: string } | null> {
304
+ const threadKey = getThreadKey(chatId, threadId)
305
+ const context = pendingQuestions.get(threadKey)
306
+
307
+ if (!context || context.awaitingFreetext === null) {
308
+ return null
309
+ }
310
+
311
+ const questionIndex = context.awaitingFreetext
312
+ const question = context.questions[questionIndex]
313
+
314
+ // Record the freetext answer
315
+ context.answers[questionIndex] = [text]
316
+ context.answeredCount++
317
+ context.awaitingFreetext = null
318
+
319
+ // Update the message to show the answer
320
+ const messageId = context.messageIds[questionIndex]
321
+ if (messageId && question) {
322
+ const editResult = await telegram.editMessage(
323
+ messageId,
324
+ `*${question.header}*\n${question.question}\n\n_${text}_`
325
+ )
326
+ if (editResult.status === "error") {
327
+ log("error", "Failed to update freetext answer", {
328
+ error: editResult.error.message,
329
+ })
330
+ }
331
+ }
332
+
333
+ log("info", "Freetext answer recorded", { threadKey, questionIndex, text: text.slice(0, 50) })
334
+
335
+ // Check if all questions are answered
336
+ if (context.answeredCount >= context.totalQuestions) {
337
+ // Build answers array
338
+ const answers = context.questions.map((_, i) => context.answers[i] ?? [])
339
+
340
+ pendingQuestions.delete(threadKey)
341
+
342
+ log("info", "All questions answered (after freetext)", {
343
+ threadKey,
344
+ requestId: context.requestId,
345
+ directory: context.directory,
346
+ })
347
+
348
+ return {
349
+ requestId: context.requestId,
350
+ answers,
351
+ directory: context.directory,
352
+ }
353
+ }
354
+
355
+ return null
356
+ }
357
+
358
+ /**
359
+ * Cancel pending question for a thread (e.g., when user sends a new message that's not a freetext answer)
360
+ */
361
+ export function cancelPendingQuestion(
362
+ chatId: number,
363
+ threadId: number | null
364
+ ): { requestId: string; answers: string[][]; directory: string } | null {
365
+ const threadKey = getThreadKey(chatId, threadId)
366
+ const context = pendingQuestions.get(threadKey)
367
+
368
+ if (!context) {
369
+ return null
370
+ }
371
+
372
+ // Build answers with cancellation markers for unanswered questions
373
+ const answers = context.questions.map((_, i) => {
374
+ return context.answers[i] ?? ["(cancelled - user sent new message)"]
375
+ })
376
+
377
+ pendingQuestions.delete(threadKey)
378
+
379
+ return {
380
+ requestId: context.requestId,
381
+ answers,
382
+ directory: context.directory,
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Check if there's a pending question for a thread
388
+ */
389
+ export function hasPendingQuestion(chatId: number, threadId: number | null): boolean {
390
+ return pendingQuestions.has(getThreadKey(chatId, threadId))
391
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * System message generator for OpenCode sessions
3
+ * Creates the system prompt injected into sessions, including
4
+ * Telegram-specific formatting rules and diff commands
5
+ */
6
+
7
+ export interface SystemMessageOptions {
8
+ sessionId: string
9
+ chatId: string
10
+ threadId?: number
11
+ }
12
+
13
+ /**
14
+ * Generate the system message to inject into OpenCode sessions
15
+ */
16
+ export function getSystemMessage(options: SystemMessageOptions): string {
17
+ const { sessionId, chatId, threadId } = options
18
+
19
+ return `
20
+ The user is reading your messages from inside Telegram, via a Telegram bot mirror.
21
+
22
+ The user cannot see bash tool outputs. If there is important information in bash output, include it in your text response.
23
+
24
+ Your current OpenCode session ID is: ${sessionId}
25
+ Your current Telegram chat ID is: ${chatId}${threadId ? `\nYour current Telegram thread ID is: ${threadId}` : ""}
26
+
27
+ ## showing diffs
28
+
29
+ IMPORTANT: After editing any files, you MUST execute the critique command using the Bash tool to get a diff URL, then share that URL with the user.
30
+
31
+ Execute this after making changes:
32
+
33
+ bunx critique web --title "Add user authentication flow"
34
+
35
+ If there are other unrelated changes in the working directory, filter to only show the files you edited:
36
+
37
+ bunx critique web --title "Fix database connection retry" -- path/to/file1.ts path/to/file2.ts
38
+
39
+ You can also show latest commit changes using:
40
+
41
+ bunx critique web --title "Refactor API endpoints" HEAD
42
+
43
+ bunx critique web --title "Update dependencies" HEAD~1 to get the one before last
44
+
45
+ Do this in case you committed the changes yourself (only if the user asks so, never commit otherwise).
46
+
47
+ The command outputs a URL - share that URL with the user so they can see the diff.
48
+
49
+ ## markdown
50
+
51
+ Telegram supports basic markdown features:
52
+ - *bold* text using single asterisks
53
+ - _italic_ text using underscores
54
+ - \`inline code\` using backticks
55
+ - \`\`\`code blocks\`\`\` using triple backticks (language specification optional)
56
+ - [links](url) using standard markdown
57
+
58
+ Telegram does NOT support:
59
+ - Headers (no # syntax)
60
+ - Tables
61
+ - Nested formatting
62
+
63
+ Keep formatting simple and readable.
64
+
65
+ ## spoilers
66
+
67
+ Telegram supports spoiler text using ||double pipes||. This can be useful for hiding long outputs or sensitive information that the user can tap to reveal.
68
+
69
+ ## message limits
70
+
71
+ Telegram messages are limited to 4096 characters. Very long responses will be automatically split into multiple messages.
72
+ `.trim()
73
+ }