@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,10 +1,14 @@
1
1
  /**
2
- * notify.ts — Simple notification function for dispatch lifecycle events.
2
+ * notify.ts — Unified notification provider for dispatch lifecycle events.
3
3
  *
4
- * One concrete Discord implementation + noop fallback.
5
- * No abstract class add provider abstraction only when a second
6
- * backend (Slack, email) actually exists.
4
+ * Uses OpenClaw's native runtime channel API for all providers (Discord, Slack,
5
+ * Telegram, Signal, etc). One formatter, one send function, config-driven
6
+ * fan-out with per-event-type toggles.
7
+ *
8
+ * Modeled on DevClaw's notify.ts pattern — the runtime handles token resolution,
9
+ * formatting differences (markdown vs mrkdwn), and delivery per channel.
7
10
  */
11
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
8
12
 
9
13
  // ---------------------------------------------------------------------------
10
14
  // Types
@@ -32,58 +36,133 @@ export interface NotifyPayload {
32
36
  export type NotifyFn = (kind: NotifyKind, payload: NotifyPayload) => Promise<void>;
33
37
 
34
38
  // ---------------------------------------------------------------------------
35
- // Discord implementation
39
+ // Provider config
36
40
  // ---------------------------------------------------------------------------
37
41
 
38
- const DISCORD_API = "https://discord.com/api/v10";
42
+ export interface NotifyTarget {
43
+ /** OpenClaw channel name: "discord", "slack", "telegram", "signal", etc. */
44
+ channel: string;
45
+ /** Channel/group/user ID to send to */
46
+ target: string;
47
+ /** Optional account ID for multi-account channel setups */
48
+ accountId?: string;
49
+ }
50
+
51
+ export interface NotificationsConfig {
52
+ targets?: NotifyTarget[];
53
+ events?: Partial<Record<NotifyKind, boolean>>;
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Unified message formatter
58
+ // ---------------------------------------------------------------------------
39
59
 
40
- function formatDiscordMessage(kind: NotifyKind, payload: NotifyPayload): string {
41
- const prefix = `**${payload.identifier}**`;
60
+ export function formatMessage(kind: NotifyKind, payload: NotifyPayload): string {
61
+ const id = payload.identifier;
42
62
  switch (kind) {
43
63
  case "dispatch":
44
- return `${prefix} dispatched — ${payload.title}`;
64
+ return `${id} dispatched — ${payload.title}`;
45
65
  case "working":
46
- return `${prefix} worker started (attempt ${payload.attempt ?? 0})`;
66
+ return `${id} worker started (attempt ${payload.attempt ?? 0})`;
47
67
  case "auditing":
48
- return `${prefix} audit in progress`;
68
+ return `${id} audit in progress`;
49
69
  case "audit_pass":
50
- return `${prefix} passed audit. PR ready.`;
70
+ return `${id} passed audit. PR ready.`;
51
71
  case "audit_fail": {
52
72
  const gaps = payload.verdict?.gaps?.join(", ") ?? "unspecified";
53
- return `${prefix} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
73
+ return `${id} failed audit (attempt ${payload.attempt ?? 0}). Gaps: ${gaps}`;
54
74
  }
55
75
  case "escalation":
56
- return `🚨 ${prefix} needs human review — ${payload.reason ?? "audit failed 2x"}`;
76
+ return `🚨 ${id} needs human review — ${payload.reason ?? "audit failed 2x"}`;
57
77
  case "stuck":
58
- return `⏰ ${prefix} stuck — ${payload.reason ?? "stale 2h"}`;
78
+ return `⏰ ${id} stuck — ${payload.reason ?? "stale 2h"}`;
59
79
  case "watchdog_kill":
60
- return `⚡ ${prefix} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
80
+ return `⚡ ${id} killed by watchdog (${payload.reason ?? "no I/O for 120s"}). ${
61
81
  payload.attempt != null ? `Retrying (attempt ${payload.attempt}).` : "Will retry."
62
82
  }`;
63
83
  default:
64
- return `${prefix} — ${kind}: ${payload.status}`;
84
+ return `${id} — ${kind}: ${payload.status}`;
65
85
  }
66
86
  }
67
87
 
68
- export function createDiscordNotifier(botToken: string, channelId: string): NotifyFn {
88
+ // ---------------------------------------------------------------------------
89
+ // Unified send — routes to OpenClaw runtime channel API
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export async function sendToTarget(
93
+ target: NotifyTarget,
94
+ message: string,
95
+ runtime: PluginRuntime,
96
+ ): Promise<void> {
97
+ const ch = target.channel;
98
+ const to = target.target;
99
+
100
+ if (ch === "discord") {
101
+ await runtime.channel.discord.sendMessageDiscord(to, message);
102
+ } else if (ch === "slack") {
103
+ await runtime.channel.slack.sendMessageSlack(to, message, {
104
+ accountId: target.accountId,
105
+ });
106
+ } else if (ch === "telegram") {
107
+ await runtime.channel.telegram.sendMessageTelegram(to, message, { silent: true });
108
+ } else if (ch === "signal") {
109
+ await runtime.channel.signal.sendMessageSignal(to, message);
110
+ } else {
111
+ // Fallback: use CLI for any channel the runtime doesn't expose directly
112
+ const { execFileSync } = await import("node:child_process");
113
+ execFileSync("openclaw", ["message", "send", "--channel", ch, "--target", to, "--message", message, "--json"], {
114
+ timeout: 30_000,
115
+ stdio: "ignore",
116
+ });
117
+ }
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Config-driven factory
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Parse notification config from plugin config.
126
+ */
127
+ export function parseNotificationsConfig(
128
+ pluginConfig: Record<string, unknown> | undefined,
129
+ ): NotificationsConfig {
130
+ const raw = pluginConfig?.notifications as NotificationsConfig | undefined;
131
+ return {
132
+ targets: raw?.targets ?? [],
133
+ events: raw?.events ?? {},
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Create a notifier from plugin config. Returns a NotifyFn that:
139
+ * 1. Checks event toggles (skip suppressed events)
140
+ * 2. Formats the message
141
+ * 3. Fans out to all configured targets (failures isolated via Promise.allSettled)
142
+ */
143
+ export function createNotifierFromConfig(
144
+ pluginConfig: Record<string, unknown> | undefined,
145
+ runtime: PluginRuntime,
146
+ ): NotifyFn {
147
+ const config = parseNotificationsConfig(pluginConfig);
148
+
149
+ if (!config.targets?.length) return createNoopNotifier();
150
+
69
151
  return async (kind, payload) => {
70
- const message = formatDiscordMessage(kind, payload);
71
- try {
72
- const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
73
- method: "POST",
74
- headers: {
75
- Authorization: `Bot ${botToken}`,
76
- "Content-Type": "application/json",
77
- },
78
- body: JSON.stringify({ content: message }),
79
- });
80
- if (!res.ok) {
81
- const body = await res.text().catch(() => "");
82
- console.error(`Discord notify failed (${res.status}): ${body}`);
83
- }
84
- } catch (err) {
85
- console.error("Discord notify error:", err);
86
- }
152
+ // Check event toggle — default is enabled (true)
153
+ if (config.events?.[kind] === false) return;
154
+
155
+ const message = formatMessage(kind, payload);
156
+
157
+ await Promise.allSettled(
158
+ config.targets!.map(async (target) => {
159
+ try {
160
+ await sendToTarget(target, message, runtime);
161
+ } catch (err) {
162
+ console.error(`Notify error (${target.channel}:${target.target}):`, err);
163
+ }
164
+ }),
165
+ );
87
166
  };
88
167
  }
89
168
 
@@ -0,0 +1,334 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks (vi.hoisted + vi.mock)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const { runAgentMock } = vi.hoisted(() => ({
8
+ runAgentMock: vi.fn().mockResolvedValue({ success: true, output: "Mock planner response" }),
9
+ }));
10
+
11
+ vi.mock("../agent/agent.js", () => ({
12
+ runAgent: runAgentMock,
13
+ }));
14
+
15
+ vi.mock("../api/linear-api.js", () => ({}));
16
+
17
+ vi.mock("openclaw/plugin-sdk", () => ({}));
18
+
19
+ const mockLinearApi = {
20
+ getProject: vi.fn().mockResolvedValue({
21
+ id: "proj-1",
22
+ name: "Test Project",
23
+ teams: { nodes: [{ id: "team-1", name: "Team" }] },
24
+ }),
25
+ getProjectIssues: vi.fn().mockResolvedValue([]),
26
+ getTeamStates: vi.fn().mockResolvedValue([
27
+ { id: "st-1", name: "Backlog", type: "backlog" },
28
+ ]),
29
+ getTeamLabels: vi.fn().mockResolvedValue([]),
30
+ createComment: vi.fn().mockResolvedValue("comment-id"),
31
+ getIssueDetails: vi.fn().mockResolvedValue({
32
+ id: "issue-1",
33
+ identifier: "PROJ-1",
34
+ title: "Root",
35
+ comments: { nodes: [] },
36
+ project: { id: "proj-1" },
37
+ team: { id: "team-1" },
38
+ }),
39
+ };
40
+
41
+ vi.mock("./planning-state.js", () => ({
42
+ registerPlanningSession: vi.fn().mockResolvedValue(undefined),
43
+ updatePlanningSession: vi.fn().mockResolvedValue({
44
+ turnCount: 1,
45
+ projectId: "proj-1",
46
+ status: "interviewing",
47
+ }),
48
+ endPlanningSession: vi.fn().mockResolvedValue(undefined),
49
+ setPlanningCache: vi.fn(),
50
+ clearPlanningCache: vi.fn(),
51
+ }));
52
+
53
+ vi.mock("../tools/planner-tools.js", () => ({
54
+ setActivePlannerContext: vi.fn(),
55
+ clearActivePlannerContext: vi.fn(),
56
+ buildPlanSnapshot: vi.fn().mockReturnValue("_No issues created yet._"),
57
+ auditPlan: vi.fn().mockReturnValue({ pass: true, problems: [], warnings: [] }),
58
+ }));
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Imports (AFTER mocks)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
65
+ import {
66
+ registerPlanningSession,
67
+ updatePlanningSession,
68
+ endPlanningSession,
69
+ setPlanningCache,
70
+ } from "./planning-state.js";
71
+ import {
72
+ setActivePlannerContext,
73
+ clearActivePlannerContext,
74
+ auditPlan,
75
+ } from "../tools/planner-tools.js";
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Helpers
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function createApi() {
82
+ return {
83
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
84
+ pluginConfig: {},
85
+ } as any;
86
+ }
87
+
88
+ function createCtx(overrides?: Partial<{ api: any; linearApi: any; pluginConfig: any }>) {
89
+ return {
90
+ api: createApi(),
91
+ linearApi: mockLinearApi,
92
+ pluginConfig: {},
93
+ ...overrides,
94
+ };
95
+ }
96
+
97
+ function createSession(overrides?: Record<string, unknown>) {
98
+ return {
99
+ projectId: "proj-1",
100
+ projectName: "Test Project",
101
+ rootIssueId: "issue-1",
102
+ rootIdentifier: "PROJ-1",
103
+ teamId: "team-1",
104
+ status: "interviewing" as const,
105
+ startedAt: new Date().toISOString(),
106
+ turnCount: 0,
107
+ ...overrides,
108
+ };
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Reset
113
+ // ---------------------------------------------------------------------------
114
+
115
+ afterEach(() => {
116
+ vi.clearAllMocks();
117
+ runAgentMock.mockResolvedValue({ success: true, output: "Mock planner response" });
118
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // initiatePlanningSession
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("initiatePlanningSession", () => {
126
+ const rootIssue = {
127
+ id: "issue-1",
128
+ identifier: "PROJ-1",
129
+ title: "Root",
130
+ team: { id: "team-1" },
131
+ };
132
+
133
+ it("registers session in state with projectId and status interviewing", async () => {
134
+ const ctx = createCtx();
135
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
136
+
137
+ expect(registerPlanningSession).toHaveBeenCalledWith(
138
+ "proj-1",
139
+ expect.objectContaining({
140
+ projectId: "proj-1",
141
+ status: "interviewing",
142
+ }),
143
+ undefined,
144
+ );
145
+ });
146
+
147
+ it("sets planning cache with the session", async () => {
148
+ const ctx = createCtx();
149
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
150
+
151
+ expect(setPlanningCache).toHaveBeenCalledWith(
152
+ expect.objectContaining({
153
+ projectId: "proj-1",
154
+ projectName: "Test Project",
155
+ status: "interviewing",
156
+ }),
157
+ );
158
+ });
159
+
160
+ it("posts welcome comment containing the project name", async () => {
161
+ const ctx = createCtx();
162
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
163
+
164
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
165
+ "issue-1",
166
+ expect.stringContaining("Test Project"),
167
+ );
168
+ });
169
+
170
+ it("fetches project metadata and team states", async () => {
171
+ const ctx = createCtx();
172
+ await initiatePlanningSession(ctx, "proj-1", rootIssue);
173
+
174
+ expect(mockLinearApi.getProject).toHaveBeenCalledWith("proj-1");
175
+ expect(mockLinearApi.getTeamStates).toHaveBeenCalledWith("team-1");
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // handlePlannerTurn
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("handlePlannerTurn", () => {
184
+ const input = {
185
+ issueId: "issue-1",
186
+ commentBody: "Let's add a search feature",
187
+ commentorName: "Tester",
188
+ };
189
+
190
+ it("increments turn count via updatePlanningSession", async () => {
191
+ const ctx = createCtx();
192
+ const session = createSession();
193
+ await handlePlannerTurn(ctx, session, input);
194
+
195
+ expect(updatePlanningSession).toHaveBeenCalledWith(
196
+ "proj-1",
197
+ { turnCount: 1 },
198
+ undefined,
199
+ );
200
+ });
201
+
202
+ it("builds plan snapshot from project issues", async () => {
203
+ const ctx = createCtx();
204
+ const session = createSession();
205
+ await handlePlannerTurn(ctx, session, input);
206
+
207
+ expect(mockLinearApi.getProjectIssues).toHaveBeenCalledWith("proj-1");
208
+ });
209
+
210
+ it("calls runAgent with system prompt", async () => {
211
+ const ctx = createCtx();
212
+ const session = createSession();
213
+ await handlePlannerTurn(ctx, session, input);
214
+
215
+ expect(runAgentMock).toHaveBeenCalledWith(
216
+ expect.objectContaining({
217
+ message: expect.stringContaining("planning"),
218
+ }),
219
+ );
220
+ });
221
+
222
+ it("posts agent response as comment", async () => {
223
+ const ctx = createCtx();
224
+ const session = createSession();
225
+ await handlePlannerTurn(ctx, session, input);
226
+
227
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
228
+ "issue-1",
229
+ "Mock planner response",
230
+ );
231
+ });
232
+
233
+ it("detects finalize plan intent and triggers audit instead of regular turn", async () => {
234
+ const ctx = createCtx();
235
+ const session = createSession();
236
+
237
+ await handlePlannerTurn(ctx, session, {
238
+ issueId: "issue-1",
239
+ commentBody: "finalize plan",
240
+ commentorName: "Tester",
241
+ });
242
+
243
+ // Audit path: auditPlan is called, runAgent is NOT called
244
+ expect(auditPlan).toHaveBeenCalled();
245
+ expect(runAgentMock).not.toHaveBeenCalled();
246
+ });
247
+
248
+ it("detects abandon intent and ends session as abandoned", async () => {
249
+ const ctx = createCtx();
250
+ const session = createSession();
251
+
252
+ await handlePlannerTurn(ctx, session, {
253
+ issueId: "issue-1",
254
+ commentBody: "abandon",
255
+ commentorName: "Tester",
256
+ });
257
+
258
+ expect(endPlanningSession).toHaveBeenCalledWith(
259
+ "proj-1",
260
+ "abandoned",
261
+ undefined,
262
+ );
263
+ // Should NOT run the agent
264
+ expect(runAgentMock).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // runPlanAudit
270
+ // ---------------------------------------------------------------------------
271
+
272
+ describe("runPlanAudit", () => {
273
+ it("posts success comment on passing audit", async () => {
274
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
275
+ const ctx = createCtx();
276
+ const session = createSession();
277
+
278
+ await runPlanAudit(ctx, session);
279
+
280
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
281
+ "issue-1",
282
+ expect.stringContaining("Approved"),
283
+ );
284
+ });
285
+
286
+ it("ends session as approved on pass", async () => {
287
+ vi.mocked(auditPlan).mockReturnValue({ pass: true, problems: [], warnings: [] });
288
+ const ctx = createCtx();
289
+ const session = createSession();
290
+
291
+ await runPlanAudit(ctx, session);
292
+
293
+ expect(endPlanningSession).toHaveBeenCalledWith(
294
+ "proj-1",
295
+ "approved",
296
+ undefined,
297
+ );
298
+ });
299
+
300
+ it("posts problems on failing audit", async () => {
301
+ vi.mocked(auditPlan).mockReturnValue({
302
+ pass: false,
303
+ problems: ["Missing description on PROJ-2"],
304
+ warnings: [],
305
+ });
306
+ const ctx = createCtx();
307
+ const session = createSession();
308
+
309
+ await runPlanAudit(ctx, session);
310
+
311
+ expect(mockLinearApi.createComment).toHaveBeenCalledWith(
312
+ "issue-1",
313
+ expect.stringContaining("Missing description on PROJ-2"),
314
+ );
315
+ });
316
+
317
+ it("does NOT end session as approved on fail", async () => {
318
+ vi.mocked(auditPlan).mockReturnValue({
319
+ pass: false,
320
+ problems: ["No estimates"],
321
+ warnings: [],
322
+ });
323
+ const ctx = createCtx();
324
+ const session = createSession();
325
+
326
+ await runPlanAudit(ctx, session);
327
+
328
+ expect(endPlanningSession).not.toHaveBeenCalledWith(
329
+ "proj-1",
330
+ "approved",
331
+ expect.anything(),
332
+ );
333
+ });
334
+ });