@pedrohnas/opencode-telegram 1.2.0 → 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.
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-41-55-194Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-42-17-115Z.yml +36 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-15-988Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-43-26-107Z.yml +26 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-03-139Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-21-579Z.yml +29 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-45-48-051Z.yml +30 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-27-632Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-46-46-519Z.yml +33 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-28-491Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-34-834Z.yml +349 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-47-54-066Z.yml +168 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-48-19-667Z.yml +219 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-32-311Z.yml +221 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-49-57-109Z.yml +230 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-24-052Z.yml +235 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-50-41-148Z.yml +248 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-10-916Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-51-28-271Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-32-324Z.yml +234 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-52-47-801Z.yml +196 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-07-361Z.yml +203 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-56-35-534Z.yml +49 -0
- package/.claude/skills/playwright-cli/data/page-2026-02-09T01-57-04-658Z.yml +52 -0
- package/docs/AUDIT.md +193 -0
- package/docs/PROGRESS.md +188 -0
- package/docs/plans/phase-5.md +410 -0
- package/docs/plans/phase-6.5.md +426 -0
- package/docs/plans/phase-6.md +349 -0
- package/e2e/helpers.ts +34 -0
- package/e2e/phase-5.test.ts +295 -0
- package/e2e/phase-6.5.test.ts +239 -0
- package/e2e/phase-6.test.ts +302 -0
- package/package.json +6 -3
- package/src/api-server.test.ts +309 -0
- package/src/api-server.ts +201 -0
- package/src/bot.test.ts +354 -0
- package/src/bot.ts +200 -2
- package/src/config.test.ts +16 -0
- package/src/config.ts +4 -0
- package/src/event-bus.test.ts +337 -1
- package/src/event-bus.ts +83 -3
- package/src/handlers/agents.test.ts +122 -0
- package/src/handlers/agents.ts +93 -0
- package/src/handlers/media.test.ts +264 -0
- package/src/handlers/media.ts +168 -0
- package/src/handlers/models.test.ts +319 -0
- package/src/handlers/models.ts +191 -0
- package/src/index.ts +15 -0
- package/src/send/draft-stream.test.ts +76 -0
- package/src/send/draft-stream.ts +13 -1
- package/src/session-manager.test.ts +46 -0
- 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
|
})
|
package/src/send/draft-stream.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|