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