paperclip-plugin-telegram 0.2.0 → 0.2.2

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 (62) hide show
  1. package/README.md +68 -4
  2. package/dist/acp-bridge.d.ts +34 -0
  3. package/dist/acp-bridge.js +805 -0
  4. package/dist/acp-bridge.js.map +1 -0
  5. package/dist/adapter.d.ts +35 -0
  6. package/dist/adapter.js +75 -0
  7. package/dist/adapter.js.map +1 -0
  8. package/dist/command-registry.d.ts +3 -0
  9. package/dist/command-registry.js +273 -0
  10. package/dist/command-registry.js.map +1 -0
  11. package/dist/commands.d.ts +10 -0
  12. package/dist/commands.js +213 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/constants.d.ts +44 -0
  15. package/dist/constants.js +48 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/escalation.d.ts +41 -0
  18. package/dist/escalation.js +254 -0
  19. package/dist/escalation.js.map +1 -0
  20. package/dist/formatters.d.ts +13 -0
  21. package/dist/formatters.js +130 -0
  22. package/dist/formatters.js.map +1 -0
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/manifest.d.ts +3 -0
  26. package/dist/manifest.js +230 -0
  27. package/dist/manifest.js.map +1 -0
  28. package/dist/media-pipeline.d.ts +46 -0
  29. package/dist/media-pipeline.js +161 -0
  30. package/dist/media-pipeline.js.map +1 -0
  31. package/dist/telegram-api.d.ts +28 -0
  32. package/dist/telegram-api.js +147 -0
  33. package/dist/telegram-api.js.map +1 -0
  34. package/dist/watch-registry.d.ts +9 -0
  35. package/dist/watch-registry.js +272 -0
  36. package/dist/watch-registry.js.map +1 -0
  37. package/dist/worker.d.ts +1 -0
  38. package/dist/worker.js +548 -0
  39. package/dist/worker.js.map +1 -0
  40. package/package.json +7 -3
  41. package/src/acp-bridge.ts +0 -1273
  42. package/src/adapter.ts +0 -129
  43. package/src/command-registry.ts +0 -482
  44. package/src/commands.ts +0 -346
  45. package/src/constants.ts +0 -51
  46. package/src/escalation.ts +0 -421
  47. package/src/formatters.ts +0 -148
  48. package/src/manifest.ts +0 -246
  49. package/src/media-pipeline.ts +0 -234
  50. package/src/telegram-api.ts +0 -202
  51. package/src/watch-registry.ts +0 -369
  52. package/src/worker.ts +0 -783
  53. package/tests/acp-bridge.test.ts +0 -314
  54. package/tests/command-registry.test.ts +0 -283
  55. package/tests/commands.test.ts +0 -213
  56. package/tests/escalation.test.ts +0 -550
  57. package/tests/formatters.test.ts +0 -185
  58. package/tests/media-pipeline.test.ts +0 -324
  59. package/tests/telegram-api.test.ts +0 -108
  60. package/tests/watch-registry.test.ts +0 -404
  61. package/tsconfig.json +0 -16
  62. /package/{src/index.ts → dist/index.d.ts} +0 -0
@@ -1,314 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { getSessions, routeMessageToAgent, handleHandoffToolCall } from "../src/acp-bridge.js";
3
- import type { PluginContext } from "@paperclipai/plugin-sdk";
4
-
5
- let sentMessages: Array<{ chatId: string; text: string; options?: Record<string, unknown> }> = [];
6
- let stateStore: Record<string, unknown> = {};
7
- let emittedEvents: Array<{ event: string; companyId: string; payload: unknown }> = [];
8
-
9
- vi.mock("../src/telegram-api.js", async () => {
10
- const actual = await vi.importActual("../src/telegram-api.js") as Record<string, unknown>;
11
- return {
12
- ...actual,
13
- sendMessage: vi.fn(async (_ctx: unknown, _token: string, chatId: string, text: string, options?: Record<string, unknown>) => {
14
- sentMessages.push({ chatId, text, options });
15
- return 100;
16
- }),
17
- sendChatAction: vi.fn(),
18
- editMessage: vi.fn().mockResolvedValue(true),
19
- };
20
- });
21
-
22
- function mockCtx(): PluginContext {
23
- return {
24
- http: { fetch: vi.fn() },
25
- metrics: { write: vi.fn() },
26
- state: {
27
- get: vi.fn(async (key: { stateKey: string }) => stateStore[key.stateKey] ?? null),
28
- set: vi.fn(async (key: { stateKey: string }, value: unknown) => {
29
- stateStore[key.stateKey] = value;
30
- }),
31
- },
32
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
33
- events: {
34
- emit: vi.fn((event: string, companyId: string, payload: unknown) => {
35
- emittedEvents.push({ event, companyId, payload });
36
- }),
37
- on: vi.fn(),
38
- },
39
- agents: {
40
- get: vi.fn().mockResolvedValue(null),
41
- sessions: {
42
- create: vi.fn().mockResolvedValue({ sessionId: "native-session-1" }),
43
- sendMessage: vi.fn(),
44
- close: vi.fn(),
45
- },
46
- },
47
- } as unknown as PluginContext;
48
- }
49
-
50
- beforeEach(() => {
51
- sentMessages = [];
52
- stateStore = {};
53
- emittedEvents = [];
54
- });
55
-
56
- describe("getSessions", () => {
57
- it("returns empty array when no sessions exist", async () => {
58
- const ctx = mockCtx();
59
- const sessions = await getSessions(ctx, "chat-1", 42);
60
- expect(sessions).toEqual([]);
61
- });
62
-
63
- it("returns stored sessions", async () => {
64
- const session = {
65
- sessionId: "s1",
66
- agentId: "a1",
67
- agentName: "builder",
68
- agentDisplayName: "Builder",
69
- transport: "acp",
70
- spawnedAt: "2026-01-01T00:00:00Z",
71
- status: "active",
72
- lastActivityAt: "2026-01-01T00:00:00Z",
73
- };
74
- stateStore["sessions_chat-1_42"] = [session];
75
- const ctx = mockCtx();
76
- const sessions = await getSessions(ctx, "chat-1", 42);
77
- expect(sessions).toHaveLength(1);
78
- expect(sessions[0].sessionId).toBe("s1");
79
- });
80
- });
81
-
82
- describe("routeMessageToAgent - @mention extraction", () => {
83
- it("routes to agent matching @mention (exact match)", async () => {
84
- const ctx = mockCtx();
85
- stateStore["sessions_chat-1_42"] = [
86
- {
87
- sessionId: "s1",
88
- agentId: "a1",
89
- agentName: "builder",
90
- agentDisplayName: "Builder",
91
- transport: "acp",
92
- spawnedAt: "2026-01-01T00:00:00Z",
93
- status: "active",
94
- lastActivityAt: "2026-01-01T00:00:00Z",
95
- },
96
- {
97
- sessionId: "s2",
98
- agentId: "a2",
99
- agentName: "tester",
100
- agentDisplayName: "Tester",
101
- transport: "acp",
102
- spawnedAt: "2026-01-01T00:00:00Z",
103
- status: "active",
104
- lastActivityAt: "2026-01-01T00:00:00Z",
105
- },
106
- ];
107
-
108
- const result = await routeMessageToAgent(ctx, "token", "chat-1", 42, "@builder hello", undefined, "company-1");
109
-
110
- expect(result).toBe(true);
111
- // Should have emitted to builder session (s1)
112
- const emitted = emittedEvents.find(e => e.event === "acp-spawn");
113
- expect(emitted).toBeDefined();
114
- expect((emitted!.payload as Record<string, unknown>).sessionId).toBe("s1");
115
- });
116
-
117
- it("routes to agent matching @mention case-insensitively", async () => {
118
- const ctx = mockCtx();
119
- stateStore["sessions_chat-1_42"] = [
120
- {
121
- sessionId: "s1",
122
- agentId: "a1",
123
- agentName: "builder",
124
- agentDisplayName: "Builder",
125
- transport: "acp",
126
- spawnedAt: "2026-01-01T00:00:00Z",
127
- status: "active",
128
- lastActivityAt: "2026-01-01T00:00:00Z",
129
- },
130
- ];
131
-
132
- const result = await routeMessageToAgent(ctx, "token", "chat-1", 42, "@BUILDER help me", undefined, "company-1");
133
- expect(result).toBe(true);
134
- });
135
-
136
- it("routes via partial @mention match", async () => {
137
- const ctx = mockCtx();
138
- stateStore["sessions_chat-1_42"] = [
139
- {
140
- sessionId: "s1",
141
- agentId: "a1",
142
- agentName: "codebuilder",
143
- agentDisplayName: "Codebuilder",
144
- transport: "acp",
145
- spawnedAt: "2026-01-01T00:00:00Z",
146
- status: "active",
147
- lastActivityAt: "2026-01-01T00:00:00Z",
148
- },
149
- ];
150
-
151
- const result = await routeMessageToAgent(ctx, "token", "chat-1", 42, "@code do this", undefined, "company-1");
152
- expect(result).toBe(true);
153
- const emitted = emittedEvents.find(e => e.event === "acp-spawn");
154
- expect((emitted!.payload as Record<string, unknown>).sessionId).toBe("s1");
155
- });
156
- });
157
-
158
- describe("routeMessageToAgent - reply-to fallback", () => {
159
- it("routes to agent via reply-to message mapping", async () => {
160
- const ctx = mockCtx();
161
- stateStore["sessions_chat-1_42"] = [
162
- {
163
- sessionId: "s1",
164
- agentId: "a1",
165
- agentName: "builder",
166
- agentDisplayName: "Builder",
167
- transport: "acp",
168
- spawnedAt: "2026-01-01T00:00:00Z",
169
- status: "active",
170
- lastActivityAt: "2026-01-01T00:00:00Z",
171
- },
172
- {
173
- sessionId: "s2",
174
- agentId: "a2",
175
- agentName: "tester",
176
- agentDisplayName: "Tester",
177
- transport: "acp",
178
- spawnedAt: "2026-01-01T00:00:00Z",
179
- status: "active",
180
- lastActivityAt: "2026-01-01T00:00:00Z",
181
- },
182
- ];
183
- stateStore["agent_msg_chat-1_99"] = { sessionId: "s2" };
184
-
185
- const result = await routeMessageToAgent(ctx, "token", "chat-1", 42, "continue this", 99, "company-1");
186
- expect(result).toBe(true);
187
-
188
- const emitted = emittedEvents.find(e => e.event === "acp-spawn");
189
- expect((emitted!.payload as Record<string, unknown>).sessionId).toBe("s2");
190
- });
191
-
192
- it("falls back to most recently active when no mention or reply match", async () => {
193
- const ctx = mockCtx();
194
- stateStore["sessions_chat-1_42"] = [
195
- {
196
- sessionId: "s1",
197
- agentId: "a1",
198
- agentName: "builder",
199
- agentDisplayName: "Builder",
200
- transport: "acp",
201
- spawnedAt: "2026-01-01T00:00:00Z",
202
- status: "active",
203
- lastActivityAt: "2026-01-01T00:00:00Z",
204
- },
205
- {
206
- sessionId: "s2",
207
- agentId: "a2",
208
- agentName: "tester",
209
- agentDisplayName: "Tester",
210
- transport: "acp",
211
- spawnedAt: "2026-01-01T00:00:00Z",
212
- status: "active",
213
- lastActivityAt: "2026-01-02T00:00:00Z", // more recent
214
- },
215
- ];
216
-
217
- await routeMessageToAgent(ctx, "token", "chat-1", 42, "do something", undefined, "company-1");
218
-
219
- const emitted = emittedEvents.find(e => e.event === "acp-spawn");
220
- expect((emitted!.payload as Record<string, unknown>).sessionId).toBe("s2");
221
- });
222
-
223
- it("returns false when no active sessions exist", async () => {
224
- const ctx = mockCtx();
225
- const result = await routeMessageToAgent(ctx, "token", "chat-1", 42, "hello", undefined, "company-1");
226
- expect(result).toBe(false);
227
- });
228
- });
229
-
230
- describe("Session registry - max enforcement", () => {
231
- it("getSessions returns sessions from state key based on chatId and threadId", async () => {
232
- const ctx = mockCtx();
233
- stateStore["sessions_chat-1_10"] = [{ sessionId: "a" }];
234
- stateStore["sessions_chat-1_20"] = [{ sessionId: "b" }];
235
-
236
- const s1 = await getSessions(ctx, "chat-1", 10);
237
- const s2 = await getSessions(ctx, "chat-1", 20);
238
- expect(s1[0].sessionId).toBe("a");
239
- expect(s2[0].sessionId).toBe("b");
240
- });
241
- });
242
-
243
- describe("handleHandoffToolCall - approval callback data", () => {
244
- it("returns error when required fields are missing", async () => {
245
- const ctx = mockCtx();
246
- const result = await handleHandoffToolCall(ctx, "token", {}, "company-1", "agent-1");
247
- expect(result.error).toContain("Missing required fields");
248
- });
249
-
250
- it("creates pending handoff with approval buttons when requiresApproval is true", async () => {
251
- const ctx = mockCtx();
252
- stateStore["sessions_chat-1_42"] = [{
253
- sessionId: "s1",
254
- agentId: "agent-1",
255
- agentName: "builder",
256
- agentDisplayName: "Builder",
257
- transport: "acp",
258
- spawnedAt: "2026-01-01T00:00:00Z",
259
- status: "active",
260
- lastActivityAt: "2026-01-01T00:00:00Z",
261
- }];
262
-
263
- const result = await handleHandoffToolCall(ctx, "token", {
264
- targetAgent: "tester",
265
- reason: "needs testing",
266
- contextSummary: "code is ready",
267
- requiresApproval: true,
268
- chatId: "chat-1",
269
- threadId: 42,
270
- }, "company-1", "agent-1");
271
-
272
- expect(result.content).toBeDefined();
273
- const parsed = JSON.parse(result.content!);
274
- expect(parsed.status).toBe("pending_approval");
275
-
276
- // Should have sent message with approve/reject buttons
277
- const msg = sentMessages.find(m => m.text.includes("Handing off"));
278
- expect(msg).toBeDefined();
279
- const keyboard = msg!.options?.inlineKeyboard as Array<Array<{ text: string; callback_data: string }>>;
280
- expect(keyboard).toBeDefined();
281
- const approveBtn = keyboard.flat().find((b: { text: string }) => b.text === "Approve");
282
- const rejectBtn = keyboard.flat().find((b: { text: string }) => b.text === "Reject");
283
- expect(approveBtn).toBeDefined();
284
- expect(rejectBtn).toBeDefined();
285
- expect(approveBtn!.callback_data).toMatch(/^handoff_approve_/);
286
- expect(rejectBtn!.callback_data).toMatch(/^handoff_reject_/);
287
- });
288
-
289
- it("executes handoff immediately when requiresApproval is false", async () => {
290
- const ctx = mockCtx();
291
- stateStore["sessions_chat-1_42"] = [{
292
- sessionId: "s1",
293
- agentId: "agent-1",
294
- agentName: "builder",
295
- agentDisplayName: "Builder",
296
- transport: "acp",
297
- spawnedAt: "2026-01-01T00:00:00Z",
298
- status: "active",
299
- lastActivityAt: "2026-01-01T00:00:00Z",
300
- }];
301
-
302
- const result = await handleHandoffToolCall(ctx, "token", {
303
- targetAgent: "tester",
304
- reason: "needs testing",
305
- contextSummary: "code is ready",
306
- requiresApproval: false,
307
- chatId: "chat-1",
308
- threadId: 42,
309
- }, "company-1", "agent-1");
310
-
311
- const parsed = JSON.parse(result.content!);
312
- expect(parsed.status).toBe("handed_off");
313
- });
314
- });
@@ -1,283 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import { handleCommandsCommand, tryCustomCommand } from "../src/command-registry.js";
3
- import type { PluginContext } from "@paperclipai/plugin-sdk";
4
-
5
- let sentMessages: Array<{ chatId: string; text: string; options?: Record<string, unknown> }> = [];
6
- let stateStore: Record<string, unknown> = {};
7
-
8
- vi.mock("../src/telegram-api.js", async () => {
9
- const actual = await vi.importActual("../src/telegram-api.js") as Record<string, unknown>;
10
- return {
11
- ...actual,
12
- sendMessage: vi.fn(async (_ctx: unknown, _token: string, chatId: string, text: string, options?: Record<string, unknown>) => {
13
- sentMessages.push({ chatId, text, options });
14
- return 1;
15
- }),
16
- sendChatAction: vi.fn(),
17
- };
18
- });
19
-
20
- function mockCtx(): PluginContext {
21
- return {
22
- http: {
23
- fetch: vi.fn().mockResolvedValue({
24
- json: () => Promise.resolve({ id: "created-issue-1" }),
25
- text: () => Promise.resolve("ok"),
26
- }),
27
- },
28
- metrics: { write: vi.fn() },
29
- state: {
30
- get: vi.fn(async (key: { stateKey: string }) => stateStore[key.stateKey] ?? null),
31
- set: vi.fn(async (key: { stateKey: string }, value: unknown) => {
32
- stateStore[key.stateKey] = value;
33
- }),
34
- },
35
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
36
- config: { get: vi.fn().mockResolvedValue({ paperclipBaseUrl: "http://localhost:3100" }) },
37
- issues: { get: vi.fn().mockResolvedValue({ id: "i1", title: "Test", status: "open" }) },
38
- agents: { invoke: vi.fn().mockResolvedValue({ runId: "run-1" }) },
39
- } as unknown as PluginContext;
40
- }
41
-
42
- beforeEach(() => {
43
- sentMessages = [];
44
- stateStore = {};
45
- });
46
-
47
- describe("handleCommandsCommand - subcommands", () => {
48
- it("shows help when no subcommand given", async () => {
49
- const ctx = mockCtx();
50
- await handleCommandsCommand(ctx, "token", "123", "", undefined, "co-1");
51
- expect(sentMessages.length).toBe(1);
52
- expect(sentMessages[0].text).toContain("Custom Commands");
53
- });
54
-
55
- it("lists empty registry", async () => {
56
- const ctx = mockCtx();
57
- await handleCommandsCommand(ctx, "token", "123", "list", undefined, "co-1");
58
- expect(sentMessages[0].text).toContain("No custom commands");
59
- });
60
-
61
- it("imports a valid command", async () => {
62
- const ctx = mockCtx();
63
- const cmd = JSON.stringify({
64
- name: "deploy",
65
- description: "Deploy to prod",
66
- steps: [{ id: "s1", type: "send_message", text: "Deploying..." }],
67
- });
68
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
69
- expect(sentMessages[0].text).toContain("deploy");
70
- expect(sentMessages[0].text).toContain("imported");
71
-
72
- // Verify it's stored
73
- const stored = stateStore["commands_co-1"] as Array<{ name: string }>;
74
- expect(stored).toBeDefined();
75
- expect(stored.length).toBe(1);
76
- expect(stored[0].name).toBe("deploy");
77
- });
78
-
79
- it("rejects import of invalid JSON", async () => {
80
- const ctx = mockCtx();
81
- await handleCommandsCommand(ctx, "token", "123", "import {invalid", undefined, "co-1");
82
- expect(sentMessages[0].text).toContain("Invalid JSON");
83
- });
84
-
85
- it("rejects import without name field", async () => {
86
- const ctx = mockCtx();
87
- const cmd = JSON.stringify({ steps: [{ id: "s1", type: "send_message", text: "hi" }] });
88
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
89
- expect(sentMessages[0].text).toContain("must have");
90
- });
91
-
92
- it("deletes a command", async () => {
93
- stateStore["commands_co-1"] = [{
94
- name: "deploy",
95
- description: "Deploy",
96
- steps: [],
97
- createdBy: "test",
98
- createdAt: "2026-01-01",
99
- }];
100
- const ctx = mockCtx();
101
- await handleCommandsCommand(ctx, "token", "123", "delete deploy", undefined, "co-1");
102
- expect(sentMessages[0].text).toContain("deleted");
103
-
104
- const stored = stateStore["commands_co-1"] as Array<{ name: string }>;
105
- expect(stored.length).toBe(0);
106
- });
107
-
108
- it("reports not found when deleting nonexistent command", async () => {
109
- const ctx = mockCtx();
110
- await handleCommandsCommand(ctx, "token", "123", "delete nonexistent", undefined, "co-1");
111
- expect(sentMessages[0].text).toContain("not found");
112
- });
113
- });
114
-
115
- describe("Namespace protection - built-in commands cannot be overridden", () => {
116
- it("rejects import of /status as custom command", async () => {
117
- const ctx = mockCtx();
118
- const cmd = JSON.stringify({
119
- name: "status",
120
- description: "Override status",
121
- steps: [{ id: "s1", type: "send_message", text: "hi" }],
122
- });
123
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
124
- expect(sentMessages[0].text).toContain("Cannot override built-in");
125
- });
126
-
127
- it("rejects import of /help as custom command", async () => {
128
- const ctx = mockCtx();
129
- const cmd = JSON.stringify({
130
- name: "help",
131
- description: "Override help",
132
- steps: [{ id: "s1", type: "send_message", text: "hi" }],
133
- });
134
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
135
- expect(sentMessages[0].text).toContain("Cannot override built-in");
136
- });
137
-
138
- it("rejects import of /acp as custom command", async () => {
139
- const ctx = mockCtx();
140
- const cmd = JSON.stringify({
141
- name: "acp",
142
- description: "Override acp",
143
- steps: [{ id: "s1", type: "send_message", text: "hi" }],
144
- });
145
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
146
- expect(sentMessages[0].text).toContain("Cannot override built-in");
147
- });
148
- });
149
-
150
- describe("tryCustomCommand - command lookup", () => {
151
- it("returns false for built-in commands", async () => {
152
- const ctx = mockCtx();
153
- const result = await tryCustomCommand(ctx, "token", "123", "status", "", undefined, "co-1");
154
- expect(result).toBe(false);
155
- });
156
-
157
- it("returns false when command not in registry", async () => {
158
- const ctx = mockCtx();
159
- const result = await tryCustomCommand(ctx, "token", "123", "unknown", "", undefined, "co-1");
160
- expect(result).toBe(false);
161
- });
162
-
163
- it("returns true and executes when command found in registry", async () => {
164
- stateStore["commands_co-1"] = [{
165
- name: "greet",
166
- description: "Greet user",
167
- steps: [{ id: "s1", type: "send_message", text: "Hello {{args}}" }],
168
- createdBy: "test",
169
- createdAt: "2026-01-01",
170
- }];
171
- const ctx = mockCtx();
172
- const result = await tryCustomCommand(ctx, "token", "123", "greet", "world", undefined, "co-1");
173
- expect(result).toBe(true);
174
- expect(sentMessages.some(m => m.text === "Hello world")).toBe(true);
175
- });
176
- });
177
-
178
- describe("Workflow step template interpolation", () => {
179
- it("interpolates {{arg0}} and {{arg1}} parameters", async () => {
180
- stateStore["commands_co-1"] = [{
181
- name: "deploy",
182
- description: "Deploy",
183
- steps: [{ id: "s1", type: "send_message", text: "Deploy {{arg0}} to {{arg1}}" }],
184
- createdBy: "test",
185
- createdAt: "2026-01-01",
186
- }];
187
- const ctx = mockCtx();
188
- await tryCustomCommand(ctx, "token", "123", "deploy", "v1.2 prod", undefined, "co-1");
189
- expect(sentMessages.some(m => m.text === "Deploy v1.2 to prod")).toBe(true);
190
- });
191
-
192
- it("interpolates {{args}} as full args string", async () => {
193
- stateStore["commands_co-1"] = [{
194
- name: "echo",
195
- description: "Echo",
196
- steps: [{ id: "s1", type: "send_message", text: "You said: {{args}}" }],
197
- createdBy: "test",
198
- createdAt: "2026-01-01",
199
- }];
200
- const ctx = mockCtx();
201
- await tryCustomCommand(ctx, "token", "123", "echo", "hello world", undefined, "co-1");
202
- expect(sentMessages.some(m => m.text === "You said: hello world")).toBe(true);
203
- });
204
-
205
- it("interpolates {{prev.result}} from previous step", async () => {
206
- stateStore["commands_co-1"] = [{
207
- name: "chain",
208
- description: "Chain",
209
- steps: [
210
- { id: "s1", type: "send_message", text: "step one" },
211
- { id: "s2", type: "send_message", text: "prev was: {{prev.result}}" },
212
- ],
213
- createdBy: "test",
214
- createdAt: "2026-01-01",
215
- }];
216
- const ctx = mockCtx();
217
- await tryCustomCommand(ctx, "token", "123", "chain", "", undefined, "co-1");
218
- expect(sentMessages.some(m => m.text === "prev was: sent")).toBe(true);
219
- });
220
-
221
- it("interpolates {{step_id.result}} from specific step", async () => {
222
- stateStore["commands_co-1"] = [{
223
- name: "multi",
224
- description: "Multi step",
225
- steps: [
226
- { id: "first", type: "send_message", text: "one" },
227
- { id: "second", type: "send_message", text: "two" },
228
- { id: "third", type: "send_message", text: "first said: {{first.result}}" },
229
- ],
230
- createdBy: "test",
231
- createdAt: "2026-01-01",
232
- }];
233
- const ctx = mockCtx();
234
- await tryCustomCommand(ctx, "token", "123", "multi", "", undefined, "co-1");
235
- expect(sentMessages.some(m => m.text === "first said: sent")).toBe(true);
236
- });
237
- });
238
-
239
- describe("Command import validation", () => {
240
- it("rejects steps missing type field", async () => {
241
- const ctx = mockCtx();
242
- const cmd = JSON.stringify({
243
- name: "bad",
244
- description: "bad",
245
- steps: [{ id: "s1" }],
246
- });
247
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
248
- expect(sentMessages[0].text).toContain("must have");
249
- });
250
-
251
- it("rejects steps with invalid type", async () => {
252
- const ctx = mockCtx();
253
- const cmd = JSON.stringify({
254
- name: "bad",
255
- description: "bad",
256
- steps: [{ id: "s1", type: "invalid_type" }],
257
- });
258
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
259
- expect(sentMessages[0].text).toContain("Invalid step type");
260
- });
261
-
262
- it("updates existing command on re-import", async () => {
263
- stateStore["commands_co-1"] = [{
264
- name: "deploy",
265
- description: "Old deploy",
266
- steps: [{ id: "s1", type: "send_message", text: "old" }],
267
- createdBy: "test",
268
- createdAt: "2026-01-01",
269
- }];
270
- const ctx = mockCtx();
271
- const cmd = JSON.stringify({
272
- name: "deploy",
273
- description: "New deploy",
274
- steps: [{ id: "s1", type: "send_message", text: "new" }],
275
- });
276
- await handleCommandsCommand(ctx, "token", "123", `import ${cmd}`, undefined, "co-1");
277
- expect(sentMessages[0].text).toContain("updated");
278
-
279
- const stored = stateStore["commands_co-1"] as Array<{ description: string }>;
280
- expect(stored.length).toBe(1);
281
- expect(stored[0].description).toBe("New deploy");
282
- });
283
- });