@pedrohnas/opencode-telegram 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
  2. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
  3. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
  4. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
  5. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
  6. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
  7. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
  8. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
  9. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
  10. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
  11. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
  12. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
  13. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
  14. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
  15. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
  16. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
  17. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
  18. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
  19. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
  20. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
  21. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
  22. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
  23. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
  24. package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
  25. package/docs/AUDIT.md +193 -0
  26. package/docs/PROGRESS.md +188 -0
  27. package/docs/plans/phase-5.md +410 -0
  28. package/docs/plans/phase-6.5.md +426 -0
  29. package/docs/plans/phase-6.md +349 -0
  30. package/e2e/helpers.ts +34 -0
  31. package/e2e/phase-5.test.ts +295 -0
  32. package/e2e/phase-6.5.test.ts +239 -0
  33. package/e2e/phase-6.test.ts +302 -0
  34. package/package.json +5 -2
  35. package/src/api-server.test.ts +309 -0
  36. package/src/api-server.ts +201 -0
  37. package/src/bot.test.ts +354 -0
  38. package/src/bot.ts +200 -2
  39. package/src/config.test.ts +16 -0
  40. package/src/config.ts +4 -0
  41. package/src/event-bus.test.ts +337 -1
  42. package/src/event-bus.ts +83 -3
  43. package/src/handlers/agents.test.ts +122 -0
  44. package/src/handlers/agents.ts +93 -0
  45. package/src/handlers/media.test.ts +264 -0
  46. package/src/handlers/media.ts +168 -0
  47. package/src/handlers/models.test.ts +319 -0
  48. package/src/handlers/models.ts +191 -0
  49. package/src/index.ts +15 -0
  50. package/src/send/draft-stream.test.ts +76 -0
  51. package/src/send/draft-stream.ts +13 -1
  52. package/src/session-manager.test.ts +46 -0
  53. package/src/session-manager.ts +10 -1
@@ -0,0 +1,319 @@
1
+ import { describe, test, expect, beforeEach, mock } from "bun:test"
2
+ import { SessionManager } from "../session-manager"
3
+ import {
4
+ parseModelCallback,
5
+ filterModels,
6
+ formatProviderList,
7
+ formatModelList,
8
+ formatCurrentModel,
9
+ handleModel,
10
+ handleModelSelect,
11
+ } from "./models"
12
+
13
+ // --- parseModelCallback ---
14
+
15
+ describe("parseModelCallback", () => {
16
+ test("parses provider callback", () => {
17
+ const result = parseModelCallback("mdl:anthropic")
18
+ expect(result).toEqual({ type: "provider", providerID: "anthropic" })
19
+ })
20
+
21
+ test("parses model callback with providerID:modelID", () => {
22
+ const result = parseModelCallback("mdl:anthropic:claude-sonnet-4-5-20250929")
23
+ expect(result).toEqual({
24
+ type: "model",
25
+ providerID: "anthropic",
26
+ modelID: "claude-sonnet-4-5-20250929",
27
+ })
28
+ })
29
+
30
+ test("parses back callback", () => {
31
+ expect(parseModelCallback("mdl:back")).toEqual({ type: "back" })
32
+ })
33
+
34
+ test("parses reset callback", () => {
35
+ expect(parseModelCallback("mdl:reset")).toEqual({ type: "reset" })
36
+ })
37
+
38
+ test("returns null for non-mdl prefix", () => {
39
+ expect(parseModelCallback("invalid")).toBeNull()
40
+ })
41
+
42
+ test("returns null for empty after prefix", () => {
43
+ expect(parseModelCallback("mdl:")).toBeNull()
44
+ })
45
+ })
46
+
47
+ // --- filterModels ---
48
+
49
+ describe("filterModels", () => {
50
+ test("groups by family, returns most recent per family", () => {
51
+ const models = [
52
+ { id: "opus-4", name: "Opus 4", family: "opus", release_date: "2025-04-01" },
53
+ { id: "opus-4.5", name: "Opus 4.5", family: "opus", release_date: "2025-09-01" },
54
+ { id: "opus-4.6", name: "Opus 4.6", family: "opus", release_date: "2025-12-01" },
55
+ { id: "sonnet-4.5", name: "Sonnet 4.5", family: "sonnet", release_date: "2025-09-01" },
56
+ ]
57
+ const result = filterModels(models)
58
+ expect(result.length).toBe(2)
59
+ expect(result.find((m: any) => m.family === "opus").id).toBe("opus-4.6")
60
+ expect(result.find((m: any) => m.family === "sonnet").id).toBe("sonnet-4.5")
61
+ })
62
+
63
+ test("keeps models without family field (uses id as key)", () => {
64
+ const models = [
65
+ { id: "custom-model", name: "Custom" },
66
+ { id: "other-model", name: "Other" },
67
+ ]
68
+ const result = filterModels(models)
69
+ expect(result.length).toBe(2)
70
+ })
71
+
72
+ test("handles single model per family (no-op)", () => {
73
+ const models = [
74
+ { id: "sonnet-4.5", name: "Sonnet 4.5", family: "sonnet", release_date: "2025-09-01" },
75
+ ]
76
+ const result = filterModels(models)
77
+ expect(result.length).toBe(1)
78
+ expect(result[0].id).toBe("sonnet-4.5")
79
+ })
80
+
81
+ test("sorts result by release_date descending", () => {
82
+ const models = [
83
+ { id: "old", name: "Old", family: "a", release_date: "2024-01-01" },
84
+ { id: "new", name: "New", family: "b", release_date: "2025-12-01" },
85
+ { id: "mid", name: "Mid", family: "c", release_date: "2025-06-01" },
86
+ ]
87
+ const result = filterModels(models)
88
+ expect(result[0].id).toBe("new")
89
+ expect(result[1].id).toBe("mid")
90
+ expect(result[2].id).toBe("old")
91
+ })
92
+
93
+ test("handles empty array", () => {
94
+ expect(filterModels([])).toEqual([])
95
+ })
96
+ })
97
+
98
+ // --- formatProviderList ---
99
+
100
+ describe("formatProviderList", () => {
101
+ test("formats 2 providers as inline keyboard rows with model count", () => {
102
+ const providers = [
103
+ { id: "anthropic", name: "Anthropic", models: { m1: {}, m2: {} } },
104
+ { id: "openai", name: "OpenAI", models: { m1: {} } },
105
+ ]
106
+ const result = formatProviderList(providers)
107
+ expect(result.reply_markup.inline_keyboard.length).toBe(2)
108
+ expect(result.reply_markup.inline_keyboard[0][0].text).toBe("Anthropic (2)")
109
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("mdl:anthropic")
110
+ expect(result.reply_markup.inline_keyboard[1][0].text).toBe("OpenAI (1)")
111
+ })
112
+
113
+ test("returns 'No providers' when list is empty", () => {
114
+ const result = formatProviderList([])
115
+ expect(result.text).toContain("No providers")
116
+ expect(result.reply_markup.inline_keyboard).toEqual([])
117
+ })
118
+
119
+ test("filters providers with 0 models", () => {
120
+ const providers = [
121
+ { id: "anthropic", name: "Anthropic", models: { m1: {} } },
122
+ { id: "empty", name: "Empty", models: {} },
123
+ ]
124
+ const result = formatProviderList(providers)
125
+ expect(result.reply_markup.inline_keyboard.length).toBe(1)
126
+ expect(result.reply_markup.inline_keyboard[0][0].text).toContain("Anthropic")
127
+ })
128
+
129
+ test("filters to only connected providers when connected array provided", () => {
130
+ const providers = [
131
+ { id: "anthropic", name: "Anthropic", models: { m1: {} } },
132
+ { id: "openai", name: "OpenAI", models: { m1: {} } },
133
+ { id: "google", name: "Google", models: { m1: {} } },
134
+ ]
135
+ const connected = ["anthropic", "google"]
136
+ const result = formatProviderList(providers, connected)
137
+ expect(result.reply_markup.inline_keyboard.length).toBe(2)
138
+ const texts = result.reply_markup.inline_keyboard.map((r: any) => r[0].callback_data)
139
+ expect(texts).toContain("mdl:anthropic")
140
+ expect(texts).toContain("mdl:google")
141
+ expect(texts).not.toContain("mdl:openai")
142
+ })
143
+
144
+ test("shows all providers when connected not provided (backward compat)", () => {
145
+ const providers = [
146
+ { id: "a", name: "A", models: { m1: {} } },
147
+ { id: "b", name: "B", models: { m1: {} } },
148
+ ]
149
+ const result = formatProviderList(providers)
150
+ expect(result.reply_markup.inline_keyboard.length).toBe(2)
151
+ })
152
+ })
153
+
154
+ // --- formatModelList ---
155
+
156
+ describe("formatModelList", () => {
157
+ test("formats 3 models as rows + back button", () => {
158
+ const models = [
159
+ { id: "claude-sonnet", name: "Claude Sonnet" },
160
+ { id: "claude-opus", name: "Claude Opus" },
161
+ { id: "claude-haiku", name: "Claude Haiku" },
162
+ ]
163
+ const result = formatModelList("anthropic", "Anthropic", models)
164
+ // 3 model rows + 1 back row
165
+ expect(result.reply_markup.inline_keyboard.length).toBe(4)
166
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe(
167
+ "mdl:anthropic:claude-sonnet",
168
+ )
169
+ // Last row is back button
170
+ const lastRow = result.reply_markup.inline_keyboard[3]
171
+ expect(lastRow[0].callback_data).toBe("mdl:back")
172
+ })
173
+
174
+ test("returns 'No models' with back button when empty", () => {
175
+ const result = formatModelList("anthropic", "Anthropic", [])
176
+ expect(result.text).toContain("No models")
177
+ // Still has back button
178
+ expect(result.reply_markup.inline_keyboard.length).toBe(1)
179
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("mdl:back")
180
+ })
181
+
182
+ test("callback_data includes providerID and modelID", () => {
183
+ const models = [{ id: "gpt-4o", name: "GPT-4o" }]
184
+ const result = formatModelList("openai", "OpenAI", models)
185
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("mdl:openai:gpt-4o")
186
+ })
187
+
188
+ test("marks active model with ✓ prefix", () => {
189
+ const models = [
190
+ { id: "claude-sonnet", name: "Claude Sonnet" },
191
+ { id: "claude-opus", name: "Claude Opus" },
192
+ ]
193
+ const result = formatModelList("anthropic", "Anthropic", models, "claude-opus")
194
+ expect(result.reply_markup.inline_keyboard[0][0].text).toBe("Claude Sonnet")
195
+ expect(result.reply_markup.inline_keyboard[1][0].text).toBe("✓ Claude Opus")
196
+ })
197
+
198
+ test("no ✓ when activeModelID not provided", () => {
199
+ const models = [
200
+ { id: "claude-sonnet", name: "Claude Sonnet" },
201
+ { id: "claude-opus", name: "Claude Opus" },
202
+ ]
203
+ const result = formatModelList("anthropic", "Anthropic", models)
204
+ expect(result.reply_markup.inline_keyboard[0][0].text).toBe("Claude Sonnet")
205
+ expect(result.reply_markup.inline_keyboard[1][0].text).toBe("Claude Opus")
206
+ })
207
+ })
208
+
209
+ // --- formatCurrentModel ---
210
+
211
+ describe("formatCurrentModel", () => {
212
+ test("shows provider/model when override set", () => {
213
+ const result = formatCurrentModel({ providerID: "anthropic", modelID: "claude-opus" })
214
+ expect(result).toContain("anthropic")
215
+ expect(result).toContain("claude-opus")
216
+ })
217
+
218
+ test("shows default message when no override", () => {
219
+ const result = formatCurrentModel(undefined)
220
+ expect(result).toContain("default")
221
+ })
222
+ })
223
+
224
+ // --- handleModel ---
225
+
226
+ describe("handleModel", () => {
227
+ let sm: SessionManager
228
+
229
+ beforeEach(() => {
230
+ sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
231
+ })
232
+
233
+ test("fetches providers and returns keyboard", async () => {
234
+ const sdk = {
235
+ provider: {
236
+ list: mock(async () => ({
237
+ data: {
238
+ all: [
239
+ { id: "anthropic", name: "Anthropic", models: { m1: { id: "m1", name: "M1" } } },
240
+ ],
241
+ connected: ["anthropic"],
242
+ },
243
+ })),
244
+ },
245
+ }
246
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
247
+ const result = await handleModel({ sdk: sdk as any, sessionManager: sm, chatKey: "123" })
248
+ expect(result.reply_markup.inline_keyboard.length).toBeGreaterThan(0)
249
+ expect(sdk.provider.list).toHaveBeenCalledTimes(1)
250
+ })
251
+
252
+ test("returns 'No providers' when list is empty", async () => {
253
+ const sdk = {
254
+ provider: {
255
+ list: mock(async () => ({ data: { all: [], connected: [] } })),
256
+ },
257
+ }
258
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
259
+ const result = await handleModel({ sdk: sdk as any, sessionManager: sm, chatKey: "123" })
260
+ expect(result.text).toContain("No providers")
261
+ })
262
+
263
+ test("passes connected array to formatProviderList", async () => {
264
+ const sdk = {
265
+ provider: {
266
+ list: mock(async () => ({
267
+ data: {
268
+ all: [
269
+ { id: "anthropic", name: "Anthropic", models: { m1: { id: "m1", name: "M1" } } },
270
+ { id: "openai", name: "OpenAI", models: { m1: { id: "m1", name: "M1" } } },
271
+ ],
272
+ connected: ["anthropic"],
273
+ },
274
+ })),
275
+ },
276
+ }
277
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
278
+ const result = await handleModel({ sdk: sdk as any, sessionManager: sm, chatKey: "123" })
279
+ // Only anthropic should be shown (connected)
280
+ expect(result.reply_markup.inline_keyboard.length).toBe(1)
281
+ expect(result.reply_markup.inline_keyboard[0][0].callback_data).toBe("mdl:anthropic")
282
+ })
283
+ })
284
+
285
+ // --- handleModelSelect ---
286
+
287
+ describe("handleModelSelect", () => {
288
+ let sm: SessionManager
289
+
290
+ beforeEach(() => {
291
+ sm = new SessionManager({ maxEntries: 10, ttlMs: 60000 })
292
+ sm.set("123", { sessionId: "s1", directory: "/tmp" })
293
+ })
294
+
295
+ test("stores override in sessionManager", async () => {
296
+ const result = await handleModelSelect({
297
+ chatKey: "123",
298
+ providerID: "anthropic",
299
+ modelID: "claude-opus",
300
+ sessionManager: sm,
301
+ })
302
+ expect(sm.get("123")?.modelOverride).toEqual({
303
+ providerID: "anthropic",
304
+ modelID: "claude-opus",
305
+ })
306
+ expect(result).toContain("anthropic")
307
+ expect(result).toContain("claude-opus")
308
+ })
309
+
310
+ test("returns error when no active session", async () => {
311
+ const result = await handleModelSelect({
312
+ chatKey: "999",
313
+ providerID: "anthropic",
314
+ modelID: "claude-opus",
315
+ sessionManager: sm,
316
+ })
317
+ expect(result).toContain("No active session")
318
+ })
319
+ })
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Model selection handlers — provider list → model list → store override.
3
+ *
4
+ * Exports:
5
+ * - parseModelCallback(data) — parse "mdl:" callback data
6
+ * - filterModels(models) — group by family, keep most recent per family
7
+ * - formatProviderList(providers, connected?) — inline keyboard of providers
8
+ * - formatModelList(providerID, providerName, models, activeModelID?) — inline keyboard of models
9
+ * - formatCurrentModel(override?) — text showing current selection
10
+ * - handleModel(params) — fetch providers, return keyboard
11
+ * - handleModelSelect(params) — store model override in SessionEntry
12
+ */
13
+
14
+ import type { OpencodeClient } from "@opencode-ai/sdk/v2"
15
+ import type { SessionManager } from "../session-manager"
16
+
17
+ // --- Pure functions ---
18
+
19
+ export type ModelCallbackResult =
20
+ | { type: "provider"; providerID: string }
21
+ | { type: "model"; providerID: string; modelID: string }
22
+ | { type: "back" }
23
+ | { type: "reset" }
24
+
25
+ export function parseModelCallback(data: string): ModelCallbackResult | null {
26
+ if (!data.startsWith("mdl:")) return null
27
+ const rest = data.slice(4)
28
+ if (!rest) return null
29
+
30
+ if (rest === "back") return { type: "back" }
31
+ if (rest === "reset") return { type: "reset" }
32
+
33
+ // "providerID:modelID" or just "providerID"
34
+ const firstColon = rest.indexOf(":")
35
+ if (firstColon === -1) {
36
+ return { type: "provider", providerID: rest }
37
+ }
38
+
39
+ const providerID = rest.slice(0, firstColon)
40
+ const modelID = rest.slice(firstColon + 1)
41
+ if (!providerID || !modelID) return null
42
+
43
+ return { type: "model", providerID, modelID }
44
+ }
45
+
46
+ /**
47
+ * Filter models: group by family, keep only the most recent per family.
48
+ * Models without a family field use their id as key (no grouping).
49
+ */
50
+ export function filterModels(models: any[]): any[] {
51
+ const families = new Map<string, any[]>()
52
+ for (const m of models) {
53
+ const key = m.family || m.id
54
+ if (!families.has(key)) families.set(key, [])
55
+ families.get(key)!.push(m)
56
+ }
57
+
58
+ const filtered: any[] = []
59
+ for (const [, members] of families) {
60
+ if (members.length === 1) {
61
+ filtered.push(members[0])
62
+ } else {
63
+ members.sort((a: any, b: any) =>
64
+ (b.release_date ?? "").localeCompare(a.release_date ?? ""),
65
+ )
66
+ filtered.push(members[0])
67
+ }
68
+ }
69
+
70
+ // Sort final list by release_date descending (newest first)
71
+ filtered.sort((a: any, b: any) =>
72
+ (b.release_date ?? "").localeCompare(a.release_date ?? ""),
73
+ )
74
+
75
+ return filtered
76
+ }
77
+
78
+ export function formatProviderList(
79
+ providers: any[],
80
+ connected?: string[],
81
+ ): {
82
+ text: string
83
+ reply_markup: { inline_keyboard: any[][] }
84
+ } {
85
+ // If connected list provided, filter to only those providers
86
+ let filtered = providers
87
+ if (connected) {
88
+ const connSet = new Set(connected)
89
+ filtered = providers.filter((p) => connSet.has(p.id))
90
+ }
91
+
92
+ const withModels = filtered.filter(
93
+ (p) => p.models && Object.keys(p.models).length > 0,
94
+ )
95
+
96
+ if (withModels.length === 0) {
97
+ return { text: "No providers available.", reply_markup: { inline_keyboard: [] } }
98
+ }
99
+
100
+ const rows = withModels.map((p) => {
101
+ const modelCount = Object.keys(p.models).length
102
+ return [
103
+ { text: `${p.name || p.id} (${modelCount})`, callback_data: `mdl:${p.id}` },
104
+ ]
105
+ })
106
+
107
+ return {
108
+ text: "Select a provider:",
109
+ reply_markup: { inline_keyboard: rows },
110
+ }
111
+ }
112
+
113
+ export function formatModelList(
114
+ providerID: string,
115
+ providerName: string,
116
+ models: any[],
117
+ activeModelID?: string,
118
+ ): {
119
+ text: string
120
+ reply_markup: { inline_keyboard: any[][] }
121
+ } {
122
+ const backRow = [{ text: "⬅ Back", callback_data: "mdl:back" }]
123
+
124
+ if (models.length === 0) {
125
+ return {
126
+ text: `No models available for ${providerName}.`,
127
+ reply_markup: { inline_keyboard: [backRow] },
128
+ }
129
+ }
130
+
131
+ const rows = models.map((m) => {
132
+ const prefix = activeModelID && m.id === activeModelID ? "✓ " : ""
133
+ return [
134
+ { text: `${prefix}${m.name || m.id}`, callback_data: `mdl:${providerID}:${m.id}` },
135
+ ]
136
+ })
137
+
138
+ rows.push(backRow)
139
+
140
+ return {
141
+ text: `Models for ${providerName}:`,
142
+ reply_markup: { inline_keyboard: rows },
143
+ }
144
+ }
145
+
146
+ export function formatCurrentModel(
147
+ override?: { providerID: string; modelID: string },
148
+ ): string {
149
+ if (!override) return "Using default model."
150
+ return `Current model: ${override.providerID}/${override.modelID}`
151
+ }
152
+
153
+ // --- Async handlers ---
154
+
155
+ export async function handleModel(params: {
156
+ sdk: OpencodeClient
157
+ sessionManager: SessionManager
158
+ chatKey: string
159
+ }): Promise<{ text: string; reply_markup: { inline_keyboard: any[][] } }> {
160
+ const { sdk, sessionManager, chatKey } = params
161
+ const result = await sdk.provider.list()
162
+ const data = (result as any).data ?? {}
163
+ const providers = data.all ?? []
164
+ const connected = data.connected as string[] | undefined
165
+ const entry = sessionManager.get(chatKey)
166
+ const currentText = formatCurrentModel(entry?.modelOverride)
167
+ const list = formatProviderList(providers, connected)
168
+
169
+ return {
170
+ text: `${currentText}\n\n${list.text}`,
171
+ reply_markup: list.reply_markup,
172
+ }
173
+ }
174
+
175
+ export async function handleModelSelect(params: {
176
+ chatKey: string
177
+ providerID: string
178
+ modelID: string
179
+ sessionManager: SessionManager
180
+ }): Promise<string> {
181
+ const { chatKey, providerID, modelID, sessionManager } = params
182
+ const entry = sessionManager.get(chatKey)
183
+ if (!entry) return "No active session."
184
+
185
+ sessionManager.set(chatKey, {
186
+ ...entry,
187
+ modelOverride: { providerID, modelID },
188
+ })
189
+
190
+ return `Model set to: ${providerID}/${modelID}`
191
+ }
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ import { SessionManager } from "./session-manager"
15
15
  import { TurnManager } from "./turn-manager"
16
16
  import { EventBus } from "./event-bus"
17
17
  import { PendingRequests } from "./pending-requests"
18
+ import { apiThrottler } from "@grammyjs/transformer-throttler"
19
+ import { createApiServer } from "./api-server"
18
20
  import { markdownToTelegramHtml } from "./send/format"
19
21
  import { chunkMessage } from "./send/chunker"
20
22
  import { formatPermissionMessage } from "./handlers/permissions"
@@ -63,6 +65,16 @@ try {
63
65
  // --- Create bot with deps ---
64
66
  const bot = createBot(config, { sdk, sessionManager, turnManager, pendingRequests })
65
67
 
68
+ // API throttler — automatic 429 rate limit handling for Telegram API
69
+ bot.api.config.use(apiThrottler() as any)
70
+
71
+ // --- Bot Control API server ---
72
+ const apiServer = createApiServer(
73
+ { sessionManager, turnManager, sdk },
74
+ config.apiPort,
75
+ )
76
+ console.log(`Bot Control API on http://127.0.0.1:${config.apiPort}`)
77
+
66
78
  // --- Response sender (format + chunk + send) ---
67
79
  async function sendFormattedResponse(chatId: number, markdown: string) {
68
80
  const html = markdownToTelegramHtml(markdown)
@@ -249,6 +261,7 @@ const shutdown = async () => {
249
261
  clearInterval(cleanupInterval)
250
262
  eventBus.stop()
251
263
  turnManager.abortAll()
264
+ apiServer.stop()
252
265
  await bot.stop()
253
266
  sdkHandle.cleanup()
254
267
  process.exit(0)
@@ -269,6 +282,8 @@ try {
269
282
  { command: "info", description: "Session info" },
270
283
  { command: "history", description: "Recent messages" },
271
284
  { command: "summarize", description: "Summarize session" },
285
+ { command: "model", description: "Select model" },
286
+ { command: "agent", description: "Select agent" },
272
287
  ])
273
288
  console.log("Command menu registered")
274
289
  } catch (err) {
@@ -226,4 +226,80 @@ describe("DraftStream", () => {
226
226
  const ds = new DraftStream(deps, 123, ac.signal)
227
227
  expect(ds.isStopped()).toBe(true)
228
228
  })
229
+
230
+ // --- Race condition: concurrent updates before first sendMessage resolves ---
231
+
232
+ test("concurrent updates while first sendMessage is in-flight only send once", async () => {
233
+ // Simulate a slow sendMessage (e.g., network latency)
234
+ let resolveFirst: ((v: { message_id: number }) => void) | null = null
235
+ deps.sendMessage = mock(
236
+ () => new Promise<{ message_id: number }>((resolve) => { resolveFirst = resolve }),
237
+ )
238
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
239
+
240
+ // Fire multiple concurrent updates — all see messageId === null
241
+ const p1 = ds.update("v1")
242
+ const p2 = ds.update("v2")
243
+ const p3 = ds.update("v3")
244
+
245
+ // Only ONE sendMessage call should have been made (the first)
246
+ expect(deps.sendMessage).toHaveBeenCalledTimes(1)
247
+
248
+ // Resolve the first sendMessage
249
+ resolveFirst!({ message_id: 42 })
250
+ await p1
251
+ await p2
252
+ await p3
253
+
254
+ // Still only one sendMessage call
255
+ expect(deps.sendMessage).toHaveBeenCalledTimes(1)
256
+ expect(ds.getMessageId()).toBe(42)
257
+
258
+ // The pending text "v3" differs from initial — a flush should be scheduled
259
+ await sleep(100)
260
+ expect(deps.editMessageText).toHaveBeenCalledTimes(1)
261
+ ac.abort()
262
+ })
263
+
264
+ test("concurrent updates during send store latest pending text", async () => {
265
+ let resolveFirst: ((v: { message_id: number }) => void) | null = null
266
+ deps.sendMessage = mock(
267
+ () => new Promise<{ message_id: number }>((resolve) => { resolveFirst = resolve }),
268
+ )
269
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
270
+
271
+ // Start first update (goes into sending state)
272
+ const p1 = ds.update("first")
273
+ // These arrive while sending — should just update pending
274
+ ds.update("second")
275
+ ds.update("final text")
276
+
277
+ resolveFirst!({ message_id: 99 })
278
+ await p1
279
+
280
+ // After flush, the edit should contain "final text"
281
+ await sleep(100)
282
+ const editedText = deps.editMessageText.mock.calls[0]?.[2] as string
283
+ expect(editedText).toContain("final text")
284
+ ac.abort()
285
+ })
286
+
287
+ test("if sendMessage fails during race, subsequent updates retry send", async () => {
288
+ let callCount = 0
289
+ deps.sendMessage = mock(async () => {
290
+ callCount++
291
+ if (callCount === 1) throw new Error("network error")
292
+ return { message_id: 55 }
293
+ })
294
+ const ds = new DraftStream(deps, 123, ac.signal, 50)
295
+
296
+ // First update fails
297
+ await ds.update("attempt1")
298
+ expect(ds.getMessageId()).toBeNull()
299
+
300
+ // Second update should retry (sending flag is cleared after failure)
301
+ await ds.update("attempt2")
302
+ expect(ds.getMessageId()).toBe(55)
303
+ ac.abort()
304
+ })
229
305
  })
@@ -34,6 +34,7 @@ export class DraftStream {
34
34
  private pending = ""
35
35
  private timer: ReturnType<typeof setTimeout> | null = null
36
36
  private stopped = false
37
+ private sending = false
37
38
  private flushing = false
38
39
  private _htmlFailed = false
39
40
  readonly throttleMs: number
@@ -57,8 +58,13 @@ export class DraftStream {
57
58
  if (this.stopped || !text.trim()) return
58
59
  this.pending = text
59
60
 
61
+ // Guard: if already sending the initial message, just store pending and return.
62
+ // This prevents concurrent sendMessage calls when multiple SSE events arrive
63
+ // before the first sendMessage resolves (race condition with fast models).
64
+ if (this.sending) return
65
+
60
66
  if (this.messageId === null) {
61
- // First update — send initial message
67
+ this.sending = true
62
68
  const truncated = text.slice(0, 4096)
63
69
  try {
64
70
  const html = markdownToTelegramHtml(truncated)
@@ -71,6 +77,12 @@ export class DraftStream {
71
77
  } catch {
72
78
  // sendMessage failed — messageId stays null
73
79
  }
80
+ this.sending = false
81
+
82
+ // If pending changed while we were sending, schedule a flush
83
+ if (this.messageId !== null && this.pending.slice(0, 4096) !== this.lastText) {
84
+ this.scheduleFlush()
85
+ }
74
86
  return
75
87
  }
76
88