@pedrohnas/opencode-telegram 0.1.0 → 1.2.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/.env.example +17 -0
- package/bunfig.toml +2 -0
- package/docs/PROGRESS.md +327 -0
- package/docs/mapping.md +326 -0
- package/docs/plans/phase-1.md +176 -0
- package/docs/plans/phase-2.md +235 -0
- package/docs/plans/phase-3.md +485 -0
- package/docs/plans/phase-4.md +566 -0
- package/docs/spec.md +2055 -0
- package/e2e/client.ts +24 -0
- package/e2e/helpers.ts +119 -0
- package/e2e/phase-0.test.ts +30 -0
- package/e2e/phase-1.test.ts +48 -0
- package/e2e/phase-2.test.ts +54 -0
- package/e2e/phase-3.test.ts +142 -0
- package/e2e/phase-4.test.ts +96 -0
- package/e2e/runner.ts +145 -0
- package/package.json +14 -12
- package/scripts/gen-session.ts +49 -0
- package/src/bot.test.ts +301 -0
- package/src/bot.ts +91 -0
- package/src/config.test.ts +130 -0
- package/src/config.ts +15 -0
- package/src/event-bus.test.ts +175 -0
- package/src/handlers/allowlist.test.ts +60 -0
- package/src/handlers/allowlist.ts +33 -0
- package/src/handlers/cancel.test.ts +105 -0
- package/src/handlers/permissions.test.ts +72 -0
- package/src/handlers/questions.test.ts +107 -0
- package/src/handlers/sessions.test.ts +479 -0
- package/src/handlers/sessions.ts +202 -0
- package/src/handlers/typing.test.ts +60 -0
- package/src/index.ts +26 -0
- package/src/pending-requests.test.ts +64 -0
- package/src/send/chunker.test.ts +74 -0
- package/src/send/draft-stream.test.ts +229 -0
- package/src/send/format.test.ts +143 -0
- package/src/send/tool-progress.test.ts +70 -0
- package/src/session-manager.test.ts +198 -0
- package/src/session-manager.ts +23 -0
- package/src/turn-manager.test.ts +155 -0
- package/src/turn-manager.ts +5 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a gramjs StringSession for E2E testing.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* TELEGRAM_API_ID=12345 TELEGRAM_API_HASH=abcdef bun run scripts/gen-session.ts
|
|
6
|
+
*
|
|
7
|
+
* This will prompt for your phone number and the code Telegram sends.
|
|
8
|
+
* At the end it prints the session string — copy it to your .env file.
|
|
9
|
+
*/
|
|
10
|
+
import { TelegramClient } from "telegram"
|
|
11
|
+
import { StringSession } from "telegram/sessions"
|
|
12
|
+
import * as readline from "node:readline"
|
|
13
|
+
|
|
14
|
+
const apiId = Number(process.env.TELEGRAM_API_ID)
|
|
15
|
+
const apiHash = process.env.TELEGRAM_API_HASH ?? ""
|
|
16
|
+
|
|
17
|
+
if (!apiId || !apiHash) {
|
|
18
|
+
console.error("Set TELEGRAM_API_ID and TELEGRAM_API_HASH env vars first.")
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function prompt(question: string): Promise<string> {
|
|
23
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
rl.question(question, (answer) => {
|
|
26
|
+
rl.close()
|
|
27
|
+
resolve(answer.trim())
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const session = new StringSession("")
|
|
33
|
+
const client = new TelegramClient(session, apiId, apiHash, {
|
|
34
|
+
connectionRetries: 3,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
await client.start({
|
|
38
|
+
phoneNumber: () => prompt("Phone number (with country code, e.g. +5511999999999): "),
|
|
39
|
+
phoneCode: () => prompt("Code from Telegram: "),
|
|
40
|
+
password: () => prompt("2FA password (if enabled, otherwise press Enter): "),
|
|
41
|
+
onError: (err) => console.error("Error:", err),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
console.log("\n=== Session string (copy this to .env as TELEGRAM_SESSION) ===\n")
|
|
45
|
+
console.log(client.session.save())
|
|
46
|
+
console.log("\n=== Done ===")
|
|
47
|
+
|
|
48
|
+
await client.disconnect()
|
|
49
|
+
process.exit(0)
|
package/src/bot.test.ts
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
|
2
|
+
import { createBot, START_MESSAGE, type BotDeps } from "./bot"
|
|
3
|
+
import { SessionManager } from "./session-manager"
|
|
4
|
+
import { TurnManager } from "./turn-manager"
|
|
5
|
+
import { PendingRequests } from "./pending-requests"
|
|
6
|
+
import type { Config } from "./config"
|
|
7
|
+
|
|
8
|
+
const testConfig: Config = {
|
|
9
|
+
botToken: "123456:ABC-DEF_test-token",
|
|
10
|
+
opencodeUrl: "http://127.0.0.1:4096",
|
|
11
|
+
projectDirectory: "/tmp/test",
|
|
12
|
+
testEnv: false,
|
|
13
|
+
allowedUsers: [],
|
|
14
|
+
allowAllUsers: true,
|
|
15
|
+
e2e: { apiId: 0, apiHash: "", session: "", botUsername: "" },
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// --- Phase 0 tests (unchanged) ---
|
|
19
|
+
|
|
20
|
+
describe("createBot", () => {
|
|
21
|
+
test("returns a Bot instance", () => {
|
|
22
|
+
const bot = createBot(testConfig)
|
|
23
|
+
expect(bot).toBeDefined()
|
|
24
|
+
expect(bot.start).toBeFunction()
|
|
25
|
+
expect(bot.stop).toBeFunction()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test("bot has error handler", () => {
|
|
29
|
+
const bot = createBot(testConfig)
|
|
30
|
+
expect(bot.errorHandler).toBeDefined()
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("START_MESSAGE", () => {
|
|
35
|
+
test("contains expected text", () => {
|
|
36
|
+
expect(START_MESSAGE).toContain("OpenCode Telegram Bot")
|
|
37
|
+
expect(START_MESSAGE).toContain("/new")
|
|
38
|
+
expect(START_MESSAGE).toContain("/cancel")
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// --- Phase 1 tests: handleMessage and handleNew ---
|
|
43
|
+
|
|
44
|
+
describe("handleMessage", () => {
|
|
45
|
+
test("calls sdk.session.prompt with correct parts", async () => {
|
|
46
|
+
const { handleMessage } = await import("./bot")
|
|
47
|
+
const promptMock = mock(async () => ({ data: {} }))
|
|
48
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
49
|
+
const tm = new TurnManager()
|
|
50
|
+
|
|
51
|
+
// Pre-populate session
|
|
52
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
53
|
+
|
|
54
|
+
const sdk = {
|
|
55
|
+
session: { prompt: promptMock, create: mock(async () => ({ data: { id: "s1" } })) },
|
|
56
|
+
} as any
|
|
57
|
+
|
|
58
|
+
await handleMessage({
|
|
59
|
+
chatId: 123,
|
|
60
|
+
text: "hello world",
|
|
61
|
+
sdk,
|
|
62
|
+
sessionManager: sm,
|
|
63
|
+
turnManager: tm,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Prompt is fire-and-forget — wait a microtick for the promise to resolve
|
|
67
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
68
|
+
|
|
69
|
+
expect(promptMock).toHaveBeenCalledTimes(1)
|
|
70
|
+
const call = promptMock.mock.calls[0]![0] as any
|
|
71
|
+
expect(call.sessionID).toBe("s1")
|
|
72
|
+
expect(call.parts[0].type).toBe("text")
|
|
73
|
+
expect(call.parts[0].text).toBe("hello world")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("creates session automatically for unknown chat", async () => {
|
|
77
|
+
const { handleMessage } = await import("./bot")
|
|
78
|
+
const createMock = mock(async () => ({
|
|
79
|
+
data: { id: "new-s1", directory: "/tmp" },
|
|
80
|
+
}))
|
|
81
|
+
const promptMock = mock(async () => ({ data: {} }))
|
|
82
|
+
|
|
83
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
84
|
+
const tm = new TurnManager()
|
|
85
|
+
const sdk = {
|
|
86
|
+
session: { create: createMock, prompt: promptMock },
|
|
87
|
+
} as any
|
|
88
|
+
|
|
89
|
+
await handleMessage({
|
|
90
|
+
chatId: 999,
|
|
91
|
+
text: "test",
|
|
92
|
+
sdk,
|
|
93
|
+
sessionManager: sm,
|
|
94
|
+
turnManager: tm,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Wait for fire-and-forget prompt
|
|
98
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
99
|
+
|
|
100
|
+
// Should have created a session
|
|
101
|
+
expect(createMock).toHaveBeenCalledTimes(1)
|
|
102
|
+
// And then prompted it
|
|
103
|
+
expect(promptMock).toHaveBeenCalledTimes(1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test("starts a turn and returns it", async () => {
|
|
107
|
+
const { handleMessage } = await import("./bot")
|
|
108
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
109
|
+
const tm = new TurnManager()
|
|
110
|
+
|
|
111
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
112
|
+
const sdk = {
|
|
113
|
+
session: {
|
|
114
|
+
prompt: mock(async () => ({ data: {} })),
|
|
115
|
+
create: mock(async () => ({ data: { id: "s1" } })),
|
|
116
|
+
},
|
|
117
|
+
} as any
|
|
118
|
+
|
|
119
|
+
const result = await handleMessage({
|
|
120
|
+
chatId: 123,
|
|
121
|
+
text: "hello",
|
|
122
|
+
sdk,
|
|
123
|
+
sessionManager: sm,
|
|
124
|
+
turnManager: tm,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(result.turn).toBeDefined()
|
|
128
|
+
expect(result.turn.chatId).toBe(123)
|
|
129
|
+
expect(result.turn.sessionId).toBe("s1")
|
|
130
|
+
expect(tm.get("s1")).toBeDefined()
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe("handleNew", () => {
|
|
135
|
+
test("removes old session and creates new one", async () => {
|
|
136
|
+
const { handleNew } = await import("./bot")
|
|
137
|
+
const createMock = mock(async () => ({
|
|
138
|
+
data: { id: "new-session", directory: "/tmp" },
|
|
139
|
+
}))
|
|
140
|
+
|
|
141
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
142
|
+
sm.set("123", { sessionId: "old-session", directory: "/tmp" })
|
|
143
|
+
|
|
144
|
+
const sdk = {
|
|
145
|
+
session: { create: createMock },
|
|
146
|
+
} as any
|
|
147
|
+
|
|
148
|
+
const result = await handleNew({
|
|
149
|
+
chatId: 123,
|
|
150
|
+
sdk,
|
|
151
|
+
sessionManager: sm,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Old session mapping removed, new one created
|
|
155
|
+
expect(sm.get("123")!.sessionId).toBe("new-session")
|
|
156
|
+
expect(sm.getBySessionId("old-session")).toBeUndefined()
|
|
157
|
+
expect(createMock).toHaveBeenCalledTimes(1)
|
|
158
|
+
expect(result.sessionId).toBe("new-session")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("creates session for chat without existing session", async () => {
|
|
162
|
+
const { handleNew } = await import("./bot")
|
|
163
|
+
const createMock = mock(async () => ({
|
|
164
|
+
data: { id: "fresh-session", directory: "/tmp" },
|
|
165
|
+
}))
|
|
166
|
+
|
|
167
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
168
|
+
const sdk = {
|
|
169
|
+
session: { create: createMock },
|
|
170
|
+
} as any
|
|
171
|
+
|
|
172
|
+
const result = await handleNew({
|
|
173
|
+
chatId: 456,
|
|
174
|
+
sdk,
|
|
175
|
+
sessionManager: sm,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
expect(sm.get("456")!.sessionId).toBe("fresh-session")
|
|
179
|
+
expect(result.sessionId).toBe("fresh-session")
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// --- Phase 4 tests: handleMessage abort, session commands ---
|
|
184
|
+
|
|
185
|
+
describe("handleMessage — streaming interruption fix", () => {
|
|
186
|
+
test("calls sdk.session.abort when existing turn present", async () => {
|
|
187
|
+
const { handleMessage } = await import("./bot")
|
|
188
|
+
const abortMock = mock(async () => ({}))
|
|
189
|
+
const promptMock = mock(async () => ({ data: {} }))
|
|
190
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
191
|
+
const tm = new TurnManager()
|
|
192
|
+
|
|
193
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
194
|
+
const sdk = {
|
|
195
|
+
session: {
|
|
196
|
+
prompt: promptMock,
|
|
197
|
+
create: mock(async () => ({ data: { id: "s1" } })),
|
|
198
|
+
abort: abortMock,
|
|
199
|
+
},
|
|
200
|
+
} as any
|
|
201
|
+
|
|
202
|
+
// First message — creates a turn
|
|
203
|
+
await handleMessage({
|
|
204
|
+
chatId: 123,
|
|
205
|
+
text: "first message",
|
|
206
|
+
sdk,
|
|
207
|
+
sessionManager: sm,
|
|
208
|
+
turnManager: tm,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Second message — should abort old turn
|
|
212
|
+
await handleMessage({
|
|
213
|
+
chatId: 123,
|
|
214
|
+
text: "second message",
|
|
215
|
+
sdk,
|
|
216
|
+
sessionManager: sm,
|
|
217
|
+
turnManager: tm,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
221
|
+
|
|
222
|
+
expect(abortMock).toHaveBeenCalledTimes(1)
|
|
223
|
+
const abortCall = abortMock.mock.calls[0]![0] as any
|
|
224
|
+
expect(abortCall.sessionID).toBe("s1")
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test("does NOT call abort when no existing turn", async () => {
|
|
228
|
+
const { handleMessage } = await import("./bot")
|
|
229
|
+
const abortMock = mock(async () => ({}))
|
|
230
|
+
const promptMock = mock(async () => ({ data: {} }))
|
|
231
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
232
|
+
const tm = new TurnManager()
|
|
233
|
+
|
|
234
|
+
sm.set("123", { sessionId: "s1", directory: "/tmp" })
|
|
235
|
+
const sdk = {
|
|
236
|
+
session: {
|
|
237
|
+
prompt: promptMock,
|
|
238
|
+
create: mock(async () => ({ data: { id: "s1" } })),
|
|
239
|
+
abort: abortMock,
|
|
240
|
+
},
|
|
241
|
+
} as any
|
|
242
|
+
|
|
243
|
+
// First message — no existing turn
|
|
244
|
+
await handleMessage({
|
|
245
|
+
chatId: 123,
|
|
246
|
+
text: "first message",
|
|
247
|
+
sdk,
|
|
248
|
+
sessionManager: sm,
|
|
249
|
+
turnManager: tm,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
253
|
+
expect(abortMock).not.toHaveBeenCalled()
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe("handleSessionCallback", () => {
|
|
258
|
+
test("switches session on valid sess: callback", async () => {
|
|
259
|
+
const { handleSessionCallback } = await import("./bot")
|
|
260
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
261
|
+
const sdk = {
|
|
262
|
+
session: {
|
|
263
|
+
list: mock(async () => ({
|
|
264
|
+
data: [
|
|
265
|
+
{ id: "session-full-id-12345", title: "My Session", directory: "/tmp", time: { created: 1000, updated: 2000 } },
|
|
266
|
+
],
|
|
267
|
+
})),
|
|
268
|
+
},
|
|
269
|
+
} as any
|
|
270
|
+
|
|
271
|
+
const result = await handleSessionCallback({
|
|
272
|
+
chatKey: "123",
|
|
273
|
+
sessionPrefix: "session-full-id-1234",
|
|
274
|
+
sdk,
|
|
275
|
+
sessionManager: sm,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
expect(result).toContain("Switched to")
|
|
279
|
+
expect(result).toContain("My Session")
|
|
280
|
+
expect(sm.get("123")?.sessionId).toBe("session-full-id-12345")
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("returns 'Session not found.' for unknown prefix", async () => {
|
|
284
|
+
const { handleSessionCallback } = await import("./bot")
|
|
285
|
+
const sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
|
|
286
|
+
const sdk = {
|
|
287
|
+
session: {
|
|
288
|
+
list: mock(async () => ({ data: [] })),
|
|
289
|
+
},
|
|
290
|
+
} as any
|
|
291
|
+
|
|
292
|
+
const result = await handleSessionCallback({
|
|
293
|
+
chatKey: "123",
|
|
294
|
+
sessionPrefix: "nonexistent",
|
|
295
|
+
sdk,
|
|
296
|
+
sessionManager: sm,
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
expect(result).toBe("Session not found.")
|
|
300
|
+
})
|
|
301
|
+
})
|
package/src/bot.ts
CHANGED
|
@@ -21,6 +21,16 @@ import {
|
|
|
21
21
|
} from "./handlers/questions"
|
|
22
22
|
import { startTypingLoop } from "./handlers/typing"
|
|
23
23
|
import { DraftStream } from "./send/draft-stream"
|
|
24
|
+
import { createAllowlistMiddleware } from "./handlers/allowlist"
|
|
25
|
+
import {
|
|
26
|
+
handleList,
|
|
27
|
+
handleRename,
|
|
28
|
+
handleDelete,
|
|
29
|
+
handleInfo,
|
|
30
|
+
handleHistory,
|
|
31
|
+
handleSummarize,
|
|
32
|
+
parseSessionCallback,
|
|
33
|
+
} from "./handlers/sessions"
|
|
24
34
|
|
|
25
35
|
export const START_MESSAGE = [
|
|
26
36
|
"OpenCode Telegram Bot",
|
|
@@ -40,6 +50,9 @@ export type BotDeps = {
|
|
|
40
50
|
export function createBot(config: Config, deps?: BotDeps) {
|
|
41
51
|
const bot = new Bot(config.botToken)
|
|
42
52
|
|
|
53
|
+
// Allowlist middleware — must be first (blocks unauthorized users)
|
|
54
|
+
bot.use(createAllowlistMiddleware(config.allowedUsers, config.allowAllUsers))
|
|
55
|
+
|
|
43
56
|
bot.command("start", async (ctx) => {
|
|
44
57
|
await ctx.reply(START_MESSAGE)
|
|
45
58
|
})
|
|
@@ -53,6 +66,42 @@ export function createBot(config: Config, deps?: BotDeps) {
|
|
|
53
66
|
await ctx.reply("New session started.")
|
|
54
67
|
})
|
|
55
68
|
|
|
69
|
+
bot.command("list", async (ctx) => {
|
|
70
|
+
const result = await handleList({ sdk })
|
|
71
|
+
await ctx.reply(result.text, { reply_markup: result.reply_markup })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
bot.command("rename", async (ctx) => {
|
|
75
|
+
const chatKey = String(ctx.chat.id)
|
|
76
|
+
const title = ctx.match?.trim() ?? ""
|
|
77
|
+
const result = await handleRename({ chatKey, title, sdk, sessionManager })
|
|
78
|
+
await ctx.reply(result)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
bot.command("delete", async (ctx) => {
|
|
82
|
+
const chatKey = String(ctx.chat.id)
|
|
83
|
+
const result = await handleDelete({ chatKey, sdk, sessionManager })
|
|
84
|
+
await ctx.reply(result)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
bot.command("info", async (ctx) => {
|
|
88
|
+
const chatKey = String(ctx.chat.id)
|
|
89
|
+
const result = await handleInfo({ chatKey, sdk, sessionManager })
|
|
90
|
+
await ctx.reply(result)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
bot.command("history", async (ctx) => {
|
|
94
|
+
const chatKey = String(ctx.chat.id)
|
|
95
|
+
const result = await handleHistory({ chatKey, sdk, sessionManager })
|
|
96
|
+
await ctx.reply(result)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
bot.command("summarize", async (ctx) => {
|
|
100
|
+
const chatKey = String(ctx.chat.id)
|
|
101
|
+
const result = await handleSummarize({ chatKey, sdk, sessionManager })
|
|
102
|
+
await ctx.reply(result)
|
|
103
|
+
})
|
|
104
|
+
|
|
56
105
|
bot.command("cancel", async (ctx) => {
|
|
57
106
|
const chatId = ctx.chat.id
|
|
58
107
|
const result = await handleCancel({
|
|
@@ -116,6 +165,20 @@ export function createBot(config: Config, deps?: BotDeps) {
|
|
|
116
165
|
}
|
|
117
166
|
return
|
|
118
167
|
}
|
|
168
|
+
|
|
169
|
+
if (data.startsWith("sess:")) {
|
|
170
|
+
const parsed = parseSessionCallback(data)
|
|
171
|
+
if (!parsed) return
|
|
172
|
+
const chatKey = String(ctx.from.id)
|
|
173
|
+
const result = await handleSessionCallback({
|
|
174
|
+
chatKey,
|
|
175
|
+
sessionPrefix: parsed.sessionPrefix,
|
|
176
|
+
sdk,
|
|
177
|
+
sessionManager,
|
|
178
|
+
})
|
|
179
|
+
await ctx.editMessageText(result)
|
|
180
|
+
return
|
|
181
|
+
}
|
|
119
182
|
})
|
|
120
183
|
|
|
121
184
|
bot.on("message:text", async (ctx) => {
|
|
@@ -166,6 +229,15 @@ export async function handleMessage(params: {
|
|
|
166
229
|
const chatKey = String(chatId)
|
|
167
230
|
|
|
168
231
|
const entry = await sessionManager.getOrCreate(chatKey, sdk)
|
|
232
|
+
|
|
233
|
+
// Streaming interruption fix (N2): abort old server-side prompt
|
|
234
|
+
const existingTurn = turnManager.get(entry.sessionId)
|
|
235
|
+
if (existingTurn) {
|
|
236
|
+
sdk.session.abort({ sessionID: entry.sessionId }).catch((err: unknown) => {
|
|
237
|
+
console.error("Abort error:", err)
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
169
241
|
const turn = turnManager.start(entry.sessionId, chatId)
|
|
170
242
|
|
|
171
243
|
// Attach draft stream BEFORE firing the prompt, so SSE events
|
|
@@ -186,6 +258,25 @@ export async function handleMessage(params: {
|
|
|
186
258
|
return { turn }
|
|
187
259
|
}
|
|
188
260
|
|
|
261
|
+
export async function handleSessionCallback(params: {
|
|
262
|
+
chatKey: string
|
|
263
|
+
sessionPrefix: string
|
|
264
|
+
sdk: OpencodeClient
|
|
265
|
+
sessionManager: SessionManager
|
|
266
|
+
}): Promise<string> {
|
|
267
|
+
const { chatKey, sessionPrefix, sdk, sessionManager } = params
|
|
268
|
+
const result = await sdk.session.list()
|
|
269
|
+
const sessions = (result as any).data ?? []
|
|
270
|
+
const match = sessions.find((s: any) => s.id.startsWith(sessionPrefix))
|
|
271
|
+
if (!match) return "Session not found."
|
|
272
|
+
|
|
273
|
+
sessionManager.set(chatKey, {
|
|
274
|
+
sessionId: match.id,
|
|
275
|
+
directory: match.directory ?? "",
|
|
276
|
+
})
|
|
277
|
+
return `Switched to: ${match.title || match.id}`
|
|
278
|
+
}
|
|
279
|
+
|
|
189
280
|
export async function handleNew(params: {
|
|
190
281
|
chatId: number
|
|
191
282
|
sdk: OpencodeClient
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import type { Config } from "./config"
|
|
3
|
+
|
|
4
|
+
describe("loadConfig", () => {
|
|
5
|
+
const originalEnv = { ...process.env }
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
// Restore original env
|
|
9
|
+
for (const key of Object.keys(process.env)) {
|
|
10
|
+
if (!(key in originalEnv)) {
|
|
11
|
+
delete process.env[key]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
Object.assign(process.env, originalEnv)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("parses required TELEGRAM_BOT_TOKEN", async () => {
|
|
18
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
19
|
+
const { loadConfig } = await import("./config")
|
|
20
|
+
const config = loadConfig()
|
|
21
|
+
expect(config.botToken).toBe("123:ABC")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("throws when TELEGRAM_BOT_TOKEN is missing", async () => {
|
|
25
|
+
delete process.env.TELEGRAM_BOT_TOKEN
|
|
26
|
+
// Re-import to get fresh module
|
|
27
|
+
const mod = await import("./config?missing")
|
|
28
|
+
expect(() => mod.loadConfig()).toThrow()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("uses default opencodeUrl when not set", async () => {
|
|
32
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
33
|
+
const { loadConfig } = await import("./config?defaults")
|
|
34
|
+
const config = loadConfig()
|
|
35
|
+
expect(config.opencodeUrl).toBe("http://127.0.0.1:4096")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("uses custom opencodeUrl when set", async () => {
|
|
39
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
40
|
+
process.env.OPENCODE_URL = "http://localhost:9999"
|
|
41
|
+
const { loadConfig } = await import("./config?custom")
|
|
42
|
+
const config = loadConfig()
|
|
43
|
+
expect(config.opencodeUrl).toBe("http://localhost:9999")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("defaults testEnv to false", async () => {
|
|
47
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
48
|
+
const { loadConfig } = await import("./config?testenv1")
|
|
49
|
+
const config = loadConfig()
|
|
50
|
+
expect(config.testEnv).toBe(false)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("sets testEnv to true when TELEGRAM_TEST_ENV=1", async () => {
|
|
54
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
55
|
+
process.env.TELEGRAM_TEST_ENV = "1"
|
|
56
|
+
const { loadConfig } = await import("./config?testenv2")
|
|
57
|
+
const config = loadConfig()
|
|
58
|
+
expect(config.testEnv).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("parses e2e config with defaults", async () => {
|
|
62
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
63
|
+
delete process.env.TELEGRAM_API_ID
|
|
64
|
+
delete process.env.TELEGRAM_API_HASH
|
|
65
|
+
delete process.env.TELEGRAM_SESSION
|
|
66
|
+
delete process.env.TELEGRAM_BOT_USERNAME
|
|
67
|
+
const { loadConfig } = await import("./config?e2e1")
|
|
68
|
+
const config = loadConfig()
|
|
69
|
+
expect(config.e2e.apiId).toBe(0)
|
|
70
|
+
expect(config.e2e.apiHash).toBe("")
|
|
71
|
+
expect(config.e2e.session).toBe("")
|
|
72
|
+
expect(config.e2e.botUsername).toBe("")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("parses e2e config from env", async () => {
|
|
76
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
77
|
+
process.env.TELEGRAM_API_ID = "12345"
|
|
78
|
+
process.env.TELEGRAM_API_HASH = "abcdef"
|
|
79
|
+
process.env.TELEGRAM_SESSION = "session123"
|
|
80
|
+
process.env.TELEGRAM_BOT_USERNAME = "test_bot"
|
|
81
|
+
const { loadConfig } = await import("./config?e2e2")
|
|
82
|
+
const config = loadConfig()
|
|
83
|
+
expect(config.e2e.apiId).toBe(12345)
|
|
84
|
+
expect(config.e2e.apiHash).toBe("abcdef")
|
|
85
|
+
expect(config.e2e.session).toBe("session123")
|
|
86
|
+
expect(config.e2e.botUsername).toBe("test_bot")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("parses TELEGRAM_ALLOWED_USERS as number array", async () => {
|
|
90
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
91
|
+
process.env.TELEGRAM_ALLOWED_USERS = "111,222,333"
|
|
92
|
+
const { loadConfig } = await import("./config?allow1")
|
|
93
|
+
const config = loadConfig()
|
|
94
|
+
expect(config.allowedUsers).toEqual([111, 222, 333])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("defaults allowedUsers to empty array when not set", async () => {
|
|
98
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
99
|
+
delete process.env.TELEGRAM_ALLOWED_USERS
|
|
100
|
+
const { loadConfig } = await import("./config?allow2")
|
|
101
|
+
const config = loadConfig()
|
|
102
|
+
expect(config.allowedUsers).toEqual([])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("defaults allowedUsers to empty array when empty string", async () => {
|
|
106
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
107
|
+
process.env.TELEGRAM_ALLOWED_USERS = ""
|
|
108
|
+
const { loadConfig } = await import("./config?allow3")
|
|
109
|
+
const config = loadConfig()
|
|
110
|
+
expect(config.allowedUsers).toEqual([])
|
|
111
|
+
expect(config.allowAllUsers).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("TELEGRAM_ALLOWED_USERS='*' sets allowAllUsers=true", async () => {
|
|
115
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
116
|
+
process.env.TELEGRAM_ALLOWED_USERS = "*"
|
|
117
|
+
const { loadConfig } = await import("./config?allow4")
|
|
118
|
+
const config = loadConfig()
|
|
119
|
+
expect(config.allowAllUsers).toBe(true)
|
|
120
|
+
expect(config.allowedUsers).toEqual([])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test("allowAllUsers defaults to false when not set", async () => {
|
|
124
|
+
process.env.TELEGRAM_BOT_TOKEN = "123:ABC"
|
|
125
|
+
delete process.env.TELEGRAM_ALLOWED_USERS
|
|
126
|
+
const { loadConfig } = await import("./config?allow5")
|
|
127
|
+
const config = loadConfig()
|
|
128
|
+
expect(config.allowAllUsers).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
})
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type Config = {
|
|
|
3
3
|
opencodeUrl: string
|
|
4
4
|
projectDirectory: string
|
|
5
5
|
testEnv: boolean
|
|
6
|
+
allowedUsers: number[]
|
|
7
|
+
allowAllUsers: boolean
|
|
6
8
|
e2e: {
|
|
7
9
|
apiId: number
|
|
8
10
|
apiHash: string
|
|
@@ -17,11 +19,24 @@ export function loadConfig(): Config {
|
|
|
17
19
|
throw new Error("TELEGRAM_BOT_TOKEN is required")
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const rawAllowed = (process.env.TELEGRAM_ALLOWED_USERS ?? "").trim()
|
|
23
|
+
const allowAllUsers = rawAllowed === "*"
|
|
24
|
+
const allowedUsers = allowAllUsers
|
|
25
|
+
? []
|
|
26
|
+
: rawAllowed
|
|
27
|
+
.split(",")
|
|
28
|
+
.map((s) => s.trim())
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map(Number)
|
|
31
|
+
.filter((n) => !isNaN(n))
|
|
32
|
+
|
|
20
33
|
return {
|
|
21
34
|
botToken,
|
|
22
35
|
opencodeUrl: process.env.OPENCODE_URL ?? "http://127.0.0.1:4096",
|
|
23
36
|
projectDirectory: process.env.OPENCODE_DIRECTORY ?? process.cwd(),
|
|
24
37
|
testEnv: process.env.TELEGRAM_TEST_ENV === "1",
|
|
38
|
+
allowedUsers,
|
|
39
|
+
allowAllUsers,
|
|
25
40
|
e2e: {
|
|
26
41
|
apiId: Number(process.env.TELEGRAM_API_ID ?? 0),
|
|
27
42
|
apiHash: process.env.TELEGRAM_API_HASH ?? "",
|