agent-orchestrator/packages/plugins/notifier-composio/src/index.ts

279 lines
8.8 KiB
TypeScript

import type {
PluginModule,
Notifier,
OrchestratorEvent,
NotifyAction,
NotifyContext,
EventPriority,
} from "@composio/ao-core";
export const manifest = {
name: "composio",
slot: "notifier" as const,
description: "Notifier plugin: Composio unified notifications (Slack, Discord, email)",
version: "0.1.0",
};
const PRIORITY_EMOJI: Record<EventPriority, string> = {
urgent: "\u{1F6A8}",
action: "\u{1F449}",
warning: "\u{26A0}\u{FE0F}",
info: "\u{2139}\u{FE0F}",
};
type ComposioApp = "slack" | "discord" | "gmail";
const APP_TOOL_SLUG: Record<ComposioApp, string> = {
slack: "SLACK_SEND_MESSAGE",
discord: "DISCORD_SEND_MESSAGE",
gmail: "GMAIL_SEND_EMAIL",
};
const VALID_APPS = new Set<string>(["slack", "discord", "gmail"]);
const GMAIL_SUBJECT = "Agent Orchestrator Notification";
interface ComposioToolkit {
executeAction(params: {
action: string;
params: Record<string, unknown>;
entityId?: string;
}): Promise<{ successful: boolean; data?: unknown; error?: string }>;
}
/**
* Lazy-load composio-core SDK.
* Returns null if the package is not installed.
*
* We use dynamic import + unknown casting because composio-core is an
* optional peer dependency — it may or may not be installed, and its
* TypeScript types may not match our internal interface exactly.
*/
async function loadComposioSDK(apiKey: string): Promise<ComposioToolkit | null> {
try {
// String literal import so vitest can intercept it for mocking.
// The `as unknown as …` cast is safe because we validate the shape below.
const mod = (await import("composio-core")) as unknown as Record<string, unknown>;
const ComposioClass = (mod.Composio ??
(mod.default as Record<string, unknown> | undefined)?.Composio ??
mod.default) as (new (opts: { apiKey: string }) => unknown) | undefined;
if (typeof ComposioClass !== "function") {
throw new Error("Could not find Composio class in composio-core module");
}
const client = new ComposioClass({ apiKey });
return client as ComposioToolkit;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
const code = err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined;
if (
message.includes("Cannot find module") ||
message.includes("Cannot find package") ||
message.includes("MODULE_NOT_FOUND") ||
code === "ERR_MODULE_NOT_FOUND"
) {
return null;
}
throw err;
}
}
function formatNotifyText(event: OrchestratorEvent): string {
const emoji = PRIORITY_EMOJI[event.priority];
const parts = [`${emoji} *${event.type}* — ${event.sessionId}`, event.message];
const prUrl = typeof event.data.prUrl === "string" ? event.data.prUrl : undefined;
if (prUrl) {
parts.push(`PR: ${prUrl}`);
}
return parts.join("\n");
}
function formatActionsText(event: OrchestratorEvent, actions: NotifyAction[]): string {
const base = formatNotifyText(event);
const actionLines = actions.map((a) => {
if (a.url) return `- ${a.label}: ${a.url}`;
return `- ${a.label}`;
});
return `${base}\n\nActions:\n${actionLines.join("\n")}`;
}
function buildToolArgs(
app: ComposioApp,
text: string,
channelId?: string,
channelName?: string,
emailTo?: string,
): Record<string, unknown> {
if (app === "slack") {
const args: Record<string, unknown> = { text };
if (channelId) args.channel = channelId;
else if (channelName) args.channel = channelName;
return args;
}
if (app === "discord") {
const args: Record<string, unknown> = { content: text };
// Discord requires numeric channel IDs — channelName is not supported
if (channelId) args.channel_id = channelId;
else if (channelName) args.channel_id = channelName;
return args;
}
// gmail — emailTo is required, validated at config time
return {
to: emailTo ?? "",
subject: GMAIL_SUBJECT,
body: text,
};
}
export function create(config?: Record<string, unknown>): Notifier {
const apiKey =
(typeof config?.composioApiKey === "string" ? config.composioApiKey : undefined) ??
process.env.COMPOSIO_API_KEY;
const defaultApp: ComposioApp =
typeof config?.defaultApp === "string" && VALID_APPS.has(config.defaultApp)
? (config.defaultApp as ComposioApp)
: "slack";
const channelName = typeof config?.channelName === "string" ? config.channelName : undefined;
const channelId = typeof config?.channelId === "string" ? config.channelId : undefined;
const emailTo = typeof config?.emailTo === "string" ? config.emailTo : undefined;
// Internal: allows tests to inject a mock client without mocking composio-core
const clientOverride =
config?._clientOverride !== undefined &&
config._clientOverride !== null &&
typeof (config._clientOverride as ComposioToolkit).executeAction === "function"
? (config._clientOverride as ComposioToolkit)
: undefined;
if (typeof config?.defaultApp === "string" && !VALID_APPS.has(config.defaultApp)) {
throw new Error(
`[notifier-composio] Invalid defaultApp: "${config.defaultApp}". Must be one of: slack, discord, gmail`,
);
}
if (defaultApp === "gmail" && !emailTo) {
throw new Error('[notifier-composio] emailTo is required when defaultApp is "gmail"');
}
let client: ComposioToolkit | null | undefined = clientOverride;
let warnedNoKey = false;
let sdkMissing = false;
async function getClient(): Promise<ComposioToolkit | null> {
// If a client override was injected, always use it
if (clientOverride) return clientOverride;
if (!apiKey) {
if (!warnedNoKey) {
console.warn(
"[notifier-composio] No composioApiKey or COMPOSIO_API_KEY configured — notifications will be no-ops",
);
warnedNoKey = true;
}
return null;
}
if (sdkMissing) return null;
if (client === undefined) {
client = await loadComposioSDK(apiKey);
if (client === null) {
sdkMissing = true;
// eslint-disable-next-line no-console
console.warn(
"[notifier-composio] composio-core package is not installed — notifications will be no-ops. Run: npm install composio-core",
);
return null;
}
}
return client;
}
async function executeWithTimeout(
composio: ComposioToolkit,
action: string,
params: Record<string, unknown>,
): Promise<void> {
const timeoutMs = 30_000;
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const actionPromise = composio.executeAction({ action, params });
// Prevent unhandled rejection if the timeout fires and actionPromise later rejects
actionPromise.catch(() => {});
const result = await Promise.race([
actionPromise,
new Promise<never>((_, reject) => {
timeoutSignal.addEventListener(
"abort",
() => {
reject(
new Error(
`[notifier-composio] Composio API call timed out after ${timeoutMs / 1000}s`,
),
);
},
{ once: true },
);
}),
]);
if (!result.successful) {
throw new Error(
`[notifier-composio] Composio action ${action} failed: ${result.error ?? "unknown error"}`,
);
}
}
return {
name: "composio",
async notify(event: OrchestratorEvent): Promise<void> {
const composio = await getClient();
if (!composio) return;
const text = formatNotifyText(event);
const toolSlug = APP_TOOL_SLUG[defaultApp];
const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo);
await executeWithTimeout(composio, toolSlug, args);
},
async notifyWithActions(event: OrchestratorEvent, actions: NotifyAction[]): Promise<void> {
const composio = await getClient();
if (!composio) return;
const text = formatActionsText(event, actions);
const toolSlug = APP_TOOL_SLUG[defaultApp];
const args = buildToolArgs(defaultApp, text, channelId, channelName, emailTo);
await executeWithTimeout(composio, toolSlug, args);
},
async post(message: string, context?: NotifyContext): Promise<string | null> {
const composio = await getClient();
if (!composio) return null;
const channel = context?.channel ?? channelId ?? channelName;
const toolSlug = APP_TOOL_SLUG[defaultApp];
const args: Record<string, unknown> =
defaultApp === "gmail"
? { to: emailTo ?? "", subject: GMAIL_SUBJECT, body: message }
: defaultApp === "discord"
? { content: message, ...(channel ? { channel_id: channel } : {}) }
: { text: message, ...(channel ? { channel } : {}) };
await executeWithTimeout(composio, toolSlug, args);
return null;
},
};
}
export default { manifest, create } satisfies PluginModule<Notifier>;