create-ironclaws 1.0.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/README.md +101 -0
- package/bin/create.js +394 -0
- package/package.json +33 -0
- package/template/.env.example +38 -0
- package/template/CLAUDE.md +104 -0
- package/template/agent-credentials.yaml +33 -0
- package/template/agents.yaml +22 -0
- package/template/container/Dockerfile +70 -0
- package/template/container/Dockerfile.argus +34 -0
- package/template/container/agent-runner/package-lock.json +1524 -0
- package/template/container/agent-runner/package.json +23 -0
- package/template/container/agent-runner/src/index.ts +630 -0
- package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
- package/template/container/agent-runner/tsconfig.json +15 -0
- package/template/container/build-argus.sh +25 -0
- package/template/container/build.sh +23 -0
- package/template/container/skills/agent-browser/SKILL.md +159 -0
- package/template/container/skills/agent-status/SKILL.md +69 -0
- package/template/container/skills/capabilities/SKILL.md +100 -0
- package/template/container/skills/edit-agent/SKILL.md +93 -0
- package/template/container/skills/slack-formatting/SKILL.md +92 -0
- package/template/container/skills/status/SKILL.md +104 -0
- package/template/container/tools/elastic_query.py +161 -0
- package/template/container/tools/gdrive_tool.py +185 -0
- package/template/container/tools/jira_tool.py +433 -0
- package/template/container/tools/slack_history_tool.py +144 -0
- package/template/container/tools/youtube_tool.py +174 -0
- package/template/docker-compose.yml +54 -0
- package/template/docs/how-it-works.md +496 -0
- package/template/eslint.config.js +32 -0
- package/template/groups/forge/CLAUDE.md +107 -0
- package/template/package-lock.json +5278 -0
- package/template/package.json +52 -0
- package/template/scripts/github-app-token.py +58 -0
- package/template/scripts/register-expense-agent.sh +121 -0
- package/template/scripts/run-migrations.ts +105 -0
- package/template/scripts/setup-onecli-secrets.sh +252 -0
- package/template/setup-agents.sh +142 -0
- package/template/src/channels/index.ts +13 -0
- package/template/src/channels/registry.test.ts +42 -0
- package/template/src/channels/registry.ts +28 -0
- package/template/src/channels/slack.test.ts +859 -0
- package/template/src/channels/slack.ts +373 -0
- package/template/src/claw-skill.test.ts +45 -0
- package/template/src/config.ts +94 -0
- package/template/src/container-runner.test.ts +221 -0
- package/template/src/container-runner.ts +1029 -0
- package/template/src/container-runtime.test.ts +149 -0
- package/template/src/container-runtime.ts +124 -0
- package/template/src/db-migration.test.ts +67 -0
- package/template/src/db.test.ts +484 -0
- package/template/src/db.ts +837 -0
- package/template/src/env.ts +42 -0
- package/template/src/formatting.test.ts +294 -0
- package/template/src/github-token.ts +48 -0
- package/template/src/google-token.ts +75 -0
- package/template/src/group-folder.test.ts +43 -0
- package/template/src/group-folder.ts +44 -0
- package/template/src/group-queue.test.ts +484 -0
- package/template/src/group-queue.ts +363 -0
- package/template/src/http-server.ts +343 -0
- package/template/src/index.ts +960 -0
- package/template/src/ipc-auth.test.ts +679 -0
- package/template/src/ipc.ts +548 -0
- package/template/src/logger.ts +16 -0
- package/template/src/mount-security.ts +421 -0
- package/template/src/network-policy.ts +119 -0
- package/template/src/remote-control.test.ts +397 -0
- package/template/src/remote-control.ts +224 -0
- package/template/src/router.ts +52 -0
- package/template/src/routing.test.ts +170 -0
- package/template/src/sender-allowlist.test.ts +216 -0
- package/template/src/sender-allowlist.ts +128 -0
- package/template/src/task-scheduler.test.ts +129 -0
- package/template/src/task-scheduler.ts +290 -0
- package/template/src/timezone.test.ts +73 -0
- package/template/src/timezone.ts +37 -0
- package/template/src/types.ts +114 -0
- package/template/src/worktree.ts +206 -0
- package/template/tsconfig.json +20 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Mocks ---
|
|
4
|
+
|
|
5
|
+
// Mock registry (registerChannel runs at import time)
|
|
6
|
+
vi.mock('./registry.js', () => ({ registerChannel: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
// Mock config
|
|
9
|
+
vi.mock('../config.js', () => ({
|
|
10
|
+
ASSISTANT_NAME: 'Jonesy',
|
|
11
|
+
TRIGGER_PATTERN: /^@Jonesy\b/i,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Mock logger
|
|
15
|
+
vi.mock('../logger.js', () => ({
|
|
16
|
+
logger: {
|
|
17
|
+
debug: vi.fn(),
|
|
18
|
+
info: vi.fn(),
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
// Mock db
|
|
25
|
+
vi.mock('../db.js', () => ({
|
|
26
|
+
updateChatName: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// --- @slack/bolt mock ---
|
|
30
|
+
|
|
31
|
+
type Handler = (...args: any[]) => any;
|
|
32
|
+
|
|
33
|
+
const appRef = vi.hoisted(() => ({ current: null as any }));
|
|
34
|
+
|
|
35
|
+
vi.mock('@slack/bolt', () => ({
|
|
36
|
+
App: class MockApp {
|
|
37
|
+
eventHandlers = new Map<string, Handler>();
|
|
38
|
+
token: string;
|
|
39
|
+
appToken: string;
|
|
40
|
+
|
|
41
|
+
client = {
|
|
42
|
+
auth: {
|
|
43
|
+
test: vi.fn().mockResolvedValue({ user_id: 'U_BOT_123' }),
|
|
44
|
+
},
|
|
45
|
+
chat: {
|
|
46
|
+
postMessage: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
},
|
|
48
|
+
conversations: {
|
|
49
|
+
list: vi.fn().mockResolvedValue({
|
|
50
|
+
channels: [],
|
|
51
|
+
response_metadata: {},
|
|
52
|
+
}),
|
|
53
|
+
},
|
|
54
|
+
users: {
|
|
55
|
+
info: vi.fn().mockResolvedValue({
|
|
56
|
+
user: { real_name: 'Alice Smith', name: 'alice' },
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
constructor(opts: any) {
|
|
62
|
+
this.token = opts.token;
|
|
63
|
+
this.appToken = opts.appToken;
|
|
64
|
+
appRef.current = this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
event(name: string, handler: Handler) {
|
|
68
|
+
this.eventHandlers.set(name, handler);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async start() {}
|
|
72
|
+
async stop() {}
|
|
73
|
+
},
|
|
74
|
+
LogLevel: { ERROR: 'error' },
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
// Mock env
|
|
78
|
+
vi.mock('../env.js', () => ({
|
|
79
|
+
readEnvFile: vi.fn().mockReturnValue({
|
|
80
|
+
SLACK_BOT_TOKEN: 'xoxb-test-token',
|
|
81
|
+
SLACK_APP_TOKEN: 'xapp-test-token',
|
|
82
|
+
}),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
import { SlackChannel, SlackChannelOpts } from './slack.js';
|
|
86
|
+
import { updateChatName } from '../db.js';
|
|
87
|
+
import { readEnvFile } from '../env.js';
|
|
88
|
+
|
|
89
|
+
// --- Test helpers ---
|
|
90
|
+
|
|
91
|
+
function createTestOpts(
|
|
92
|
+
overrides?: Partial<SlackChannelOpts>,
|
|
93
|
+
): SlackChannelOpts {
|
|
94
|
+
return {
|
|
95
|
+
onMessage: vi.fn(),
|
|
96
|
+
onChatMetadata: vi.fn(),
|
|
97
|
+
registeredGroups: vi.fn(() => ({
|
|
98
|
+
'slack:C0123456789': {
|
|
99
|
+
name: 'Test Channel',
|
|
100
|
+
folder: 'test-channel',
|
|
101
|
+
trigger: '@Jonesy',
|
|
102
|
+
added_at: '2024-01-01T00:00:00.000Z',
|
|
103
|
+
},
|
|
104
|
+
})),
|
|
105
|
+
...overrides,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createMessageEvent(overrides: {
|
|
110
|
+
channel?: string;
|
|
111
|
+
channelType?: string;
|
|
112
|
+
user?: string;
|
|
113
|
+
text?: string;
|
|
114
|
+
ts?: string;
|
|
115
|
+
threadTs?: string;
|
|
116
|
+
subtype?: string;
|
|
117
|
+
botId?: string;
|
|
118
|
+
}) {
|
|
119
|
+
return {
|
|
120
|
+
channel: overrides.channel ?? 'C0123456789',
|
|
121
|
+
channel_type: overrides.channelType ?? 'channel',
|
|
122
|
+
user: overrides.user ?? 'U_USER_456',
|
|
123
|
+
text: 'text' in overrides ? overrides.text : 'Hello everyone',
|
|
124
|
+
ts: overrides.ts ?? '1704067200.000000',
|
|
125
|
+
thread_ts: overrides.threadTs,
|
|
126
|
+
subtype: overrides.subtype,
|
|
127
|
+
bot_id: overrides.botId,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function currentApp() {
|
|
132
|
+
return appRef.current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function triggerMessageEvent(
|
|
136
|
+
event: ReturnType<typeof createMessageEvent>,
|
|
137
|
+
) {
|
|
138
|
+
const handler = currentApp().eventHandlers.get('message');
|
|
139
|
+
if (handler) await handler({ event });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- Tests ---
|
|
143
|
+
|
|
144
|
+
describe('SlackChannel', () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
vi.clearAllMocks();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
vi.restoreAllMocks();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- Connection lifecycle ---
|
|
154
|
+
|
|
155
|
+
describe('connection lifecycle', () => {
|
|
156
|
+
it('resolves connect() when app starts', async () => {
|
|
157
|
+
const opts = createTestOpts();
|
|
158
|
+
const channel = new SlackChannel(opts);
|
|
159
|
+
|
|
160
|
+
await channel.connect();
|
|
161
|
+
|
|
162
|
+
expect(channel.isConnected()).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('registers message event handler on construction', () => {
|
|
166
|
+
const opts = createTestOpts();
|
|
167
|
+
new SlackChannel(opts);
|
|
168
|
+
|
|
169
|
+
expect(currentApp().eventHandlers.has('message')).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('gets bot user ID on connect', async () => {
|
|
173
|
+
const opts = createTestOpts();
|
|
174
|
+
const channel = new SlackChannel(opts);
|
|
175
|
+
|
|
176
|
+
await channel.connect();
|
|
177
|
+
|
|
178
|
+
expect(currentApp().client.auth.test).toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('disconnects cleanly', async () => {
|
|
182
|
+
const opts = createTestOpts();
|
|
183
|
+
const channel = new SlackChannel(opts);
|
|
184
|
+
|
|
185
|
+
await channel.connect();
|
|
186
|
+
expect(channel.isConnected()).toBe(true);
|
|
187
|
+
|
|
188
|
+
await channel.disconnect();
|
|
189
|
+
expect(channel.isConnected()).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('isConnected() returns false before connect', () => {
|
|
193
|
+
const opts = createTestOpts();
|
|
194
|
+
const channel = new SlackChannel(opts);
|
|
195
|
+
|
|
196
|
+
expect(channel.isConnected()).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- Message handling ---
|
|
201
|
+
|
|
202
|
+
describe('message handling', () => {
|
|
203
|
+
it('delivers message for registered channel', async () => {
|
|
204
|
+
const opts = createTestOpts();
|
|
205
|
+
const channel = new SlackChannel(opts);
|
|
206
|
+
await channel.connect();
|
|
207
|
+
|
|
208
|
+
const event = createMessageEvent({ text: 'Hello everyone' });
|
|
209
|
+
await triggerMessageEvent(event);
|
|
210
|
+
|
|
211
|
+
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
|
212
|
+
'slack:C0123456789',
|
|
213
|
+
expect.any(String),
|
|
214
|
+
undefined,
|
|
215
|
+
'slack',
|
|
216
|
+
true,
|
|
217
|
+
);
|
|
218
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
219
|
+
'slack:C0123456789',
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
id: '1704067200.000000',
|
|
222
|
+
chat_jid: 'slack:C0123456789',
|
|
223
|
+
sender: 'U_USER_456',
|
|
224
|
+
content: 'Hello everyone',
|
|
225
|
+
is_from_me: false,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('only emits metadata for unregistered channels', async () => {
|
|
231
|
+
const opts = createTestOpts();
|
|
232
|
+
const channel = new SlackChannel(opts);
|
|
233
|
+
await channel.connect();
|
|
234
|
+
|
|
235
|
+
const event = createMessageEvent({ channel: 'C9999999999' });
|
|
236
|
+
await triggerMessageEvent(event);
|
|
237
|
+
|
|
238
|
+
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
|
239
|
+
'slack:C9999999999',
|
|
240
|
+
expect.any(String),
|
|
241
|
+
undefined,
|
|
242
|
+
'slack',
|
|
243
|
+
true,
|
|
244
|
+
);
|
|
245
|
+
expect(opts.onMessage).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('skips non-text subtypes (channel_join, etc.)', async () => {
|
|
249
|
+
const opts = createTestOpts();
|
|
250
|
+
const channel = new SlackChannel(opts);
|
|
251
|
+
await channel.connect();
|
|
252
|
+
|
|
253
|
+
const event = createMessageEvent({ subtype: 'channel_join' });
|
|
254
|
+
await triggerMessageEvent(event);
|
|
255
|
+
|
|
256
|
+
expect(opts.onMessage).not.toHaveBeenCalled();
|
|
257
|
+
expect(opts.onChatMetadata).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('allows bot_message subtype through', async () => {
|
|
261
|
+
const opts = createTestOpts();
|
|
262
|
+
const channel = new SlackChannel(opts);
|
|
263
|
+
await channel.connect();
|
|
264
|
+
|
|
265
|
+
const event = createMessageEvent({
|
|
266
|
+
subtype: 'bot_message',
|
|
267
|
+
botId: 'B_OTHER_BOT',
|
|
268
|
+
text: 'Bot message',
|
|
269
|
+
});
|
|
270
|
+
await triggerMessageEvent(event);
|
|
271
|
+
|
|
272
|
+
expect(opts.onChatMetadata).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('skips messages with no text', async () => {
|
|
276
|
+
const opts = createTestOpts();
|
|
277
|
+
const channel = new SlackChannel(opts);
|
|
278
|
+
await channel.connect();
|
|
279
|
+
|
|
280
|
+
const event = createMessageEvent({ text: undefined as any });
|
|
281
|
+
await triggerMessageEvent(event);
|
|
282
|
+
|
|
283
|
+
expect(opts.onMessage).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('detects bot messages by bot_id', async () => {
|
|
287
|
+
const opts = createTestOpts();
|
|
288
|
+
const channel = new SlackChannel(opts);
|
|
289
|
+
await channel.connect();
|
|
290
|
+
|
|
291
|
+
const event = createMessageEvent({
|
|
292
|
+
subtype: 'bot_message',
|
|
293
|
+
botId: 'B_MY_BOT',
|
|
294
|
+
text: 'Bot response',
|
|
295
|
+
});
|
|
296
|
+
await triggerMessageEvent(event);
|
|
297
|
+
|
|
298
|
+
// Has bot_id so should be marked as bot message
|
|
299
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
300
|
+
'slack:C0123456789',
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
is_from_me: true,
|
|
303
|
+
is_bot_message: true,
|
|
304
|
+
sender_name: 'Jonesy',
|
|
305
|
+
}),
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('detects bot messages by matching bot user ID', async () => {
|
|
310
|
+
const opts = createTestOpts();
|
|
311
|
+
const channel = new SlackChannel(opts);
|
|
312
|
+
await channel.connect();
|
|
313
|
+
|
|
314
|
+
const event = createMessageEvent({
|
|
315
|
+
user: 'U_BOT_123',
|
|
316
|
+
text: 'Self message',
|
|
317
|
+
});
|
|
318
|
+
await triggerMessageEvent(event);
|
|
319
|
+
|
|
320
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
321
|
+
'slack:C0123456789',
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
is_from_me: true,
|
|
324
|
+
is_bot_message: true,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('identifies IM channel type as non-group', async () => {
|
|
330
|
+
const opts = createTestOpts({
|
|
331
|
+
registeredGroups: vi.fn(() => ({
|
|
332
|
+
'slack:D0123456789': {
|
|
333
|
+
name: 'DM',
|
|
334
|
+
folder: 'dm',
|
|
335
|
+
trigger: '@Jonesy',
|
|
336
|
+
added_at: '2024-01-01T00:00:00.000Z',
|
|
337
|
+
},
|
|
338
|
+
})),
|
|
339
|
+
});
|
|
340
|
+
const channel = new SlackChannel(opts);
|
|
341
|
+
await channel.connect();
|
|
342
|
+
|
|
343
|
+
const event = createMessageEvent({
|
|
344
|
+
channel: 'D0123456789',
|
|
345
|
+
channelType: 'im',
|
|
346
|
+
});
|
|
347
|
+
await triggerMessageEvent(event);
|
|
348
|
+
|
|
349
|
+
expect(opts.onChatMetadata).toHaveBeenCalledWith(
|
|
350
|
+
'slack:D0123456789',
|
|
351
|
+
expect.any(String),
|
|
352
|
+
undefined,
|
|
353
|
+
'slack',
|
|
354
|
+
false, // IM is not a group
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('converts ts to ISO timestamp', async () => {
|
|
359
|
+
const opts = createTestOpts();
|
|
360
|
+
const channel = new SlackChannel(opts);
|
|
361
|
+
await channel.connect();
|
|
362
|
+
|
|
363
|
+
const event = createMessageEvent({ ts: '1704067200.000000' });
|
|
364
|
+
await triggerMessageEvent(event);
|
|
365
|
+
|
|
366
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
367
|
+
'slack:C0123456789',
|
|
368
|
+
expect.objectContaining({
|
|
369
|
+
timestamp: '2024-01-01T00:00:00.000Z',
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('resolves user name from Slack API', async () => {
|
|
375
|
+
const opts = createTestOpts();
|
|
376
|
+
const channel = new SlackChannel(opts);
|
|
377
|
+
await channel.connect();
|
|
378
|
+
|
|
379
|
+
const event = createMessageEvent({ user: 'U_USER_456', text: 'Hello' });
|
|
380
|
+
await triggerMessageEvent(event);
|
|
381
|
+
|
|
382
|
+
expect(currentApp().client.users.info).toHaveBeenCalledWith({
|
|
383
|
+
user: 'U_USER_456',
|
|
384
|
+
});
|
|
385
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
386
|
+
'slack:C0123456789',
|
|
387
|
+
expect.objectContaining({
|
|
388
|
+
sender_name: 'Alice Smith',
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('caches user names to avoid repeated API calls', async () => {
|
|
394
|
+
const opts = createTestOpts();
|
|
395
|
+
const channel = new SlackChannel(opts);
|
|
396
|
+
await channel.connect();
|
|
397
|
+
|
|
398
|
+
// First message — API call
|
|
399
|
+
await triggerMessageEvent(
|
|
400
|
+
createMessageEvent({ user: 'U_USER_456', text: 'First' }),
|
|
401
|
+
);
|
|
402
|
+
// Second message — should use cache
|
|
403
|
+
await triggerMessageEvent(
|
|
404
|
+
createMessageEvent({
|
|
405
|
+
user: 'U_USER_456',
|
|
406
|
+
text: 'Second',
|
|
407
|
+
ts: '1704067201.000000',
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(currentApp().client.users.info).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('falls back to user ID when API fails', async () => {
|
|
415
|
+
const opts = createTestOpts();
|
|
416
|
+
const channel = new SlackChannel(opts);
|
|
417
|
+
await channel.connect();
|
|
418
|
+
|
|
419
|
+
currentApp().client.users.info.mockRejectedValueOnce(
|
|
420
|
+
new Error('API error'),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const event = createMessageEvent({ user: 'U_UNKNOWN', text: 'Hi' });
|
|
424
|
+
await triggerMessageEvent(event);
|
|
425
|
+
|
|
426
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
427
|
+
'slack:C0123456789',
|
|
428
|
+
expect.objectContaining({
|
|
429
|
+
sender_name: 'U_UNKNOWN',
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('flattens threaded replies into channel messages', async () => {
|
|
435
|
+
const opts = createTestOpts();
|
|
436
|
+
const channel = new SlackChannel(opts);
|
|
437
|
+
await channel.connect();
|
|
438
|
+
|
|
439
|
+
const event = createMessageEvent({
|
|
440
|
+
ts: '1704067201.000000',
|
|
441
|
+
threadTs: '1704067200.000000', // parent message ts — this is a reply
|
|
442
|
+
text: 'Thread reply',
|
|
443
|
+
});
|
|
444
|
+
await triggerMessageEvent(event);
|
|
445
|
+
|
|
446
|
+
// Threaded replies are delivered as regular channel messages
|
|
447
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
448
|
+
'slack:C0123456789',
|
|
449
|
+
expect.objectContaining({
|
|
450
|
+
content: 'Thread reply',
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('delivers thread parent messages normally', async () => {
|
|
456
|
+
const opts = createTestOpts();
|
|
457
|
+
const channel = new SlackChannel(opts);
|
|
458
|
+
await channel.connect();
|
|
459
|
+
|
|
460
|
+
const event = createMessageEvent({
|
|
461
|
+
ts: '1704067200.000000',
|
|
462
|
+
threadTs: '1704067200.000000', // same as ts — this IS the parent
|
|
463
|
+
text: 'Thread parent',
|
|
464
|
+
});
|
|
465
|
+
await triggerMessageEvent(event);
|
|
466
|
+
|
|
467
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
468
|
+
'slack:C0123456789',
|
|
469
|
+
expect.objectContaining({
|
|
470
|
+
content: 'Thread parent',
|
|
471
|
+
}),
|
|
472
|
+
);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('delivers messages without thread_ts normally', async () => {
|
|
476
|
+
const opts = createTestOpts();
|
|
477
|
+
const channel = new SlackChannel(opts);
|
|
478
|
+
await channel.connect();
|
|
479
|
+
|
|
480
|
+
const event = createMessageEvent({ text: 'Normal message' });
|
|
481
|
+
await triggerMessageEvent(event);
|
|
482
|
+
|
|
483
|
+
expect(opts.onMessage).toHaveBeenCalled();
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// --- @mention translation ---
|
|
488
|
+
|
|
489
|
+
describe('@mention translation', () => {
|
|
490
|
+
it('prepends trigger when bot is @mentioned via Slack format', async () => {
|
|
491
|
+
const opts = createTestOpts();
|
|
492
|
+
const channel = new SlackChannel(opts);
|
|
493
|
+
await channel.connect(); // sets botUserId to 'U_BOT_123'
|
|
494
|
+
|
|
495
|
+
const event = createMessageEvent({
|
|
496
|
+
text: 'Hey <@U_BOT_123> what do you think?',
|
|
497
|
+
user: 'U_USER_456',
|
|
498
|
+
});
|
|
499
|
+
await triggerMessageEvent(event);
|
|
500
|
+
|
|
501
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
502
|
+
'slack:C0123456789',
|
|
503
|
+
expect.objectContaining({
|
|
504
|
+
content: '@Jonesy Hey <@U_BOT_123> what do you think?',
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('does not prepend trigger when trigger pattern already matches', async () => {
|
|
510
|
+
const opts = createTestOpts();
|
|
511
|
+
const channel = new SlackChannel(opts);
|
|
512
|
+
await channel.connect();
|
|
513
|
+
|
|
514
|
+
const event = createMessageEvent({
|
|
515
|
+
text: '@Jonesy <@U_BOT_123> hello',
|
|
516
|
+
user: 'U_USER_456',
|
|
517
|
+
});
|
|
518
|
+
await triggerMessageEvent(event);
|
|
519
|
+
|
|
520
|
+
// Content should be unchanged since it already matches TRIGGER_PATTERN
|
|
521
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
522
|
+
'slack:C0123456789',
|
|
523
|
+
expect.objectContaining({
|
|
524
|
+
content: '@Jonesy <@U_BOT_123> hello',
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('does not translate mentions in bot messages', async () => {
|
|
530
|
+
const opts = createTestOpts();
|
|
531
|
+
const channel = new SlackChannel(opts);
|
|
532
|
+
await channel.connect();
|
|
533
|
+
|
|
534
|
+
const event = createMessageEvent({
|
|
535
|
+
text: 'Echo: <@U_BOT_123>',
|
|
536
|
+
subtype: 'bot_message',
|
|
537
|
+
botId: 'B_MY_BOT',
|
|
538
|
+
});
|
|
539
|
+
await triggerMessageEvent(event);
|
|
540
|
+
|
|
541
|
+
// Bot messages skip mention translation
|
|
542
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
543
|
+
'slack:C0123456789',
|
|
544
|
+
expect.objectContaining({
|
|
545
|
+
content: 'Echo: <@U_BOT_123>',
|
|
546
|
+
}),
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('does not translate mentions for other users', async () => {
|
|
551
|
+
const opts = createTestOpts();
|
|
552
|
+
const channel = new SlackChannel(opts);
|
|
553
|
+
await channel.connect();
|
|
554
|
+
|
|
555
|
+
const event = createMessageEvent({
|
|
556
|
+
text: 'Hey <@U_OTHER_USER> look at this',
|
|
557
|
+
user: 'U_USER_456',
|
|
558
|
+
});
|
|
559
|
+
await triggerMessageEvent(event);
|
|
560
|
+
|
|
561
|
+
// Mention is for a different user, not the bot
|
|
562
|
+
expect(opts.onMessage).toHaveBeenCalledWith(
|
|
563
|
+
'slack:C0123456789',
|
|
564
|
+
expect.objectContaining({
|
|
565
|
+
content: 'Hey <@U_OTHER_USER> look at this',
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// --- sendMessage ---
|
|
572
|
+
|
|
573
|
+
describe('sendMessage', () => {
|
|
574
|
+
it('sends message via Slack client', async () => {
|
|
575
|
+
const opts = createTestOpts();
|
|
576
|
+
const channel = new SlackChannel(opts);
|
|
577
|
+
await channel.connect();
|
|
578
|
+
|
|
579
|
+
await channel.sendMessage('slack:C0123456789', 'Hello');
|
|
580
|
+
|
|
581
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
|
582
|
+
channel: 'C0123456789',
|
|
583
|
+
text: 'Hello',
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('strips slack: prefix from JID', async () => {
|
|
588
|
+
const opts = createTestOpts();
|
|
589
|
+
const channel = new SlackChannel(opts);
|
|
590
|
+
await channel.connect();
|
|
591
|
+
|
|
592
|
+
await channel.sendMessage('slack:D9876543210', 'DM message');
|
|
593
|
+
|
|
594
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
|
595
|
+
channel: 'D9876543210',
|
|
596
|
+
text: 'DM message',
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('queues message when disconnected', async () => {
|
|
601
|
+
const opts = createTestOpts();
|
|
602
|
+
const channel = new SlackChannel(opts);
|
|
603
|
+
|
|
604
|
+
// Don't connect — should queue
|
|
605
|
+
await channel.sendMessage('slack:C0123456789', 'Queued message');
|
|
606
|
+
|
|
607
|
+
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('queues message on send failure', async () => {
|
|
611
|
+
const opts = createTestOpts();
|
|
612
|
+
const channel = new SlackChannel(opts);
|
|
613
|
+
await channel.connect();
|
|
614
|
+
|
|
615
|
+
currentApp().client.chat.postMessage.mockRejectedValueOnce(
|
|
616
|
+
new Error('Network error'),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
// Should not throw
|
|
620
|
+
await expect(
|
|
621
|
+
channel.sendMessage('slack:C0123456789', 'Will fail'),
|
|
622
|
+
).resolves.toBeUndefined();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('splits long messages at 4000 character boundary', async () => {
|
|
626
|
+
const opts = createTestOpts();
|
|
627
|
+
const channel = new SlackChannel(opts);
|
|
628
|
+
await channel.connect();
|
|
629
|
+
|
|
630
|
+
// Create a message longer than 4000 chars
|
|
631
|
+
const longText = 'A'.repeat(4500);
|
|
632
|
+
await channel.sendMessage('slack:C0123456789', longText);
|
|
633
|
+
|
|
634
|
+
// Should be split into 2 messages: 4000 + 500
|
|
635
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(2);
|
|
636
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(1, {
|
|
637
|
+
channel: 'C0123456789',
|
|
638
|
+
text: 'A'.repeat(4000),
|
|
639
|
+
});
|
|
640
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenNthCalledWith(2, {
|
|
641
|
+
channel: 'C0123456789',
|
|
642
|
+
text: 'A'.repeat(500),
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('sends exactly-4000-char messages as a single message', async () => {
|
|
647
|
+
const opts = createTestOpts();
|
|
648
|
+
const channel = new SlackChannel(opts);
|
|
649
|
+
await channel.connect();
|
|
650
|
+
|
|
651
|
+
const text = 'B'.repeat(4000);
|
|
652
|
+
await channel.sendMessage('slack:C0123456789', text);
|
|
653
|
+
|
|
654
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(1);
|
|
655
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
|
656
|
+
channel: 'C0123456789',
|
|
657
|
+
text,
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it('splits messages into 3 parts when over 8000 chars', async () => {
|
|
662
|
+
const opts = createTestOpts();
|
|
663
|
+
const channel = new SlackChannel(opts);
|
|
664
|
+
await channel.connect();
|
|
665
|
+
|
|
666
|
+
const longText = 'C'.repeat(8500);
|
|
667
|
+
await channel.sendMessage('slack:C0123456789', longText);
|
|
668
|
+
|
|
669
|
+
// 4000 + 4000 + 500 = 3 messages
|
|
670
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledTimes(3);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('flushes queued messages on connect', async () => {
|
|
674
|
+
const opts = createTestOpts();
|
|
675
|
+
const channel = new SlackChannel(opts);
|
|
676
|
+
|
|
677
|
+
// Queue messages while disconnected
|
|
678
|
+
await channel.sendMessage('slack:C0123456789', 'First queued');
|
|
679
|
+
await channel.sendMessage('slack:C0123456789', 'Second queued');
|
|
680
|
+
|
|
681
|
+
expect(currentApp().client.chat.postMessage).not.toHaveBeenCalled();
|
|
682
|
+
|
|
683
|
+
// Connect triggers flush
|
|
684
|
+
await channel.connect();
|
|
685
|
+
|
|
686
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
|
687
|
+
channel: 'C0123456789',
|
|
688
|
+
text: 'First queued',
|
|
689
|
+
});
|
|
690
|
+
expect(currentApp().client.chat.postMessage).toHaveBeenCalledWith({
|
|
691
|
+
channel: 'C0123456789',
|
|
692
|
+
text: 'Second queued',
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// --- ownsJid ---
|
|
698
|
+
|
|
699
|
+
describe('ownsJid', () => {
|
|
700
|
+
it('owns slack: JIDs', () => {
|
|
701
|
+
const channel = new SlackChannel(createTestOpts());
|
|
702
|
+
expect(channel.ownsJid('slack:C0123456789')).toBe(true);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('owns slack: DM JIDs', () => {
|
|
706
|
+
const channel = new SlackChannel(createTestOpts());
|
|
707
|
+
expect(channel.ownsJid('slack:D0123456789')).toBe(true);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it('does not own WhatsApp group JIDs', () => {
|
|
711
|
+
const channel = new SlackChannel(createTestOpts());
|
|
712
|
+
expect(channel.ownsJid('12345@g.us')).toBe(false);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('does not own WhatsApp DM JIDs', () => {
|
|
716
|
+
const channel = new SlackChannel(createTestOpts());
|
|
717
|
+
expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(false);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('does not own Telegram JIDs', () => {
|
|
721
|
+
const channel = new SlackChannel(createTestOpts());
|
|
722
|
+
expect(channel.ownsJid('tg:123456')).toBe(false);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('does not own unknown JID formats', () => {
|
|
726
|
+
const channel = new SlackChannel(createTestOpts());
|
|
727
|
+
expect(channel.ownsJid('random-string')).toBe(false);
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
// --- syncChannelMetadata ---
|
|
732
|
+
|
|
733
|
+
describe('syncChannelMetadata', () => {
|
|
734
|
+
it('calls conversations.list and updates chat names', async () => {
|
|
735
|
+
const opts = createTestOpts();
|
|
736
|
+
const channel = new SlackChannel(opts);
|
|
737
|
+
|
|
738
|
+
currentApp().client.conversations.list.mockResolvedValue({
|
|
739
|
+
channels: [
|
|
740
|
+
{ id: 'C001', name: 'general', is_member: true },
|
|
741
|
+
{ id: 'C002', name: 'random', is_member: true },
|
|
742
|
+
{ id: 'C003', name: 'external', is_member: false },
|
|
743
|
+
],
|
|
744
|
+
response_metadata: {},
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
await channel.connect();
|
|
748
|
+
|
|
749
|
+
// connect() calls syncChannelMetadata internally
|
|
750
|
+
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
|
|
751
|
+
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
|
|
752
|
+
// Non-member channels are skipped
|
|
753
|
+
expect(updateChatName).not.toHaveBeenCalledWith('slack:C003', 'external');
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it('handles API errors gracefully', async () => {
|
|
757
|
+
const opts = createTestOpts();
|
|
758
|
+
const channel = new SlackChannel(opts);
|
|
759
|
+
|
|
760
|
+
currentApp().client.conversations.list.mockRejectedValue(
|
|
761
|
+
new Error('API error'),
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
// Should not throw
|
|
765
|
+
await expect(channel.connect()).resolves.toBeUndefined();
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// --- setTyping ---
|
|
770
|
+
|
|
771
|
+
describe('setTyping', () => {
|
|
772
|
+
it('resolves without error (no-op)', async () => {
|
|
773
|
+
const opts = createTestOpts();
|
|
774
|
+
const channel = new SlackChannel(opts);
|
|
775
|
+
|
|
776
|
+
// Should not throw — Slack has no bot typing indicator API
|
|
777
|
+
await expect(
|
|
778
|
+
channel.setTyping('slack:C0123456789', true),
|
|
779
|
+
).resolves.toBeUndefined();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it('accepts false without error', async () => {
|
|
783
|
+
const opts = createTestOpts();
|
|
784
|
+
const channel = new SlackChannel(opts);
|
|
785
|
+
|
|
786
|
+
await expect(
|
|
787
|
+
channel.setTyping('slack:C0123456789', false),
|
|
788
|
+
).resolves.toBeUndefined();
|
|
789
|
+
});
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
// --- Constructor error handling ---
|
|
793
|
+
|
|
794
|
+
describe('constructor', () => {
|
|
795
|
+
it('throws when SLACK_BOT_TOKEN is missing', () => {
|
|
796
|
+
vi.mocked(readEnvFile).mockReturnValueOnce({
|
|
797
|
+
SLACK_BOT_TOKEN: '',
|
|
798
|
+
SLACK_APP_TOKEN: 'xapp-test-token',
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
expect(() => new SlackChannel(createTestOpts())).toThrow(
|
|
802
|
+
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('throws when SLACK_APP_TOKEN is missing', () => {
|
|
807
|
+
vi.mocked(readEnvFile).mockReturnValueOnce({
|
|
808
|
+
SLACK_BOT_TOKEN: 'xoxb-test-token',
|
|
809
|
+
SLACK_APP_TOKEN: '',
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
expect(() => new SlackChannel(createTestOpts())).toThrow(
|
|
813
|
+
'SLACK_BOT_TOKEN and SLACK_APP_TOKEN must be set in .env',
|
|
814
|
+
);
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// --- syncChannelMetadata pagination ---
|
|
819
|
+
|
|
820
|
+
describe('syncChannelMetadata pagination', () => {
|
|
821
|
+
it('paginates through multiple pages of channels', async () => {
|
|
822
|
+
const opts = createTestOpts();
|
|
823
|
+
const channel = new SlackChannel(opts);
|
|
824
|
+
|
|
825
|
+
// First page returns a cursor; second page returns no cursor
|
|
826
|
+
currentApp()
|
|
827
|
+
.client.conversations.list.mockResolvedValueOnce({
|
|
828
|
+
channels: [{ id: 'C001', name: 'general', is_member: true }],
|
|
829
|
+
response_metadata: { next_cursor: 'cursor_page2' },
|
|
830
|
+
})
|
|
831
|
+
.mockResolvedValueOnce({
|
|
832
|
+
channels: [{ id: 'C002', name: 'random', is_member: true }],
|
|
833
|
+
response_metadata: {},
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
await channel.connect();
|
|
837
|
+
|
|
838
|
+
// Should have called conversations.list twice (once per page)
|
|
839
|
+
expect(currentApp().client.conversations.list).toHaveBeenCalledTimes(2);
|
|
840
|
+
expect(currentApp().client.conversations.list).toHaveBeenNthCalledWith(
|
|
841
|
+
2,
|
|
842
|
+
expect.objectContaining({ cursor: 'cursor_page2' }),
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// Both channels from both pages stored
|
|
846
|
+
expect(updateChatName).toHaveBeenCalledWith('slack:C001', 'general');
|
|
847
|
+
expect(updateChatName).toHaveBeenCalledWith('slack:C002', 'random');
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
// --- Channel properties ---
|
|
852
|
+
|
|
853
|
+
describe('channel properties', () => {
|
|
854
|
+
it('has name "slack"', () => {
|
|
855
|
+
const channel = new SlackChannel(createTestOpts());
|
|
856
|
+
expect(channel.name).toBe('slack');
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
});
|