189 lines
5.0 KiB
TypeScript
189 lines
5.0 KiB
TypeScript
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<EventPriority, string> = {
|
|
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:* <!date^${Math.floor(event.timestamp.getTime() / 1000)}^{date_short_pretty} {time}|${event.timestamp.toISOString()}>`,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// 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<string, unknown>): Promise<void> {
|
|
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<string, unknown>): 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<void> {
|
|
if (!webhookUrl) return;
|
|
|
|
const payload: Record<string, unknown> = {
|
|
username,
|
|
blocks: buildBlocks(event),
|
|
};
|
|
if (defaultChannel) payload.channel = defaultChannel;
|
|
|
|
await postToWebhook(webhookUrl, payload);
|
|
},
|
|
|
|
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
|
|
if (!webhookUrl) return;
|
|
|
|
const payload: Record<string, unknown> = {
|
|
username,
|
|
blocks: buildBlocks(event, actions),
|
|
};
|
|
if (defaultChannel) payload.channel = defaultChannel;
|
|
|
|
await postToWebhook(webhookUrl, payload);
|
|
},
|
|
|
|
async post(message: string, context?: NotifyContext): Promise<string | null> {
|
|
if (!webhookUrl) return null;
|
|
|
|
const channel = context?.channel ?? defaultChannel;
|
|
const payload: Record<string, unknown> = {
|
|
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<Notifier>;
|