import { validateUrl, type PluginModule, type Notifier, type OrchestratorEvent, type NotifyAction, type NotifyContext, type EventPriority, CI_STATUS, } from "@composio/ao-core"; export const manifest = { name: "slack", slot: "notifier" as const, description: "Notifier plugin: Slack webhook notifications", version: "0.1.0", }; const PRIORITY_EMOJI: Record = { urgent: ":rotating_light:", action: ":point_right:", warning: ":warning:", info: ":information_source:", }; function buildBlocks(event: OrchestratorEvent, actions?: NotifyAction[]): unknown[] { const blocks: unknown[] = [ { type: "header", text: { type: "plain_text", text: `${PRIORITY_EMOJI[event.priority]} ${event.type} — ${event.sessionId}`, emoji: true, }, }, { type: "section", text: { type: "mrkdwn", text: event.message, }, }, { type: "context", elements: [ { type: "mrkdwn", text: `*Project:* ${event.projectId} | *Priority:* ${event.priority} | *Time:* `, }, ], }, ]; // Add PR link if available (type-guarded) const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined; if (prUrl) { blocks.push({ type: "section", text: { type: "mrkdwn", text: `:github: <${prUrl}|View Pull Request>`, }, }); } // Add CI status if available (type-guarded) const ciStatus = typeof event.data.ciStatus === "string" ? event.data.ciStatus : undefined; if (ciStatus) { const ciEmoji = ciStatus === CI_STATUS.PASSING ? ":white_check_mark:" : ":x:"; blocks.push({ type: "context", elements: [ { type: "mrkdwn", text: `${ciEmoji} CI: ${ciStatus}`, }, ], }); } // Add action buttons if (actions && actions.length > 0) { const elements = actions .filter((a) => a.url || a.callbackEndpoint) .map((action) => { if (action.url) { return { type: "button", text: { type: "plain_text", text: action.label, emoji: true }, url: action.url, }; } const sanitized = action.label .toLowerCase() .replace(/[^a-z0-9]+/g, "_") .replace(/^_|_$/g, ""); const idx = actions.indexOf(action); const actionId = sanitized ? `${sanitized}_${idx}` : `action_${idx}`; return { type: "button", text: { type: "plain_text", text: action.label, emoji: true }, action_id: `ao_${actionId}`, value: action.callbackEndpoint, }; }); if (elements.length > 0) { blocks.push({ type: "actions", elements, }); } } blocks.push({ type: "divider" }); return blocks; } async function postToWebhook(webhookUrl: string, payload: Record): Promise { const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { const body = await response.text(); throw new Error(`Slack webhook failed (${response.status}): ${body}`); } } export function create(config?: Record): Notifier { const webhookUrl = config?.webhookUrl as string | undefined; const defaultChannel = config?.channel as string | undefined; const username = (config?.username as string) ?? "Agent Orchestrator"; if (!webhookUrl) { console.warn("[notifier-slack] No webhookUrl configured — notifications will be no-ops"); } else { validateUrl(webhookUrl, "notifier-slack"); } return { name: "slack", async notify(event: OrchestratorEvent): Promise { if (!webhookUrl) return; const payload: Record = { username, blocks: buildBlocks(event), }; if (defaultChannel) payload.channel = defaultChannel; await postToWebhook(webhookUrl, payload); }, async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise { if (!webhookUrl) return; const payload: Record = { username, blocks: buildBlocks(event, actions), }; if (defaultChannel) payload.channel = defaultChannel; await postToWebhook(webhookUrl, payload); }, async post(message: string, context?: NotifyContext): Promise { if (!webhookUrl) return null; const channel = context?.channel ?? defaultChannel; const payload: Record = { username, text: message, }; if (channel) payload.channel = channel; await postToWebhook(webhookUrl, payload); // Incoming webhooks don't return a message ID return null; }, }; } export default { manifest, create } satisfies PluginModule;