@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.
Files changed (43) hide show
  1. package/.env.example +17 -0
  2. package/bunfig.toml +2 -0
  3. package/docs/PROGRESS.md +327 -0
  4. package/docs/mapping.md +326 -0
  5. package/docs/plans/phase-1.md +176 -0
  6. package/docs/plans/phase-2.md +235 -0
  7. package/docs/plans/phase-3.md +485 -0
  8. package/docs/plans/phase-4.md +566 -0
  9. package/docs/spec.md +2055 -0
  10. package/e2e/client.ts +24 -0
  11. package/e2e/helpers.ts +119 -0
  12. package/e2e/phase-0.test.ts +30 -0
  13. package/e2e/phase-1.test.ts +48 -0
  14. package/e2e/phase-2.test.ts +54 -0
  15. package/e2e/phase-3.test.ts +142 -0
  16. package/e2e/phase-4.test.ts +96 -0
  17. package/e2e/runner.ts +145 -0
  18. package/package.json +14 -12
  19. package/scripts/gen-session.ts +49 -0
  20. package/src/bot.test.ts +301 -0
  21. package/src/bot.ts +91 -0
  22. package/src/config.test.ts +130 -0
  23. package/src/config.ts +15 -0
  24. package/src/event-bus.test.ts +175 -0
  25. package/src/handlers/allowlist.test.ts +60 -0
  26. package/src/handlers/allowlist.ts +33 -0
  27. package/src/handlers/cancel.test.ts +105 -0
  28. package/src/handlers/permissions.test.ts +72 -0
  29. package/src/handlers/questions.test.ts +107 -0
  30. package/src/handlers/sessions.test.ts +479 -0
  31. package/src/handlers/sessions.ts +202 -0
  32. package/src/handlers/typing.test.ts +60 -0
  33. package/src/index.ts +26 -0
  34. package/src/pending-requests.test.ts +64 -0
  35. package/src/send/chunker.test.ts +74 -0
  36. package/src/send/draft-stream.test.ts +229 -0
  37. package/src/send/format.test.ts +143 -0
  38. package/src/send/tool-progress.test.ts +70 -0
  39. package/src/session-manager.test.ts +198 -0
  40. package/src/session-manager.ts +23 -0
  41. package/src/turn-manager.test.ts +155 -0
  42. package/src/turn-manager.ts +5 -0
  43. 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)
@@ -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 ?? "",