vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -0,0 +1,855 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+ import { mkdtempSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import * as net from 'node:net';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'handlers-telegram-cfg-test-'));
8
+
9
+ // Track loadRawConfig / saveRawConfig calls
10
+ let rawConfigStore: Record<string, unknown> = {};
11
+
12
+ mock.module('../config/loader.js', () => ({
13
+ getConfig: () => ({}),
14
+ loadConfig: () => ({}),
15
+ loadRawConfig: () => ({ ...rawConfigStore }),
16
+ saveRawConfig: (cfg: Record<string, unknown>) => {
17
+ rawConfigStore = { ...cfg };
18
+ },
19
+ saveConfig: () => {},
20
+ invalidateConfigCache: () => {},
21
+ }));
22
+
23
+ mock.module('../util/platform.js', () => ({
24
+ getRootDir: () => testDir,
25
+ getDataDir: () => testDir,
26
+ getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
27
+ isMacOS: () => process.platform === 'darwin',
28
+ isLinux: () => process.platform === 'linux',
29
+ isWindows: () => process.platform === 'win32',
30
+ getSocketPath: () => join(testDir, 'test.sock'),
31
+ getPidPath: () => join(testDir, 'test.pid'),
32
+ getDbPath: () => join(testDir, 'test.db'),
33
+ getLogPath: () => join(testDir, 'test.log'),
34
+ ensureDataDir: () => {},
35
+ readHttpToken: () => undefined,
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () => ({
40
+ info: () => {},
41
+ warn: () => {},
42
+ error: () => {},
43
+ debug: () => {},
44
+ trace: () => {},
45
+ fatal: () => {},
46
+ isDebug: () => false,
47
+ child: () => ({
48
+ info: () => {},
49
+ warn: () => {},
50
+ error: () => {},
51
+ debug: () => {},
52
+ isDebug: () => false,
53
+ }),
54
+ }),
55
+ }));
56
+
57
+ // Mock secure key storage
58
+ let secureKeyStore: Record<string, string> = {};
59
+ let setSecureKeyOverride: ((account: string, value: string) => boolean) | null = null;
60
+
61
+ mock.module('../security/secure-keys.js', () => ({
62
+ getSecureKey: (account: string) => secureKeyStore[account] ?? undefined,
63
+ setSecureKey: (account: string, value: string) => {
64
+ if (setSecureKeyOverride) return setSecureKeyOverride(account, value);
65
+ secureKeyStore[account] = value;
66
+ return true;
67
+ },
68
+ deleteSecureKey: (account: string) => {
69
+ if (account in secureKeyStore) {
70
+ delete secureKeyStore[account];
71
+ return true;
72
+ }
73
+ return false;
74
+ },
75
+ listSecureKeys: () => Object.keys(secureKeyStore),
76
+ getBackendType: () => 'encrypted',
77
+ isDowngradedFromKeychain: () => false,
78
+ _resetBackend: () => {},
79
+ _setBackend: () => {},
80
+ }));
81
+
82
+ // Mock credential metadata store
83
+ let credentialMetadataStore: Array<{ service: string; field: string; accountInfo?: string }> = [];
84
+ const deletedMetadata: Array<{ service: string; field: string }> = [];
85
+
86
+ mock.module('../tools/credentials/metadata-store.js', () => ({
87
+ getCredentialMetadata: (service: string, field: string) =>
88
+ credentialMetadataStore.find((m) => m.service === service && m.field === field) ?? undefined,
89
+ upsertCredentialMetadata: (service: string, field: string, policy?: Record<string, unknown>) => {
90
+ const existing = credentialMetadataStore.find((m) => m.service === service && m.field === field);
91
+ if (existing) {
92
+ if (policy?.accountInfo !== undefined) existing.accountInfo = policy.accountInfo as string;
93
+ return existing;
94
+ }
95
+ const record = { service, field, accountInfo: policy?.accountInfo as string | undefined };
96
+ credentialMetadataStore.push(record);
97
+ return record;
98
+ },
99
+ deleteCredentialMetadata: (service: string, field: string) => {
100
+ deletedMetadata.push({ service, field });
101
+ const idx = credentialMetadataStore.findIndex((m) => m.service === service && m.field === field);
102
+ if (idx !== -1) {
103
+ credentialMetadataStore.splice(idx, 1);
104
+ return true;
105
+ }
106
+ return false;
107
+ },
108
+ listCredentialMetadata: () => credentialMetadataStore,
109
+ assertMetadataWritable: () => {},
110
+ _setMetadataPath: () => {},
111
+ }));
112
+
113
+ // Mock fetch for Telegram getMe API validation
114
+ let _fetchMock: ((url: string | URL | Request) => Promise<Response>) | null = null;
115
+ const originalFetch = globalThis.fetch;
116
+
117
+ import { handleTelegramConfig } from '../daemon/handlers/config.js';
118
+ import type { HandlerContext } from '../daemon/handlers.js';
119
+ import type {
120
+ TelegramConfigRequest,
121
+ ServerMessage,
122
+ } from '../daemon/ipc-contract.js';
123
+ import { DebouncerMap } from '../util/debounce.js';
124
+
125
+ function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
126
+ const sent: ServerMessage[] = [];
127
+ const ctx: HandlerContext = {
128
+ sessions: new Map(),
129
+ socketToSession: new Map(),
130
+ cuSessions: new Map(),
131
+ socketToCuSession: new Map(),
132
+ cuObservationParseSequence: new Map(),
133
+ socketSandboxOverride: new Map(),
134
+ sharedRequestTimestamps: [],
135
+ debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
136
+ suppressConfigReload: false,
137
+ setSuppressConfigReload: () => {},
138
+ updateConfigFingerprint: () => {},
139
+ send: (_socket, msg) => { sent.push(msg); },
140
+ broadcast: () => {},
141
+ clearAllSessions: () => 0,
142
+ getOrCreateSession: () => { throw new Error('not implemented'); },
143
+ touchSession: () => {},
144
+ };
145
+ return { ctx, sent };
146
+ }
147
+
148
+ describe('Telegram config handler', () => {
149
+ beforeEach(() => {
150
+ rawConfigStore = {};
151
+ secureKeyStore = {};
152
+ setSecureKeyOverride = null;
153
+ credentialMetadataStore = [];
154
+ deletedMetadata.length = 0;
155
+ _fetchMock = null;
156
+ globalThis.fetch = originalFetch;
157
+ });
158
+
159
+ test('get action returns correct state when not configured', async () => {
160
+ const msg: TelegramConfigRequest = {
161
+ type: 'telegram_config',
162
+ action: 'get',
163
+ };
164
+
165
+ const { ctx, sent } = createTestContext();
166
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
167
+
168
+ expect(sent).toHaveLength(1);
169
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
170
+ expect(res.type).toBe('telegram_config_response');
171
+ expect(res.success).toBe(true);
172
+ expect(res.hasBotToken).toBe(false);
173
+ expect(res.connected).toBe(false);
174
+ expect(res.hasWebhookSecret).toBe(false);
175
+ });
176
+
177
+ test('get action returns correct state when configured', async () => {
178
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
179
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
180
+ credentialMetadataStore.push({
181
+ service: 'telegram',
182
+ field: 'bot_token',
183
+ accountInfo: 'my_test_bot',
184
+ });
185
+
186
+ const msg: TelegramConfigRequest = {
187
+ type: 'telegram_config',
188
+ action: 'get',
189
+ };
190
+
191
+ const { ctx, sent } = createTestContext();
192
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
193
+
194
+ expect(sent).toHaveLength(1);
195
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean; hasWebhookSecret: boolean };
196
+ expect(res.type).toBe('telegram_config_response');
197
+ expect(res.success).toBe(true);
198
+ expect(res.hasBotToken).toBe(true);
199
+ expect(res.botUsername).toBe('my_test_bot');
200
+ expect(res.connected).toBe(true);
201
+ expect(res.hasWebhookSecret).toBe(true);
202
+ });
203
+
204
+ test('set action validates token, stores credentials, returns success', async () => {
205
+ // Mock successful Telegram getMe response
206
+ globalThis.fetch = (async (url: string | URL | Request) => {
207
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
208
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
209
+ return new Response(JSON.stringify({
210
+ ok: true,
211
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
212
+ }), { status: 200 });
213
+ }
214
+ return originalFetch(url);
215
+ }) as typeof fetch;
216
+
217
+ const msg: TelegramConfigRequest = {
218
+ type: 'telegram_config',
219
+ action: 'set',
220
+ botToken: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
221
+ };
222
+
223
+ const { ctx, sent } = createTestContext();
224
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
225
+
226
+ expect(sent).toHaveLength(1);
227
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean; hasWebhookSecret: boolean };
228
+ expect(res.type).toBe('telegram_config_response');
229
+ expect(res.success).toBe(true);
230
+ expect(res.hasBotToken).toBe(true);
231
+ expect(res.botUsername).toBe('test_bot');
232
+ expect(res.connected).toBe(true);
233
+ expect(res.hasWebhookSecret).toBe(true);
234
+
235
+ // Verify token was stored
236
+ expect(secureKeyStore['credential:telegram:bot_token']).toBe('123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11');
237
+ // Verify webhook secret was generated
238
+ expect(secureKeyStore['credential:telegram:webhook_secret']).toBeDefined();
239
+ // Verify metadata was stored
240
+ expect(credentialMetadataStore.find((m) => m.service === 'telegram' && m.field === 'bot_token')?.accountInfo).toBe('test_bot');
241
+ });
242
+
243
+ test('set action with invalid token returns error', async () => {
244
+ // Mock failed Telegram getMe response
245
+ globalThis.fetch = (async (url: string | URL | Request) => {
246
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
247
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
248
+ return new Response(JSON.stringify({
249
+ ok: false,
250
+ error_code: 401,
251
+ description: 'Unauthorized',
252
+ }), { status: 401 });
253
+ }
254
+ return originalFetch(url);
255
+ }) as typeof fetch;
256
+
257
+ const msg: TelegramConfigRequest = {
258
+ type: 'telegram_config',
259
+ action: 'set',
260
+ botToken: 'invalid-token',
261
+ };
262
+
263
+ const { ctx, sent } = createTestContext();
264
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
265
+
266
+ expect(sent).toHaveLength(1);
267
+ const res = sent[0] as { type: string; success: boolean; error?: string };
268
+ expect(res.type).toBe('telegram_config_response');
269
+ expect(res.success).toBe(false);
270
+ expect(res.error).toContain('Telegram API validation failed');
271
+
272
+ // Verify token was NOT stored
273
+ expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
274
+ });
275
+
276
+ test('set action without botToken returns error when no token in secure storage', async () => {
277
+ const msg: TelegramConfigRequest = {
278
+ type: 'telegram_config',
279
+ action: 'set',
280
+ };
281
+
282
+ const { ctx, sent } = createTestContext();
283
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
284
+
285
+ expect(sent).toHaveLength(1);
286
+ const res = sent[0] as { type: string; success: boolean; error?: string };
287
+ expect(res.success).toBe(false);
288
+ expect(res.error).toContain('botToken is required');
289
+ });
290
+
291
+ test('set action without botToken falls back to secure storage', async () => {
292
+ // Pre-populate token in secure storage (as credential_store prompt would)
293
+ secureKeyStore['credential:telegram:bot_token'] = '123456:stored-token';
294
+
295
+ globalThis.fetch = (async (url: string | URL | Request) => {
296
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
297
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
298
+ // Verify the stored token is being used
299
+ expect(urlStr).toContain('123456:stored-token');
300
+ return new Response(JSON.stringify({
301
+ ok: true,
302
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'stored_bot' },
303
+ }), { status: 200 });
304
+ }
305
+ return originalFetch(url);
306
+ }) as typeof fetch;
307
+
308
+ const msg: TelegramConfigRequest = {
309
+ type: 'telegram_config',
310
+ action: 'set',
311
+ // No botToken provided — should fall back to secure storage
312
+ };
313
+
314
+ const { ctx, sent } = createTestContext();
315
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
316
+
317
+ expect(sent).toHaveLength(1);
318
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; botUsername: string; connected: boolean };
319
+ expect(res.success).toBe(true);
320
+ expect(res.hasBotToken).toBe(true);
321
+ expect(res.botUsername).toBe('stored_bot');
322
+ expect(res.connected).toBe(true);
323
+ });
324
+
325
+ test('clear action removes credentials', async () => {
326
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
327
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
328
+ credentialMetadataStore.push({
329
+ service: 'telegram',
330
+ field: 'bot_token',
331
+ accountInfo: 'my_test_bot',
332
+ });
333
+ credentialMetadataStore.push({
334
+ service: 'telegram',
335
+ field: 'webhook_secret',
336
+ });
337
+
338
+ const msg: TelegramConfigRequest = {
339
+ type: 'telegram_config',
340
+ action: 'clear',
341
+ };
342
+
343
+ const { ctx, sent } = createTestContext();
344
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
345
+
346
+ expect(sent).toHaveLength(1);
347
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
348
+ expect(res.type).toBe('telegram_config_response');
349
+ expect(res.success).toBe(true);
350
+ expect(res.hasBotToken).toBe(false);
351
+ expect(res.connected).toBe(false);
352
+ expect(res.hasWebhookSecret).toBe(false);
353
+
354
+ // Verify everything was cleaned up
355
+ expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
356
+ expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
357
+ expect(deletedMetadata).toContainEqual({ service: 'telegram', field: 'bot_token' });
358
+ expect(deletedMetadata).toContainEqual({ service: 'telegram', field: 'webhook_secret' });
359
+ });
360
+
361
+ test('clear action is idempotent when no credentials exist', async () => {
362
+ const msg: TelegramConfigRequest = {
363
+ type: 'telegram_config',
364
+ action: 'clear',
365
+ };
366
+
367
+ const { ctx, sent } = createTestContext();
368
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
369
+
370
+ expect(sent).toHaveLength(1);
371
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
372
+ expect(res.success).toBe(true);
373
+ expect(res.hasBotToken).toBe(false);
374
+ expect(res.connected).toBe(false);
375
+ expect(res.hasWebhookSecret).toBe(false);
376
+ });
377
+
378
+ test('set action preserves existing webhook secret', async () => {
379
+ // Pre-populate webhook secret
380
+ secureKeyStore['credential:telegram:webhook_secret'] = 'existing-secret';
381
+
382
+ globalThis.fetch = (async (url: string | URL | Request) => {
383
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
384
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
385
+ return new Response(JSON.stringify({
386
+ ok: true,
387
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
388
+ }), { status: 200 });
389
+ }
390
+ return originalFetch(url);
391
+ }) as typeof fetch;
392
+
393
+ const msg: TelegramConfigRequest = {
394
+ type: 'telegram_config',
395
+ action: 'set',
396
+ botToken: '123456:valid-token',
397
+ };
398
+
399
+ const { ctx, sent } = createTestContext();
400
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
401
+
402
+ expect(sent).toHaveLength(1);
403
+ const res = sent[0] as { type: string; success: boolean; hasWebhookSecret: boolean };
404
+ expect(res.success).toBe(true);
405
+ expect(res.hasWebhookSecret).toBe(true);
406
+
407
+ // Existing webhook secret should not be overwritten
408
+ expect(secureKeyStore['credential:telegram:webhook_secret']).toBe('existing-secret');
409
+ });
410
+
411
+ test('set action upserts webhook_secret metadata even when secret already exists', async () => {
412
+ // Pre-populate webhook secret WITHOUT metadata to simulate lost/corrupted metadata
413
+ secureKeyStore['credential:telegram:webhook_secret'] = 'existing-secret';
414
+
415
+ globalThis.fetch = (async (url: string | URL | Request) => {
416
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
417
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
418
+ return new Response(JSON.stringify({
419
+ ok: true,
420
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
421
+ }), { status: 200 });
422
+ }
423
+ return originalFetch(url);
424
+ }) as typeof fetch;
425
+
426
+ const msg: TelegramConfigRequest = {
427
+ type: 'telegram_config',
428
+ action: 'set',
429
+ botToken: '123456:valid-token',
430
+ };
431
+
432
+ const { ctx, sent } = createTestContext();
433
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
434
+
435
+ expect(sent).toHaveLength(1);
436
+ const res = sent[0] as { type: string; success: boolean };
437
+ expect(res.success).toBe(true);
438
+
439
+ // Metadata for webhook_secret should have been upserted even though the
440
+ // secret already existed (self-heal for lost/corrupted metadata)
441
+ const webhookMeta = credentialMetadataStore.find(
442
+ (m) => m.service === 'telegram' && m.field === 'webhook_secret',
443
+ );
444
+ expect(webhookMeta).toBeDefined();
445
+ });
446
+
447
+ test('set action fails when secure storage fails', async () => {
448
+ setSecureKeyOverride = () => false;
449
+
450
+ globalThis.fetch = (async (url: string | URL | Request) => {
451
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
452
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
453
+ return new Response(JSON.stringify({
454
+ ok: true,
455
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
456
+ }), { status: 200 });
457
+ }
458
+ return originalFetch(url);
459
+ }) as typeof fetch;
460
+
461
+ const msg: TelegramConfigRequest = {
462
+ type: 'telegram_config',
463
+ action: 'set',
464
+ botToken: '123456:valid-token',
465
+ };
466
+
467
+ const { ctx, sent } = createTestContext();
468
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
469
+
470
+ expect(sent).toHaveLength(1);
471
+ const res = sent[0] as { type: string; success: boolean; error?: string };
472
+ expect(res.success).toBe(false);
473
+ expect(res.error).toContain('Failed to store bot token');
474
+ });
475
+
476
+ test('unrecognized action returns error response', async () => {
477
+ const msg = {
478
+ type: 'telegram_config',
479
+ action: 'nonexistent_action',
480
+ } as unknown as TelegramConfigRequest;
481
+
482
+ const { ctx, sent } = createTestContext();
483
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
484
+
485
+ expect(sent).toHaveLength(1);
486
+ const res = sent[0] as { type: string; success: boolean; error?: string };
487
+ expect(res.type).toBe('telegram_config_response');
488
+ expect(res.success).toBe(false);
489
+ expect(res.error).toContain('Unknown action');
490
+ expect(res.error).toContain('nonexistent_action');
491
+ });
492
+
493
+ test('response messages never contain raw bot token values', async () => {
494
+ secureKeyStore['credential:telegram:bot_token'] = 'secret-bot-token-abc123';
495
+ secureKeyStore['credential:telegram:webhook_secret'] = 'secret-webhook-xyz789';
496
+ credentialMetadataStore.push({
497
+ service: 'telegram',
498
+ field: 'bot_token',
499
+ accountInfo: 'my_test_bot',
500
+ });
501
+
502
+ const msg: TelegramConfigRequest = {
503
+ type: 'telegram_config',
504
+ action: 'get',
505
+ };
506
+
507
+ const { ctx, sent } = createTestContext();
508
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
509
+
510
+ expect(sent).toHaveLength(1);
511
+ const responseStr = JSON.stringify(sent[0]);
512
+ // No raw credential values should leak into the response
513
+ expect(responseStr).not.toContain('secret-bot-token-abc123');
514
+ expect(responseStr).not.toContain('secret-webhook-xyz789');
515
+ });
516
+
517
+ test('set action handles getMe returning unexpected response', async () => {
518
+ globalThis.fetch = (async (url: string | URL | Request) => {
519
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
520
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
521
+ return new Response(JSON.stringify({
522
+ ok: true,
523
+ result: { id: 123456, is_bot: true, first_name: 'TestBot' },
524
+ // username is missing
525
+ }), { status: 200 });
526
+ }
527
+ return originalFetch(url);
528
+ }) as typeof fetch;
529
+
530
+ const msg: TelegramConfigRequest = {
531
+ type: 'telegram_config',
532
+ action: 'set',
533
+ botToken: '123456:valid-token',
534
+ };
535
+
536
+ const { ctx, sent } = createTestContext();
537
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
538
+
539
+ expect(sent).toHaveLength(1);
540
+ const res = sent[0] as { type: string; success: boolean; error?: string };
541
+ expect(res.success).toBe(false);
542
+ expect(res.error).toContain('unexpected response');
543
+ });
544
+
545
+ test('set action handles network error during getMe', async () => {
546
+ const tgToken = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
547
+ globalThis.fetch = (async () => {
548
+ const err = new Error('Network error: ECONNREFUSED') as Error & { path?: string; code?: string };
549
+ err.path = `https://api.telegram.org/bot${tgToken}/getMe`;
550
+ err.code = 'ConnectionRefused';
551
+ throw err;
552
+ }) as unknown as typeof fetch;
553
+
554
+ const msg: TelegramConfigRequest = {
555
+ type: 'telegram_config',
556
+ action: 'set',
557
+ botToken: '123456:valid-token',
558
+ };
559
+
560
+ const { ctx, sent } = createTestContext();
561
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
562
+
563
+ expect(sent).toHaveLength(1);
564
+ const res = sent[0] as { type: string; success: boolean; error?: string };
565
+ expect(res.success).toBe(false);
566
+ expect(res.error).toContain('Failed to validate bot token');
567
+ expect(res.error).toContain('ECONNREFUSED');
568
+ expect(res.error).toContain('[REDACTED]');
569
+ expect(res.error).not.toContain(tgToken);
570
+ });
571
+
572
+ test('get action reports connected only when both bot_token and webhook_secret exist', async () => {
573
+ // Only bot_token, no webhook_secret — should NOT be connected
574
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
575
+
576
+ const { ctx, sent } = createTestContext();
577
+ await handleTelegramConfig(
578
+ { type: 'telegram_config', action: 'get' },
579
+ {} as net.Socket,
580
+ ctx,
581
+ );
582
+
583
+ expect(sent).toHaveLength(1);
584
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean };
585
+ expect(res.success).toBe(true);
586
+ expect(res.hasBotToken).toBe(true);
587
+ expect(res.hasWebhookSecret).toBe(false);
588
+ expect(res.connected).toBe(false);
589
+ });
590
+
591
+ test('set action rolls back bot token when webhook secret storage fails', async () => {
592
+ // Let bot token storage succeed but webhook secret storage fail
593
+ setSecureKeyOverride = (account: string, value: string) => {
594
+ if (account === 'credential:telegram:webhook_secret') return false;
595
+ secureKeyStore[account] = value;
596
+ return true;
597
+ };
598
+
599
+ globalThis.fetch = (async (url: string | URL | Request) => {
600
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
601
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
602
+ return new Response(JSON.stringify({
603
+ ok: true,
604
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'test_bot' },
605
+ }), { status: 200 });
606
+ }
607
+ return originalFetch(url);
608
+ }) as typeof fetch;
609
+
610
+ const msg: TelegramConfigRequest = {
611
+ type: 'telegram_config',
612
+ action: 'set',
613
+ botToken: '123456:valid-token',
614
+ };
615
+
616
+ const { ctx, sent } = createTestContext();
617
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
618
+
619
+ expect(sent).toHaveLength(1);
620
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean; error?: string };
621
+ expect(res.success).toBe(false);
622
+ expect(res.error).toBe('Failed to store webhook secret');
623
+ expect(res.hasBotToken).toBe(false);
624
+ expect(res.connected).toBe(false);
625
+ expect(res.hasWebhookSecret).toBe(false);
626
+
627
+ // Bot token should have been rolled back
628
+ expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
629
+ // Metadata should have been cleaned up
630
+ expect(credentialMetadataStore.find((m) => m.service === 'telegram' && m.field === 'bot_token')).toBeUndefined();
631
+ });
632
+
633
+ test('set action preserves storage-fallback token when webhook secret storage fails', async () => {
634
+ // Pre-populate token in secure storage (simulating credential_store prompt)
635
+ secureKeyStore['credential:telegram:bot_token'] = '123456:stored-token';
636
+
637
+ // Let bot token storage succeed but webhook secret storage fail
638
+ setSecureKeyOverride = (account: string, value: string) => {
639
+ if (account === 'credential:telegram:webhook_secret') return false;
640
+ secureKeyStore[account] = value;
641
+ return true;
642
+ };
643
+
644
+ globalThis.fetch = (async (url: string | URL | Request) => {
645
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
646
+ if (urlStr.includes('api.telegram.org') && urlStr.includes('/getMe')) {
647
+ return new Response(JSON.stringify({
648
+ ok: true,
649
+ result: { id: 123456, is_bot: true, first_name: 'TestBot', username: 'stored_bot' },
650
+ }), { status: 200 });
651
+ }
652
+ return originalFetch(url);
653
+ }) as typeof fetch;
654
+
655
+ const msg: TelegramConfigRequest = {
656
+ type: 'telegram_config',
657
+ action: 'set',
658
+ // No botToken — falls back to secure storage
659
+ };
660
+
661
+ const { ctx, sent } = createTestContext();
662
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
663
+
664
+ expect(sent).toHaveLength(1);
665
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean; hasWebhookSecret: boolean; error?: string };
666
+ expect(res.success).toBe(false);
667
+ expect(res.error).toBe('Failed to store webhook secret');
668
+ // The pre-existing token from storage should NOT be deleted
669
+ expect(res.hasBotToken).toBe(true);
670
+ expect(res.connected).toBe(false);
671
+ expect(res.hasWebhookSecret).toBe(false);
672
+
673
+ // Token should still exist in secure storage
674
+ expect(secureKeyStore['credential:telegram:bot_token']).toBe('123456:stored-token');
675
+ });
676
+
677
+ test('clear action deregisters webhook before deleting credentials', async () => {
678
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
679
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
680
+ credentialMetadataStore.push({ service: 'telegram', field: 'bot_token', accountInfo: 'my_test_bot' });
681
+ credentialMetadataStore.push({ service: 'telegram', field: 'webhook_secret' });
682
+
683
+ let deleteWebhookCalled = false;
684
+ let deleteWebhookUrl = '';
685
+ globalThis.fetch = (async (url: string | URL | Request) => {
686
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
687
+ if (urlStr.includes('/deleteWebhook')) {
688
+ deleteWebhookCalled = true;
689
+ deleteWebhookUrl = urlStr;
690
+ return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
691
+ }
692
+ return originalFetch(url);
693
+ }) as typeof fetch;
694
+
695
+ const { ctx, sent } = createTestContext();
696
+ await handleTelegramConfig(
697
+ { type: 'telegram_config', action: 'clear' },
698
+ {} as net.Socket,
699
+ ctx,
700
+ );
701
+
702
+ expect(sent).toHaveLength(1);
703
+ const res = sent[0] as { type: string; success: boolean };
704
+ expect(res.success).toBe(true);
705
+
706
+ // deleteWebhook should have been called with the bot token
707
+ expect(deleteWebhookCalled).toBe(true);
708
+ expect(deleteWebhookUrl).toContain('test-bot-token');
709
+
710
+ // Credentials should still be cleaned up
711
+ expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
712
+ expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
713
+ });
714
+
715
+ test('clear action proceeds even when webhook deregistration fails', async () => {
716
+ const tgToken = ['123456789', ':', 'ABCDefGHIJklmnopQRSTuvwxyz012345678'].join('');
717
+ secureKeyStore['credential:telegram:bot_token'] = tgToken;
718
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
719
+
720
+ globalThis.fetch = (async () => {
721
+ const err = new Error('Network error') as Error & { path?: string; code?: string };
722
+ err.path = `https://api.telegram.org/bot${tgToken}/deleteWebhook`;
723
+ err.code = 'ConnectionRefused';
724
+ throw err;
725
+ }) as unknown as typeof fetch;
726
+
727
+ const { ctx, sent } = createTestContext();
728
+ await handleTelegramConfig(
729
+ { type: 'telegram_config', action: 'clear' },
730
+ {} as net.Socket,
731
+ ctx,
732
+ );
733
+
734
+ expect(sent).toHaveLength(1);
735
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean };
736
+ expect(res.success).toBe(true);
737
+ expect(res.hasBotToken).toBe(false);
738
+ expect(res.connected).toBe(false);
739
+
740
+ // Credentials should still be cleaned up despite webhook deregistration failure
741
+ expect(secureKeyStore['credential:telegram:bot_token']).toBeUndefined();
742
+ expect(secureKeyStore['credential:telegram:webhook_secret']).toBeUndefined();
743
+ });
744
+
745
+ test('set_commands action registers default commands', async () => {
746
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
747
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
748
+
749
+ let setCommandsCalled = false;
750
+ let setCommandsBody: unknown = null;
751
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
752
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
753
+ if (urlStr.includes('/setMyCommands')) {
754
+ setCommandsCalled = true;
755
+ setCommandsBody = JSON.parse(init?.body as string);
756
+ return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
757
+ }
758
+ return originalFetch(url);
759
+ }) as typeof fetch;
760
+
761
+ const msg: TelegramConfigRequest = {
762
+ type: 'telegram_config',
763
+ action: 'set_commands',
764
+ };
765
+
766
+ const { ctx, sent } = createTestContext();
767
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
768
+
769
+ expect(sent).toHaveLength(1);
770
+ const res = sent[0] as { type: string; success: boolean; hasBotToken: boolean; connected: boolean };
771
+ expect(res.success).toBe(true);
772
+ expect(res.hasBotToken).toBe(true);
773
+ expect(res.connected).toBe(true);
774
+ expect(setCommandsCalled).toBe(true);
775
+ expect((setCommandsBody as { commands: Array<{ command: string; description: string }> }).commands).toEqual([
776
+ { command: 'new', description: 'Start a new conversation' },
777
+ ]);
778
+ });
779
+
780
+ test('set_commands action with custom commands', async () => {
781
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
782
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
783
+
784
+ let setCommandsBody: unknown = null;
785
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
786
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
787
+ if (urlStr.includes('/setMyCommands')) {
788
+ setCommandsBody = JSON.parse(init?.body as string);
789
+ return new Response(JSON.stringify({ ok: true, result: true }), { status: 200 });
790
+ }
791
+ return originalFetch(url);
792
+ }) as typeof fetch;
793
+
794
+ const msg: TelegramConfigRequest = {
795
+ type: 'telegram_config',
796
+ action: 'set_commands',
797
+ commands: [
798
+ { command: 'new', description: 'Start a new conversation' },
799
+ { command: 'help', description: 'Show help' },
800
+ ],
801
+ };
802
+
803
+ const { ctx, sent } = createTestContext();
804
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
805
+
806
+ expect(sent).toHaveLength(1);
807
+ const res = sent[0] as { type: string; success: boolean };
808
+ expect(res.success).toBe(true);
809
+ expect((setCommandsBody as { commands: Array<{ command: string; description: string }> }).commands).toEqual([
810
+ { command: 'new', description: 'Start a new conversation' },
811
+ { command: 'help', description: 'Show help' },
812
+ ]);
813
+ });
814
+
815
+ test('set_commands action fails when no bot token is configured', async () => {
816
+ const msg: TelegramConfigRequest = {
817
+ type: 'telegram_config',
818
+ action: 'set_commands',
819
+ };
820
+
821
+ const { ctx, sent } = createTestContext();
822
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
823
+
824
+ expect(sent).toHaveLength(1);
825
+ const res = sent[0] as { type: string; success: boolean; error?: string };
826
+ expect(res.success).toBe(false);
827
+ expect(res.error).toContain('Bot token not configured');
828
+ });
829
+
830
+ test('set_commands action handles Telegram API error', async () => {
831
+ secureKeyStore['credential:telegram:bot_token'] = 'test-bot-token';
832
+ secureKeyStore['credential:telegram:webhook_secret'] = 'test-webhook-secret';
833
+
834
+ globalThis.fetch = (async (url: string | URL | Request) => {
835
+ const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
836
+ if (urlStr.includes('/setMyCommands')) {
837
+ return new Response(JSON.stringify({ ok: false, description: 'Bad Request' }), { status: 400 });
838
+ }
839
+ return originalFetch(url);
840
+ }) as typeof fetch;
841
+
842
+ const msg: TelegramConfigRequest = {
843
+ type: 'telegram_config',
844
+ action: 'set_commands',
845
+ };
846
+
847
+ const { ctx, sent } = createTestContext();
848
+ await handleTelegramConfig(msg, {} as net.Socket, ctx);
849
+
850
+ expect(sent).toHaveLength(1);
851
+ const res = sent[0] as { type: string; success: boolean; error?: string };
852
+ expect(res.success).toBe(false);
853
+ expect(res.error).toContain('Failed to set bot commands');
854
+ });
855
+ });