feat: add notifier-openclaw plugin for OpenClaw gateway
Adds a new notifier plugin that sends session events to an OpenClaw gateway via HTTP POST. Includes configurable host, port, token, and event filtering. Added to urgent and action notification channels by default. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a3723be48
commit
e31690278f
|
|
@ -92,8 +92,8 @@ const OrchestratorConfigSchema = z.object({
|
|||
projects: z.record(ProjectConfigSchema),
|
||||
notifiers: z.record(NotifierConfigSchema).default({}),
|
||||
notificationRouting: z.record(z.array(z.string())).default({
|
||||
urgent: ["desktop", "composio"],
|
||||
action: ["desktop", "composio"],
|
||||
urgent: ["desktop", "composio", "openclaw"],
|
||||
action: ["desktop", "composio", "openclaw"],
|
||||
warning: ["composio"],
|
||||
info: ["composio"],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@composio/ao-plugin-notifier-openclaw",
|
||||
"version": "0.1.0",
|
||||
"description": "Notifier plugin: OpenClaw gateway",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ComposioHQ/agent-orchestrator.git",
|
||||
"directory": "packages/plugins/notifier-openclaw"
|
||||
},
|
||||
"homepage": "https://github.com/ComposioHQ/agent-orchestrator",
|
||||
"bugs": {
|
||||
"url": "https://github.com/ComposioHQ/agent-orchestrator/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@composio/ao-core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { OrchestratorEvent } from "@composio/ao-core";
|
||||
import { manifest, create } from "./index.js";
|
||||
|
||||
function makeEvent(overrides: Partial<OrchestratorEvent> = {}): OrchestratorEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
type: "ci.failing",
|
||||
priority: "action",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-project",
|
||||
timestamp: new Date("2025-06-15T12:00:00Z"),
|
||||
message: "CI check failed on app-1",
|
||||
data: { branch: "feat/add-login", status: "failing" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notifier-openclaw", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("openclaw");
|
||||
expect(manifest.slot).toBe("notifier");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a notifier with name 'openclaw'", () => {
|
||||
const notifier = create({ host: "localhost", port: 8080, token: "tok123" });
|
||||
expect(notifier.name).toBe("openclaw");
|
||||
});
|
||||
|
||||
it("warns when no token configured", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
create();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("No token configured"));
|
||||
});
|
||||
|
||||
it("uses default host and port when not provided", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://localhost:8080/api/sessions/main/message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notify", () => {
|
||||
it("does nothing when no token", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const notifier = create();
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSTs message to the OpenClaw gateway", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ host: "myhost", port: 9000, token: "tok123" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("http://myhost:9000/api/sessions/main/message");
|
||||
|
||||
const opts = fetchMock.mock.calls[0][1];
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.headers["Content-Type"]).toBe("application/json");
|
||||
expect(opts.headers["Authorization"]).toBe("Bearer tok123");
|
||||
|
||||
const body = JSON.parse(opts.body);
|
||||
expect(body.message).toContain("my-project");
|
||||
expect(body.message).toContain("app-1");
|
||||
expect(body.message).toContain("CI check failed on app-1");
|
||||
});
|
||||
|
||||
it("includes session ID, project, branch, and status in message", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
await notifier.notify(makeEvent());
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).toContain("[my-project/app-1]");
|
||||
expect(body.message).toContain("(feat/add-login)");
|
||||
expect(body.message).toContain("status=failing");
|
||||
expect(body.message).toContain("CI check failed on app-1");
|
||||
});
|
||||
|
||||
it("omits branch when not in event data", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
await notifier.notify(makeEvent({ data: {} }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).not.toContain("(");
|
||||
expect(body.message).toContain("[my-project/app-1]");
|
||||
});
|
||||
|
||||
it("omits status when not in event data", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
await notifier.notify(makeEvent({ data: {} }));
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.message).not.toContain("status=");
|
||||
});
|
||||
|
||||
it("skips events not in the default allowed set", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
await notifier.notify(makeEvent({ type: "session.working" }));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends events that are in the default allowed set", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123" });
|
||||
|
||||
for (const type of [
|
||||
"session.spawned",
|
||||
"session.exited",
|
||||
"ci.failing",
|
||||
"pr.created",
|
||||
"merge.ready",
|
||||
"summary.all_complete",
|
||||
] as const) {
|
||||
await notifier.notify(makeEvent({ type }));
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("respects custom events filter", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({
|
||||
token: "tok123",
|
||||
events: ["session.spawned", "session.exited"],
|
||||
});
|
||||
|
||||
await notifier.notify(makeEvent({ type: "session.spawned" }));
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
|
||||
await notifier.notify(makeEvent({ type: "ci.failing" }));
|
||||
expect(fetchMock).toHaveBeenCalledOnce(); // still 1, ci.failing filtered out
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry logic", () => {
|
||||
it("retries on 5xx and succeeds", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: () => Promise.resolve("unavailable"),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 });
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries on 429 Too Many Requests", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
text: () => Promise.resolve("rate limited"),
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 });
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does NOT retry on 400 Bad Request", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 400, text: () => Promise.resolve("bad request") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed (400)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT retry on 401 Unauthorized", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed (401)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws after all retries exhausted on 5xx", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("error") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 2, retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow(
|
||||
"OpenClaw POST failed (500): error",
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on network errors", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("ECONNREFUSED"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 1, retryDelayMs: 1 });
|
||||
await notifier.notify(makeEvent());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("respects retries=0 (no retries)", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, status: 500, text: () => Promise.resolve("fail") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const notifier = create({ token: "tok123", retries: 0, retryDelayMs: 1 });
|
||||
await expect(notifier.notify(makeEvent())).rejects.toThrow("OpenClaw POST failed");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type {
|
||||
PluginModule,
|
||||
Notifier,
|
||||
OrchestratorEvent,
|
||||
EventType,
|
||||
} from "@composio/ao-core";
|
||||
|
||||
export const manifest = {
|
||||
name: "openclaw",
|
||||
slot: "notifier" as const,
|
||||
description: "Notifier plugin: OpenClaw gateway",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
const DEFAULT_EVENTS: ReadonlySet<EventType> = new Set([
|
||||
"session.spawned",
|
||||
"session.exited",
|
||||
"session.killed",
|
||||
"session.stuck",
|
||||
"session.needs_input",
|
||||
"session.errored",
|
||||
"pr.created",
|
||||
"pr.merged",
|
||||
"ci.failing",
|
||||
"ci.fix_failed",
|
||||
"review.changes_requested",
|
||||
"merge.ready",
|
||||
"merge.conflicts",
|
||||
"summary.all_complete",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns true if the HTTP status code should be retried.
|
||||
* Only 429 (Too Many Requests) and 5xx (server errors) are retryable.
|
||||
*/
|
||||
function isRetryableStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
async function postWithRetry(
|
||||
url: string,
|
||||
body: { message: string },
|
||||
token: string,
|
||||
retries: number,
|
||||
retryDelayMs: number,
|
||||
): Promise<void> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
const text = await response.text();
|
||||
lastError = new Error(`OpenClaw POST failed (${response.status}): ${text}`);
|
||||
|
||||
if (!isRetryableStatus(response.status)) {
|
||||
throw lastError;
|
||||
}
|
||||
} catch (err) {
|
||||
if (err === lastError) throw err;
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
|
||||
if (attempt < retries) {
|
||||
const delay = retryDelayMs * 2 ** attempt;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function formatMessage(event: OrchestratorEvent): string {
|
||||
const branch =
|
||||
typeof event.data["branch"] === "string" ? event.data["branch"] : null;
|
||||
const status =
|
||||
typeof event.data["status"] === "string" ? event.data["status"] : null;
|
||||
|
||||
const parts: string[] = [
|
||||
`[${event.projectId}/${event.sessionId}]`,
|
||||
];
|
||||
|
||||
if (branch) {
|
||||
parts.push(`(${branch})`);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
parts.push(`status=${status}`);
|
||||
}
|
||||
|
||||
parts.push(event.message);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function buildUrl(host: string, port: number): string {
|
||||
return `http://${host}:${port}/api/sessions/main/message`;
|
||||
}
|
||||
|
||||
export function create(config?: Record<string, unknown>): Notifier {
|
||||
const host = (config?.host as string | undefined) ?? "localhost";
|
||||
const port = (config?.port as number | undefined) ?? 8080;
|
||||
const token = config?.token as string | undefined;
|
||||
const rawRetries = (config?.retries as number) ?? 2;
|
||||
const rawDelay = (config?.retryDelayMs as number) ?? 1000;
|
||||
const retries = Number.isFinite(rawRetries) ? Math.max(0, rawRetries) : 2;
|
||||
const retryDelayMs = Number.isFinite(rawDelay) && rawDelay >= 0 ? rawDelay : 1000;
|
||||
|
||||
// Parse optional event filter
|
||||
const rawEvents = config?.events as string[] | undefined;
|
||||
const allowedEvents: ReadonlySet<string> =
|
||||
Array.isArray(rawEvents) && rawEvents.length > 0
|
||||
? new Set(rawEvents)
|
||||
: DEFAULT_EVENTS;
|
||||
|
||||
if (!token) {
|
||||
console.warn("[notifier-openclaw] No token configured — notifications will be no-ops");
|
||||
}
|
||||
|
||||
const url = buildUrl(host, port);
|
||||
|
||||
return {
|
||||
name: "openclaw",
|
||||
|
||||
async notify(event: OrchestratorEvent): Promise<void> {
|
||||
if (!token) return;
|
||||
if (!allowedEvents.has(event.type)) return;
|
||||
|
||||
const message = formatMessage(event);
|
||||
await postWithRetry(url, { message }, token, retries, retryDelayMs);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Notifier>;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -258,6 +258,22 @@ importers:
|
|||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/plugins/notifier-openclaw:
|
||||
dependencies:
|
||||
'@composio/ao-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../core
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.2.3
|
||||
version: 25.2.3
|
||||
typescript:
|
||||
specifier: ^5.7.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
packages/plugins/notifier-slack:
|
||||
dependencies:
|
||||
'@composio/ao-core':
|
||||
|
|
|
|||
Loading…
Reference in New Issue