@calltelemetry/openclaw-linear 0.6.0 → 0.7.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.
@@ -1,169 +1,418 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest";
2
2
  import {
3
3
  createNoopNotifier,
4
- createDiscordNotifier,
4
+ createNotifierFromConfig,
5
+ formatMessage,
6
+ sendToTarget,
7
+ parseNotificationsConfig,
8
+ type NotifyKind,
5
9
  type NotifyPayload,
10
+ type NotifyTarget,
6
11
  } from "./notify.js";
7
12
 
8
13
  // ---------------------------------------------------------------------------
9
- // Noop notifier
14
+ // formatMessage
10
15
  // ---------------------------------------------------------------------------
11
16
 
12
- describe("createNoopNotifier", () => {
13
- it("returns function that resolves without error", async () => {
14
- const notify = createNoopNotifier();
15
- await expect(notify("dispatch", {
16
- identifier: "API-1",
17
- title: "test",
18
- status: "dispatched",
19
- })).resolves.toBeUndefined();
20
- });
21
- });
22
-
23
- // ---------------------------------------------------------------------------
24
- // Discord notifier
25
- // ---------------------------------------------------------------------------
26
-
27
- describe("createDiscordNotifier", () => {
28
- const botToken = "test-bot-token";
29
- const channelId = "123456";
30
-
31
- afterEach(() => {
32
- vi.restoreAllMocks();
33
- });
34
-
35
- function stubFetch(): { getCalls: () => { url: string; body: any }[] } {
36
- const calls: { url: string; body: any }[] = [];
37
- vi.stubGlobal("fetch", vi.fn(async (url: string, opts: any) => {
38
- calls.push({ url, body: JSON.parse(opts.body) });
39
- return { ok: true, status: 200 } as Response;
40
- }));
41
- return { getCalls: () => calls };
42
- }
43
-
17
+ describe("formatMessage", () => {
44
18
  const basePayload: NotifyPayload = {
45
19
  identifier: "API-42",
46
20
  title: "Fix auth",
47
21
  status: "dispatched",
48
22
  };
49
23
 
50
- it("formats dispatch message", async () => {
51
- const { getCalls } = stubFetch();
52
- const notify = createDiscordNotifier(botToken, channelId);
53
- await notify("dispatch", basePayload);
54
- expect(getCalls()).toHaveLength(1);
55
- const msg = getCalls()[0].body.content;
56
- expect(msg).toContain("**API-42**");
57
- expect(msg).toContain("dispatched");
58
- expect(msg).toContain("Fix auth");
59
- });
60
-
61
- it("formats working message with attempt", async () => {
62
- const { getCalls } = stubFetch();
63
- const notify = createDiscordNotifier(botToken, channelId);
64
- await notify("working", { ...basePayload, status: "working", attempt: 1 });
65
- const msg = getCalls()[0].body.content;
24
+ it("formats dispatch message", () => {
25
+ const msg = formatMessage("dispatch", basePayload);
26
+ expect(msg).toBe("API-42 dispatched Fix auth");
27
+ });
28
+
29
+ it("formats working message with attempt", () => {
30
+ const msg = formatMessage("working", { ...basePayload, attempt: 1 });
66
31
  expect(msg).toContain("worker started");
67
32
  expect(msg).toContain("attempt 1");
68
33
  });
69
34
 
70
- it("formats audit_pass message", async () => {
71
- const { getCalls } = stubFetch();
72
- const notify = createDiscordNotifier(botToken, channelId);
73
- await notify("audit_pass", { ...basePayload, status: "done", verdict: { pass: true } });
74
- const msg = getCalls()[0].body.content;
35
+ it("formats auditing message", () => {
36
+ const msg = formatMessage("auditing", basePayload);
37
+ expect(msg).toContain("audit in progress");
38
+ });
39
+
40
+ it("formats audit_pass message", () => {
41
+ const msg = formatMessage("audit_pass", basePayload);
75
42
  expect(msg).toContain("passed audit");
76
43
  expect(msg).toContain("PR ready");
77
44
  });
78
45
 
79
- it("formats audit_fail message with gaps", async () => {
80
- const { getCalls } = stubFetch();
81
- const notify = createDiscordNotifier(botToken, channelId);
82
- await notify("audit_fail", {
46
+ it("formats audit_fail message with gaps", () => {
47
+ const msg = formatMessage("audit_fail", {
83
48
  ...basePayload,
84
- status: "working",
85
49
  attempt: 1,
86
50
  verdict: { pass: false, gaps: ["no tests", "missing validation"] },
87
51
  });
88
- const msg = getCalls()[0].body.content;
89
52
  expect(msg).toContain("failed audit");
53
+ expect(msg).toContain("attempt 1");
90
54
  expect(msg).toContain("no tests");
91
55
  expect(msg).toContain("missing validation");
92
56
  });
93
57
 
94
- it("formats escalation message", async () => {
95
- const { getCalls } = stubFetch();
96
- const notify = createDiscordNotifier(botToken, channelId);
97
- await notify("escalation", {
58
+ it("formats audit_fail with default gaps text", () => {
59
+ const msg = formatMessage("audit_fail", {
60
+ ...basePayload,
61
+ attempt: 0,
62
+ verdict: { pass: false },
63
+ });
64
+ expect(msg).toContain("unspecified");
65
+ });
66
+
67
+ it("formats escalation message with reason", () => {
68
+ const msg = formatMessage("escalation", {
98
69
  ...basePayload,
99
- status: "stuck",
100
70
  reason: "audit failed 3x",
101
71
  });
102
- const msg = getCalls()[0].body.content;
103
72
  expect(msg).toContain("needs human review");
104
73
  expect(msg).toContain("audit failed 3x");
105
74
  });
106
75
 
107
- it("formats watchdog_kill message", async () => {
108
- const { getCalls } = stubFetch();
109
- const notify = createDiscordNotifier(botToken, channelId);
110
- await notify("watchdog_kill", {
76
+ it("formats stuck message", () => {
77
+ const msg = formatMessage("stuck", {
78
+ ...basePayload,
79
+ reason: "stale 2h",
80
+ });
81
+ expect(msg).toContain("stuck");
82
+ expect(msg).toContain("stale 2h");
83
+ });
84
+
85
+ it("formats watchdog_kill with attempt", () => {
86
+ const msg = formatMessage("watchdog_kill", {
111
87
  ...basePayload,
112
- status: "stuck",
113
88
  attempt: 0,
114
89
  reason: "no I/O for 120s",
115
90
  });
116
- const msg = getCalls()[0].body.content;
117
91
  expect(msg).toContain("killed by watchdog");
118
92
  expect(msg).toContain("no I/O for 120s");
119
- expect(msg).toContain("attempt 0");
93
+ expect(msg).toContain("Retrying (attempt 0)");
94
+ });
95
+
96
+ it("formats watchdog_kill without attempt", () => {
97
+ const msg = formatMessage("watchdog_kill", {
98
+ ...basePayload,
99
+ reason: "timeout",
100
+ });
101
+ expect(msg).toContain("Will retry.");
102
+ });
103
+
104
+ it("handles unknown kind via default case", () => {
105
+ const msg = formatMessage("unknown_kind" as NotifyKind, basePayload);
106
+ expect(msg).toContain("API-42");
107
+ expect(msg).toContain("unknown_kind");
108
+ });
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // sendToTarget
113
+ // ---------------------------------------------------------------------------
114
+
115
+ describe("sendToTarget", () => {
116
+ function mockRuntime(): any {
117
+ return {
118
+ channel: {
119
+ discord: {
120
+ sendMessageDiscord: vi.fn(async () => {}),
121
+ },
122
+ slack: {
123
+ sendMessageSlack: vi.fn(async () => ({ messageId: "ts-1", channelId: "C999" })),
124
+ },
125
+ telegram: {
126
+ sendMessageTelegram: vi.fn(async () => {}),
127
+ },
128
+ signal: {
129
+ sendMessageSignal: vi.fn(async () => {}),
130
+ },
131
+ },
132
+ };
133
+ }
134
+
135
+ afterEach(() => {
136
+ vi.restoreAllMocks();
137
+ });
138
+
139
+ it("routes discord target to sendMessageDiscord", async () => {
140
+ const runtime = mockRuntime();
141
+ const target: NotifyTarget = { channel: "discord", target: "123456" };
142
+ await sendToTarget(target, "test message", runtime);
143
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith("123456", "test message");
144
+ });
145
+
146
+ it("routes slack target to sendMessageSlack with accountId", async () => {
147
+ const runtime = mockRuntime();
148
+ const target: NotifyTarget = { channel: "slack", target: "C-100", accountId: "acct-x" };
149
+ await sendToTarget(target, "test message", runtime);
150
+ expect(runtime.channel.slack.sendMessageSlack).toHaveBeenCalledWith(
151
+ "C-100",
152
+ "test message",
153
+ { accountId: "acct-x" },
154
+ );
120
155
  });
121
156
 
122
- it("handles fetch failure gracefully", async () => {
123
- vi.stubGlobal("fetch", vi.fn(async () => {
124
- throw new Error("network error");
157
+ it("routes slack target without accountId", async () => {
158
+ const runtime = mockRuntime();
159
+ const target: NotifyTarget = { channel: "slack", target: "C-200" };
160
+ await sendToTarget(target, "test message", runtime);
161
+ expect(runtime.channel.slack.sendMessageSlack).toHaveBeenCalledWith(
162
+ "C-200",
163
+ "test message",
164
+ { accountId: undefined },
165
+ );
166
+ });
167
+
168
+ it("routes telegram target to sendMessageTelegram with silent", async () => {
169
+ const runtime = mockRuntime();
170
+ const target: NotifyTarget = { channel: "telegram", target: "-100388" };
171
+ await sendToTarget(target, "test message", runtime);
172
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
173
+ "-100388",
174
+ "test message",
175
+ { silent: true },
176
+ );
177
+ });
178
+
179
+ it("routes signal target to sendMessageSignal", async () => {
180
+ const runtime = mockRuntime();
181
+ const target: NotifyTarget = { channel: "signal", target: "+1234567890" };
182
+ await sendToTarget(target, "test message", runtime);
183
+ expect(runtime.channel.signal.sendMessageSignal).toHaveBeenCalledWith("+1234567890", "test message");
184
+ });
185
+
186
+ it("falls back to CLI for unknown channels", async () => {
187
+ const runtime = mockRuntime();
188
+ const target: NotifyTarget = { channel: "matrix", target: "!room:server" };
189
+
190
+ const { execFileSync } = await import("node:child_process");
191
+ vi.mock("node:child_process", () => ({
192
+ execFileSync: vi.fn(),
125
193
  }));
126
- const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
127
- const notify = createDiscordNotifier(botToken, channelId);
128
- // Should not throw
129
- await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
130
- consoleSpy.mockRestore();
194
+
195
+ // Since the dynamic import is already cached, we test that it doesn't call any known channel
196
+ // and doesn't throw for an unknown channel type
197
+ try {
198
+ await sendToTarget(target, "test message", runtime);
199
+ } catch {
200
+ // CLI fallback may fail in test env — that's expected
201
+ }
202
+
203
+ // None of the known channels should have been called
204
+ expect(runtime.channel.discord.sendMessageDiscord).not.toHaveBeenCalled();
205
+ expect(runtime.channel.slack.sendMessageSlack).not.toHaveBeenCalled();
206
+ expect(runtime.channel.telegram.sendMessageTelegram).not.toHaveBeenCalled();
207
+ expect(runtime.channel.signal.sendMessageSignal).not.toHaveBeenCalled();
208
+ });
209
+ });
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // parseNotificationsConfig
213
+ // ---------------------------------------------------------------------------
214
+
215
+ describe("parseNotificationsConfig", () => {
216
+ it("returns empty targets for undefined config", () => {
217
+ const config = parseNotificationsConfig(undefined);
218
+ expect(config.targets).toEqual([]);
219
+ expect(config.events).toEqual({});
220
+ });
221
+
222
+ it("returns empty targets for config without notifications", () => {
223
+ const config = parseNotificationsConfig({ enabled: true });
224
+ expect(config.targets).toEqual([]);
225
+ });
226
+
227
+ it("parses targets and events", () => {
228
+ const config = parseNotificationsConfig({
229
+ notifications: {
230
+ targets: [{ channel: "discord", target: "123" }],
231
+ events: { auditing: false },
232
+ },
233
+ });
234
+ expect(config.targets).toHaveLength(1);
235
+ expect(config.targets![0].channel).toBe("discord");
236
+ expect(config.events?.auditing).toBe(false);
237
+ });
238
+ });
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // createNotifierFromConfig
242
+ // ---------------------------------------------------------------------------
243
+
244
+ describe("createNotifierFromConfig", () => {
245
+ function mockRuntime(): any {
246
+ return {
247
+ channel: {
248
+ discord: {
249
+ sendMessageDiscord: vi.fn(async () => {}),
250
+ },
251
+ slack: {
252
+ sendMessageSlack: vi.fn(async () => ({ messageId: "ts-1", channelId: "C999" })),
253
+ },
254
+ telegram: {
255
+ sendMessageTelegram: vi.fn(async () => {}),
256
+ },
257
+ signal: {
258
+ sendMessageSignal: vi.fn(async () => {}),
259
+ },
260
+ },
261
+ };
262
+ }
263
+
264
+ const basePayload: NotifyPayload = {
265
+ identifier: "CFG-1",
266
+ title: "Config test",
267
+ status: "dispatched",
268
+ };
269
+
270
+ afterEach(() => {
271
+ vi.restoreAllMocks();
131
272
  });
132
273
 
133
- it("sends to correct Discord API URL", async () => {
134
- const { getCalls } = stubFetch();
135
- const notify = createDiscordNotifier(botToken, channelId);
274
+ it("returns noop when no targets configured", async () => {
275
+ const runtime = mockRuntime();
276
+ const notify = createNotifierFromConfig({}, runtime);
136
277
  await notify("dispatch", basePayload);
137
- expect(getCalls()[0].url).toContain(`/channels/${channelId}/messages`);
278
+ expect(runtime.channel.discord.sendMessageDiscord).not.toHaveBeenCalled();
279
+ expect(runtime.channel.slack.sendMessageSlack).not.toHaveBeenCalled();
138
280
  });
139
281
 
140
- it("formats auditing message", async () => {
141
- const { getCalls } = stubFetch();
142
- const notify = createDiscordNotifier(botToken, channelId);
143
- await notify("auditing", { ...basePayload, status: "auditing" });
144
- const msg = getCalls()[0].body.content;
145
- expect(msg).toContain("audit in progress");
282
+ it("returns noop when targets array is empty", async () => {
283
+ const runtime = mockRuntime();
284
+ const notify = createNotifierFromConfig({ notifications: { targets: [] } }, runtime);
285
+ await notify("dispatch", basePayload);
286
+ expect(runtime.channel.discord.sendMessageDiscord).not.toHaveBeenCalled();
146
287
  });
147
288
 
148
- it("formats stuck message", async () => {
149
- const { getCalls } = stubFetch();
150
- const notify = createDiscordNotifier(botToken, channelId);
151
- await notify("stuck", { ...basePayload, status: "stuck", reason: "stale 2h" });
152
- const msg = getCalls()[0].body.content;
153
- expect(msg).toContain("stuck");
154
- expect(msg).toContain("stale 2h");
289
+ it("sends to single discord target", async () => {
290
+ const runtime = mockRuntime();
291
+ const notify = createNotifierFromConfig({
292
+ notifications: {
293
+ targets: [{ channel: "discord", target: "D-100" }],
294
+ },
295
+ }, runtime);
296
+ await notify("dispatch", basePayload);
297
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledOnce();
298
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith(
299
+ "D-100",
300
+ expect.stringContaining("CFG-1"),
301
+ );
155
302
  });
156
303
 
157
- it("handles non-ok response gracefully", async () => {
158
- vi.stubGlobal("fetch", vi.fn(async () => ({
159
- ok: false,
160
- status: 429,
161
- text: async () => "rate limited",
162
- })));
304
+ it("sends to single slack target with accountId", async () => {
305
+ const runtime = mockRuntime();
306
+ const notify = createNotifierFromConfig({
307
+ notifications: {
308
+ targets: [{ channel: "slack", target: "C-200", accountId: "acct-x" }],
309
+ },
310
+ }, runtime);
311
+ await notify("audit_pass", basePayload);
312
+ expect(runtime.channel.slack.sendMessageSlack).toHaveBeenCalledOnce();
313
+ const [, , opts] = runtime.channel.slack.sendMessageSlack.mock.calls[0];
314
+ expect(opts.accountId).toBe("acct-x");
315
+ });
316
+
317
+ it("sends to telegram target", async () => {
318
+ const runtime = mockRuntime();
319
+ const notify = createNotifierFromConfig({
320
+ notifications: {
321
+ targets: [{ channel: "telegram", target: "-100388" }],
322
+ },
323
+ }, runtime);
324
+ await notify("working", { ...basePayload, attempt: 1 });
325
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
326
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
327
+ "-100388",
328
+ expect.stringContaining("worker started"),
329
+ { silent: true },
330
+ );
331
+ });
332
+
333
+ it("fans out to multiple targets", async () => {
334
+ const runtime = mockRuntime();
335
+ const notify = createNotifierFromConfig({
336
+ notifications: {
337
+ targets: [
338
+ { channel: "discord", target: "D-100" },
339
+ { channel: "slack", target: "C-200" },
340
+ { channel: "telegram", target: "-100388" },
341
+ ],
342
+ },
343
+ }, runtime);
344
+ await notify("dispatch", basePayload);
345
+
346
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledOnce();
347
+ expect(runtime.channel.slack.sendMessageSlack).toHaveBeenCalledOnce();
348
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
349
+ });
350
+
351
+ it("isolates failures between targets", async () => {
352
+ const runtime = mockRuntime();
353
+ runtime.channel.slack.sendMessageSlack = vi.fn(async () => {
354
+ throw new Error("Slack down");
355
+ });
163
356
  const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
164
- const notify = createDiscordNotifier(botToken, channelId);
165
- await expect(notify("dispatch", basePayload)).resolves.toBeUndefined();
166
- expect(consoleSpy).toHaveBeenCalled();
357
+
358
+ const notify = createNotifierFromConfig({
359
+ notifications: {
360
+ targets: [
361
+ { channel: "discord", target: "D-100" },
362
+ { channel: "slack", target: "C-200" },
363
+ ],
364
+ },
365
+ }, runtime);
366
+ await expect(notify("escalation", basePayload)).resolves.toBeUndefined();
367
+
368
+ // Discord should still succeed
369
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledOnce();
167
370
  consoleSpy.mockRestore();
168
371
  });
372
+
373
+ it("skips suppressed events", async () => {
374
+ const runtime = mockRuntime();
375
+ const notify = createNotifierFromConfig({
376
+ notifications: {
377
+ targets: [{ channel: "discord", target: "D-100" }],
378
+ events: { auditing: false },
379
+ },
380
+ }, runtime);
381
+
382
+ // Suppressed event — should not send
383
+ await notify("auditing", basePayload);
384
+ expect(runtime.channel.discord.sendMessageDiscord).not.toHaveBeenCalled();
385
+
386
+ // Non-suppressed event — should send
387
+ await notify("dispatch", basePayload);
388
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledOnce();
389
+ });
390
+
391
+ it("sends events that are explicitly enabled", async () => {
392
+ const runtime = mockRuntime();
393
+ const notify = createNotifierFromConfig({
394
+ notifications: {
395
+ targets: [{ channel: "discord", target: "D-100" }],
396
+ events: { dispatch: true, auditing: false },
397
+ },
398
+ }, runtime);
399
+
400
+ await notify("dispatch", basePayload);
401
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledOnce();
402
+ });
403
+ });
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // createNoopNotifier
407
+ // ---------------------------------------------------------------------------
408
+
409
+ describe("createNoopNotifier", () => {
410
+ it("returns function that resolves without error", async () => {
411
+ const notify = createNoopNotifier();
412
+ await expect(notify("dispatch", {
413
+ identifier: "API-1",
414
+ title: "test",
415
+ status: "dispatched",
416
+ })).resolves.toBeUndefined();
417
+ });
169
418
  });