@pedrohnas/opencode-telegram 0.1.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/package.json +21 -0
- package/src/bot.ts +203 -0
- package/src/config.ts +32 -0
- package/src/event-bus.ts +90 -0
- package/src/handlers/cancel.ts +30 -0
- package/src/handlers/permissions.ts +82 -0
- package/src/handlers/questions.ts +107 -0
- package/src/handlers/typing.ts +27 -0
- package/src/index.ts +255 -0
- package/src/pending-requests.ts +73 -0
- package/src/sdk.ts +113 -0
- package/src/send/chunker.ts +59 -0
- package/src/send/draft-stream.ts +161 -0
- package/src/send/format.ts +93 -0
- package/src/send/tool-progress.ts +22 -0
- package/src/session-manager.ts +143 -0
- package/src/turn-manager.ts +87 -0
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pedrohnas/opencode-telegram",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Telegram bot for OpenCode — AI coding assistant via Telegram",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"opencode-telegram": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/**/*.ts",
|
|
11
|
+
"!src/**/*.test.ts"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@opencode-ai/sdk": "^1.1.53",
|
|
16
|
+
"grammy": "^1.35.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grammy bot creation and handler logic.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - createBot(config, deps?) — creates Grammy Bot with all handlers
|
|
6
|
+
* - handleMessage(params) — processes a text message (testable standalone)
|
|
7
|
+
* - handleNew(params) — processes /new command (testable standalone)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Bot } from "grammy"
|
|
11
|
+
import type { Config } from "./config"
|
|
12
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
13
|
+
import type { SessionManager } from "./session-manager"
|
|
14
|
+
import type { TurnManager, ActiveTurn } from "./turn-manager"
|
|
15
|
+
import type { PendingRequests } from "./pending-requests"
|
|
16
|
+
import { handleCancel } from "./handlers/cancel"
|
|
17
|
+
import { parsePermissionCallback } from "./handlers/permissions"
|
|
18
|
+
import {
|
|
19
|
+
parseQuestionCallback,
|
|
20
|
+
resolveQuestionAnswer,
|
|
21
|
+
} from "./handlers/questions"
|
|
22
|
+
import { startTypingLoop } from "./handlers/typing"
|
|
23
|
+
import { DraftStream } from "./send/draft-stream"
|
|
24
|
+
|
|
25
|
+
export const START_MESSAGE = [
|
|
26
|
+
"OpenCode Telegram Bot",
|
|
27
|
+
"",
|
|
28
|
+
"Send any message to start coding.",
|
|
29
|
+
"/new — New session",
|
|
30
|
+
"/cancel — Stop generation",
|
|
31
|
+
].join("\n")
|
|
32
|
+
|
|
33
|
+
export type BotDeps = {
|
|
34
|
+
sdk: OpencodeClient
|
|
35
|
+
sessionManager: SessionManager
|
|
36
|
+
turnManager: TurnManager
|
|
37
|
+
pendingRequests: PendingRequests
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createBot(config: Config, deps?: BotDeps) {
|
|
41
|
+
const bot = new Bot(config.botToken)
|
|
42
|
+
|
|
43
|
+
bot.command("start", async (ctx) => {
|
|
44
|
+
await ctx.reply(START_MESSAGE)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
if (deps) {
|
|
48
|
+
const { sdk, sessionManager, turnManager, pendingRequests } = deps
|
|
49
|
+
|
|
50
|
+
bot.command("new", async (ctx) => {
|
|
51
|
+
const chatId = ctx.chat.id
|
|
52
|
+
await handleNew({ chatId, sdk, sessionManager })
|
|
53
|
+
await ctx.reply("New session started.")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
bot.command("cancel", async (ctx) => {
|
|
57
|
+
const chatId = ctx.chat.id
|
|
58
|
+
const result = await handleCancel({
|
|
59
|
+
chatId,
|
|
60
|
+
sdk,
|
|
61
|
+
sessionManager,
|
|
62
|
+
turnManager,
|
|
63
|
+
})
|
|
64
|
+
await ctx.reply(result)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
68
|
+
const data = ctx.callbackQuery.data
|
|
69
|
+
await ctx.answerCallbackQuery()
|
|
70
|
+
|
|
71
|
+
if (data.startsWith("perm:")) {
|
|
72
|
+
const parsed = parsePermissionCallback(data)
|
|
73
|
+
if (!parsed) return
|
|
74
|
+
|
|
75
|
+
const pending = pendingRequests.get(parsed.requestID)
|
|
76
|
+
if (!pending) {
|
|
77
|
+
await ctx.editMessageText("This request has expired.")
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
pendingRequests.delete(parsed.requestID)
|
|
81
|
+
|
|
82
|
+
await sdk.permission.reply({
|
|
83
|
+
requestID: parsed.requestID,
|
|
84
|
+
reply: parsed.reply as "once" | "always" | "reject",
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const label =
|
|
88
|
+
parsed.reply === "reject"
|
|
89
|
+
? "Denied"
|
|
90
|
+
: `Granted (${parsed.reply})`
|
|
91
|
+
await ctx.editMessageText(`Permission ${label}`)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (data.startsWith("q:")) {
|
|
96
|
+
const parsed = parseQuestionCallback(data)
|
|
97
|
+
if (!parsed) return
|
|
98
|
+
|
|
99
|
+
const pending = pendingRequests.get(parsed.requestID)
|
|
100
|
+
if (!pending) {
|
|
101
|
+
await ctx.editMessageText("This question has expired.")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
pendingRequests.delete(parsed.requestID)
|
|
105
|
+
|
|
106
|
+
if (parsed.action === "skip") {
|
|
107
|
+
await sdk.question.reject({ requestID: parsed.requestID })
|
|
108
|
+
await ctx.editMessageText("Question skipped.")
|
|
109
|
+
} else {
|
|
110
|
+
const answer = resolveQuestionAnswer(parsed.optionIndex, pending)
|
|
111
|
+
await sdk.question.reply({
|
|
112
|
+
requestID: parsed.requestID,
|
|
113
|
+
answers: [answer],
|
|
114
|
+
})
|
|
115
|
+
await ctx.editMessageText(`Selected: ${answer.join(", ")}`)
|
|
116
|
+
}
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
bot.on("message:text", async (ctx) => {
|
|
122
|
+
const chatId = ctx.chat.id
|
|
123
|
+
const text = ctx.message.text.trim()
|
|
124
|
+
if (!text) return
|
|
125
|
+
|
|
126
|
+
const { turn } = await handleMessage({
|
|
127
|
+
chatId,
|
|
128
|
+
text,
|
|
129
|
+
sdk,
|
|
130
|
+
sessionManager,
|
|
131
|
+
turnManager,
|
|
132
|
+
draftDeps: {
|
|
133
|
+
sendMessage: (id, t, o) =>
|
|
134
|
+
bot.api.sendMessage(id, t, o as any),
|
|
135
|
+
editMessageText: (id, m, t, o) =>
|
|
136
|
+
bot.api.editMessageText(id, m, t, o as any),
|
|
137
|
+
},
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
startTypingLoop(
|
|
141
|
+
chatId,
|
|
142
|
+
(id, action) => bot.api.sendChatAction(id, action as any),
|
|
143
|
+
turn.abortController.signal,
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
bot.catch((err) => {
|
|
149
|
+
console.error("Bot error:", err.message)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return bot
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Testable handler functions ---
|
|
156
|
+
|
|
157
|
+
export async function handleMessage(params: {
|
|
158
|
+
chatId: number
|
|
159
|
+
text: string
|
|
160
|
+
sdk: OpencodeClient
|
|
161
|
+
sessionManager: SessionManager
|
|
162
|
+
turnManager: TurnManager
|
|
163
|
+
draftDeps?: import("./send/draft-stream").DraftStreamDeps
|
|
164
|
+
}): Promise<{ turn: ActiveTurn }> {
|
|
165
|
+
const { chatId, text, sdk, sessionManager, turnManager, draftDeps } = params
|
|
166
|
+
const chatKey = String(chatId)
|
|
167
|
+
|
|
168
|
+
const entry = await sessionManager.getOrCreate(chatKey, sdk)
|
|
169
|
+
const turn = turnManager.start(entry.sessionId, chatId)
|
|
170
|
+
|
|
171
|
+
// Attach draft stream BEFORE firing the prompt, so SSE events
|
|
172
|
+
// can update the draft immediately as they arrive.
|
|
173
|
+
if (draftDeps) {
|
|
174
|
+
turn.draft = new DraftStream(draftDeps, chatId, turn.abortController.signal)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fire-and-forget: don't block the Grammy handler.
|
|
178
|
+
// The response comes via SSE events → DraftStream → finalizeResponse.
|
|
179
|
+
sdk.session.prompt({
|
|
180
|
+
sessionID: entry.sessionId,
|
|
181
|
+
parts: [{ type: "text", text }],
|
|
182
|
+
}).catch((err: unknown) => {
|
|
183
|
+
console.error("Prompt error:", err)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return { turn }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function handleNew(params: {
|
|
190
|
+
chatId: number
|
|
191
|
+
sdk: OpencodeClient
|
|
192
|
+
sessionManager: SessionManager
|
|
193
|
+
}) {
|
|
194
|
+
const { chatId, sdk, sessionManager } = params
|
|
195
|
+
const chatKey = String(chatId)
|
|
196
|
+
|
|
197
|
+
// Remove existing session mapping (session persists on server)
|
|
198
|
+
sessionManager.remove(chatKey)
|
|
199
|
+
|
|
200
|
+
// Create fresh session
|
|
201
|
+
const entry = await sessionManager.getOrCreate(chatKey, sdk)
|
|
202
|
+
return entry
|
|
203
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type Config = {
|
|
2
|
+
botToken: string
|
|
3
|
+
opencodeUrl: string
|
|
4
|
+
projectDirectory: string
|
|
5
|
+
testEnv: boolean
|
|
6
|
+
e2e: {
|
|
7
|
+
apiId: number
|
|
8
|
+
apiHash: string
|
|
9
|
+
session: string
|
|
10
|
+
botUsername: string
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadConfig(): Config {
|
|
15
|
+
const botToken = process.env.TELEGRAM_BOT_TOKEN
|
|
16
|
+
if (!botToken) {
|
|
17
|
+
throw new Error("TELEGRAM_BOT_TOKEN is required")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
botToken,
|
|
22
|
+
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4096",
|
|
23
|
+
projectDirectory: process.env.OPENCODE_DIRECTORY ?? process.cwd(),
|
|
24
|
+
testEnv: process.env.TELEGRAM_TEST_ENV === "1",
|
|
25
|
+
e2e: {
|
|
26
|
+
apiId: Number(process.env.TELEGRAM_API_ID ?? 0),
|
|
27
|
+
apiHash: process.env.TELEGRAM_API_HASH ?? "",
|
|
28
|
+
session: process.env.TELEGRAM_SESSION ?? "",
|
|
29
|
+
botUsername: process.env.TELEGRAM_BOT_USERNAME ?? "",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/event-bus.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single SSE connection to the OpenCode server.
|
|
3
|
+
*
|
|
4
|
+
* Routes incoming events to the correct Telegram chat by looking up
|
|
5
|
+
* the sessionId → chatKey mapping via SessionManager.
|
|
6
|
+
*
|
|
7
|
+
* Anti-leak design:
|
|
8
|
+
* - One connection for ALL sessions (not one per session)
|
|
9
|
+
* - AbortController for clean shutdown
|
|
10
|
+
* - Auto-reconnect with backoff on stream end
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
14
|
+
import type { SessionManager } from "./session-manager"
|
|
15
|
+
|
|
16
|
+
export type EventHandler = (
|
|
17
|
+
sessionId: string,
|
|
18
|
+
chatKey: string,
|
|
19
|
+
event: any,
|
|
20
|
+
) => void
|
|
21
|
+
|
|
22
|
+
export type EventBusOptions = {
|
|
23
|
+
sdk: OpencodeClient
|
|
24
|
+
sessionManager: SessionManager
|
|
25
|
+
onEvent: EventHandler
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class EventBus {
|
|
29
|
+
private readonly sdk: OpencodeClient
|
|
30
|
+
private readonly sessionManager: SessionManager
|
|
31
|
+
private readonly onEvent: EventHandler
|
|
32
|
+
private stopped = false
|
|
33
|
+
|
|
34
|
+
constructor(opts: EventBusOptions) {
|
|
35
|
+
this.sdk = opts.sdk
|
|
36
|
+
this.sessionManager = opts.sessionManager
|
|
37
|
+
this.onEvent = opts.onEvent
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start(): Promise<void> {
|
|
41
|
+
this.stopped = false
|
|
42
|
+
this.listen().catch((err) => {
|
|
43
|
+
if (!this.stopped) {
|
|
44
|
+
console.error("EventBus stream error:", err)
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop(): void {
|
|
50
|
+
this.stopped = true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private async listen(): Promise<void> {
|
|
54
|
+
const result = await this.sdk.event.subscribe()
|
|
55
|
+
for await (const event of result.stream) {
|
|
56
|
+
if (this.stopped) break
|
|
57
|
+
|
|
58
|
+
const sessionId = extractSessionId(event)
|
|
59
|
+
if (!sessionId) continue
|
|
60
|
+
|
|
61
|
+
const lookup = this.sessionManager.getBySessionId(sessionId)
|
|
62
|
+
if (!lookup) continue
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
this.onEvent(sessionId, lookup.chatKey, event)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.error("EventBus handler error:", err)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract sessionID from various event shapes.
|
|
75
|
+
* Events may have it at:
|
|
76
|
+
* - event.properties.part.sessionID (message.part.updated)
|
|
77
|
+
* - event.properties.info.sessionID (message.updated)
|
|
78
|
+
* - event.properties.sessionID (session.idle, session.error, etc.)
|
|
79
|
+
*/
|
|
80
|
+
function extractSessionId(event: any): string | undefined {
|
|
81
|
+
const props = event?.properties
|
|
82
|
+
if (!props) return undefined
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
props.part?.sessionID ??
|
|
86
|
+
props.info?.sessionID ??
|
|
87
|
+
props.sessionID ??
|
|
88
|
+
undefined
|
|
89
|
+
)
|
|
90
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /cancel command handler — aborts the active generation.
|
|
3
|
+
*
|
|
4
|
+
* Calls sdk.session.abort() and cleans up the TurnManager.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
8
|
+
import type { SessionManager } from "../session-manager"
|
|
9
|
+
import type { TurnManager } from "../turn-manager"
|
|
10
|
+
|
|
11
|
+
export async function handleCancel(params: {
|
|
12
|
+
chatId: number
|
|
13
|
+
sdk: OpencodeClient
|
|
14
|
+
sessionManager: SessionManager
|
|
15
|
+
turnManager: TurnManager
|
|
16
|
+
}): Promise<string> {
|
|
17
|
+
const { chatId, sdk, sessionManager, turnManager } = params
|
|
18
|
+
const chatKey = String(chatId)
|
|
19
|
+
|
|
20
|
+
const entry = sessionManager.get(chatKey)
|
|
21
|
+
if (!entry) return "No active session."
|
|
22
|
+
|
|
23
|
+
const turn = turnManager.get(entry.sessionId)
|
|
24
|
+
if (!turn) return "Nothing running."
|
|
25
|
+
|
|
26
|
+
await sdk.session.abort({ sessionID: entry.sessionId })
|
|
27
|
+
turnManager.end(entry.sessionId)
|
|
28
|
+
|
|
29
|
+
return "Generation cancelled."
|
|
30
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission event → Telegram inline keyboard message.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions:
|
|
5
|
+
* - formatPermissionMessage(perm) → { text, reply_markup } for sendMessage
|
|
6
|
+
* - parsePermissionCallback(data) → { requestID, reply } parsed from callback_data
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type PermissionEvent = {
|
|
10
|
+
id: string
|
|
11
|
+
sessionID: string
|
|
12
|
+
permission: string
|
|
13
|
+
patterns: string[]
|
|
14
|
+
metadata: Record<string, unknown>
|
|
15
|
+
always: string[]
|
|
16
|
+
tool?: { messageID: string; callID: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type InlineKeyboardButton = {
|
|
20
|
+
text: string
|
|
21
|
+
callback_data: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type InlineKeyboardMarkup = {
|
|
25
|
+
inline_keyboard: InlineKeyboardButton[][]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatPermissionMessage(perm: PermissionEvent): {
|
|
29
|
+
text: string
|
|
30
|
+
reply_markup: InlineKeyboardMarkup
|
|
31
|
+
} {
|
|
32
|
+
const description = perm.patterns.length > 0
|
|
33
|
+
? perm.patterns.join(", ")
|
|
34
|
+
: perm.permission
|
|
35
|
+
|
|
36
|
+
const text = `Permission needed: <b>${escapeHtml(perm.permission)}</b>\n<code>${escapeHtml(description)}</code>`
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
text,
|
|
40
|
+
reply_markup: {
|
|
41
|
+
inline_keyboard: [
|
|
42
|
+
[
|
|
43
|
+
{ text: "✓ Allow", callback_data: `perm:once:${perm.id}` },
|
|
44
|
+
{ text: "✓ Always", callback_data: `perm:always:${perm.id}` },
|
|
45
|
+
{ text: "✗ Deny", callback_data: `perm:deny:${perm.id}` },
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VALID_ACTIONS = new Set(["once", "always", "deny"])
|
|
53
|
+
|
|
54
|
+
const ACTION_TO_REPLY: Record<string, "once" | "always" | "reject"> = {
|
|
55
|
+
once: "once",
|
|
56
|
+
always: "always",
|
|
57
|
+
deny: "reject",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parsePermissionCallback(
|
|
61
|
+
data: string,
|
|
62
|
+
): { requestID: string; reply: "once" | "always" | "reject" } | null {
|
|
63
|
+
if (!data.startsWith("perm:")) return null
|
|
64
|
+
|
|
65
|
+
const firstColon = data.indexOf(":")
|
|
66
|
+
const secondColon = data.indexOf(":", firstColon + 1)
|
|
67
|
+
if (secondColon === -1) return null
|
|
68
|
+
|
|
69
|
+
const action = data.slice(firstColon + 1, secondColon)
|
|
70
|
+
const requestID = data.slice(secondColon + 1)
|
|
71
|
+
|
|
72
|
+
if (!VALID_ACTIONS.has(action) || !requestID) return null
|
|
73
|
+
|
|
74
|
+
return { requestID, reply: ACTION_TO_REPLY[action] }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeHtml(text: string): string {
|
|
78
|
+
return text
|
|
79
|
+
.replace(/&/g, "&")
|
|
80
|
+
.replace(/</g, "<")
|
|
81
|
+
.replace(/>/g, ">")
|
|
82
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Question event → Telegram inline keyboard message.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions:
|
|
5
|
+
* - formatQuestionMessage(event) → { text, reply_markup }
|
|
6
|
+
* - parseQuestionCallback(data) → parsed callback action
|
|
7
|
+
* - resolveQuestionAnswer(index, pending) → answer labels array
|
|
8
|
+
*
|
|
9
|
+
* Phase 2 MVP: handles first question only (single-select).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PendingEntry } from "../pending-requests"
|
|
13
|
+
|
|
14
|
+
export type QuestionOption = {
|
|
15
|
+
label: string
|
|
16
|
+
description: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type QuestionInfo = {
|
|
20
|
+
question: string
|
|
21
|
+
header: string
|
|
22
|
+
options: QuestionOption[]
|
|
23
|
+
multiple?: boolean
|
|
24
|
+
custom?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type QuestionEvent = {
|
|
28
|
+
id: string
|
|
29
|
+
sessionID: string
|
|
30
|
+
questions: QuestionInfo[]
|
|
31
|
+
tool?: { messageID: string; callID: string }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type InlineKeyboardButton = {
|
|
35
|
+
text: string
|
|
36
|
+
callback_data: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type InlineKeyboardMarkup = {
|
|
40
|
+
inline_keyboard: InlineKeyboardButton[][]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatQuestionMessage(event: QuestionEvent): {
|
|
44
|
+
text: string
|
|
45
|
+
reply_markup: InlineKeyboardMarkup
|
|
46
|
+
} {
|
|
47
|
+
// Phase 2 MVP: handle first question only
|
|
48
|
+
const q = event.questions[0]
|
|
49
|
+
const text = q.question
|
|
50
|
+
|
|
51
|
+
const optionRows: InlineKeyboardButton[][] = q.options.map((opt, i) => [
|
|
52
|
+
{ text: opt.label, callback_data: `q:${event.id}:${i}` },
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
// Skip button as last row
|
|
56
|
+
optionRows.push([
|
|
57
|
+
{ text: "✗ Skip", callback_data: `q:${event.id}:skip` },
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
text,
|
|
62
|
+
reply_markup: { inline_keyboard: optionRows },
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ParsedQuestionCallback =
|
|
67
|
+
| { requestID: string; action: "select"; optionIndex: number }
|
|
68
|
+
| { requestID: string; action: "skip" }
|
|
69
|
+
|
|
70
|
+
export function parseQuestionCallback(
|
|
71
|
+
data: string,
|
|
72
|
+
): ParsedQuestionCallback | null {
|
|
73
|
+
if (!data.startsWith("q:")) return null
|
|
74
|
+
|
|
75
|
+
// Format: q:{requestID}:{index|skip}
|
|
76
|
+
const firstColon = data.indexOf(":")
|
|
77
|
+
const lastColon = data.lastIndexOf(":")
|
|
78
|
+
if (firstColon === lastColon) return null
|
|
79
|
+
|
|
80
|
+
const requestID = data.slice(firstColon + 1, lastColon)
|
|
81
|
+
const suffix = data.slice(lastColon + 1)
|
|
82
|
+
|
|
83
|
+
if (!requestID) return null
|
|
84
|
+
|
|
85
|
+
if (suffix === "skip") {
|
|
86
|
+
return { requestID, action: "skip" }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const optionIndex = parseInt(suffix, 10)
|
|
90
|
+
if (isNaN(optionIndex)) return null
|
|
91
|
+
|
|
92
|
+
return { requestID, action: "select", optionIndex }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolveQuestionAnswer(
|
|
96
|
+
optionIndex: number,
|
|
97
|
+
pending: PendingEntry,
|
|
98
|
+
): string[] {
|
|
99
|
+
const questions = pending.questions ?? []
|
|
100
|
+
const firstQuestion = questions[0]
|
|
101
|
+
if (!firstQuestion) return []
|
|
102
|
+
|
|
103
|
+
const option = firstQuestion.options[optionIndex]
|
|
104
|
+
if (!option) return []
|
|
105
|
+
|
|
106
|
+
return [option.label]
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing indicator loop — sends "typing" chat action every 4 seconds.
|
|
3
|
+
*
|
|
4
|
+
* Telegram's typing indicator expires after ~5s, so we re-send it
|
|
5
|
+
* at 4s intervals. Tied to an AbortSignal for automatic cleanup
|
|
6
|
+
* when the turn ends.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function startTypingLoop(
|
|
10
|
+
chatId: number,
|
|
11
|
+
sendAction: (chatId: number, action: string) => Promise<unknown>,
|
|
12
|
+
signal: AbortSignal,
|
|
13
|
+
): void {
|
|
14
|
+
const send = () => {
|
|
15
|
+
sendAction(chatId, "typing").catch(() => {})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const schedule = () => {
|
|
19
|
+
if (signal.aborted) return
|
|
20
|
+
send()
|
|
21
|
+
const timer = setTimeout(schedule, 4000)
|
|
22
|
+
const onAbort = () => clearTimeout(timer)
|
|
23
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
schedule()
|
|
27
|
+
}
|