@calltelemetry/openclaw-linear 0.7.0 → 0.8.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * file-lock.ts — Shared file-level locking for state files.
3
+ *
4
+ * Used by dispatch-state.ts and planning-state.ts to prevent
5
+ * concurrent read-modify-write races on JSON state files.
6
+ */
7
+ import fs from "node:fs/promises";
8
+
9
+ const LOCK_STALE_MS = 30_000;
10
+ const LOCK_RETRY_MS = 50;
11
+ const LOCK_TIMEOUT_MS = 10_000;
12
+
13
+ function lockPath(statePath: string): string {
14
+ return statePath + ".lock";
15
+ }
16
+
17
+ export async function acquireLock(statePath: string): Promise<void> {
18
+ const lock = lockPath(statePath);
19
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
20
+
21
+ while (Date.now() < deadline) {
22
+ try {
23
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
24
+ return;
25
+ } catch (err: any) {
26
+ if (err.code !== "EEXIST") throw err;
27
+
28
+ // Check for stale lock
29
+ try {
30
+ const content = await fs.readFile(lock, "utf-8");
31
+ const lockTime = Number(content);
32
+ if (Date.now() - lockTime > LOCK_STALE_MS) {
33
+ try { await fs.unlink(lock); } catch { /* race */ }
34
+ continue;
35
+ }
36
+ } catch { /* lock disappeared — retry */ }
37
+
38
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
39
+ }
40
+ }
41
+
42
+ // Last resort: force remove potentially stale lock
43
+ try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
44
+ await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
45
+ }
46
+
47
+ export async function releaseLock(statePath: string): Promise<void> {
48
+ try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
49
+ }
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { resolveRepos, isMultiRepo, validateRepoPath, type RepoResolution } from "./multi-repo.ts";
3
+
4
+ vi.mock("node:fs", async (importOriginal) => {
5
+ const actual = await importOriginal<typeof import("node:fs")>();
6
+ return {
7
+ ...actual,
8
+ existsSync: vi.fn(),
9
+ statSync: vi.fn(),
10
+ };
11
+ });
12
+
13
+ import { existsSync, statSync } from "node:fs";
14
+ const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
15
+ const mockStatSync = statSync as ReturnType<typeof vi.fn>;
16
+
17
+ describe("resolveRepos", () => {
18
+ it("parses <!-- repos: api, frontend --> from description", () => {
19
+ const result = resolveRepos(
20
+ "Fix the bug\n<!-- repos: api, frontend -->",
21
+ [],
22
+ );
23
+ expect(result.source).toBe("issue_body");
24
+ expect(result.repos).toHaveLength(2);
25
+ expect(result.repos[0].name).toBe("api");
26
+ expect(result.repos[1].name).toBe("frontend");
27
+ });
28
+
29
+ it("parses [repos: web, worker] from description", () => {
30
+ const result = resolveRepos(
31
+ "Some issue\n[repos: web, worker]",
32
+ [],
33
+ );
34
+ expect(result.source).toBe("issue_body");
35
+ expect(result.repos).toHaveLength(2);
36
+ expect(result.repos[0].name).toBe("web");
37
+ expect(result.repos[1].name).toBe("worker");
38
+ });
39
+
40
+ it("extracts from repo:api and repo:frontend labels", () => {
41
+ const result = resolveRepos("No markers here", ["repo:api", "repo:frontend"]);
42
+ expect(result.source).toBe("labels");
43
+ expect(result.repos).toHaveLength(2);
44
+ expect(result.repos[0].name).toBe("api");
45
+ expect(result.repos[1].name).toBe("frontend");
46
+ });
47
+
48
+ it("falls back to config repos when no markers/labels", () => {
49
+ const config = { codexBaseRepo: "/home/claw/myproject" };
50
+ const result = resolveRepos("Plain description", [], config);
51
+ expect(result.source).toBe("config_default");
52
+ expect(result.repos).toHaveLength(1);
53
+ expect(result.repos[0].name).toBe("default");
54
+ expect(result.repos[0].path).toBe("/home/claw/myproject");
55
+ });
56
+
57
+ it("body markers take priority over labels", () => {
58
+ const result = resolveRepos(
59
+ "<!-- repos: api -->",
60
+ ["repo:frontend"],
61
+ );
62
+ expect(result.source).toBe("issue_body");
63
+ expect(result.repos).toHaveLength(1);
64
+ expect(result.repos[0].name).toBe("api");
65
+ });
66
+
67
+ it("returns single repo from codexBaseRepo when no repos config", () => {
68
+ const result = resolveRepos("Nothing special", []);
69
+ expect(result.source).toBe("config_default");
70
+ expect(result.repos).toHaveLength(1);
71
+ expect(result.repos[0].name).toBe("default");
72
+ expect(result.repos[0].path).toBe("/home/claw/ai-workspace");
73
+ });
74
+
75
+ it("handles empty description + no labels (single repo fallback)", () => {
76
+ const result = resolveRepos("", []);
77
+ expect(result.source).toBe("config_default");
78
+ expect(result.repos).toHaveLength(1);
79
+ expect(result.repos[0].name).toBe("default");
80
+ });
81
+
82
+ it("trims whitespace in repo names from markers", () => {
83
+ const result = resolveRepos(
84
+ "<!-- repos: api , frontend -->",
85
+ [],
86
+ );
87
+ expect(result.source).toBe("issue_body");
88
+ expect(result.repos[0].name).toBe("api");
89
+ expect(result.repos[1].name).toBe("frontend");
90
+ });
91
+
92
+ it("handles null/undefined description", () => {
93
+ const resultNull = resolveRepos(null, []);
94
+ expect(resultNull.source).toBe("config_default");
95
+ expect(resultNull.repos).toHaveLength(1);
96
+
97
+ const resultUndefined = resolveRepos(undefined, []);
98
+ expect(resultUndefined.source).toBe("config_default");
99
+ expect(resultUndefined.repos).toHaveLength(1);
100
+ });
101
+ });
102
+
103
+ describe("isMultiRepo", () => {
104
+ it("returns true for 2+ repos", () => {
105
+ const resolution: RepoResolution = {
106
+ repos: [
107
+ { name: "api", path: "/home/claw/api" },
108
+ { name: "frontend", path: "/home/claw/frontend" },
109
+ ],
110
+ source: "issue_body",
111
+ };
112
+ expect(isMultiRepo(resolution)).toBe(true);
113
+ });
114
+
115
+ it("returns false for 1 repo", () => {
116
+ const resolution: RepoResolution = {
117
+ repos: [{ name: "default", path: "/home/claw/ai-workspace" }],
118
+ source: "config_default",
119
+ };
120
+ expect(isMultiRepo(resolution)).toBe(false);
121
+ });
122
+
123
+ it("returns false for empty result", () => {
124
+ const resolution: RepoResolution = {
125
+ repos: [],
126
+ source: "config_default",
127
+ };
128
+ expect(isMultiRepo(resolution)).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("validateRepoPath", () => {
133
+ beforeEach(() => {
134
+ vi.restoreAllMocks();
135
+ });
136
+
137
+ it("returns exists:false for missing path", () => {
138
+ mockExistsSync.mockReturnValue(false);
139
+ const result = validateRepoPath("/no/such/path");
140
+ expect(result).toEqual({ exists: false, isGitRepo: false, isSubmodule: false });
141
+ });
142
+
143
+ it("returns isGitRepo:true, isSubmodule:false for normal repo (.git is directory)", () => {
144
+ mockExistsSync.mockReturnValue(true);
145
+ mockStatSync.mockReturnValue({ isFile: () => false, isDirectory: () => true });
146
+ const result = validateRepoPath("/home/claw/repos/api");
147
+ expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: false });
148
+ });
149
+
150
+ it("returns isGitRepo:true, isSubmodule:true for submodule (.git is file)", () => {
151
+ mockExistsSync.mockReturnValue(true);
152
+ mockStatSync.mockReturnValue({ isFile: () => true, isDirectory: () => false });
153
+ const result = validateRepoPath("/home/claw/workspace/submod");
154
+ expect(result).toEqual({ exists: true, isGitRepo: true, isSubmodule: true });
155
+ });
156
+
157
+ it("returns isGitRepo:false for directory without .git", () => {
158
+ // First call: path exists. Second call: .git does not exist
159
+ mockExistsSync.mockImplementation((p: string) => !String(p).endsWith(".git"));
160
+ const result = validateRepoPath("/home/claw/not-a-repo");
161
+ expect(result).toEqual({ exists: true, isGitRepo: false, isSubmodule: false });
162
+ });
163
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
3
+ *
4
+ * Three-tier resolution:
5
+ * 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
6
+ * 2. Linear labels: repo:api, repo:frontend
7
+ * 3. Config default: Falls back to single codexBaseRepo
8
+ */
9
+
10
+ import { existsSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export interface RepoConfig {
14
+ name: string;
15
+ path: string;
16
+ }
17
+
18
+ export interface RepoResolution {
19
+ repos: RepoConfig[];
20
+ source: "issue_body" | "labels" | "config_default";
21
+ }
22
+
23
+ /**
24
+ * Resolve which repos a dispatch should work with.
25
+ */
26
+ export function resolveRepos(
27
+ description: string | null | undefined,
28
+ labels: string[],
29
+ pluginConfig?: Record<string, unknown>,
30
+ ): RepoResolution {
31
+ // 1. Check issue body for repo markers
32
+ // Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
33
+ const htmlComment = description?.match(/<!--\s*repos:\s*([^>]+?)\s*-->/i);
34
+ const bracketMatch = description?.match(/\[repos:\s*([^\]]+)\]/i);
35
+ const bodyMatch = htmlComment?.[1] ?? bracketMatch?.[1];
36
+
37
+ if (bodyMatch) {
38
+ const names = bodyMatch.split(",").map(s => s.trim()).filter(Boolean);
39
+ if (names.length > 0) {
40
+ const repoMap = getRepoMap(pluginConfig);
41
+ const repos = names.map(name => ({
42
+ name,
43
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
44
+ }));
45
+ return { repos, source: "issue_body" };
46
+ }
47
+ }
48
+
49
+ // 2. Check labels for repo: prefix
50
+ const repoLabels = labels
51
+ .filter(l => l.startsWith("repo:"))
52
+ .map(l => l.slice(5).trim())
53
+ .filter(Boolean);
54
+
55
+ if (repoLabels.length > 0) {
56
+ const repoMap = getRepoMap(pluginConfig);
57
+ const repos = repoLabels.map(name => ({
58
+ name,
59
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
60
+ }));
61
+ return { repos, source: "labels" };
62
+ }
63
+
64
+ // 3. Config default: single repo
65
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
66
+ return {
67
+ repos: [{ name: "default", path: baseRepo }],
68
+ source: "config_default",
69
+ };
70
+ }
71
+
72
+ function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
73
+ const repos = pluginConfig?.repos as Record<string, string> | undefined;
74
+ return repos ?? {};
75
+ }
76
+
77
+ function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
78
+ // Convention: {parentDir}/{name}
79
+ const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? "/home/claw/ai-workspace";
80
+ const parentDir = path.dirname(baseRepo);
81
+ return path.join(parentDir, name);
82
+ }
83
+
84
+ export function isMultiRepo(resolution: RepoResolution): boolean {
85
+ return resolution.repos.length > 1;
86
+ }
87
+
88
+ /**
89
+ * Validate a repo path: exists, is a git repo, and whether it's a submodule.
90
+ * Submodules have a `.git` *file* (not directory) that points to the parent's
91
+ * `.git/modules/` — `git worktree add` won't work on them.
92
+ */
93
+ export function validateRepoPath(repoPath: string): {
94
+ exists: boolean;
95
+ isGitRepo: boolean;
96
+ isSubmodule: boolean;
97
+ } {
98
+ if (!existsSync(repoPath)) {
99
+ return { exists: false, isGitRepo: false, isSubmodule: false };
100
+ }
101
+
102
+ const gitPath = path.join(repoPath, ".git");
103
+ if (!existsSync(gitPath)) {
104
+ return { exists: true, isGitRepo: false, isSubmodule: false };
105
+ }
106
+
107
+ const stat = statSync(gitPath);
108
+ if (stat.isFile()) {
109
+ // .git is a file → submodule (points to parent's .git/modules/)
110
+ return { exists: true, isGitRepo: true, isSubmodule: true };
111
+ }
112
+
113
+ return { exists: true, isGitRepo: true, isSubmodule: false };
114
+ }
@@ -3,6 +3,7 @@ import {
3
3
  createNoopNotifier,
4
4
  createNotifierFromConfig,
5
5
  formatMessage,
6
+ formatRichMessage,
6
7
  sendToTarget,
7
8
  parseNotificationsConfig,
8
9
  type NotifyKind,
@@ -23,24 +24,24 @@ describe("formatMessage", () => {
23
24
 
24
25
  it("formats dispatch message", () => {
25
26
  const msg = formatMessage("dispatch", basePayload);
26
- expect(msg).toBe("API-42 dispatched — Fix auth");
27
+ expect(msg).toBe("API-42 started — Fix auth");
27
28
  });
28
29
 
29
30
  it("formats working message with attempt", () => {
30
31
  const msg = formatMessage("working", { ...basePayload, attempt: 1 });
31
- expect(msg).toContain("worker started");
32
- expect(msg).toContain("attempt 1");
32
+ expect(msg).toContain("working on it");
33
+ expect(msg).toContain("attempt 2"); // 1-based for humans
33
34
  });
34
35
 
35
36
  it("formats auditing message", () => {
36
37
  const msg = formatMessage("auditing", basePayload);
37
- expect(msg).toContain("audit in progress");
38
+ expect(msg).toContain("checking the work");
38
39
  });
39
40
 
40
41
  it("formats audit_pass message", () => {
41
42
  const msg = formatMessage("audit_pass", basePayload);
42
- expect(msg).toContain("passed audit");
43
- expect(msg).toContain("PR ready");
43
+ expect(msg).toContain("done!");
44
+ expect(msg).toContain("Ready for review");
44
45
  });
45
46
 
46
47
  it("formats audit_fail message with gaps", () => {
@@ -49,8 +50,8 @@ describe("formatMessage", () => {
49
50
  attempt: 1,
50
51
  verdict: { pass: false, gaps: ["no tests", "missing validation"] },
51
52
  });
52
- expect(msg).toContain("failed audit");
53
- expect(msg).toContain("attempt 1");
53
+ expect(msg).toContain("needs more work");
54
+ expect(msg).toContain("attempt 2"); // 1-based for humans
54
55
  expect(msg).toContain("no tests");
55
56
  expect(msg).toContain("missing validation");
56
57
  });
@@ -67,10 +68,10 @@ describe("formatMessage", () => {
67
68
  it("formats escalation message with reason", () => {
68
69
  const msg = formatMessage("escalation", {
69
70
  ...basePayload,
70
- reason: "audit failed 3x",
71
+ attempt: 2,
71
72
  });
72
- expect(msg).toContain("needs human review");
73
- expect(msg).toContain("audit failed 3x");
73
+ expect(msg).toContain("needs your help");
74
+ expect(msg).toContain("3 tries"); // 1-based
74
75
  });
75
76
 
76
77
  it("formats stuck message", () => {
@@ -86,11 +87,11 @@ describe("formatMessage", () => {
86
87
  const msg = formatMessage("watchdog_kill", {
87
88
  ...basePayload,
88
89
  attempt: 0,
89
- reason: "no I/O for 120s",
90
+ reason: "no activity for 120s",
90
91
  });
91
- expect(msg).toContain("killed by watchdog");
92
- expect(msg).toContain("no I/O for 120s");
93
- expect(msg).toContain("Retrying (attempt 0)");
92
+ expect(msg).toContain("timed out");
93
+ expect(msg).toContain("no activity for 120s");
94
+ expect(msg).toContain("Retrying (attempt 1)"); // 1-based
94
95
  });
95
96
 
96
97
  it("formats watchdog_kill without attempt", () => {
@@ -325,7 +326,7 @@ describe("createNotifierFromConfig", () => {
325
326
  expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledOnce();
326
327
  expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
327
328
  "-100388",
328
- expect.stringContaining("worker started"),
329
+ expect.stringContaining("working on it"),
329
330
  { silent: true },
330
331
  );
331
332
  });
@@ -402,6 +403,144 @@ describe("createNotifierFromConfig", () => {
402
403
  });
403
404
  });
404
405
 
406
+ // ---------------------------------------------------------------------------
407
+ // formatRichMessage
408
+ // ---------------------------------------------------------------------------
409
+
410
+ describe("formatRichMessage", () => {
411
+ const basePayload: NotifyPayload = {
412
+ identifier: "CT-10",
413
+ title: "Add caching",
414
+ status: "dispatched",
415
+ };
416
+
417
+ it("returns Discord embed with correct color for dispatch (blue)", () => {
418
+ const msg = formatRichMessage("dispatch", basePayload);
419
+ expect(msg.discord?.embeds).toHaveLength(1);
420
+ expect(msg.discord!.embeds[0].color).toBe(0x3498db);
421
+ });
422
+
423
+ it("returns Discord embed with green for audit_pass", () => {
424
+ const msg = formatRichMessage("audit_pass", basePayload);
425
+ expect(msg.discord!.embeds[0].color).toBe(0x2ecc71);
426
+ });
427
+
428
+ it("returns Discord embed with red for audit_fail", () => {
429
+ const msg = formatRichMessage("audit_fail", { ...basePayload, attempt: 1, verdict: { pass: false, gaps: ["no tests"] } });
430
+ expect(msg.discord!.embeds[0].color).toBe(0xe74c3c);
431
+ expect(msg.discord!.embeds[0].fields).toEqual(
432
+ expect.arrayContaining([expect.objectContaining({ name: "Issues to fix", value: "no tests" })]),
433
+ );
434
+ });
435
+
436
+ it("returns Discord embed with orange for stuck", () => {
437
+ const msg = formatRichMessage("stuck", { ...basePayload, reason: "stale 2h" });
438
+ expect(msg.discord!.embeds[0].color).toBe(0xe67e22);
439
+ });
440
+
441
+ it("returns Telegram HTML with bold identifier", () => {
442
+ const msg = formatRichMessage("dispatch", basePayload);
443
+ expect(msg.telegram?.html).toContain("<b>CT-10</b>");
444
+ expect(msg.telegram?.html).toContain("<i>Add caching</i>");
445
+ });
446
+
447
+ it("includes plain text fallback", () => {
448
+ const msg = formatRichMessage("dispatch", basePayload);
449
+ expect(msg.text).toBe("CT-10 started — Add caching");
450
+ });
451
+ });
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // sendToTarget with RichMessage
455
+ // ---------------------------------------------------------------------------
456
+
457
+ describe("sendToTarget (RichMessage)", () => {
458
+ function mockRuntime(): any {
459
+ return {
460
+ channel: {
461
+ discord: { sendMessageDiscord: vi.fn(async () => {}) },
462
+ slack: { sendMessageSlack: vi.fn(async () => ({})) },
463
+ telegram: { sendMessageTelegram: vi.fn(async () => {}) },
464
+ signal: { sendMessageSignal: vi.fn(async () => {}) },
465
+ },
466
+ };
467
+ }
468
+
469
+ afterEach(() => { vi.restoreAllMocks(); });
470
+
471
+ it("passes embeds to Discord when RichMessage provided", async () => {
472
+ const runtime = mockRuntime();
473
+ const target: NotifyTarget = { channel: "discord", target: "D-1" };
474
+ const rich = {
475
+ text: "plain",
476
+ discord: { embeds: [{ title: "test", color: 0x3498db }] },
477
+ };
478
+ await sendToTarget(target, rich, runtime);
479
+ expect(runtime.channel.discord.sendMessageDiscord).toHaveBeenCalledWith(
480
+ "D-1", "plain", { embeds: [{ title: "test", color: 0x3498db }] },
481
+ );
482
+ });
483
+
484
+ it("passes textMode html to Telegram when RichMessage provided", async () => {
485
+ const runtime = mockRuntime();
486
+ const target: NotifyTarget = { channel: "telegram", target: "-999" };
487
+ const rich = {
488
+ text: "plain",
489
+ telegram: { html: "<b>CT-10</b> dispatched" },
490
+ };
491
+ await sendToTarget(target, rich, runtime);
492
+ expect(runtime.channel.telegram.sendMessageTelegram).toHaveBeenCalledWith(
493
+ "-999", "<b>CT-10</b> dispatched", { silent: true, textMode: "html" },
494
+ );
495
+ });
496
+ });
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // createNotifierFromConfig (richFormat)
500
+ // ---------------------------------------------------------------------------
501
+
502
+ describe("createNotifierFromConfig (richFormat)", () => {
503
+ function mockRuntime(): any {
504
+ return {
505
+ channel: {
506
+ discord: { sendMessageDiscord: vi.fn(async () => {}) },
507
+ slack: { sendMessageSlack: vi.fn(async () => ({})) },
508
+ telegram: { sendMessageTelegram: vi.fn(async () => {}) },
509
+ signal: { sendMessageSignal: vi.fn(async () => {}) },
510
+ },
511
+ };
512
+ }
513
+
514
+ afterEach(() => { vi.restoreAllMocks(); });
515
+
516
+ it("sends Discord embeds when richFormat is true", async () => {
517
+ const runtime = mockRuntime();
518
+ const notify = createNotifierFromConfig({
519
+ notifications: {
520
+ richFormat: true,
521
+ targets: [{ channel: "discord", target: "D-1" }],
522
+ },
523
+ }, runtime);
524
+ await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
525
+ const [, , opts] = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
526
+ expect(opts?.embeds).toBeDefined();
527
+ expect(opts.embeds).toHaveLength(1);
528
+ });
529
+
530
+ it("sends plain text when richFormat is false", async () => {
531
+ const runtime = mockRuntime();
532
+ const notify = createNotifierFromConfig({
533
+ notifications: {
534
+ richFormat: false,
535
+ targets: [{ channel: "discord", target: "D-1" }],
536
+ },
537
+ }, runtime);
538
+ await notify("dispatch", { identifier: "CT-1", title: "Test", status: "dispatched" });
539
+ const call = runtime.channel.discord.sendMessageDiscord.mock.calls[0];
540
+ expect(call).toHaveLength(2); // no third arg with embeds
541
+ });
542
+ });
543
+
405
544
  // ---------------------------------------------------------------------------
406
545
  // createNoopNotifier
407
546
  // ---------------------------------------------------------------------------