@pedrohnas/opencode-telegram 1.2.1 → 1.3.1

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 (39) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-40-45-070Z.png +0 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-15-698Z.yml +168 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-25-514Z.yml +219 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-40-888Z.yml +221 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-46-079Z.yml +230 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-41-53-985Z.yml +235 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-03-227Z.yml +235 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-08-587Z.yml +248 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-16-524Z.yml +234 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T02-42-26-086Z.yml +196 -0
  11. package/docs/AUDIT.md +193 -0
  12. package/docs/PROGRESS.md +191 -0
  13. package/docs/plans/phase-5.md +410 -0
  14. package/docs/plans/phase-6.5.md +426 -0
  15. package/docs/plans/phase-6.md +349 -0
  16. package/e2e/helpers.ts +34 -0
  17. package/e2e/phase-5.test.ts +295 -0
  18. package/e2e/phase-6.5.test.ts +239 -0
  19. package/e2e/phase-6.test.ts +302 -0
  20. package/package.json +5 -2
  21. package/src/api-server.test.ts +309 -0
  22. package/src/api-server.ts +201 -0
  23. package/src/bot.test.ts +354 -0
  24. package/src/bot.ts +200 -2
  25. package/src/config.test.ts +16 -0
  26. package/src/config.ts +4 -0
  27. package/src/event-bus.test.ts +337 -1
  28. package/src/event-bus.ts +83 -3
  29. package/src/handlers/agents.test.ts +122 -0
  30. package/src/handlers/agents.ts +93 -0
  31. package/src/handlers/media.test.ts +264 -0
  32. package/src/handlers/media.ts +168 -0
  33. package/src/handlers/models.test.ts +319 -0
  34. package/src/handlers/models.ts +191 -0
  35. package/src/index.ts +15 -0
  36. package/src/send/draft-stream.test.ts +76 -0
  37. package/src/send/draft-stream.ts +13 -1
  38. package/src/session-manager.test.ts +46 -0
  39. package/src/session-manager.ts +10 -1
@@ -0,0 +1,309 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test"
2
+ import { createApiApp, type ApiDeps } from "./api-server"
3
+ import { SessionManager } from "./session-manager"
4
+ import { TurnManager } from "./turn-manager"
5
+
6
+ function createMockSdk() {
7
+ return {
8
+ provider: {
9
+ list: mock(async () => ({
10
+ data: {
11
+ all: [
12
+ {
13
+ id: "anthropic",
14
+ name: "Anthropic",
15
+ models: {
16
+ "opus-4.6": {
17
+ id: "opus-4.6",
18
+ name: "Claude Opus 4.6",
19
+ family: "opus",
20
+ release_date: "2025-12-01",
21
+ },
22
+ "opus-4.5": {
23
+ id: "opus-4.5",
24
+ name: "Claude Opus 4.5",
25
+ family: "opus",
26
+ release_date: "2025-09-01",
27
+ },
28
+ "sonnet-4.5": {
29
+ id: "sonnet-4.5",
30
+ name: "Claude Sonnet 4.5",
31
+ family: "sonnet",
32
+ release_date: "2025-09-01",
33
+ },
34
+ },
35
+ },
36
+ ],
37
+ connected: ["anthropic"],
38
+ default: { anthropic: "sonnet-4.5" },
39
+ },
40
+ })),
41
+ },
42
+ app: {
43
+ agents: mock(async () => ({
44
+ data: [
45
+ { name: "code", description: "Coding agent" },
46
+ { name: "build", description: "Build agent" },
47
+ { name: "hidden-agent", description: "Secret", hidden: true },
48
+ ],
49
+ })),
50
+ },
51
+ session: {
52
+ create: mock(async (params: any) => ({
53
+ data: {
54
+ id: "new-session-id",
55
+ title: params.title,
56
+ directory: "/tmp",
57
+ },
58
+ })),
59
+ },
60
+ }
61
+ }
62
+
63
+ function createDeps(sdkOverride?: any): { deps: ApiDeps; sm: SessionManager; tm: TurnManager } {
64
+ const sm = new SessionManager({ maxEntries: 100, ttlMs: 60000 })
65
+ const tm = new TurnManager()
66
+ const sdk = sdkOverride ?? createMockSdk()
67
+ return { deps: { sessionManager: sm, turnManager: tm, sdk: sdk as any }, sm, tm }
68
+ }
69
+
70
+ // --- Session endpoints ---
71
+
72
+ describe("GET /api/sessions", () => {
73
+ test("returns all active sessions", async () => {
74
+ const { deps, sm } = createDeps()
75
+ sm.set("123", { sessionId: "s1", directory: "/tmp", modelOverride: { providerID: "anthropic", modelID: "opus" } })
76
+ sm.set("456", { sessionId: "s2", directory: "/tmp", agentOverride: "code" })
77
+
78
+ const app = createApiApp(deps)
79
+ const res = await app.request("/api/sessions")
80
+ expect(res.status).toBe(200)
81
+
82
+ const data = await res.json()
83
+ expect(data.length).toBe(2)
84
+ expect(data.find((s: any) => s.chatId === "123").model).toEqual({ providerID: "anthropic", modelID: "opus" })
85
+ expect(data.find((s: any) => s.chatId === "456").agent).toBe("code")
86
+ })
87
+
88
+ test("returns empty array when no sessions", async () => {
89
+ const { deps } = createDeps()
90
+ const app = createApiApp(deps)
91
+ const res = await app.request("/api/sessions")
92
+ const data = await res.json()
93
+ expect(data).toEqual([])
94
+ })
95
+ })
96
+
97
+ describe("GET /api/session/:sessionId", () => {
98
+ test("returns session info", async () => {
99
+ const { deps, sm, tm } = createDeps()
100
+ sm.set("123", {
101
+ sessionId: "s1",
102
+ directory: "/tmp",
103
+ modelOverride: { providerID: "google", modelID: "gemini" },
104
+ agentOverride: "build",
105
+ })
106
+
107
+ const app = createApiApp(deps)
108
+ const res = await app.request("/api/session/s1")
109
+ expect(res.status).toBe(200)
110
+
111
+ const data = await res.json()
112
+ expect(data.chatId).toBe("123")
113
+ expect(data.sessionId).toBe("s1")
114
+ expect(data.model).toEqual({ providerID: "google", modelID: "gemini" })
115
+ expect(data.agent).toBe("build")
116
+ expect(data.hasTurn).toBe(false)
117
+ })
118
+
119
+ test("returns 404 for unknown session", async () => {
120
+ const { deps } = createDeps()
121
+ const app = createApiApp(deps)
122
+ const res = await app.request("/api/session/nonexistent")
123
+ expect(res.status).toBe(404)
124
+ })
125
+
126
+ test("shows hasTurn=true when turn is active", async () => {
127
+ const { deps, sm, tm } = createDeps()
128
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
129
+ tm.start("s1", 123)
130
+
131
+ const app = createApiApp(deps)
132
+ const res = await app.request("/api/session/s1")
133
+ const data = await res.json()
134
+ expect(data.hasTurn).toBe(true)
135
+
136
+ tm.end("s1")
137
+ })
138
+ })
139
+
140
+ // --- Model/Agent override ---
141
+
142
+ describe("POST /api/session/:sessionId/model", () => {
143
+ test("sets modelOverride in SessionManager", async () => {
144
+ const { deps, sm } = createDeps()
145
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
146
+
147
+ const app = createApiApp(deps)
148
+ const res = await app.request("/api/session/s1/model", {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ providerID: "anthropic", modelID: "opus-4.6" }),
152
+ })
153
+ expect(res.status).toBe(200)
154
+
155
+ const data = await res.json()
156
+ expect(data.model).toEqual({ providerID: "anthropic", modelID: "opus-4.6" })
157
+
158
+ // Verify SessionManager was updated
159
+ expect(sm.get("123")?.modelOverride).toEqual({ providerID: "anthropic", modelID: "opus-4.6" })
160
+ })
161
+
162
+ test("returns 404 for unknown session", async () => {
163
+ const { deps } = createDeps()
164
+ const app = createApiApp(deps)
165
+ const res = await app.request("/api/session/nonexistent/model", {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify({ providerID: "anthropic", modelID: "opus" }),
169
+ })
170
+ expect(res.status).toBe(404)
171
+ })
172
+
173
+ test("returns 400 when providerID or modelID missing", async () => {
174
+ const { deps, sm } = createDeps()
175
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
176
+
177
+ const app = createApiApp(deps)
178
+ const res = await app.request("/api/session/s1/model", {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/json" },
181
+ body: JSON.stringify({ providerID: "anthropic" }),
182
+ })
183
+ expect(res.status).toBe(400)
184
+ })
185
+ })
186
+
187
+ describe("POST /api/session/:sessionId/agent", () => {
188
+ test("sets agentOverride in SessionManager", async () => {
189
+ const { deps, sm } = createDeps()
190
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
191
+
192
+ const app = createApiApp(deps)
193
+ const res = await app.request("/api/session/s1/agent", {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/json" },
196
+ body: JSON.stringify({ agent: "code" }),
197
+ })
198
+ expect(res.status).toBe(200)
199
+
200
+ const data = await res.json()
201
+ expect(data.agent).toBe("code")
202
+ expect(sm.get("123")?.agentOverride).toBe("code")
203
+ })
204
+
205
+ test("returns 404 for unknown session", async () => {
206
+ const { deps } = createDeps()
207
+ const app = createApiApp(deps)
208
+ const res = await app.request("/api/session/nonexistent/agent", {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: JSON.stringify({ agent: "code" }),
212
+ })
213
+ expect(res.status).toBe(404)
214
+ })
215
+
216
+ test("returns 400 when agent missing", async () => {
217
+ const { deps, sm } = createDeps()
218
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
219
+
220
+ const app = createApiApp(deps)
221
+ const res = await app.request("/api/session/s1/agent", {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json" },
224
+ body: JSON.stringify({}),
225
+ })
226
+ expect(res.status).toBe(400)
227
+ })
228
+ })
229
+
230
+ // --- Session management ---
231
+
232
+ describe("POST /api/session/:sessionId/new", () => {
233
+ test("creates new session for the chat", async () => {
234
+ const { deps, sm } = createDeps()
235
+ sm.set("123", {
236
+ sessionId: "old-session",
237
+ directory: "/tmp",
238
+ modelOverride: { providerID: "anthropic", modelID: "opus" },
239
+ agentOverride: "code",
240
+ })
241
+
242
+ const app = createApiApp(deps)
243
+ const res = await app.request("/api/session/old-session/new", {
244
+ method: "POST",
245
+ })
246
+ expect(res.status).toBe(200)
247
+
248
+ const data = await res.json()
249
+ expect(data.sessionId).toBe("new-session-id")
250
+ expect(data.oldSessionId).toBe("old-session")
251
+ expect(data.chatId).toBe("123")
252
+ // Overrides should be preserved
253
+ expect(data.model).toEqual({ providerID: "anthropic", modelID: "opus" })
254
+ expect(data.agent).toBe("code")
255
+
256
+ // Verify SessionManager was updated
257
+ const entry = sm.get("123")
258
+ expect(entry?.sessionId).toBe("new-session-id")
259
+ expect(entry?.modelOverride).toEqual({ providerID: "anthropic", modelID: "opus" })
260
+ })
261
+
262
+ test("returns 404 for unknown session", async () => {
263
+ const { deps } = createDeps()
264
+ const app = createApiApp(deps)
265
+ const res = await app.request("/api/session/nonexistent/new", {
266
+ method: "POST",
267
+ })
268
+ expect(res.status).toBe(404)
269
+ })
270
+ })
271
+
272
+ // --- Provider/agent listing ---
273
+
274
+ describe("GET /api/models", () => {
275
+ test("returns connected providers with filtered models", async () => {
276
+ const { deps } = createDeps()
277
+ const app = createApiApp(deps)
278
+ const res = await app.request("/api/models")
279
+ expect(res.status).toBe(200)
280
+
281
+ const data = await res.json()
282
+ expect(data.length).toBe(1) // Only anthropic (connected)
283
+ expect(data[0].id).toBe("anthropic")
284
+ expect(data[0].default).toBe("sonnet-4.5")
285
+
286
+ // Should be filtered (opus family → only opus-4.6, sonnet family → sonnet-4.5)
287
+ expect(data[0].models.length).toBe(2)
288
+ const modelIds = data[0].models.map((m: any) => m.id)
289
+ expect(modelIds).toContain("opus-4.6")
290
+ expect(modelIds).toContain("sonnet-4.5")
291
+ expect(modelIds).not.toContain("opus-4.5") // filtered out by filterModels
292
+ })
293
+ })
294
+
295
+ describe("GET /api/agents", () => {
296
+ test("returns available agents (excludes hidden)", async () => {
297
+ const { deps } = createDeps()
298
+ const app = createApiApp(deps)
299
+ const res = await app.request("/api/agents")
300
+ expect(res.status).toBe(200)
301
+
302
+ const data = await res.json()
303
+ expect(data.length).toBe(2) // code + build, hidden excluded
304
+ const names = data.map((a: any) => a.name)
305
+ expect(names).toContain("code")
306
+ expect(names).toContain("build")
307
+ expect(names).not.toContain("hidden-agent")
308
+ })
309
+ })
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Bot Control API — Hono HTTP server exposing bot state for AI-driven control.
3
+ *
4
+ * Endpoints:
5
+ * GET /api/sessions — List all active sessions
6
+ * GET /api/session/:sessionId — Get session info
7
+ * POST /api/session/:sessionId/model — Set model override
8
+ * POST /api/session/:sessionId/agent — Set agent override
9
+ * POST /api/session/:sessionId/new — Create new session for the chat
10
+ * GET /api/models — Connected providers + filtered models
11
+ * GET /api/agents — Available agents
12
+ */
13
+
14
+ import { Hono } from "hono"
15
+ import type { SessionManager } from "./session-manager"
16
+ import type { TurnManager } from "./turn-manager"
17
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
18
+ import { filterModels } from "./handlers/models"
19
+
20
+ export type ApiDeps = {
21
+ sessionManager: SessionManager
22
+ turnManager: TurnManager
23
+ sdk: OpencodeClient
24
+ }
25
+
26
+ export function createApiApp(deps: ApiDeps) {
27
+ const { sessionManager, turnManager, sdk } = deps
28
+
29
+ const app = new Hono()
30
+
31
+ app.get("/api/sessions", (c) => {
32
+ const sessions: any[] = []
33
+ // SessionManager doesn't expose iteration — use getBySessionId reverse map
34
+ // We need to access internal state. Add a list method or iterate differently.
35
+ // For now, use the approach of listing all from SDK and matching
36
+ // Actually, let's expose what we need: all active sessions in memory
37
+ const entries = (sessionManager as any).map as Map<string, any>
38
+ for (const [chatKey, entry] of entries) {
39
+ sessions.push({
40
+ chatId: chatKey,
41
+ sessionId: entry.sessionId,
42
+ model: entry.modelOverride ?? null,
43
+ agent: entry.agentOverride ?? null,
44
+ })
45
+ }
46
+ return c.json(sessions)
47
+ })
48
+
49
+ app.get("/api/session/:sessionId", (c) => {
50
+ const sessionId = c.req.param("sessionId")
51
+ const lookup = sessionManager.getBySessionId(sessionId)
52
+ if (!lookup) {
53
+ return c.json({ error: "Session not found" }, 404)
54
+ }
55
+ const turn = turnManager.get(sessionId)
56
+ return c.json({
57
+ chatId: lookup.chatKey,
58
+ sessionId: lookup.entry.sessionId,
59
+ model: lookup.entry.modelOverride ?? null,
60
+ agent: lookup.entry.agentOverride ?? null,
61
+ hasTurn: !!turn,
62
+ })
63
+ })
64
+
65
+ app.post("/api/session/:sessionId/model", async (c) => {
66
+ const sessionId = c.req.param("sessionId")
67
+ const lookup = sessionManager.getBySessionId(sessionId)
68
+ if (!lookup) {
69
+ return c.json({ error: "Session not found" }, 404)
70
+ }
71
+ const body = await c.req.json()
72
+ const { providerID, modelID } = body
73
+ if (!providerID || !modelID) {
74
+ return c.json({ error: "providerID and modelID are required" }, 400)
75
+ }
76
+ sessionManager.set(lookup.chatKey, {
77
+ ...lookup.entry,
78
+ modelOverride: { providerID, modelID },
79
+ })
80
+ return c.json({
81
+ chatId: lookup.chatKey,
82
+ sessionId,
83
+ model: { providerID, modelID },
84
+ agent: lookup.entry.agentOverride ?? null,
85
+ })
86
+ })
87
+
88
+ app.post("/api/session/:sessionId/agent", async (c) => {
89
+ const sessionId = c.req.param("sessionId")
90
+ const lookup = sessionManager.getBySessionId(sessionId)
91
+ if (!lookup) {
92
+ return c.json({ error: "Session not found" }, 404)
93
+ }
94
+ const body = await c.req.json()
95
+ const { agent } = body
96
+ if (!agent) {
97
+ return c.json({ error: "agent is required" }, 400)
98
+ }
99
+ sessionManager.set(lookup.chatKey, {
100
+ ...lookup.entry,
101
+ agentOverride: agent,
102
+ })
103
+ return c.json({
104
+ chatId: lookup.chatKey,
105
+ sessionId,
106
+ model: lookup.entry.modelOverride ?? null,
107
+ agent,
108
+ })
109
+ })
110
+
111
+ app.post("/api/session/:sessionId/new", async (c) => {
112
+ const sessionId = c.req.param("sessionId")
113
+ const lookup = sessionManager.getBySessionId(sessionId)
114
+ if (!lookup) {
115
+ return c.json({ error: "Session not found" }, 404)
116
+ }
117
+
118
+ const chatKey = lookup.chatKey
119
+ const oldOverrides = {
120
+ modelOverride: lookup.entry.modelOverride,
121
+ agentOverride: lookup.entry.agentOverride,
122
+ }
123
+
124
+ // Remove old session mapping
125
+ sessionManager.remove(chatKey)
126
+
127
+ // Create new session via SDK
128
+ const result = await sdk.session.create({
129
+ title: `Telegram ${chatKey}`,
130
+ })
131
+ const session = result.data!
132
+
133
+ // Set new session with preserved overrides
134
+ sessionManager.set(chatKey, {
135
+ sessionId: session.id,
136
+ directory: session.directory ?? "",
137
+ ...oldOverrides,
138
+ })
139
+
140
+ return c.json({
141
+ chatId: chatKey,
142
+ sessionId: session.id,
143
+ oldSessionId: sessionId,
144
+ model: oldOverrides.modelOverride ?? null,
145
+ agent: oldOverrides.agentOverride ?? null,
146
+ })
147
+ })
148
+
149
+ app.get("/api/models", async (c) => {
150
+ const result = await sdk.provider.list()
151
+ const data = (result as any).data ?? {}
152
+ const providers = data.all ?? []
153
+ const connected = data.connected as string[] | undefined
154
+ const defaults = data.default ?? {}
155
+
156
+ const connectedProviders = connected
157
+ ? providers.filter((p: any) => connected.includes(p.id))
158
+ : providers
159
+
160
+ const output = connectedProviders.map((p: any) => {
161
+ const allModels = Object.values(p.models ?? {}) as any[]
162
+ const filtered = filterModels(allModels)
163
+ return {
164
+ id: p.id,
165
+ name: p.name,
166
+ default: defaults[p.id] ?? null,
167
+ models: filtered.map((m: any) => ({
168
+ id: m.id,
169
+ name: m.name,
170
+ family: m.family ?? null,
171
+ release_date: m.release_date ?? null,
172
+ })),
173
+ }
174
+ })
175
+
176
+ return c.json(output)
177
+ })
178
+
179
+ app.get("/api/agents", async (c) => {
180
+ const result = await sdk.app.agents()
181
+ const agents = (result as any).data ?? []
182
+ const visible = agents.filter((a: any) => !a.hidden)
183
+ return c.json(
184
+ visible.map((a: any) => ({
185
+ name: a.name,
186
+ description: a.description ?? null,
187
+ })),
188
+ )
189
+ })
190
+
191
+ return app
192
+ }
193
+
194
+ export function createApiServer(deps: ApiDeps, port: number) {
195
+ const app = createApiApp(deps)
196
+ return Bun.serve({
197
+ hostname: "127.0.0.1",
198
+ port,
199
+ fetch: app.fetch,
200
+ })
201
+ }