/** * notify.ts — Programmatic alerting for worker lifecycle events. * * Sends notifications to project groups for visibility into the DevClaw pipeline. * * Event types: * - workerStart: Worker spawned/resumed for a task (→ project group) * - workerComplete: Worker completed task (→ project group) */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { log as auditLog } from "./audit.js"; import type { TickAction } from "./services/tick.js"; const execFileAsync = promisify(execFile); /** Per-event-type toggle. All default to true — set to false to suppress. */ export type NotificationConfig = Partial>; export type NotifyEvent = | { type: "workerStart"; project: string; groupId: string; issueId: number; issueTitle: string; issueUrl: string; role: "dev" | "qa"; level: string; sessionAction: "spawn" | "send"; } | { type: "workerComplete"; project: string; groupId: string; issueId: number; issueUrl: string; role: "dev" | "qa"; result: "done" | "pass" | "fail" | "refine" | "blocked"; summary?: string; nextState?: string; }; /** * Build a human-readable message for a notification event. */ function buildMessage(event: NotifyEvent): string { switch (event.type) { case "workerStart": { const action = event.sessionAction === "spawn" ? "šŸš€ Started" : "ā–¶ļø Resumed"; return `${action} ${event.role.toUpperCase()} (${event.level}) on #${event.issueId}: ${event.issueTitle}\nšŸ”— ${event.issueUrl}`; } case "workerComplete": { const icons: Record = { done: "āœ…", pass: "šŸŽ‰", fail: "āŒ", refine: "šŸ¤”", blocked: "🚫", }; const icon = icons[event.result] ?? "šŸ“‹"; const resultText: Record = { done: "completed", pass: "PASSED", fail: "FAILED", refine: "needs refinement", blocked: "BLOCKED", }; const text = resultText[event.result] ?? event.result; let msg = `${icon} ${event.role.toUpperCase()} ${text} #${event.issueId}`; if (event.summary) { msg += ` — ${event.summary}`; } if (event.nextState) { msg += ` → ${event.nextState}`; } msg += `\nšŸ”— ${event.issueUrl}`; return msg; } } } /** * Send a notification message via the native OpenClaw messaging CLI. * * Uses `openclaw message send` which handles target resolution, chunking, * retries, and error reporting for all supported channels. * Fails silently (logs error but doesn't throw) to avoid breaking the main flow. */ async function sendMessage( target: string, message: string, channel: string, workspaceDir: string, ): Promise { try { await execFileAsync( "openclaw", [ "message", "send", "--channel", channel, "--target", target, "--message", message, "--json", ], { timeout: 30_000 }, ); return true; } catch (err) { // Log but don't throw — notifications shouldn't break the main flow await auditLog(workspaceDir, "notify_error", { target, channel, error: (err as Error).message, }); return false; } } /** * Send a notification for a worker lifecycle event. * * Returns true if notification was sent, false on error. */ export async function notify( event: NotifyEvent, opts: { workspaceDir: string; config?: NotificationConfig; /** Target for project-scoped notifications (groupId) */ groupId?: string; /** Channel type for routing (e.g. "telegram", "whatsapp", "discord", "slack") */ channel?: string; }, ): Promise { if (opts.config?.[event.type] === false) return true; const channel = opts.channel ?? "telegram"; const message = buildMessage(event); const target = opts.groupId ?? (event as { groupId?: string }).groupId; if (!target) { await auditLog(opts.workspaceDir, "notify_skip", { eventType: event.type, reason: "no target", }); return true; // Not an error, just nothing to do } await auditLog(opts.workspaceDir, "notify", { eventType: event.type, target, channel, message, }); return sendMessage(target, message, channel, opts.workspaceDir); } /** * Send workerStart notifications for each tick pickup. * * Called after projectTick() returns pickups — callers pass the array * so each dispatched task gets a visible start notification in the project group. */ export async function notifyTickPickups( pickups: TickAction[], opts: { workspaceDir: string; config?: NotificationConfig; channel?: string; }, ): Promise { for (const pickup of pickups) { await notify( { type: "workerStart", project: pickup.project, groupId: pickup.groupId, issueId: pickup.issueId, issueTitle: pickup.issueTitle, issueUrl: pickup.issueUrl, role: pickup.role, level: pickup.level, sessionAction: pickup.sessionAction, }, { workspaceDir: opts.workspaceDir, config: opts.config, groupId: pickup.groupId, channel: opts.channel, }, ); } } /** * Extract notification config from plugin config. * All event types default to enabled (true). */ export function getNotificationConfig( pluginConfig?: Record, ): NotificationConfig { return (pluginConfig?.notifications as NotificationConfig) ?? {}; }