diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..44b09e24592cc30ad6ecda91d40ee92fee67b47f --- /dev/null +++ b/extensions/bluebubbles/index.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { bluebubblesPlugin } from "./src/channel.js"; +import { handleBlueBubblesWebhookRequest } from "./src/monitor.js"; +import { setBlueBubblesRuntime } from "./src/runtime.js"; + +const plugin = { + id: "bluebubbles", + name: "BlueBubbles", + description: "BlueBubbles channel plugin (macOS app)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setBlueBubblesRuntime(api.runtime); + api.registerChannel({ plugin: bluebubblesPlugin }); + api.registerHttpHandler(handleBlueBubblesWebhookRequest); + }, +}; + +export default plugin; diff --git a/extensions/bluebubbles/openclaw.plugin.json b/extensions/bluebubbles/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..b6145764aba2dc466e051050b4eb35bd852f3cea --- /dev/null +++ b/extensions/bluebubbles/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "bluebubbles", + "channels": ["bluebubbles"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1b770537edcfa1da03113b2b32ed870de164c73f --- /dev/null +++ b/extensions/bluebubbles/package.json @@ -0,0 +1,36 @@ +{ + "name": "@openclaw/bluebubbles", + "version": "2026.1.30", + "description": "OpenClaw BlueBubbles channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "bluebubbles", + "label": "BlueBubbles", + "selectionLabel": "BlueBubbles (macOS app)", + "detailLabel": "BlueBubbles", + "docsPath": "/channels/bluebubbles", + "docsLabel": "bluebubbles", + "blurb": "iMessage via the BlueBubbles mac app + REST API.", + "aliases": [ + "bb" + ], + "preferOver": [ + "imessage" + ], + "systemImage": "bubble.left.and.text.bubble.right", + "order": 75 + }, + "install": { + "npmSpec": "@openclaw/bluebubbles", + "localPath": "extensions/bluebubbles", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..04320701e5f3deccdaf9b27c488e33de27ec0536 --- /dev/null +++ b/extensions/bluebubbles/src/accounts.ts @@ -0,0 +1,88 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js"; + +export type ResolvedBlueBubblesAccount = { + accountId: string; + enabled: boolean; + name?: string; + config: BlueBubblesAccountConfig; + configured: boolean; + baseUrl?: string; +}; + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const accounts = cfg.channels?.bluebubbles?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + return Object.keys(accounts).filter(Boolean); +} + +export function listBlueBubblesAccountIds(cfg: OpenClawConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultBlueBubblesAccountId(cfg: OpenClawConfig): string { + const ids = listBlueBubblesAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): BlueBubblesAccountConfig | undefined { + const accounts = cfg.channels?.bluebubbles?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + return accounts[accountId] as BlueBubblesAccountConfig | undefined; +} + +function mergeBlueBubblesAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): BlueBubblesAccountConfig { + const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & { + accounts?: unknown; + }; + const { accounts: _ignored, ...rest } = base; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length"; + return { ...rest, ...account, chunkMode }; +} + +export function resolveBlueBubblesAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedBlueBubblesAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.bluebubbles?.enabled; + const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const serverUrl = merged.serverUrl?.trim(); + const password = merged.password?.trim(); + const configured = Boolean(serverUrl && password); + const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined; + return { + accountId, + enabled: baseEnabled !== false && accountEnabled, + name: merged.name?.trim() || undefined, + config: merged, + configured, + baseUrl, + }; +} + +export function listEnabledBlueBubblesAccounts(cfg: OpenClawConfig): ResolvedBlueBubblesAccount[] { + return listBlueBubblesAccountIds(cfg) + .map((accountId) => resolveBlueBubblesAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dc55b1eff39203bce6fd582d3c6109162b7260d --- /dev/null +++ b/extensions/bluebubbles/src/actions.test.ts @@ -0,0 +1,650 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { bluebubblesMessageActions } from "./actions.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +vi.mock("./reactions.js", () => ({ + sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./send.js", () => ({ + resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), + sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), +})); + +vi.mock("./chat.js", () => ({ + editBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), + unsendBlueBubblesMessage: vi.fn().mockResolvedValue(undefined), + renameBlueBubblesChat: vi.fn().mockResolvedValue(undefined), + setGroupIconBlueBubbles: vi.fn().mockResolvedValue(undefined), + addBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), + removeBlueBubblesParticipant: vi.fn().mockResolvedValue(undefined), + leaveBlueBubblesChat: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./attachments.js", () => ({ + sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), +})); + +vi.mock("./monitor.js", () => ({ + resolveBlueBubblesMessageId: vi.fn((id: string) => id), +})); + +describe("bluebubblesMessageActions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("listActions", () => { + it("returns empty array when account is not enabled", () => { + const cfg: OpenClawConfig = { + channels: { bluebubbles: { enabled: false } }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toEqual([]); + }); + + it("returns empty array when account is not configured", () => { + const cfg: OpenClawConfig = { + channels: { bluebubbles: { enabled: true } }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toEqual([]); + }); + + it("returns react action when enabled and configured", () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).toContain("react"); + }); + + it("excludes react action when reactions are gated off", () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + enabled: true, + serverUrl: "http://localhost:1234", + password: "test-password", + actions: { reactions: false }, + }, + }, + }; + const actions = bluebubblesMessageActions.listActions({ cfg }); + expect(actions).not.toContain("react"); + // Other actions should still be present + expect(actions).toContain("edit"); + expect(actions).toContain("unsend"); + }); + }); + + describe("supportsAction", () => { + it("returns true for react action", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true); + }); + + it("returns true for all supported actions", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true); + expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true); + }); + + it("returns false for unsupported actions", () => { + expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false); + expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false); + }); + }); + + describe("extractToolSend", () => { + it("extracts send params from sendMessage action", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { + action: "sendMessage", + to: "+15551234567", + accountId: "test-account", + }, + }); + expect(result).toEqual({ + to: "+15551234567", + accountId: "test-account", + }); + }); + + it("returns null for non-sendMessage action", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { action: "react", to: "+15551234567" }, + }); + expect(result).toBeNull(); + }); + + it("returns null when to is missing", () => { + const result = bluebubblesMessageActions.extractToolSend({ + args: { action: "sendMessage" }, + }); + expect(result).toBeNull(); + }); + }); + + describe("handleAction", () => { + it("throws for unsupported actions", async () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "unknownAction", + params: {}, + cfg, + accountId: null, + }), + ).rejects.toThrow("is not supported"); + }); + + it("throws when emoji is missing for react action", async () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { messageId: "msg-123" }, + cfg, + accountId: null, + }), + ).rejects.toThrow(/emoji/i); + }); + + it("throws when messageId is missing", async () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { emoji: "❤️" }, + cfg, + accountId: null, + }), + ).rejects.toThrow("messageId"); + }); + + it("throws when chatGuid cannot be resolved", async () => { + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce(null); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" }, + cfg, + accountId: null, + }), + ).rejects.toThrow("chatGuid not found"); + }); + + it("sends reaction successfully with chatGuid", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const result = await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15551234567", + messageGuid: "msg-123", + emoji: "❤️", + }), + ); + // jsonResult returns { content: [...], details: payload } + expect(result).toMatchObject({ + details: { ok: true, added: "❤️" }, + }); + }); + + it("sends reaction removal successfully", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + const result = await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "msg-123", + chatGuid: "iMessage;-;+15551234567", + remove: true, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + remove: true, + }), + ); + // jsonResult returns { content: [...], details: payload } + expect(result).toMatchObject({ + details: { ok: true, removed: true }, + }); + }); + + it("resolves chatGuid from to parameter", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15559876543"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "👍", + messageId: "msg-456", + to: "+15559876543", + }, + cfg, + accountId: null, + }); + + expect(resolveChatGuidForTarget).toHaveBeenCalled(); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15559876543", + }), + ); + }); + + it("passes partIndex when provided", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "😂", + messageId: "msg-789", + chatGuid: "iMessage;-;chat-guid", + partIndex: 2, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + partIndex: 2, + }), + ); + }); + + it("uses toolContext currentChannelId when no explicit target is provided", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "👍", + messageId: "msg-456", + }, + cfg, + accountId: null, + toolContext: { + currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111", + }, + }); + + expect(resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" }, + }), + ); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15550001111", + }), + ); + }); + + it("resolves short messageId before reacting", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + const { sendBlueBubblesReaction } = await import("./reactions.js"); + vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "1", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }); + + expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true }); + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + messageGuid: "resolved-uuid", + }), + ); + }); + + it("propagates short-id errors from the resolver", async () => { + const { resolveBlueBubblesMessageId } = await import("./monitor.js"); + vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => { + throw new Error("short id expired"); + }); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await expect( + bluebubblesMessageActions.handleAction({ + action: "react", + params: { + emoji: "❤️", + messageId: "999", + chatGuid: "iMessage;-;+15551234567", + }, + cfg, + accountId: null, + }), + ).rejects.toThrow("short id expired"); + }); + + it("accepts message param for edit action", async () => { + const { editBlueBubblesMessage } = await import("./chat.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await bluebubblesMessageActions.handleAction({ + action: "edit", + params: { messageId: "msg-123", message: "updated" }, + cfg, + accountId: null, + }); + + expect(editBlueBubblesMessage).toHaveBeenCalledWith( + "msg-123", + "updated", + expect.objectContaining({ cfg, accountId: undefined }), + ); + }); + + it("accepts message/target aliases for sendWithEffect", async () => { + const { sendMessageBlueBubbles } = await import("./send.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + const result = await bluebubblesMessageActions.handleAction({ + action: "sendWithEffect", + params: { + message: "peekaboo", + target: "+15551234567", + effect: "invisible ink", + }, + cfg, + accountId: null, + }); + + expect(sendMessageBlueBubbles).toHaveBeenCalledWith( + "+15551234567", + "peekaboo", + expect.objectContaining({ effectId: "invisible ink" }), + ); + expect(result).toMatchObject({ + details: { ok: true, messageId: "msg-123", effect: "invisible ink" }, + }); + }); + + it("passes asVoice through sendAttachment", async () => { + const { sendBlueBubblesAttachment } = await import("./attachments.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + const base64Buffer = Buffer.from("voice").toString("base64"); + + await bluebubblesMessageActions.handleAction({ + action: "sendAttachment", + params: { + to: "+15551234567", + filename: "voice.mp3", + buffer: base64Buffer, + contentType: "audio/mpeg", + asVoice: true, + }, + cfg, + accountId: null, + }); + + expect(sendBlueBubblesAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + filename: "voice.mp3", + contentType: "audio/mpeg", + asVoice: true, + }), + ); + }); + + it("throws when buffer is missing for setGroupIcon", async () => { + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + await expect( + bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { chatGuid: "iMessage;-;chat-guid" }, + cfg, + accountId: null, + }), + ).rejects.toThrow(/requires an image/i); + }); + + it("sets group icon successfully with chatGuid and buffer", async () => { + const { setGroupIconBlueBubbles } = await import("./chat.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + // Base64 encode a simple test buffer + const testBuffer = Buffer.from("fake-image-data"); + const base64Buffer = testBuffer.toString("base64"); + + const result = await bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { + chatGuid: "iMessage;-;chat-guid", + buffer: base64Buffer, + filename: "group-icon.png", + contentType: "image/png", + }, + cfg, + accountId: null, + }); + + expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( + "iMessage;-;chat-guid", + expect.any(Uint8Array), + "group-icon.png", + expect.objectContaining({ contentType: "image/png" }), + ); + expect(result).toMatchObject({ + details: { ok: true, chatGuid: "iMessage;-;chat-guid", iconSet: true }, + }); + }); + + it("uses default filename when not provided for setGroupIcon", async () => { + const { setGroupIconBlueBubbles } = await import("./chat.js"); + + const cfg: OpenClawConfig = { + channels: { + bluebubbles: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }, + }; + + const base64Buffer = Buffer.from("test").toString("base64"); + + await bluebubblesMessageActions.handleAction({ + action: "setGroupIcon", + params: { + chatGuid: "iMessage;-;chat-guid", + buffer: base64Buffer, + }, + cfg, + accountId: null, + }); + + expect(setGroupIconBlueBubbles).toHaveBeenCalledWith( + "iMessage;-;chat-guid", + expect.any(Uint8Array), + "icon.png", + expect.anything(), + ); + }); + }); +}); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3c2832a2182a28b3b19a01a219912c094d8091e --- /dev/null +++ b/extensions/bluebubbles/src/actions.ts @@ -0,0 +1,438 @@ +import { + BLUEBUBBLES_ACTION_NAMES, + BLUEBUBBLES_ACTIONS, + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, + type ChannelMessageActionAdapter, + type ChannelMessageActionName, + type ChannelToolSend, +} from "openclaw/plugin-sdk"; +import type { BlueBubblesSendTarget } from "./types.js"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { sendBlueBubblesAttachment } from "./attachments.js"; +import { + editBlueBubblesMessage, + unsendBlueBubblesMessage, + renameBlueBubblesChat, + setGroupIconBlueBubbles, + addBlueBubblesParticipant, + removeBlueBubblesParticipant, + leaveBlueBubblesChat, +} from "./chat.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; +import { isMacOS26OrHigher } from "./probe.js"; +import { sendBlueBubblesReaction } from "./reactions.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; +import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js"; + +const providerId = "bluebubbles"; + +function mapTarget(raw: string): BlueBubblesSendTarget { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "chat_guid") { + return { kind: "chat_guid", chatGuid: parsed.chatGuid }; + } + if (parsed.kind === "chat_id") { + return { kind: "chat_id", chatId: parsed.chatId }; + } + if (parsed.kind === "chat_identifier") { + return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; + } + return { + kind: "handle", + address: normalizeBlueBubblesHandle(parsed.to), + service: parsed.service, + }; +} + +function readMessageText(params: Record): string | undefined { + return readStringParam(params, "text") ?? readStringParam(params, "message"); +} + +function readBooleanParam(params: Record, key: string): boolean | undefined { + const raw = params[key]; + if (typeof raw === "boolean") { + return raw; + } + if (typeof raw === "string") { + const trimmed = raw.trim().toLowerCase(); + if (trimmed === "true") { + return true; + } + if (trimmed === "false") { + return false; + } + } + return undefined; +} + +/** Supported action names for BlueBubbles */ +const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES); + +export const bluebubblesMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const account = resolveBlueBubblesAccount({ cfg: cfg }); + if (!account.enabled || !account.configured) { + return []; + } + const gate = createActionGate(cfg.channels?.bluebubbles?.actions); + const actions = new Set(); + const macOS26 = isMacOS26OrHigher(account.accountId); + for (const action of BLUEBUBBLES_ACTION_NAMES) { + const spec = BLUEBUBBLES_ACTIONS[action]; + if (!spec?.gate) { + continue; + } + if (spec.unsupportedOnMacOS26 && macOS26) { + continue; + } + if (gate(spec.gate)) { + actions.add(action); + } + } + return Array.from(actions); + }, + supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action), + extractToolSend: ({ args }): ChannelToolSend | null => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") { + return null; + } + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) { + return null; + } + const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; + return { to, accountId }; + }, + handleAction: async ({ action, params, cfg, accountId, toolContext }) => { + const account = resolveBlueBubblesAccount({ + cfg: cfg, + accountId: accountId ?? undefined, + }); + const baseUrl = account.config.serverUrl?.trim(); + const password = account.config.password?.trim(); + const opts = { cfg: cfg, accountId: accountId ?? undefined }; + + // Helper to resolve chatGuid from various params or session context + const resolveChatGuid = async (): Promise => { + const chatGuid = readStringParam(params, "chatGuid"); + if (chatGuid?.trim()) { + return chatGuid.trim(); + } + + const chatIdentifier = readStringParam(params, "chatIdentifier"); + const chatId = readNumberParam(params, "chatId", { integer: true }); + const to = readStringParam(params, "to"); + // Fall back to session context if no explicit target provided + const contextTarget = toolContext?.currentChannelId?.trim(); + + const target = chatIdentifier?.trim() + ? ({ + kind: "chat_identifier", + chatIdentifier: chatIdentifier.trim(), + } as BlueBubblesSendTarget) + : typeof chatId === "number" + ? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget) + : to + ? mapTarget(to) + : contextTarget + ? mapTarget(contextTarget) + : null; + + if (!target) { + throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`); + } + if (!baseUrl || !password) { + throw new Error(`BlueBubbles ${action} requires serverUrl and password.`); + } + + const resolved = await resolveChatGuidForTarget({ baseUrl, password, target }); + if (!resolved) { + throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`); + } + return resolved; + }; + + // Handle react action + if (action === "react") { + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.", + }); + if (isEmpty && !remove) { + throw new Error( + "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", + ); + } + const rawMessageId = readStringParam(params, "messageId"); + if (!rawMessageId) { + throw new Error( + "BlueBubbles react requires messageId parameter (the message ID to react to). " + + "Use action=react with messageId=, emoji=, and to/chatGuid to identify the chat.", + ); + } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const resolvedChatGuid = await resolveChatGuid(); + + await sendBlueBubblesReaction({ + chatGuid: resolvedChatGuid, + messageGuid: messageId, + emoji, + remove: remove || undefined, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + opts, + }); + + return jsonResult({ ok: true, ...(remove ? { removed: true } : { added: emoji }) }); + } + + // Handle edit action + if (action === "edit") { + // Edit is not supported on macOS 26+ + if (isMacOS26OrHigher(accountId ?? undefined)) { + throw new Error( + "BlueBubbles edit is not supported on macOS 26 or higher. " + + "Apple removed the ability to edit iMessages in this version.", + ); + } + const rawMessageId = readStringParam(params, "messageId"); + const newText = + readStringParam(params, "text") ?? + readStringParam(params, "newText") ?? + readStringParam(params, "message"); + if (!rawMessageId || !newText) { + const missing: string[] = []; + if (!rawMessageId) { + missing.push("messageId (the message ID to edit)"); + } + if (!newText) { + missing.push("text (the new message content)"); + } + throw new Error( + `BlueBubbles edit requires: ${missing.join(", ")}. ` + + `Use action=edit with messageId=, text=.`, + ); + } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage"); + + await editBlueBubblesMessage(messageId, newText, { + ...opts, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + backwardsCompatMessage: backwardsCompatMessage ?? undefined, + }); + + return jsonResult({ ok: true, edited: rawMessageId }); + } + + // Handle unsend action + if (action === "unsend") { + const rawMessageId = readStringParam(params, "messageId"); + if (!rawMessageId) { + throw new Error( + "BlueBubbles unsend requires messageId parameter (the message ID to unsend). " + + "Use action=unsend with messageId=.", + ); + } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + + await unsendBlueBubblesMessage(messageId, { + ...opts, + partIndex: typeof partIndex === "number" ? partIndex : undefined, + }); + + return jsonResult({ ok: true, unsent: rawMessageId }); + } + + // Handle reply action + if (action === "reply") { + const rawMessageId = readStringParam(params, "messageId"); + const text = readMessageText(params); + const to = readStringParam(params, "to") ?? readStringParam(params, "target"); + if (!rawMessageId || !text || !to) { + const missing: string[] = []; + if (!rawMessageId) { + missing.push("messageId (the message ID to reply to)"); + } + if (!text) { + missing.push("text or message (the reply message content)"); + } + if (!to) { + missing.push("to or target (the chat target)"); + } + throw new Error( + `BlueBubbles reply requires: ${missing.join(", ")}. ` + + `Use action=reply with messageId=, message=, target=.`, + ); + } + // Resolve short ID (e.g., "1", "2") to full UUID + const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true }); + const partIndex = readNumberParam(params, "partIndex", { integer: true }); + + const result = await sendMessageBlueBubbles(to, text, { + ...opts, + replyToMessageGuid: messageId, + replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined, + }); + + return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId }); + } + + // Handle sendWithEffect action + if (action === "sendWithEffect") { + const text = readMessageText(params); + const to = readStringParam(params, "to") ?? readStringParam(params, "target"); + const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect"); + if (!text || !to || !effectId) { + const missing: string[] = []; + if (!text) { + missing.push("text or message (the message content)"); + } + if (!to) { + missing.push("to or target (the chat target)"); + } + if (!effectId) { + missing.push( + "effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)", + ); + } + throw new Error( + `BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` + + `Use action=sendWithEffect with message=, target=, effectId=.`, + ); + } + + const result = await sendMessageBlueBubbles(to, text, { + ...opts, + effectId, + }); + + return jsonResult({ ok: true, messageId: result.messageId, effect: effectId }); + } + + // Handle renameGroup action + if (action === "renameGroup") { + const resolvedChatGuid = await resolveChatGuid(); + const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name"); + if (!displayName) { + throw new Error("BlueBubbles renameGroup requires displayName or name parameter."); + } + + await renameBlueBubblesChat(resolvedChatGuid, displayName, opts); + + return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName }); + } + + // Handle setGroupIcon action + if (action === "setGroupIcon") { + const resolvedChatGuid = await resolveChatGuid(); + const base64Buffer = readStringParam(params, "buffer"); + const filename = + readStringParam(params, "filename") ?? readStringParam(params, "name") ?? "icon.png"; + const contentType = + readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + + if (!base64Buffer) { + throw new Error( + "BlueBubbles setGroupIcon requires an image. " + + "Use action=setGroupIcon with media= or path= to set the group icon.", + ); + } + + // Decode base64 to buffer + const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); + + await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, { + ...opts, + contentType: contentType ?? undefined, + }); + + return jsonResult({ ok: true, chatGuid: resolvedChatGuid, iconSet: true }); + } + + // Handle addParticipant action + if (action === "addParticipant") { + const resolvedChatGuid = await resolveChatGuid(); + const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); + if (!address) { + throw new Error("BlueBubbles addParticipant requires address or participant parameter."); + } + + await addBlueBubblesParticipant(resolvedChatGuid, address, opts); + + return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid }); + } + + // Handle removeParticipant action + if (action === "removeParticipant") { + const resolvedChatGuid = await resolveChatGuid(); + const address = readStringParam(params, "address") ?? readStringParam(params, "participant"); + if (!address) { + throw new Error("BlueBubbles removeParticipant requires address or participant parameter."); + } + + await removeBlueBubblesParticipant(resolvedChatGuid, address, opts); + + return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid }); + } + + // Handle leaveGroup action + if (action === "leaveGroup") { + const resolvedChatGuid = await resolveChatGuid(); + + await leaveBlueBubblesChat(resolvedChatGuid, opts); + + return jsonResult({ ok: true, left: resolvedChatGuid }); + } + + // Handle sendAttachment action + if (action === "sendAttachment") { + const to = readStringParam(params, "to", { required: true }); + const filename = readStringParam(params, "filename", { required: true }); + const caption = readStringParam(params, "caption"); + const contentType = + readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); + const asVoice = readBooleanParam(params, "asVoice"); + + // Buffer can come from params.buffer (base64) or params.path (file path) + const base64Buffer = readStringParam(params, "buffer"); + const filePath = readStringParam(params, "path") ?? readStringParam(params, "filePath"); + + let buffer: Uint8Array; + if (base64Buffer) { + // Decode base64 to buffer + buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0)); + } else if (filePath) { + // Read file from path (will be handled by caller providing buffer) + throw new Error( + "BlueBubbles sendAttachment: filePath not supported in action, provide buffer as base64.", + ); + } else { + throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter."); + } + + const result = await sendBlueBubblesAttachment({ + to, + buffer, + filename, + contentType: contentType ?? undefined, + caption: caption ?? undefined, + asVoice: asVoice ?? undefined, + opts, + }); + + return jsonResult({ ok: true, messageId: result.messageId }); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + }, +}; diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bc0e4d217bc7720c59a83a9c7b6fbdc80459575 --- /dev/null +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { BlueBubblesAttachment } from "./types.js"; +import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("downloadBlueBubblesAttachment", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("throws when guid is missing", async () => { + const attachment: BlueBubblesAttachment = {}; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("guid is required"); + }); + + it("throws when guid is empty string", async () => { + const attachment: BlueBubblesAttachment = { guid: " " }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }), + ).rejects.toThrow("guid is required"); + }); + + it("throws when serverUrl is missing", async () => { + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + await expect(downloadBlueBubblesAttachment(attachment, {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("downloads attachment successfully", async () => { + const mockBuffer = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/png" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-123" }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(result.buffer).toEqual(mockBuffer); + expect(result.contentType).toBe("image/png"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/attachment/att-123/download"), + expect.objectContaining({ method: "GET" }), + ); + }); + + it("includes password in URL query", async () => { + const mockBuffer = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/jpeg" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-456" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "my-secret-password", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-secret-password"); + }); + + it("encodes guid in URL", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; + await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("att%2Fwith%2Fspecial%20chars"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve("Attachment not found"), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-missing" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("download failed (404): Attachment not found"); + }); + + it("throws when attachment exceeds max bytes", async () => { + const largeBuffer = new Uint8Array(10 * 1024 * 1024); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + maxBytes: 5 * 1024 * 1024, + }), + ).rejects.toThrow("too large"); + }); + + it("uses default max bytes when not specified", async () => { + const largeBuffer = new Uint8Array(9 * 1024 * 1024); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(largeBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-large" }; + await expect( + downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("too large"); + }); + + it("uses attachment mimeType as fallback when response has no content-type", async () => { + const mockBuffer = new Uint8Array([1, 2, 3]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { + guid: "att-789", + mimeType: "video/mp4", + }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.contentType).toBe("video/mp4"); + }); + + it("prefers response content-type over attachment mimeType", async () => { + const mockBuffer = new Uint8Array([1, 2, 3]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers({ "content-type": "image/webp" }), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { + guid: "att-xyz", + mimeType: "image/png", + }; + const result = await downloadBlueBubblesAttachment(attachment, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.contentType).toBe("image/webp"); + }); + + it("resolves credentials from config when opts not provided", async () => { + const mockBuffer = new Uint8Array([1]); + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(mockBuffer.buffer), + }); + + const attachment: BlueBubblesAttachment = { guid: "att-config" }; + const result = await downloadBlueBubblesAttachment(attachment, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:5678", + password: "config-password", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:5678"); + expect(calledUrl).toContain("password=config-password"); + expect(result.buffer).toEqual(new Uint8Array([1])); + }); +}); + +describe("sendBlueBubblesAttachment", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function decodeBody(body: Uint8Array) { + return Buffer.from(body).toString("utf8"); + } + + it("marks voice memos when asVoice is true and mp3 is provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice.mp3", + contentType: "audio/mpeg", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('name="isAudioMessage"'); + expect(bodyText).toContain("true"); + expect(bodyText).toContain('filename="voice.mp3"'); + }); + + it("normalizes mp3 filenames for voice memos", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice", + contentType: "audio/mpeg", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('filename="voice.mp3"'); + expect(bodyText).toContain('name="voice.mp3"'); + }); + + it("throws when asVoice is true but media is not audio", async () => { + await expect( + sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "image.png", + contentType: "image/png", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }), + ).rejects.toThrow("voice messages require audio"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when asVoice is true but audio is not mp3 or caf", async () => { + await expect( + sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "voice.wav", + contentType: "audio/wav", + asVoice: true, + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }), + ).rejects.toThrow("require mp3 or caf"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("sanitizes filenames before sending", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })), + }); + + await sendBlueBubblesAttachment({ + to: "chat_guid:iMessage;-;+15551234567", + buffer: new Uint8Array([1, 2, 3]), + filename: "../evil.mp3", + contentType: "audio/mpeg", + opts: { serverUrl: "http://localhost:1234", password: "test" }, + }); + + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('filename="evil.mp3"'); + expect(bodyText).toContain('name="evil.mp3"'); + }); +}); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ce8342d8a339202f07e84d6c3bb430e11b90215 --- /dev/null +++ b/extensions/bluebubbles/src/attachments.ts @@ -0,0 +1,300 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import crypto from "node:crypto"; +import path from "node:path"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { resolveChatGuidForTarget } from "./send.js"; +import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js"; +import { + blueBubblesFetchWithTimeout, + buildBlueBubblesApiUrl, + type BlueBubblesAttachment, + type BlueBubblesSendTarget, +} from "./types.js"; + +export type BlueBubblesAttachmentOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; +}; + +const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; +const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]); +const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]); + +function sanitizeFilename(input: string | undefined, fallback: string): string { + const trimmed = input?.trim() ?? ""; + const base = trimmed ? path.basename(trimmed) : ""; + return base || fallback; +} + +function ensureExtension(filename: string, extension: string, fallbackBase: string): string { + const currentExt = path.extname(filename); + if (currentExt.toLowerCase() === extension) { + return filename; + } + const base = currentExt ? filename.slice(0, -currentExt.length) : filename; + return `${base || fallbackBase}${extension}`; +} + +function resolveVoiceInfo(filename: string, contentType?: string) { + const normalizedType = contentType?.trim().toLowerCase(); + const extension = path.extname(filename).toLowerCase(); + const isMp3 = + extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false); + const isCaf = + extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false); + const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/")); + return { isAudio, isMp3, isCaf }; +} + +function resolveAccount(params: BlueBubblesAttachmentOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) { + throw new Error("BlueBubbles serverUrl is required"); + } + if (!password) { + throw new Error("BlueBubbles password is required"); + } + return { baseUrl, password }; +} + +export async function downloadBlueBubblesAttachment( + attachment: BlueBubblesAttachment, + opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {}, +): Promise<{ buffer: Uint8Array; contentType?: string }> { + const guid = attachment.guid?.trim(); + if (!guid) { + throw new Error("BlueBubbles attachment guid is required"); + } + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`, + password, + }); + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`, + ); + } + const contentType = res.headers.get("content-type") ?? undefined; + const buf = new Uint8Array(await res.arrayBuffer()); + const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES; + if (buf.byteLength > maxBytes) { + throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`); + } + return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined }; +} + +export type SendBlueBubblesAttachmentResult = { + messageId: string; +}; + +function resolveSendTarget(raw: string): BlueBubblesSendTarget { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "handle") { + return { + kind: "handle", + address: normalizeBlueBubblesHandle(parsed.to), + service: parsed.service, + }; + } + if (parsed.kind === "chat_id") { + return { kind: "chat_id", chatId: parsed.chatId }; + } + if (parsed.kind === "chat_guid") { + return { kind: "chat_guid", chatGuid: parsed.chatGuid }; + } + return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; +} + +function extractMessageId(payload: unknown): string { + if (!payload || typeof payload !== "object") { + return "unknown"; + } + const record = payload as Record; + const data = + record.data && typeof record.data === "object" + ? (record.data as Record) + : null; + const candidates = [ + record.messageId, + record.guid, + record.id, + data?.messageId, + data?.guid, + data?.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return String(candidate); + } + } + return "unknown"; +} + +/** + * Send an attachment via BlueBubbles API. + * Supports sending media files (images, videos, audio, documents) to a chat. + * When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo. + */ +export async function sendBlueBubblesAttachment(params: { + to: string; + buffer: Uint8Array; + filename: string; + contentType?: string; + caption?: string; + replyToMessageGuid?: string; + replyToPartIndex?: number; + asVoice?: boolean; + opts?: BlueBubblesAttachmentOpts; +}): Promise { + const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params; + let { buffer, filename, contentType } = params; + const wantsVoice = asVoice === true; + const fallbackName = wantsVoice ? "Audio Message" : "attachment"; + filename = sanitizeFilename(filename, fallbackName); + contentType = contentType?.trim() || undefined; + const { baseUrl, password } = resolveAccount(opts); + + // Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage). + const isAudioMessage = wantsVoice; + if (isAudioMessage) { + const voiceInfo = resolveVoiceInfo(filename, contentType); + if (!voiceInfo.isAudio) { + throw new Error("BlueBubbles voice messages require audio media (mp3 or caf)."); + } + if (voiceInfo.isMp3) { + filename = ensureExtension(filename, ".mp3", fallbackName); + contentType = contentType ?? "audio/mpeg"; + } else if (voiceInfo.isCaf) { + filename = ensureExtension(filename, ".caf", fallbackName); + contentType = contentType ?? "audio/x-caf"; + } else { + throw new Error( + "BlueBubbles voice messages require mp3 or caf audio (convert before sending).", + ); + } + } + + const target = resolveSendTarget(to); + const chatGuid = await resolveChatGuidForTarget({ + baseUrl, + password, + timeoutMs: opts.timeoutMs, + target, + }); + if (!chatGuid) { + throw new Error( + "BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", + ); + } + + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/attachment", + password, + }); + + // Build FormData with the attachment + const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; + const parts: Uint8Array[] = []; + const encoder = new TextEncoder(); + + // Helper to add a form field + const addField = (name: string, value: string) => { + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push(encoder.encode(`Content-Disposition: form-data; name="${name}"\r\n\r\n`)); + parts.push(encoder.encode(`${value}\r\n`)); + }; + + // Helper to add a file field + const addFile = (name: string, fileBuffer: Uint8Array, fileName: string, mimeType?: string) => { + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push( + encoder.encode(`Content-Disposition: form-data; name="${name}"; filename="${fileName}"\r\n`), + ); + parts.push(encoder.encode(`Content-Type: ${mimeType ?? "application/octet-stream"}\r\n\r\n`)); + parts.push(fileBuffer); + parts.push(encoder.encode("\r\n")); + }; + + // Add required fields + addFile("attachment", buffer, filename, contentType); + addField("chatGuid", chatGuid); + addField("name", filename); + addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); + addField("method", "private-api"); + + // Add isAudioMessage flag for voice memos + if (isAudioMessage) { + addField("isAudioMessage", "true"); + } + + const trimmedReplyTo = replyToMessageGuid?.trim(); + if (trimmedReplyTo) { + addField("selectedMessageGuid", trimmedReplyTo); + addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0"); + } + + // Add optional caption + if (caption) { + addField("message", caption); + addField("text", caption); + addField("caption", caption); + } + + // Close the multipart body + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + // Combine all parts into a single buffer + const totalLength = parts.reduce((acc, part) => acc + part.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + body, + }, + opts.timeoutMs ?? 60_000, // longer timeout for file uploads + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error( + `BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`, + ); + } + + const responseBody = await res.text(); + if (!responseBody) { + return { messageId: "ok" }; + } + try { + const parsed = JSON.parse(responseBody) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..74ea0b759836a76632ee40b0a669b0076648ebdb --- /dev/null +++ b/extensions/bluebubbles/src/channel.ts @@ -0,0 +1,414 @@ +import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + collectBlueBubblesStatusIssues, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + resolveBlueBubblesGroupRequireMention, + resolveBlueBubblesGroupToolPolicy, + setAccountEnabledInConfigSection, +} from "openclaw/plugin-sdk"; +import { + listBlueBubblesAccountIds, + type ResolvedBlueBubblesAccount, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { bluebubblesMessageActions } from "./actions.js"; +import { BlueBubblesConfigSchema } from "./config-schema.js"; +import { sendBlueBubblesMedia } from "./media-send.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; +import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; +import { blueBubblesOnboardingAdapter } from "./onboarding.js"; +import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; +import { sendMessageBlueBubbles } from "./send.js"; +import { + extractHandleFromChatGuid, + looksLikeBlueBubblesTargetId, + normalizeBlueBubblesHandle, + normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, +} from "./targets.js"; + +const meta = { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + detailLabel: "BlueBubbles", + docsPath: "/channels/bluebubbles", + docsLabel: "bluebubbles", + blurb: "iMessage via the BlueBubbles mac app + REST API.", + systemImage: "bubble.left.and.text.bubble.right", + aliases: ["bb"], + order: 75, + preferOver: ["imessage"], +}; + +export const bluebubblesPlugin: ChannelPlugin = { + id: "bluebubbles", + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: true, + edit: true, + unsend: true, + reply: true, + effects: true, + groupManagement: true, + }, + groups: { + resolveRequireMention: resolveBlueBubblesGroupRequireMention, + resolveToolPolicy: resolveBlueBubblesGroupToolPolicy, + }, + threading: { + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId, + hasRepliedRef, + }), + }, + reload: { configPrefixes: ["channels.bluebubbles"] }, + configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema), + onboarding: blueBubblesOnboardingAdapter, + config: { + listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg, + sectionKey: "bluebubbles", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg, + sectionKey: "bluebubbles", + accountId, + clearBaseFields: ["serverUrl", "password", "name", "webhookPath"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^bluebubbles:/i, "")) + .map((entry) => normalizeBlueBubblesHandle(entry)), + }, + actions: bluebubblesMessageActions, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.bluebubbles?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.bluebubbles.accounts.${resolvedAccountId}.` + : "channels.bluebubbles."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("bluebubbles"), + normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), + }; + }, + collectWarnings: ({ account }) => { + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + `- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`, + ]; + }, + }, + messaging: { + normalizeTarget: normalizeBlueBubblesMessagingTarget, + targetResolver: { + looksLikeId: looksLikeBlueBubblesTargetId, + hint: "", + }, + formatTargetDisplay: ({ target, display }) => { + const shouldParseDisplay = (value: string): boolean => { + if (looksLikeBlueBubblesTargetId(value)) { + return true; + } + return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value); + }; + + // Helper to extract a clean handle from any BlueBubbles target format + const extractCleanDisplay = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = parseBlueBubblesTarget(trimmed); + if (parsed.kind === "chat_guid") { + const handle = extractHandleFromChatGuid(parsed.chatGuid); + if (handle) { + return handle; + } + } + if (parsed.kind === "handle") { + return normalizeBlueBubblesHandle(parsed.to); + } + } catch { + // Fall through + } + // Strip common prefixes and try raw extraction + const stripped = trimmed + .replace(/^bluebubbles:/i, "") + .replace(/^chat_guid:/i, "") + .replace(/^chat_id:/i, "") + .replace(/^chat_identifier:/i, ""); + const handle = extractHandleFromChatGuid(stripped); + if (handle) { + return handle; + } + // Don't return raw chat_guid formats - they contain internal routing info + if (stripped.includes(";-;") || stripped.includes(";+;")) { + return null; + } + return stripped; + }; + + // Try to get a clean display from the display parameter first + const trimmedDisplay = display?.trim(); + if (trimmedDisplay) { + if (!shouldParseDisplay(trimmedDisplay)) { + return trimmedDisplay; + } + const cleanDisplay = extractCleanDisplay(trimmedDisplay); + if (cleanDisplay) { + return cleanDisplay; + } + } + + // Fall back to extracting from target + const cleanTarget = extractCleanDisplay(target); + if (cleanTarget) { + return cleanTarget; + } + + // Last resort: return display or target as-is + return display?.trim() || target?.trim() || ""; + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "bluebubbles", + accountId, + name, + }), + validateInput: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "bluebubbles", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "bluebubbles", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), + }, + }, + } as OpenClawConfig; + } + return { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + accounts: { + ...next.channels?.bluebubbles?.accounts, + [accountId]: { + ...next.channels?.bluebubbles?.accounts?.[accountId], + enabled: true, + ...(input.httpUrl ? { serverUrl: input.httpUrl } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.webhookPath ? { webhookPath: input.webhookPath } : {}), + }, + }, + }, + }, + } as OpenClawConfig; + }, + }, + pairing: { + idLabel: "bluebubblesSenderId", + normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), + notifyApproval: async ({ cfg, id }) => { + await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + cfg: cfg, + }); + }, + }, + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error("Delivering to BlueBubbles requires --to "), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; + const result = await sendMessageBlueBubbles(to, text, { + cfg: cfg, + accountId: accountId ?? undefined, + replyToMessageGuid: replyToMessageGuid || undefined, + }); + return { channel: "bluebubbles", ...result }; + }, + sendMedia: async (ctx) => { + const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; + const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + }; + const resolvedCaption = caption ?? text; + const result = await sendBlueBubblesMedia({ + cfg: cfg, + to, + mediaUrl, + mediaPath, + mediaBuffer, + contentType, + filename, + caption: resolvedCaption ?? undefined, + replyToId: replyToId ?? null, + accountId: accountId ?? undefined, + }); + + return { channel: "bluebubbles", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectBlueBubblesStatusIssues, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + probeBlueBubbles({ + baseUrl: account.baseUrl, + password: account.config.password ?? null, + timeoutMs, + }), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const running = runtime?.running ?? false; + const probeOk = (probe as BlueBubblesProbe | undefined)?.ok; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.baseUrl, + running, + connected: probeOk ?? running, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const webhookPath = resolveWebhookPathFromConfig(account.config); + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.baseUrl, + }); + ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`); + return monitorBlueBubblesProvider({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + webhookPath, + }); + }, + }, +}; diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..39ac3ba325aaf25b52ed13d7a027d6a02023ab37 --- /dev/null +++ b/extensions/bluebubbles/src/chat.test.ts @@ -0,0 +1,461 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("chat", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("markBlueBubblesChatRead", () => { + it("does nothing when chatGuid is empty", async () => { + await markBlueBubblesChatRead("", { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("does nothing when chatGuid is whitespace", async () => { + await markBlueBubblesChatRead(" ", { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when serverUrl is missing", async () => { + await expect(markBlueBubblesChatRead("chat-guid", {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + markBlueBubblesChatRead("chat-guid", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("marks chat as read successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("iMessage;-;+15551234567", { + serverUrl: "http://localhost:1234", + password: "test-password", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/read"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("chat-123", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-secret"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: () => Promise.resolve("Chat not found"), + }); + + await expect( + markBlueBubblesChatRead("missing-chat", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("read failed (404): Chat not found"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead(" chat-with-spaces ", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/read"); + expect(calledUrl).not.toContain("%20chat"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await markBlueBubblesChatRead("chat-123", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:9999", + password: "config-pass", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:9999"); + expect(calledUrl).toContain("password=config-pass"); + }); + }); + + describe("sendBlueBubblesTyping", () => { + it("does nothing when chatGuid is empty", async () => { + await sendBlueBubblesTyping("", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("does nothing when chatGuid is whitespace", async () => { + await sendBlueBubblesTyping(" ", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("throws when serverUrl is missing", async () => { + await expect(sendBlueBubblesTyping("chat-guid", true, {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + sendBlueBubblesTyping("chat-guid", true, { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("sends typing start with POST method", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("sends typing stop with DELETE method", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;-;+15551234567", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3B%2B15551234567/typing"), + expect.objectContaining({ method: "DELETE" }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "typing-secret", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=typing-secret"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal error"), + }); + + await expect( + sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("typing failed (500): Internal error"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping(" trimmed-chat ", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/trimmed-chat/typing"); + }); + + it("encodes special characters in chatGuid", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("iMessage;+;group@chat.com", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("iMessage%3B%2B%3Bgroup%40chat.com"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://typing-server:8888", + password: "typing-pass", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("typing-server:8888"); + expect(calledUrl).toContain("password=typing-pass"); + }); + + it("can start and stop typing in sequence", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesTyping("chat-123", true, { + serverUrl: "http://localhost:1234", + password: "test", + }); + await sendBlueBubblesTyping("chat-123", false, { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0][1].method).toBe("POST"); + expect(mockFetch.mock.calls[1][1].method).toBe("DELETE"); + }); + }); + + describe("setGroupIconBlueBubbles", () => { + it("throws when chatGuid is empty", async () => { + await expect( + setGroupIconBlueBubbles("", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("chatGuid"); + }); + + it("throws when buffer is empty", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array(0), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("image buffer"); + }); + + it("throws when serverUrl is missing", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {}), + ).rejects.toThrow("serverUrl is required"); + }); + + it("throws when password is missing", async () => { + await expect( + setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("sets group icon successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG magic bytes + await setGroupIconBlueBubbles("iMessage;-;chat-guid", buffer, "icon.png", { + serverUrl: "http://localhost:1234", + password: "test-password", + contentType: "image/png", + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/iMessage%3B-%3Bchat-guid/icon"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": expect.stringContaining("multipart/form-data"), + }), + }), + ); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "my-secret", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-secret"); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal error"), + }); + + await expect( + setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("setGroupIcon failed (500): Internal error"); + }); + + it("trims chatGuid before using", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles(" chat-with-spaces ", new Uint8Array([1]), "icon.png", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("/api/v1/chat/chat-with-spaces/icon"); + expect(calledUrl).not.toContain("%20chat"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1]), "icon.png", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:9999", + password: "config-pass", + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:9999"); + expect(calledUrl).toContain("password=config-pass"); + }); + + it("includes filename in multipart body", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await setGroupIconBlueBubbles("chat-123", new Uint8Array([1, 2, 3]), "custom-icon.jpg", { + serverUrl: "http://localhost:1234", + password: "test", + contentType: "image/jpeg", + }); + + const body = mockFetch.mock.calls[0][1].body as Uint8Array; + const bodyString = new TextDecoder().decode(body); + expect(bodyString).toContain('filename="custom-icon.jpg"'); + expect(bodyString).toContain("image/jpeg"); + }); + }); +}); diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..374c5a896ea342c3f87046882d38bdbeee1a35b9 --- /dev/null +++ b/extensions/bluebubbles/src/chat.ts @@ -0,0 +1,378 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import crypto from "node:crypto"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesChatOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; +}; + +function resolveAccount(params: BlueBubblesChatOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) { + throw new Error("BlueBubbles serverUrl is required"); + } + if (!password) { + throw new Error("BlueBubbles password is required"); + } + return { baseUrl, password }; +} + +export async function markBlueBubblesChatRead( + chatGuid: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmed = chatGuid.trim(); + if (!trimmed) { + return; + } + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`, + password, + }); + const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`); + } +} + +export async function sendBlueBubblesTyping( + chatGuid: string, + typing: boolean, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmed = chatGuid.trim(); + if (!trimmed) { + return; + } + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`, + password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { method: typing ? "POST" : "DELETE" }, + opts.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Edit a message via BlueBubbles API. + * Requires macOS 13 (Ventura) or higher with Private API enabled. + */ +export async function editBlueBubblesMessage( + messageGuid: string, + newText: string, + opts: BlueBubblesChatOpts & { partIndex?: number; backwardsCompatMessage?: string } = {}, +): Promise { + const trimmedGuid = messageGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles edit requires messageGuid"); + } + const trimmedText = newText.trim(); + if (!trimmedText) { + throw new Error("BlueBubbles edit requires newText"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`, + password, + }); + + const payload = { + editedMessage: trimmedText, + backwardsCompatibilityMessage: opts.backwardsCompatMessage ?? `Edited to: ${trimmedText}`, + partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, + }; + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles edit failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Unsend (retract) a message via BlueBubbles API. + * Requires macOS 13 (Ventura) or higher with Private API enabled. + */ +export async function unsendBlueBubblesMessage( + messageGuid: string, + opts: BlueBubblesChatOpts & { partIndex?: number } = {}, +): Promise { + const trimmedGuid = messageGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles unsend requires messageGuid"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`, + password, + }); + + const payload = { + partIndex: typeof opts.partIndex === "number" ? opts.partIndex : 0, + }; + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles unsend failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Rename a group chat via BlueBubbles API. + */ +export async function renameBlueBubblesChat( + chatGuid: string, + displayName: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles rename requires chatGuid"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles rename failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Add a participant to a group chat via BlueBubbles API. + */ +export async function addBlueBubblesParticipant( + chatGuid: string, + address: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles addParticipant requires chatGuid"); + } + const trimmedAddress = address.trim(); + if (!trimmedAddress) { + throw new Error("BlueBubbles addParticipant requires address"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: trimmedAddress }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles addParticipant failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Remove a participant from a group chat via BlueBubbles API. + */ +export async function removeBlueBubblesParticipant( + chatGuid: string, + address: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles removeParticipant requires chatGuid"); + } + const trimmedAddress = address.trim(); + if (!trimmedAddress) { + throw new Error("BlueBubbles removeParticipant requires address"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`, + password, + }); + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address: trimmedAddress }), + }, + opts.timeoutMs, + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `BlueBubbles removeParticipant failed (${res.status}): ${errorText || "unknown"}`, + ); + } +} + +/** + * Leave a group chat via BlueBubbles API. + */ +export async function leaveBlueBubblesChat( + chatGuid: string, + opts: BlueBubblesChatOpts = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles leaveChat requires chatGuid"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`, + password, + }); + + const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles leaveChat failed (${res.status}): ${errorText || "unknown"}`); + } +} + +/** + * Set a group chat's icon/photo via BlueBubbles API. + * Requires Private API to be enabled. + */ +export async function setGroupIconBlueBubbles( + chatGuid: string, + buffer: Uint8Array, + filename: string, + opts: BlueBubblesChatOpts & { contentType?: string } = {}, +): Promise { + const trimmedGuid = chatGuid.trim(); + if (!trimmedGuid) { + throw new Error("BlueBubbles setGroupIcon requires chatGuid"); + } + if (!buffer || buffer.length === 0) { + throw new Error("BlueBubbles setGroupIcon requires image buffer"); + } + + const { baseUrl, password } = resolveAccount(opts); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`, + password, + }); + + // Build multipart form-data + const boundary = `----BlueBubblesFormBoundary${crypto.randomUUID().replace(/-/g, "")}`; + const parts: Uint8Array[] = []; + const encoder = new TextEncoder(); + + // Add file field named "icon" as per API spec + parts.push(encoder.encode(`--${boundary}\r\n`)); + parts.push( + encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`), + ); + parts.push( + encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`), + ); + parts.push(buffer); + parts.push(encoder.encode("\r\n")); + + // Close multipart body + parts.push(encoder.encode(`--${boundary}--\r\n`)); + + // Combine into single buffer + const totalLength = parts.reduce((acc, part) => acc + part.length, 0); + const body = new Uint8Array(totalLength); + let offset = 0; + for (const part of parts) { + body.set(part, offset); + offset += part.length; + } + + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }, + body, + }, + opts.timeoutMs ?? 60_000, // longer timeout for file uploads + ); + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a5e1b393b7e26b2a33861bf7bcead34e8a77ed7 --- /dev/null +++ b/extensions/bluebubbles/src/config-schema.ts @@ -0,0 +1,51 @@ +import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const bluebubblesActionSchema = z + .object({ + reactions: z.boolean().default(true), + edit: z.boolean().default(true), + unsend: z.boolean().default(true), + reply: z.boolean().default(true), + sendWithEffect: z.boolean().default(true), + renameGroup: z.boolean().default(true), + setGroupIcon: z.boolean().default(true), + addParticipant: z.boolean().default(true), + removeParticipant: z.boolean().default(true), + leaveGroup: z.boolean().default(true), + sendAttachment: z.boolean().default(true), + }) + .optional(); + +const bluebubblesGroupConfigSchema = z.object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, +}); + +const bluebubblesAccountSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + serverUrl: z.string().optional(), + password: z.string().optional(), + webhookPath: z.string().optional(), + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + mediaMaxMb: z.number().int().positive().optional(), + sendReadReceipts: z.boolean().optional(), + blockStreaming: z.boolean().optional(), + groups: z.object({}).catchall(bluebubblesGroupConfigSchema).optional(), +}); + +export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({ + accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(), + actions: bluebubblesActionSchema, +}); diff --git a/extensions/bluebubbles/src/media-send.ts b/extensions/bluebubbles/src/media-send.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab7572105674ffd9ed61bbf80a3ca9f937f9ebc3 --- /dev/null +++ b/extensions/bluebubbles/src/media-send.ts @@ -0,0 +1,174 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk"; +import { sendBlueBubblesAttachment } from "./attachments.js"; +import { resolveBlueBubblesMessageId } from "./monitor.js"; +import { getBlueBubblesRuntime } from "./runtime.js"; +import { sendMessageBlueBubbles } from "./send.js"; + +const HTTP_URL_RE = /^https?:\/\//i; +const MB = 1024 * 1024; + +function assertMediaWithinLimit(sizeBytes: number, maxBytes?: number): void { + if (typeof maxBytes !== "number" || maxBytes <= 0) { + return; + } + if (sizeBytes <= maxBytes) { + return; + } + const maxLabel = (maxBytes / MB).toFixed(0); + const sizeLabel = (sizeBytes / MB).toFixed(2); + throw new Error(`Media exceeds ${maxLabel}MB limit (got ${sizeLabel}MB)`); +} + +function resolveLocalMediaPath(source: string): string { + if (!source.startsWith("file://")) { + return source; + } + try { + return fileURLToPath(source); + } catch { + throw new Error(`Invalid file:// URL: ${source}`); + } +} + +function resolveFilenameFromSource(source?: string): string | undefined { + if (!source) { + return undefined; + } + if (source.startsWith("file://")) { + try { + return path.basename(fileURLToPath(source)) || undefined; + } catch { + return undefined; + } + } + if (HTTP_URL_RE.test(source)) { + try { + return path.basename(new URL(source).pathname) || undefined; + } catch { + return undefined; + } + } + const base = path.basename(source); + return base || undefined; +} + +export async function sendBlueBubblesMedia(params: { + cfg: OpenClawConfig; + to: string; + mediaUrl?: string; + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + replyToId?: string | null; + accountId?: string; + asVoice?: boolean; +}) { + const { + cfg, + to, + mediaUrl, + mediaPath, + mediaBuffer, + contentType, + filename, + caption, + replyToId, + accountId, + asVoice, + } = params; + const core = getBlueBubblesRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.bluebubbles?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.bluebubbles?.mediaMaxMb, + accountId, + }); + + let buffer: Uint8Array; + let resolvedContentType = contentType ?? undefined; + let resolvedFilename = filename ?? undefined; + + if (mediaBuffer) { + assertMediaWithinLimit(mediaBuffer.byteLength, maxBytes); + buffer = mediaBuffer; + if (!resolvedContentType) { + const hint = mediaPath ?? mediaUrl; + const detected = await core.media.detectMime({ + buffer: Buffer.isBuffer(mediaBuffer) ? mediaBuffer : Buffer.from(mediaBuffer), + filePath: hint, + }); + resolvedContentType = detected ?? undefined; + } + if (!resolvedFilename) { + resolvedFilename = resolveFilenameFromSource(mediaPath ?? mediaUrl); + } + } else { + const source = mediaPath ?? mediaUrl; + if (!source) { + throw new Error("BlueBubbles media delivery requires mediaUrl, mediaPath, or mediaBuffer."); + } + if (HTTP_URL_RE.test(source)) { + const fetched = await core.channel.media.fetchRemoteMedia({ + url: source, + maxBytes: typeof maxBytes === "number" && maxBytes > 0 ? maxBytes : undefined, + }); + buffer = fetched.buffer; + resolvedContentType = resolvedContentType ?? fetched.contentType ?? undefined; + resolvedFilename = resolvedFilename ?? fetched.fileName; + } else { + const localPath = resolveLocalMediaPath(source); + const fs = await import("node:fs/promises"); + if (typeof maxBytes === "number" && maxBytes > 0) { + const stats = await fs.stat(localPath); + assertMediaWithinLimit(stats.size, maxBytes); + } + const data = await fs.readFile(localPath); + assertMediaWithinLimit(data.byteLength, maxBytes); + buffer = new Uint8Array(data); + if (!resolvedContentType) { + const detected = await core.media.detectMime({ + buffer: data, + filePath: localPath, + }); + resolvedContentType = detected ?? undefined; + } + if (!resolvedFilename) { + resolvedFilename = resolveFilenameFromSource(localPath); + } + } + } + + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = replyToId?.trim() + ? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true }) + : undefined; + + const attachmentResult = await sendBlueBubblesAttachment({ + to, + buffer, + filename: resolvedFilename ?? "attachment", + contentType: resolvedContentType ?? undefined, + replyToMessageGuid, + asVoice, + opts: { + cfg, + accountId, + }, + }); + + const trimmedCaption = caption?.trim(); + if (trimmedCaption) { + await sendMessageBlueBubbles(to, trimmedCaption, { + cfg, + accountId, + replyToMessageGuid, + }); + } + + return attachmentResult; +} diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..902828ece2ed3007c002c003c0702cf409dccc1b --- /dev/null +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -0,0 +1,2340 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { EventEmitter } from "node:events"; +import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import { + handleBlueBubblesWebhookRequest, + registerBlueBubblesWebhookTarget, + resolveBlueBubblesMessageId, + _resetBlueBubblesShortIdState, +} from "./monitor.js"; +import { setBlueBubblesRuntime } from "./runtime.js"; + +// Mock dependencies +vi.mock("./send.js", () => ({ + resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"), + sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }), +})); + +vi.mock("./chat.js", () => ({ + markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined), + sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./attachments.js", () => ({ + downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({ + buffer: Buffer.from("test"), + contentType: "image/jpeg", + }), +})); + +vi.mock("./reactions.js", async () => { + const actual = await vi.importActual("./reactions.js"); + return { + ...actual, + sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined), + }; +}); + +// Mock runtime +const mockEnqueueSystemEvent = vi.fn(); +const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE"); +const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); +const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true }); +const mockResolveAgentRoute = vi.fn(() => ({ + agentId: "main", + accountId: "default", + sessionKey: "agent:main:bluebubbles:dm:+15551234567", +})); +const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]); +const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) => + regexes.some((r) => r.test(text)), +); +const mockResolveRequireMention = vi.fn(() => false); +const mockResolveGroupPolicy = vi.fn(() => "open"); +const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined); +const mockHasControlCommand = vi.fn(() => false); +const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false); +const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/test-media.jpg", + contentType: "image/jpeg", +}); +const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json"); +const mockReadSessionUpdatedAt = vi.fn(() => undefined); +const mockResolveEnvelopeFormatOptions = vi.fn(() => ({ + template: "channel+name+time", +})); +const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body); +const mockChunkMarkdownText = vi.fn((text: string) => [text]); + +function createMockRuntime(): PluginRuntime { + return { + version: "1.0.0", + config: { + loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], + writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], + }, + system: { + enqueueSystemEvent: + mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"], + runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"], + }, + media: { + loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"], + detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"], + mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"], + isVoiceCompatibleAudio: + vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"], + getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"], + resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"], + }, + tools: { + createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"], + createMemorySearchTool: + vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"], + registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"], + }, + channel: { + text: { + chunkMarkdownText: + mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"], + chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], + resolveTextChunkLimit: vi.fn( + () => 4000, + ) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], + hasControlCommand: + mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + resolveMarkdownTableMode: vi.fn( + () => "code", + ) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"], + convertMarkdownTables: vi.fn( + (text: string) => text, + ) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"], + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher: + mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + createReplyDispatcherWithTyping: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"], + resolveEffectiveMessagesConfig: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"], + resolveHumanDelayConfig: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"], + dispatchReplyFromConfig: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"], + finalizeInboundContext: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + formatAgentEnvelope: + mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], + formatInboundEnvelope: + vi.fn() as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], + resolveEnvelopeFormatOptions: + mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], + }, + routing: { + resolveAgentRoute: + mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + pairing: { + buildPairingReply: + mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"], + readAllowFromStore: + mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"], + upsertPairingRequest: + mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"], + }, + media: { + fetchRemoteMedia: + vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: + mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + session: { + resolveStorePath: + mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], + readSessionUpdatedAt: + mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + recordInboundSession: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + recordSessionMetaFromInbound: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], + updateLastRoute: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], + }, + mentions: { + buildMentionRegexes: + mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], + matchesMentionPatterns: + mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], + }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, + }, + groups: { + resolveGroupPolicy: + mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], + resolveRequireMention: + mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], + }, + debounce: { + // Create a pass-through debouncer that immediately calls onFlush + createInboundDebouncer: vi.fn( + (params: { onFlush: (items: unknown[]) => Promise }) => ({ + enqueue: async (item: unknown) => { + await params.onFlush([item]); + }, + flushKey: vi.fn(), + }), + ) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"], + resolveInboundDebounceMs: vi.fn( + () => 0, + ) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"], + }, + commands: { + resolveCommandAuthorizedFromAuthorizers: + mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + isControlCommandMessage: + vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], + shouldComputeCommandAuthorized: + vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"], + shouldHandleTextCommands: + vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"], + }, + discord: {} as PluginRuntime["channel"]["discord"], + slack: {} as PluginRuntime["channel"]["slack"], + telegram: {} as PluginRuntime["channel"]["telegram"], + signal: {} as PluginRuntime["channel"]["signal"], + imessage: {} as PluginRuntime["channel"]["imessage"], + whatsapp: {} as PluginRuntime["channel"]["whatsapp"], + }, + logging: { + shouldLogVerbose: vi.fn( + () => false, + ) as unknown as PluginRuntime["logging"]["shouldLogVerbose"], + getChildLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })) as unknown as PluginRuntime["logging"]["getChildLogger"], + }, + state: { + resolveStateDir: vi.fn( + () => "/tmp/openclaw", + ) as unknown as PluginRuntime["state"]["resolveStateDir"], + }, + }; +} + +function createMockAccount( + overrides: Partial = {}, +): ResolvedBlueBubblesAccount { + return { + accountId: "default", + enabled: true, + configured: true, + config: { + serverUrl: "http://localhost:1234", + password: "test-password", + dmPolicy: "open", + groupPolicy: "open", + allowFrom: [], + groupAllowFrom: [], + ...overrides, + }, + }; +} + +function createMockRequest( + method: string, + url: string, + body: unknown, + headers: Record = {}, +): IncomingMessage { + const req = new EventEmitter() as IncomingMessage; + req.method = method; + req.url = url; + req.headers = headers; + (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" }; + + // Emit body data after a microtask + // oxlint-disable-next-line no-floating-promises + Promise.resolve().then(() => { + const bodyStr = typeof body === "string" ? body : JSON.stringify(body); + req.emit("data", Buffer.from(bodyStr)); + req.emit("end"); + }); + + return req; +} + +function createMockResponse(): ServerResponse & { body: string; statusCode: number } { + const res = { + statusCode: 200, + body: "", + setHeader: vi.fn(), + end: vi.fn((data?: string) => { + res.body = data ?? ""; + }), + } as unknown as ServerResponse & { body: string; statusCode: number }; + return res; +} + +const flushAsync = async () => { + for (let i = 0; i < 2; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + +describe("BlueBubbles webhook monitor", () => { + let unregister: () => void; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset short ID state between tests for predictable behavior + _resetBlueBubblesShortIdState(); + mockReadAllowFromStore.mockResolvedValue([]); + mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true }); + mockResolveRequireMention.mockReturnValue(false); + mockHasControlCommand.mockReturnValue(false); + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); + mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]); + + setBlueBubblesRuntime(createMockRuntime()); + }); + + afterEach(() => { + unregister?.(); + }); + + describe("webhook parsing + auth handling", () => { + it("rejects non-POST requests", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("GET", "/bluebubbles-webhook", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(405); + }); + + it("accepts POST requests with valid JSON payload", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.body).toBe("ok"); + }); + + it("rejects requests with invalid JSON", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + }); + + it("authenticates via password query parameter", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + // Mock non-localhost request + const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("authenticates via x-password header", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest( + "POST", + "/bluebubbles-webhook", + { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }, + { "x-password": "secret-token" }, + ); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("rejects unauthorized requests with wrong password", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "192.168.1.100", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + }); + + it("allows localhost requests without authentication", async () => { + const account = createMockAccount({ password: "secret-token" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const req = createMockRequest("POST", "/bluebubbles-webhook", { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + }, + }); + // Localhost address + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + }); + + it("ignores unregistered webhook paths", async () => { + const req = createMockRequest("POST", "/unregistered-path", {}); + const res = createMockResponse(); + + const handled = await handleBlueBubblesWebhookRequest(req, res); + + expect(handled).toBe(false); + }); + + it("parses chatId when provided as a string (webhook variant)", async () => { + const { resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(resolveChatGuidForTarget).mockClear(); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatId: "123", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(resolveChatGuidForTarget).toHaveBeenCalledWith( + expect.objectContaining({ + target: { kind: "chat_id", chatId: 123 }, + }), + ); + }); + + it("extracts chatGuid from nested chat object fields (webhook variant)", async () => { + const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js"); + vi.mocked(sendMessageBlueBubbles).mockClear(); + vi.mocked(resolveChatGuidForTarget).mockClear(); + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); + }); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chat: { chatGuid: "iMessage;+;chat123456" }, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); + expect(sendMessageBlueBubbles).toHaveBeenCalledWith( + "chat_guid:iMessage;+;chat123456", + expect.any(String), + expect.any(Object), + ); + }); + }); + + describe("DM pairing behavior vs allowFrom", () => { + it("allows DM from sender in allowFrom list", async () => { + const account = createMockAccount({ + dmPolicy: "allowlist", + allowFrom: ["+15551234567"], + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from allowed sender", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + + // Wait for async processing + await flushAsync(); + + expect(res.statusCode).toBe(200); + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks DM from sender not in allowFrom when dmPolicy=allowlist", async () => { + const account = createMockAccount({ + dmPolicy: "allowlist", + allowFrom: ["+15559999999"], // Different number + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from blocked sender", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(res.statusCode).toBe(200); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("triggers pairing flow for unknown sender when dmPolicy=pairing", async () => { + // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty + // allowlist that doesn't include the sender + const account = createMockAccount({ + dmPolicy: "pairing", + allowFrom: ["+15559999999"], // Different number than sender + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockUpsertPairingRequest).toHaveBeenCalled(); + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("does not resend pairing reply when request already exists", async () => { + mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: false }); + + // Note: empty allowFrom = allow all. To trigger pairing, we need a non-empty + // allowlist that doesn't include the sender + const account = createMockAccount({ + dmPolicy: "pairing", + allowFrom: ["+15559999999"], // Different number than sender + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello again", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-2", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockUpsertPairingRequest).toHaveBeenCalled(); + // Should not send pairing reply since created=false + const { sendMessageBlueBubbles } = await import("./send.js"); + expect(sendMessageBlueBubbles).not.toHaveBeenCalled(); + }); + + it("allows all DMs when dmPolicy=open", async () => { + const account = createMockAccount({ + dmPolicy: "open", + allowFrom: [], + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from anyone", + handle: { address: "+15559999999" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks all DMs when dmPolicy=disabled", async () => { + const account = createMockAccount({ + dmPolicy: "disabled", + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + }); + + describe("group message gating", () => { + it("allows group messages when groupPolicy=open and no allowlist", async () => { + const account = createMockAccount({ + groupPolicy: "open", + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks group messages when groupPolicy=disabled", async () => { + const account = createMockAccount({ + groupPolicy: "disabled", + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("treats chat_guid groups as group even when isGroup=false", async () => { + const account = createMockAccount({ + groupPolicy: "allowlist", + dmPolicy: "open", + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from group", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("allows group messages from allowed chat_guid in groupAllowFrom", async () => { + const account = createMockAccount({ + groupPolicy: "allowlist", + groupAllowFrom: ["chat_guid:iMessage;+;chat123456"], + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello from allowed group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + }); + + describe("mention gating (group messages)", () => { + it("processes group message when mentioned and requireMention=true", async () => { + mockResolveRequireMention.mockReturnValue(true); + mockMatchesMentionPatterns.mockReturnValue(true); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "bert, can you help me?", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.WasMentioned).toBe(true); + }); + + it("skips group message when not mentioned and requireMention=true", async () => { + mockResolveRequireMention.mockReturnValue(true); + mockMatchesMentionPatterns.mockReturnValue(false); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello everyone", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + + it("processes group message without mention when requireMention=false", async () => { + mockResolveRequireMention.mockReturnValue(false); + + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello everyone", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + }); + + describe("group metadata", () => { + it("includes group subject + members in ctx", async () => { + const account = createMockAccount({ groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello group", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + chatName: "Family", + participants: [ + { address: "+15551234567", displayName: "Alice" }, + { address: "+15557654321", displayName: "Bob" }, + ], + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.GroupSubject).toBe("Family"); + expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)"); + }); + }); + + describe("inbound debouncing", () => { + it("coalesces text-only then attachment webhook events by messageId", async () => { + vi.useFakeTimers(); + try { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + + // Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce. + core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => { + type Item = any; + const buckets = new Map< + string, + { items: Item[]; timer: ReturnType | null } + >(); + + const flush = async (key: string) => { + const bucket = buckets.get(key); + if (!bucket) { + return; + } + if (bucket.timer) { + clearTimeout(bucket.timer); + bucket.timer = null; + } + const items = bucket.items; + bucket.items = []; + if (items.length > 0) { + try { + await params.onFlush(items); + } catch (err) { + params.onError?.(err); + throw err; + } + } + }; + + return { + enqueue: async (item: Item) => { + if (params.shouldDebounce && !params.shouldDebounce(item)) { + await params.onFlush([item]); + return; + } + + const key = params.buildKey(item); + const existing = buckets.get(key); + const bucket = existing ?? { items: [], timer: null }; + bucket.items.push(item); + if (bucket.timer) { + clearTimeout(bucket.timer); + } + bucket.timer = setTimeout(async () => { + await flush(key); + }, params.debounceMs); + buckets.set(key, bucket); + }, + flushKey: vi.fn(async (key: string) => { + await flush(key); + }), + }; + }) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"]; + + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const messageId = "race-msg-1"; + const chatGuid = "iMessage;-;+15551234567"; + + const payloadA = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: messageId, + chatGuid, + date: Date.now(), + }, + }; + + const payloadB = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: messageId, + chatGuid, + attachments: [ + { + guid: "att-1", + mimeType: "image/jpeg", + totalBytes: 1024, + }, + ], + date: Date.now(), + }, + }; + + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", payloadA), + createMockResponse(), + ); + + // Simulate the real-world delay where the attachment-bearing webhook arrives shortly after. + await vi.advanceTimersByTimeAsync(300); + + await handleBlueBubblesWebhookRequest( + createMockRequest("POST", "/bluebubbles-webhook", payloadB), + createMockResponse(), + ); + + // Not flushed yet; still within the debounce window. + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + + // After the debounce window, the combined message should be processed exactly once. + await vi.advanceTimersByTimeAsync(600); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]); + expect(callArgs.ctx.Body).toContain("hello"); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("reply metadata", () => { + it("surfaces reply fields in ctx when provided", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "replying now", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + replyTo: { + guid: "msg-0", + text: "original message", + handle: { address: "+15550000000", displayName: "Alice" }, + }, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // ReplyToId is the full UUID since it wasn't previously cached + expect(callArgs.ctx.ReplyToId).toBe("msg-0"); + expect(callArgs.ctx.ReplyToBody).toBe("original message"); + expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); + // Body uses inline [[reply_to:N]] tag format + expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]"); + }); + + it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "replying now", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + replyTo: { + guid: "p:1/msg-0", + text: "original message", + handle: { address: "+15550000000", displayName: "Alice" }, + }, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0"); + expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0"); + expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]"); + }); + + it("hydrates missing reply sender/body from the recent-message cache", async () => { + const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const chatGuid = "iMessage;+;chat-reply-cache"; + + const originalPayload = { + type: "new-message", + data: { + text: "original message (cached)", + handle: { address: "+15550000000" }, + isGroup: true, + isFromMe: false, + guid: "cache-msg-0", + chatGuid, + date: Date.now(), + }, + }; + + const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload); + const originalRes = createMockResponse(); + + await handleBlueBubblesWebhookRequest(originalReq, originalRes); + await flushAsync(); + + // Only assert the reply message behavior below. + mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); + + const replyPayload = { + type: "new-message", + data: { + text: "replying now", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "cache-msg-1", + chatGuid, + // Only the GUID is provided; sender/body must be hydrated. + replyToMessageGuid: "cache-msg-0", + date: Date.now(), + }, + }; + + const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload); + const replyRes = createMockResponse(); + + await handleBlueBubblesWebhookRequest(replyReq, replyRes); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // ReplyToId uses short ID "1" (first cached message) for token savings + expect(callArgs.ctx.ReplyToId).toBe("1"); + expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0"); + expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)"); + expect(callArgs.ctx.ReplyToSender).toBe("+15550000000"); + // Body uses inline [[reply_to:N]] tag format with short ID + expect(callArgs.ctx.Body).toContain("[[reply_to:1]]"); + }); + + it("falls back to threadOriginatorGuid when reply metadata is absent", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "replying now", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + threadOriginatorGuid: "msg-0", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.ReplyToId).toBe("msg-0"); + }); + }); + + describe("tapback text parsing", () => { + it("does not rewrite tapback-like text without metadata", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "Loved this idea", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.RawBody).toBe("Loved this idea"); + expect(callArgs.ctx.Body).toContain("Loved this idea"); + expect(callArgs.ctx.Body).not.toContain("reacted with"); + }); + + it("parses tapback text with custom emoji when metadata is present", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: 'Reacted 😅 to "nice one"', + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-2", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + expect(callArgs.ctx.RawBody).toBe("reacted with 😅"); + expect(callArgs.ctx.Body).toContain("reacted with 😅"); + expect(callArgs.ctx.Body).not.toContain("[[reply_to:"); + }); + }); + + describe("ack reactions", () => { + it("sends ack reaction when configured", async () => { + const { sendBlueBubblesReaction } = await import("./reactions.js"); + vi.mocked(sendBlueBubblesReaction).mockClear(); + + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = { + messages: { + ackReaction: "❤️", + ackReactionScope: "direct", + }, + }; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(sendBlueBubblesReaction).toHaveBeenCalledWith( + expect.objectContaining({ + chatGuid: "iMessage;-;+15551234567", + messageGuid: "msg-1", + emoji: "❤️", + opts: expect.objectContaining({ accountId: "default" }), + }), + ); + }); + }); + + describe("command gating", () => { + it("allows control command to bypass mention gating when authorized", async () => { + mockResolveRequireMention.mockReturnValue(true); + mockMatchesMentionPatterns.mockReturnValue(false); // Not mentioned + mockHasControlCommand.mockReturnValue(true); // Has control command + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true); // Authorized + + const account = createMockAccount({ + groupPolicy: "open", + allowFrom: ["+15551234567"], + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "/status", + handle: { address: "+15551234567" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + // Should process even without mention because it's an authorized control command + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + }); + + it("blocks control command from unauthorized sender in group", async () => { + mockHasControlCommand.mockReturnValue(true); + mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false); + + const account = createMockAccount({ + groupPolicy: "open", + allowFrom: [], // No one authorized + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "/status", + handle: { address: "+15559999999" }, + isGroup: true, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;+;chat123456", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + }); + + describe("typing/read receipt toggles", () => { + it("marks chat as read when sendReadReceipts=true (default)", async () => { + const { markBlueBubblesChatRead } = await import("./chat.js"); + vi.mocked(markBlueBubblesChatRead).mockClear(); + + const account = createMockAccount({ + sendReadReceipts: true, + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(markBlueBubblesChatRead).toHaveBeenCalled(); + }); + + it("does not mark chat as read when sendReadReceipts=false", async () => { + const { markBlueBubblesChatRead } = await import("./chat.js"); + vi.mocked(markBlueBubblesChatRead).mockClear(); + + const account = createMockAccount({ + sendReadReceipts: false, + }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); + }); + + it("sends typing indicator when processing message", async () => { + const { sendBlueBubblesTyping } = await import("./chat.js"); + vi.mocked(sendBlueBubblesTyping).mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + await params.dispatcherOptions.onReplyStart?.(); + }); + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + // Should call typing start when reply flow triggers it. + expect(sendBlueBubblesTyping).toHaveBeenCalledWith( + expect.any(String), + true, + expect.any(Object), + ); + }); + + it("stops typing on idle", async () => { + const { sendBlueBubblesTyping } = await import("./chat.js"); + vi.mocked(sendBlueBubblesTyping).mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + await params.dispatcherOptions.onReplyStart?.(); + await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); + await params.dispatcherOptions.onIdle?.(); + }); + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(sendBlueBubblesTyping).toHaveBeenCalledWith( + expect.any(String), + false, + expect.any(Object), + ); + }); + + it("stops typing when no reply is sent", async () => { + const { sendBlueBubblesTyping } = await import("./chat.js"); + vi.mocked(sendBlueBubblesTyping).mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined); + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(sendBlueBubblesTyping).toHaveBeenCalledWith( + expect.any(String), + false, + expect.any(Object), + ); + }); + }); + + describe("outbound message ids", () => { + it("enqueues system event for outbound message id", async () => { + mockEnqueueSystemEvent.mockClear(); + + mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => { + await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" }); + }); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "msg-1", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + 'Assistant sent "replying now" [message_id:2]', + expect.objectContaining({ + sessionKey: "agent:main:bluebubbles:dm:+15551234567", + }), + ); + }); + }); + + describe("reaction events", () => { + it("enqueues system event for reaction added", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "message-reaction", + data: { + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + associatedMessageGuid: "msg-original-123", + associatedMessageType: 2000, // Heart reaction added + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("reacted with ❤️ [[reply_to:"), + expect.any(Object), + ); + }); + + it("enqueues system event for reaction removed", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "message-reaction", + data: { + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + associatedMessageGuid: "msg-original-123", + associatedMessageType: 3000, // Heart reaction removed + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("removed ❤️ reaction [[reply_to:"), + expect.any(Object), + ); + }); + + it("ignores reaction from self (fromMe=true)", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "message-reaction", + data: { + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: true, // From self + associatedMessageGuid: "msg-original-123", + associatedMessageType: 2000, + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("maps reaction types to correct emojis", async () => { + mockEnqueueSystemEvent.mockClear(); + + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + // Test thumbs up reaction (2001) + const payload = { + type: "message-reaction", + data: { + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + associatedMessageGuid: "msg-123", + associatedMessageType: 2001, // Thumbs up + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( + expect.stringContaining("👍"), + expect.any(Object), + ); + }); + }); + + describe("short message ID mapping", () => { + it("assigns sequential short IDs to messages", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "p:1/msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); + const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; + // MessageSid should be short ID "1" instead of full UUID + expect(callArgs.ctx.MessageSid).toBe("1"); + expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345"); + }); + + it("resolves short ID back to UUID", async () => { + const account = createMockAccount({ dmPolicy: "open" }); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "hello", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: false, + guid: "p:1/msg-uuid-12345", + chatGuid: "iMessage;-;+15551234567", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + // The short ID "1" should resolve back to the full UUID + expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345"); + }); + + it("returns UUID unchanged when not in cache", () => { + expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached"); + }); + + it("returns short ID unchanged when numeric but not in cache", () => { + expect(resolveBlueBubblesMessageId("999")).toBe("999"); + }); + + it("throws when numeric short ID is missing and requireKnownShortId is set", () => { + expect(() => resolveBlueBubblesMessageId("999", { requireKnownShortId: true })).toThrow( + /short message id/i, + ); + }); + }); + + describe("fromMe messages", () => { + it("ignores messages from self (fromMe=true)", async () => { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + const payload = { + type: "new-message", + data: { + text: "my own message", + handle: { address: "+15551234567" }, + isGroup: false, + isFromMe: true, + guid: "msg-1", + date: Date.now(), + }, + }; + + const req = createMockRequest("POST", "/bluebubbles-webhook", payload); + const res = createMockResponse(); + + await handleBlueBubblesWebhookRequest(req, res); + await flushAsync(); + + expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts new file mode 100644 index 0000000000000000000000000000000000000000..00a5f36e24eb3c4f8f367b953a3ea8df9049be9d --- /dev/null +++ b/extensions/bluebubbles/src/monitor.ts @@ -0,0 +1,2469 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { + logAckFailure, + logInboundDrop, + logTypingFailure, + resolveAckReaction, + resolveControlCommandGate, +} from "openclaw/plugin-sdk"; +import type { ResolvedBlueBubblesAccount } from "./accounts.js"; +import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js"; +import { downloadBlueBubblesAttachment } from "./attachments.js"; +import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; +import { sendBlueBubblesMedia } from "./media-send.js"; +import { fetchBlueBubblesServerInfo } from "./probe.js"; +import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js"; +import { getBlueBubblesRuntime } from "./runtime.js"; +import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js"; +import { + formatBlueBubblesChatTarget, + isAllowedBlueBubblesSender, + normalizeBlueBubblesHandle, +} from "./targets.js"; + +export type BlueBubblesRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type BlueBubblesMonitorOptions = { + account: ResolvedBlueBubblesAccount; + config: OpenClawConfig; + runtime: BlueBubblesRuntimeEnv; + abortSignal: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + webhookPath?: string; +}; + +const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; +const DEFAULT_TEXT_LIMIT = 4000; +const invalidAckReactions = new Set(); + +const REPLY_CACHE_MAX = 2000; +const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000; + +type BlueBubblesReplyCacheEntry = { + accountId: string; + messageId: string; + shortId: string; + chatGuid?: string; + chatIdentifier?: string; + chatId?: number; + senderLabel?: string; + body?: string; + timestamp: number; +}; + +// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body. +const blueBubblesReplyCacheByMessageId = new Map(); + +// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization) +const blueBubblesShortIdToUuid = new Map(); +const blueBubblesUuidToShortId = new Map(); +let blueBubblesShortIdCounter = 0; + +function trimOrUndefined(value?: string | null): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function generateShortId(): string { + blueBubblesShortIdCounter += 1; + return String(blueBubblesShortIdCounter); +} + +function rememberBlueBubblesReplyCache( + entry: Omit, +): BlueBubblesReplyCacheEntry { + const messageId = entry.messageId.trim(); + if (!messageId) { + return { ...entry, shortId: "" }; + } + + // Check if we already have a short ID for this GUID + let shortId = blueBubblesUuidToShortId.get(messageId); + if (!shortId) { + shortId = generateShortId(); + blueBubblesShortIdToUuid.set(shortId, messageId); + blueBubblesUuidToShortId.set(messageId, shortId); + } + + const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId }; + + // Refresh insertion order. + blueBubblesReplyCacheByMessageId.delete(messageId); + blueBubblesReplyCacheByMessageId.set(messageId, fullEntry); + + // Opportunistic prune. + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + for (const [key, value] of blueBubblesReplyCacheByMessageId) { + if (value.timestamp < cutoff) { + blueBubblesReplyCacheByMessageId.delete(key); + // Clean up short ID mappings for expired entries + if (value.shortId) { + blueBubblesShortIdToUuid.delete(value.shortId); + blueBubblesUuidToShortId.delete(key); + } + continue; + } + break; + } + while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) { + const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined; + if (!oldest) { + break; + } + const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest); + blueBubblesReplyCacheByMessageId.delete(oldest); + // Clean up short ID mappings for evicted entries + if (oldEntry?.shortId) { + blueBubblesShortIdToUuid.delete(oldEntry.shortId); + blueBubblesUuidToShortId.delete(oldest); + } + } + + return fullEntry; +} + +/** + * Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID. + * Returns the input unchanged if it's already a GUID or not found in the mapping. + */ +export function resolveBlueBubblesMessageId( + shortOrUuid: string, + opts?: { requireKnownShortId?: boolean }, +): string { + const trimmed = shortOrUuid.trim(); + if (!trimmed) { + return trimmed; + } + + // If it looks like a short ID (numeric), try to resolve it + if (/^\d+$/.test(trimmed)) { + const uuid = blueBubblesShortIdToUuid.get(trimmed); + if (uuid) { + return uuid; + } + if (opts?.requireKnownShortId) { + throw new Error( + `BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`, + ); + } + } + + // Return as-is (either already a UUID or not found) + return trimmed; +} + +/** + * Resets the short ID state. Only use in tests. + * @internal + */ +export function _resetBlueBubblesShortIdState(): void { + blueBubblesShortIdToUuid.clear(); + blueBubblesUuidToShortId.clear(); + blueBubblesReplyCacheByMessageId.clear(); + blueBubblesShortIdCounter = 0; +} + +/** + * Gets the short ID for a message GUID, if one exists. + */ +function getShortIdForUuid(uuid: string): string | undefined { + return blueBubblesUuidToShortId.get(uuid.trim()); +} + +function resolveReplyContextFromCache(params: { + accountId: string; + replyToId: string; + chatGuid?: string; + chatIdentifier?: string; + chatId?: number; +}): BlueBubblesReplyCacheEntry | null { + const replyToId = params.replyToId.trim(); + if (!replyToId) { + return null; + } + + const cached = blueBubblesReplyCacheByMessageId.get(replyToId); + if (!cached) { + return null; + } + if (cached.accountId !== params.accountId) { + return null; + } + + const cutoff = Date.now() - REPLY_CACHE_TTL_MS; + if (cached.timestamp < cutoff) { + blueBubblesReplyCacheByMessageId.delete(replyToId); + return null; + } + + const chatGuid = trimOrUndefined(params.chatGuid); + const chatIdentifier = trimOrUndefined(params.chatIdentifier); + const cachedChatGuid = trimOrUndefined(cached.chatGuid); + const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier); + const chatId = typeof params.chatId === "number" ? params.chatId : undefined; + const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined; + + // Avoid cross-chat collisions if we have identifiers. + if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) { + return null; + } + if ( + !chatGuid && + chatIdentifier && + cachedChatIdentifier && + chatIdentifier !== cachedChatIdentifier + ) { + return null; + } + if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) { + return null; + } + + return cached; +} + +type BlueBubblesCoreRuntime = ReturnType; + +function logVerbose( + core: BlueBubblesCoreRuntime, + runtime: BlueBubblesRuntimeEnv, + message: string, +): void { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[bluebubbles] ${message}`); + } +} + +function logGroupAllowlistHint(params: { + runtime: BlueBubblesRuntimeEnv; + reason: string; + entry: string | null; + chatName?: string; + accountId?: string; +}): void { + const log = params.runtime.log ?? console.log; + const nameHint = params.chatName ? ` (group name: ${params.chatName})` : ""; + const accountHint = params.accountId + ? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)` + : ""; + if (params.entry) { + log( + `[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` + + `"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`, + ); + log( + `[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`, + ); + return; + } + log( + `[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` + + `channels.bluebubbles.groupPolicy="open" or adding a group id to ` + + `channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`, + ); +} + +type WebhookTarget = { + account: ResolvedBlueBubblesAccount; + config: OpenClawConfig; + runtime: BlueBubblesRuntimeEnv; + core: BlueBubblesCoreRuntime; + path: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +/** + * Entry type for debouncing inbound messages. + * Captures the normalized message and its target for later combined processing. + */ +type BlueBubblesDebounceEntry = { + message: NormalizedWebhookMessage; + target: WebhookTarget; +}; + +/** + * Default debounce window for inbound message coalescing (ms). + * This helps combine URL text + link preview balloon messages that BlueBubbles + * sends as separate webhook events when no explicit inbound debounce config exists. + */ +const DEFAULT_INBOUND_DEBOUNCE_MS = 500; + +/** + * Combines multiple debounced messages into a single message for processing. + * Used when multiple webhook events arrive within the debounce window. + */ +function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage { + if (entries.length === 0) { + throw new Error("Cannot combine empty entries"); + } + if (entries.length === 1) { + return entries[0].message; + } + + // Use the first message as the base (typically the text message) + const first = entries[0].message; + + // Combine text from all entries, filtering out duplicates and empty strings + const seenTexts = new Set(); + const textParts: string[] = []; + + for (const entry of entries) { + const text = entry.message.text.trim(); + if (!text) { + continue; + } + // Skip duplicate text (URL might be in both text message and balloon) + const normalizedText = text.toLowerCase(); + if (seenTexts.has(normalizedText)) { + continue; + } + seenTexts.add(normalizedText); + textParts.push(text); + } + + // Merge attachments from all entries + const allAttachments = entries.flatMap((e) => e.message.attachments ?? []); + + // Use the latest timestamp + const timestamps = entries + .map((e) => e.message.timestamp) + .filter((t): t is number => typeof t === "number"); + const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp; + + // Collect all message IDs for reference + const messageIds = entries + .map((e) => e.message.messageId) + .filter((id): id is string => Boolean(id)); + + // Prefer reply context from any entry that has it + const entryWithReply = entries.find((e) => e.message.replyToId); + + return { + ...first, + text: textParts.join(" "), + attachments: allAttachments.length > 0 ? allAttachments : first.attachments, + timestamp: latestTimestamp, + // Use first message's ID as primary (for reply reference), but we've coalesced others + messageId: messageIds[0] ?? first.messageId, + // Preserve reply context if present + replyToId: entryWithReply?.message.replyToId ?? first.replyToId, + replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody, + replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender, + // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon) + balloonBundleId: undefined, + }; +} + +const webhookTargets = new Map(); + +/** + * Maps webhook targets to their inbound debouncers. + * Each target gets its own debouncer keyed by a unique identifier. + */ +const targetDebouncers = new Map< + WebhookTarget, + ReturnType +>(); + +function resolveBlueBubblesDebounceMs( + config: OpenClawConfig, + core: BlueBubblesCoreRuntime, +): number { + const inbound = config.messages?.inbound; + const hasExplicitDebounce = + typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number"; + if (!hasExplicitDebounce) { + return DEFAULT_INBOUND_DEBOUNCE_MS; + } + return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" }); +} + +/** + * Creates or retrieves a debouncer for a webhook target. + */ +function getOrCreateDebouncer(target: WebhookTarget) { + const existing = targetDebouncers.get(target); + if (existing) { + return existing; + } + + const { account, config, runtime, core } = target; + + const debouncer = core.channel.debounce.createInboundDebouncer({ + debounceMs: resolveBlueBubblesDebounceMs(config, core), + buildKey: (entry) => { + const msg = entry.message; + // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the + // same message (e.g., text-only then text+attachment). + // + // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different + // messageId than the originating text. When present, key by associatedMessageGuid + // to keep text + balloon coalescing working. + const balloonBundleId = msg.balloonBundleId?.trim(); + const associatedMessageGuid = msg.associatedMessageGuid?.trim(); + if (balloonBundleId && associatedMessageGuid) { + return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`; + } + + const messageId = msg.messageId?.trim(); + if (messageId) { + return `bluebubbles:${account.accountId}:msg:${messageId}`; + } + + const chatKey = + msg.chatGuid?.trim() ?? + msg.chatIdentifier?.trim() ?? + (msg.chatId ? String(msg.chatId) : "dm"); + return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`; + }, + shouldDebounce: (entry) => { + const msg = entry.message; + // Skip debouncing for from-me messages (they're just cached, not processed) + if (msg.fromMe) { + return false; + } + // Skip debouncing for control commands - process immediately + if (core.channel.text.hasControlCommand(msg.text, config)) { + return false; + } + // Debounce all other messages to coalesce rapid-fire webhook events + // (e.g., text+image arriving as separate webhooks for the same messageId) + return true; + }, + onFlush: async (entries) => { + if (entries.length === 0) { + return; + } + + // Use target from first entry (all entries have same target due to key structure) + const flushTarget = entries[0].target; + + if (entries.length === 1) { + // Single message - process normally + await processMessage(entries[0].message, flushTarget); + return; + } + + // Multiple messages - combine and process + const combined = combineDebounceEntries(entries); + + if (core.logging.shouldLogVerbose()) { + const count = entries.length; + const preview = combined.text.slice(0, 50); + runtime.log?.( + `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`, + ); + } + + await processMessage(combined, flushTarget); + }, + onError: (err) => { + runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`); + }, + }); + + targetDebouncers.set(target, debouncer); + return debouncer; +} + +/** + * Removes a debouncer for a target (called during unregistration). + */ +function removeDebouncer(target: WebhookTarget): void { + targetDebouncers.delete(target); +} + +function normalizeWebhookPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return "/"; + } + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withSlash.length > 1 && withSlash.endsWith("/")) { + return withSlash.slice(0, -1); + } + return withSlash; +} + +export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void { + const key = normalizeWebhookPath(target.path); + const normalizedTarget = { ...target, path: key }; + const existing = webhookTargets.get(key) ?? []; + const next = [...existing, normalizedTarget]; + webhookTargets.set(key, next); + return () => { + const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); + if (updated.length > 0) { + webhookTargets.set(key, updated); + } else { + webhookTargets.delete(key); + } + // Clean up debouncer when target is unregistered + removeDebouncer(normalizedTarget); + }; +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks: Buffer[] = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({ ok: false, error: "empty payload" }); + return; + } + try { + resolve({ ok: true, value: JSON.parse(raw) as unknown }); + return; + } catch { + const params = new URLSearchParams(raw); + const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); + if (payload) { + resolve({ ok: true, value: JSON.parse(payload) as unknown }); + return; + } + throw new Error("invalid json"); + } + } catch (err) { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readString(record: Record | null, key: string): string | undefined { + if (!record) { + return undefined; + } + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +function readNumber(record: Record | null, key: string): number | undefined { + if (!record) { + return undefined; + } + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readBoolean(record: Record | null, key: string): boolean | undefined { + if (!record) { + return undefined; + } + const value = record[key]; + return typeof value === "boolean" ? value : undefined; +} + +function extractAttachments(message: Record): BlueBubblesAttachment[] { + const raw = message["attachments"]; + if (!Array.isArray(raw)) { + return []; + } + const out: BlueBubblesAttachment[] = []; + for (const entry of raw) { + const record = asRecord(entry); + if (!record) { + continue; + } + out.push({ + guid: readString(record, "guid"), + uti: readString(record, "uti"), + mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"), + transferName: readString(record, "transferName") ?? readString(record, "transfer_name"), + totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"), + height: readNumberLike(record, "height"), + width: readNumberLike(record, "width"), + originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"), + }); + } + return out; +} + +function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string { + if (attachments.length === 0) { + return ""; + } + const mimeTypes = attachments.map((entry) => entry.mimeType ?? ""); + const allImages = mimeTypes.every((entry) => entry.startsWith("image/")); + const allVideos = mimeTypes.every((entry) => entry.startsWith("video/")); + const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/")); + const tag = allImages + ? "" + : allVideos + ? "" + : allAudio + ? "" + : ""; + const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file"; + const suffix = attachments.length === 1 ? label : `${label}s`; + return `${tag} (${attachments.length} ${suffix})`; +} + +function buildMessagePlaceholder(message: NormalizedWebhookMessage): string { + const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []); + if (attachmentPlaceholder) { + return attachmentPlaceholder; + } + if (message.balloonBundleId) { + return ""; + } + return ""; +} + +// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body +function formatReplyTag(message: { replyToId?: string; replyToShortId?: string }): string | null { + // Prefer short ID + const rawId = message.replyToShortId || message.replyToId; + if (!rawId) { + return null; + } + return `[[reply_to:${rawId}]]`; +} + +function readNumberLike(record: Record | null, key: string): number | undefined { + if (!record) { + return undefined; + } + const value = record[key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +function extractReplyMetadata(message: Record): { + replyToId?: string; + replyToBody?: string; + replyToSender?: string; +} { + const replyRaw = + message["replyTo"] ?? + message["reply_to"] ?? + message["replyToMessage"] ?? + message["reply_to_message"] ?? + message["repliedMessage"] ?? + message["quotedMessage"] ?? + message["associatedMessage"] ?? + message["reply"]; + const replyRecord = asRecord(replyRaw); + const replyHandle = + asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null; + const replySenderRaw = + readString(replyHandle, "address") ?? + readString(replyHandle, "handle") ?? + readString(replyHandle, "id") ?? + readString(replyRecord, "senderId") ?? + readString(replyRecord, "sender") ?? + readString(replyRecord, "from"); + const normalizedSender = replySenderRaw + ? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim() + : undefined; + + const replyToBody = + readString(replyRecord, "text") ?? + readString(replyRecord, "body") ?? + readString(replyRecord, "message") ?? + readString(replyRecord, "subject") ?? + undefined; + + const directReplyId = + readString(message, "replyToMessageGuid") ?? + readString(message, "replyToGuid") ?? + readString(message, "replyGuid") ?? + readString(message, "selectedMessageGuid") ?? + readString(message, "selectedMessageId") ?? + readString(message, "replyToMessageId") ?? + readString(message, "replyId") ?? + readString(replyRecord, "guid") ?? + readString(replyRecord, "id") ?? + readString(replyRecord, "messageId"); + + const associatedType = + readNumberLike(message, "associatedMessageType") ?? + readNumberLike(message, "associated_message_type"); + const associatedGuid = + readString(message, "associatedMessageGuid") ?? + readString(message, "associated_message_guid") ?? + readString(message, "associatedMessageId"); + const isReactionAssociation = + typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType); + + const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined); + const threadOriginatorGuid = readString(message, "threadOriginatorGuid"); + const messageGuid = readString(message, "guid"); + const fallbackReplyId = + !replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid + ? threadOriginatorGuid + : undefined; + + return { + replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined, + replyToBody: replyToBody?.trim() || undefined, + replyToSender: normalizedSender || undefined, + }; +} + +function readFirstChatRecord(message: Record): Record | null { + const chats = message["chats"]; + if (!Array.isArray(chats) || chats.length === 0) { + return null; + } + const first = chats[0]; + return asRecord(first); +} + +function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null { + if (typeof entry === "string" || typeof entry === "number") { + const raw = String(entry).trim(); + if (!raw) { + return null; + } + const normalized = normalizeBlueBubblesHandle(raw) || raw; + return normalized ? { id: normalized } : null; + } + const record = asRecord(entry); + if (!record) { + return null; + } + const nestedHandle = + asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null; + const idRaw = + readString(record, "address") ?? + readString(record, "handle") ?? + readString(record, "id") ?? + readString(record, "phoneNumber") ?? + readString(record, "phone_number") ?? + readString(record, "email") ?? + readString(nestedHandle, "address") ?? + readString(nestedHandle, "handle") ?? + readString(nestedHandle, "id"); + const nameRaw = + readString(record, "displayName") ?? + readString(record, "name") ?? + readString(record, "title") ?? + readString(nestedHandle, "displayName") ?? + readString(nestedHandle, "name"); + const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : ""; + if (!normalizedId) { + return null; + } + const name = nameRaw?.trim() || undefined; + return { id: normalizedId, name }; +} + +function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] { + if (!Array.isArray(raw) || raw.length === 0) { + return []; + } + const seen = new Set(); + const output: BlueBubblesParticipant[] = []; + for (const entry of raw) { + const normalized = normalizeParticipantEntry(entry); + if (!normalized?.id) { + continue; + } + const key = normalized.id.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} + +function formatGroupMembers(params: { + participants?: BlueBubblesParticipant[]; + fallback?: BlueBubblesParticipant; +}): string | undefined { + const seen = new Set(); + const ordered: BlueBubblesParticipant[] = []; + for (const entry of params.participants ?? []) { + if (!entry?.id) { + continue; + } + const key = entry.id.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + ordered.push(entry); + } + if (ordered.length === 0 && params.fallback?.id) { + ordered.push(params.fallback); + } + if (ordered.length === 0) { + return undefined; + } + return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", "); +} + +function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined { + const guid = chatGuid?.trim(); + if (!guid) { + return undefined; + } + const parts = guid.split(";"); + if (parts.length >= 3) { + if (parts[1] === "+") { + return true; + } + if (parts[1] === "-") { + return false; + } + } + if (guid.includes(";+;")) { + return true; + } + if (guid.includes(";-;")) { + return false; + } + return undefined; +} + +function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined { + const guid = chatGuid?.trim(); + if (!guid) { + return undefined; + } + const parts = guid.split(";"); + if (parts.length < 3) { + return undefined; + } + const identifier = parts[2]?.trim(); + return identifier || undefined; +} + +function formatGroupAllowlistEntry(params: { + chatGuid?: string; + chatId?: number; + chatIdentifier?: string; +}): string | null { + const guid = params.chatGuid?.trim(); + if (guid) { + return `chat_guid:${guid}`; + } + const chatId = params.chatId; + if (typeof chatId === "number" && Number.isFinite(chatId)) { + return `chat_id:${chatId}`; + } + const identifier = params.chatIdentifier?.trim(); + if (identifier) { + return `chat_identifier:${identifier}`; + } + return null; +} + +type BlueBubblesParticipant = { + id: string; + name?: string; +}; + +type NormalizedWebhookMessage = { + text: string; + senderId: string; + senderName?: string; + messageId?: string; + timestamp?: number; + isGroup: boolean; + chatId?: number; + chatGuid?: string; + chatIdentifier?: string; + chatName?: string; + fromMe?: boolean; + attachments?: BlueBubblesAttachment[]; + balloonBundleId?: string; + associatedMessageGuid?: string; + associatedMessageType?: number; + associatedMessageEmoji?: string; + isTapback?: boolean; + participants?: BlueBubblesParticipant[]; + replyToId?: string; + replyToBody?: string; + replyToSender?: string; +}; + +type NormalizedWebhookReaction = { + action: "added" | "removed"; + emoji: string; + senderId: string; + senderName?: string; + messageId: string; + timestamp?: number; + isGroup: boolean; + chatId?: number; + chatGuid?: string; + chatIdentifier?: string; + chatName?: string; + fromMe?: boolean; +}; + +const REACTION_TYPE_MAP = new Map([ + [2000, { emoji: "❤️", action: "added" }], + [2001, { emoji: "👍", action: "added" }], + [2002, { emoji: "👎", action: "added" }], + [2003, { emoji: "😂", action: "added" }], + [2004, { emoji: "‼️", action: "added" }], + [2005, { emoji: "❓", action: "added" }], + [3000, { emoji: "❤️", action: "removed" }], + [3001, { emoji: "👍", action: "removed" }], + [3002, { emoji: "👎", action: "removed" }], + [3003, { emoji: "😂", action: "removed" }], + [3004, { emoji: "‼️", action: "removed" }], + [3005, { emoji: "❓", action: "removed" }], +]); + +// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action +const TAPBACK_TEXT_MAP = new Map([ + ["loved", { emoji: "❤️", action: "added" }], + ["liked", { emoji: "👍", action: "added" }], + ["disliked", { emoji: "👎", action: "added" }], + ["laughed at", { emoji: "😂", action: "added" }], + ["emphasized", { emoji: "‼️", action: "added" }], + ["questioned", { emoji: "❓", action: "added" }], + // Removal patterns (e.g., "Removed a heart from") + ["removed a heart from", { emoji: "❤️", action: "removed" }], + ["removed a like from", { emoji: "👍", action: "removed" }], + ["removed a dislike from", { emoji: "👎", action: "removed" }], + ["removed a laugh from", { emoji: "😂", action: "removed" }], + ["removed an emphasis from", { emoji: "‼️", action: "removed" }], + ["removed a question from", { emoji: "❓", action: "removed" }], +]); + +const TAPBACK_EMOJI_REGEX = + /(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u; + +function extractFirstEmoji(text: string): string | null { + const match = text.match(TAPBACK_EMOJI_REGEX); + return match ? match[0] : null; +} + +function extractQuotedTapbackText(text: string): string | null { + const match = text.match(/[“"]([^”"]+)[”"]/s); + return match ? match[1] : null; +} + +function isTapbackAssociatedType(type: number | undefined): boolean { + return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000; +} + +function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined { + if (typeof type !== "number" || !Number.isFinite(type)) { + return undefined; + } + if (type >= 3000 && type < 4000) { + return "removed"; + } + if (type >= 2000 && type < 3000) { + return "added"; + } + return undefined; +} + +function resolveTapbackContext(message: NormalizedWebhookMessage): { + emojiHint?: string; + actionHint?: "added" | "removed"; + replyToId?: string; +} | null { + const associatedType = message.associatedMessageType; + const hasTapbackType = isTapbackAssociatedType(associatedType); + const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback); + if (!hasTapbackType && !hasTapbackMarker) { + return null; + } + const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined; + const actionHint = resolveTapbackActionHint(associatedType); + const emojiHint = + message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji; + return { emojiHint, actionHint, replyToId }; +} + +// Detects tapback text patterns like 'Loved "message"' and converts to structured format +function parseTapbackText(params: { + text: string; + emojiHint?: string; + actionHint?: "added" | "removed"; + requireQuoted?: boolean; +}): { + emoji: string; + action: "added" | "removed"; + quotedText: string; +} | null { + const trimmed = params.text.trim(); + const lower = trimmed.toLowerCase(); + if (!trimmed) { + return null; + } + + for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { + if (lower.startsWith(pattern)) { + // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello") + const afterPattern = trimmed.slice(pattern.length).trim(); + if (params.requireQuoted) { + const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s); + if (!strictMatch) { + return null; + } + return { emoji, action, quotedText: strictMatch[1] }; + } + const quotedText = + extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern; + return { emoji, action, quotedText }; + } + } + + if (lower.startsWith("reacted")) { + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) { + return null; + } + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) { + return null; + } + const fallback = trimmed.slice("reacted".length).trim(); + return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; + } + + if (lower.startsWith("removed")) { + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) { + return null; + } + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) { + return null; + } + const fallback = trimmed.slice("removed".length).trim(); + return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; + } + return null; +} + +function maskSecret(value: string): string { + if (value.length <= 6) { + return "***"; + } + return `${value.slice(0, 2)}***${value.slice(-2)}`; +} + +function resolveBlueBubblesAckReaction(params: { + cfg: OpenClawConfig; + agentId: string; + core: BlueBubblesCoreRuntime; + runtime: BlueBubblesRuntimeEnv; +}): string | null { + const raw = resolveAckReaction(params.cfg, params.agentId).trim(); + if (!raw) { + return null; + } + try { + normalizeBlueBubblesReactionInput(raw); + return raw; + } catch { + const key = raw.toLowerCase(); + if (!invalidAckReactions.has(key)) { + invalidAckReactions.add(key); + logVerbose( + params.core, + params.runtime, + `ack reaction skipped (unsupported for BlueBubbles): ${raw}`, + ); + } + return null; + } +} + +function extractMessagePayload(payload: Record): Record | null { + const dataRaw = payload.data ?? payload.payload ?? payload.event; + const data = + asRecord(dataRaw) ?? + (typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null); + const messageRaw = payload.message ?? data?.message ?? data; + const message = + asRecord(messageRaw) ?? + (typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null); + if (!message) { + return null; + } + return message; +} + +function normalizeWebhookMessage( + payload: Record, +): NormalizedWebhookMessage | null { + const message = extractMessagePayload(payload); + if (!message) { + return null; + } + + const text = + readString(message, "text") ?? + readString(message, "body") ?? + readString(message, "subject") ?? + ""; + + const handleValue = message.handle ?? message.sender; + const handle = + asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null); + const senderId = + readString(handle, "address") ?? + readString(handle, "handle") ?? + readString(handle, "id") ?? + readString(message, "senderId") ?? + readString(message, "sender") ?? + readString(message, "from") ?? + ""; + + const senderName = + readString(handle, "displayName") ?? + readString(handle, "name") ?? + readString(message, "senderName") ?? + undefined; + + const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; + const chatFromList = readFirstChatRecord(message); + const chatGuid = + readString(message, "chatGuid") ?? + readString(message, "chat_guid") ?? + readString(chat, "chatGuid") ?? + readString(chat, "chat_guid") ?? + readString(chat, "guid") ?? + readString(chatFromList, "chatGuid") ?? + readString(chatFromList, "chat_guid") ?? + readString(chatFromList, "guid"); + const chatIdentifier = + readString(message, "chatIdentifier") ?? + readString(message, "chat_identifier") ?? + readString(chat, "chatIdentifier") ?? + readString(chat, "chat_identifier") ?? + readString(chat, "identifier") ?? + readString(chatFromList, "chatIdentifier") ?? + readString(chatFromList, "chat_identifier") ?? + readString(chatFromList, "identifier") ?? + extractChatIdentifierFromChatGuid(chatGuid); + const chatId = + readNumberLike(message, "chatId") ?? + readNumberLike(message, "chat_id") ?? + readNumberLike(chat, "chatId") ?? + readNumberLike(chat, "chat_id") ?? + readNumberLike(chat, "id") ?? + readNumberLike(chatFromList, "chatId") ?? + readNumberLike(chatFromList, "chat_id") ?? + readNumberLike(chatFromList, "id"); + const chatName = + readString(message, "chatName") ?? + readString(chat, "displayName") ?? + readString(chat, "name") ?? + readString(chatFromList, "displayName") ?? + readString(chatFromList, "name") ?? + undefined; + + const chatParticipants = chat ? chat["participants"] : undefined; + const messageParticipants = message["participants"]; + const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; + const participants = Array.isArray(chatParticipants) + ? chatParticipants + : Array.isArray(messageParticipants) + ? messageParticipants + : Array.isArray(chatsParticipants) + ? chatsParticipants + : []; + const normalizedParticipants = normalizeParticipantList(participants); + const participantsCount = participants.length; + const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); + const explicitIsGroup = + readBoolean(message, "isGroup") ?? + readBoolean(message, "is_group") ?? + readBoolean(chat, "isGroup") ?? + readBoolean(message, "group"); + const isGroup = + typeof groupFromChatGuid === "boolean" + ? groupFromChatGuid + : (explicitIsGroup ?? participantsCount > 2); + + const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); + const messageId = + readString(message, "guid") ?? + readString(message, "id") ?? + readString(message, "messageId") ?? + undefined; + const balloonBundleId = readString(message, "balloonBundleId"); + const associatedMessageGuid = + readString(message, "associatedMessageGuid") ?? + readString(message, "associated_message_guid") ?? + readString(message, "associatedMessageId") ?? + undefined; + const associatedMessageType = + readNumberLike(message, "associatedMessageType") ?? + readNumberLike(message, "associated_message_type"); + const associatedMessageEmoji = + readString(message, "associatedMessageEmoji") ?? + readString(message, "associated_message_emoji") ?? + readString(message, "reactionEmoji") ?? + readString(message, "reaction_emoji") ?? + undefined; + const isTapback = + readBoolean(message, "isTapback") ?? + readBoolean(message, "is_tapback") ?? + readBoolean(message, "tapback") ?? + undefined; + + const timestampRaw = + readNumber(message, "date") ?? + readNumber(message, "dateCreated") ?? + readNumber(message, "timestamp"); + const timestamp = + typeof timestampRaw === "number" + ? timestampRaw > 1_000_000_000_000 + ? timestampRaw + : timestampRaw * 1000 + : undefined; + + const normalizedSender = normalizeBlueBubblesHandle(senderId); + if (!normalizedSender) { + return null; + } + const replyMetadata = extractReplyMetadata(message); + + return { + text, + senderId: normalizedSender, + senderName, + messageId, + timestamp, + isGroup, + chatId, + chatGuid, + chatIdentifier, + chatName, + fromMe, + attachments: extractAttachments(message), + balloonBundleId, + associatedMessageGuid, + associatedMessageType, + associatedMessageEmoji, + isTapback, + participants: normalizedParticipants, + replyToId: replyMetadata.replyToId, + replyToBody: replyMetadata.replyToBody, + replyToSender: replyMetadata.replyToSender, + }; +} + +function normalizeWebhookReaction( + payload: Record, +): NormalizedWebhookReaction | null { + const message = extractMessagePayload(payload); + if (!message) { + return null; + } + + const associatedGuid = + readString(message, "associatedMessageGuid") ?? + readString(message, "associated_message_guid") ?? + readString(message, "associatedMessageId"); + const associatedType = + readNumberLike(message, "associatedMessageType") ?? + readNumberLike(message, "associated_message_type"); + if (!associatedGuid || associatedType === undefined) { + return null; + } + + const mapping = REACTION_TYPE_MAP.get(associatedType); + const associatedEmoji = + readString(message, "associatedMessageEmoji") ?? + readString(message, "associated_message_emoji") ?? + readString(message, "reactionEmoji") ?? + readString(message, "reaction_emoji"); + const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`; + const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added"; + + const handleValue = message.handle ?? message.sender; + const handle = + asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null); + const senderId = + readString(handle, "address") ?? + readString(handle, "handle") ?? + readString(handle, "id") ?? + readString(message, "senderId") ?? + readString(message, "sender") ?? + readString(message, "from") ?? + ""; + const senderName = + readString(handle, "displayName") ?? + readString(handle, "name") ?? + readString(message, "senderName") ?? + undefined; + + const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null; + const chatFromList = readFirstChatRecord(message); + const chatGuid = + readString(message, "chatGuid") ?? + readString(message, "chat_guid") ?? + readString(chat, "chatGuid") ?? + readString(chat, "chat_guid") ?? + readString(chat, "guid") ?? + readString(chatFromList, "chatGuid") ?? + readString(chatFromList, "chat_guid") ?? + readString(chatFromList, "guid"); + const chatIdentifier = + readString(message, "chatIdentifier") ?? + readString(message, "chat_identifier") ?? + readString(chat, "chatIdentifier") ?? + readString(chat, "chat_identifier") ?? + readString(chat, "identifier") ?? + readString(chatFromList, "chatIdentifier") ?? + readString(chatFromList, "chat_identifier") ?? + readString(chatFromList, "identifier") ?? + extractChatIdentifierFromChatGuid(chatGuid); + const chatId = + readNumberLike(message, "chatId") ?? + readNumberLike(message, "chat_id") ?? + readNumberLike(chat, "chatId") ?? + readNumberLike(chat, "chat_id") ?? + readNumberLike(chat, "id") ?? + readNumberLike(chatFromList, "chatId") ?? + readNumberLike(chatFromList, "chat_id") ?? + readNumberLike(chatFromList, "id"); + const chatName = + readString(message, "chatName") ?? + readString(chat, "displayName") ?? + readString(chat, "name") ?? + readString(chatFromList, "displayName") ?? + readString(chatFromList, "name") ?? + undefined; + + const chatParticipants = chat ? chat["participants"] : undefined; + const messageParticipants = message["participants"]; + const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined; + const participants = Array.isArray(chatParticipants) + ? chatParticipants + : Array.isArray(messageParticipants) + ? messageParticipants + : Array.isArray(chatsParticipants) + ? chatsParticipants + : []; + const participantsCount = participants.length; + const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid); + const explicitIsGroup = + readBoolean(message, "isGroup") ?? + readBoolean(message, "is_group") ?? + readBoolean(chat, "isGroup") ?? + readBoolean(message, "group"); + const isGroup = + typeof groupFromChatGuid === "boolean" + ? groupFromChatGuid + : (explicitIsGroup ?? participantsCount > 2); + + const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me"); + const timestampRaw = + readNumberLike(message, "date") ?? + readNumberLike(message, "dateCreated") ?? + readNumberLike(message, "timestamp"); + const timestamp = + typeof timestampRaw === "number" + ? timestampRaw > 1_000_000_000_000 + ? timestampRaw + : timestampRaw * 1000 + : undefined; + + const normalizedSender = normalizeBlueBubblesHandle(senderId); + if (!normalizedSender) { + return null; + } + + return { + action, + emoji, + senderId: normalizedSender, + senderName, + messageId: associatedGuid, + timestamp, + isGroup, + chatId, + chatGuid, + chatIdentifier, + chatName, + fromMe, + }; +} + +export async function handleBlueBubblesWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const path = normalizeWebhookPath(url.pathname); + const targets = webhookTargets.get(path); + if (!targets || targets.length === 0) { + return false; + } + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return true; + } + + const body = await readJsonBody(req, 1024 * 1024); + if (!body.ok) { + res.statusCode = body.error === "payload too large" ? 413 : 400; + res.end(body.error ?? "invalid payload"); + console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`); + return true; + } + + const payload = asRecord(body.value) ?? {}; + const firstTarget = targets[0]; + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`, + ); + } + const eventTypeRaw = payload.type; + const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : ""; + const allowedEventTypes = new Set([ + "new-message", + "updated-message", + "message-reaction", + "reaction", + ]); + if (eventType && !allowedEventTypes.has(eventType)) { + res.statusCode = 200; + res.end("ok"); + if (firstTarget) { + logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`); + } + return true; + } + const reaction = normalizeWebhookReaction(payload); + if ( + (eventType === "updated-message" || + eventType === "message-reaction" || + eventType === "reaction") && + !reaction + ) { + res.statusCode = 200; + res.end("ok"); + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook ignored ${eventType || "event"} without reaction`, + ); + } + return true; + } + const message = reaction ? null : normalizeWebhookMessage(payload); + if (!message && !reaction) { + res.statusCode = 400; + res.end("invalid payload"); + console.warn("[bluebubbles] webhook rejected: unable to parse message payload"); + return true; + } + + const matching = targets.filter((target) => { + const token = target.account.config.password?.trim(); + if (!token) { + return true; + } + const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password"); + const headerToken = + req.headers["x-guid"] ?? + req.headers["x-password"] ?? + req.headers["x-bluebubbles-guid"] ?? + req.headers["authorization"]; + const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? ""; + if (guid && guid.trim() === token) { + return true; + } + const remote = req.socket?.remoteAddress ?? ""; + if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") { + return true; + } + return false; + }); + + if (matching.length === 0) { + res.statusCode = 401; + res.end("unauthorized"); + console.warn( + `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`, + ); + return true; + } + + for (const target of matching) { + target.statusSink?.({ lastInboundAt: Date.now() }); + if (reaction) { + processReaction(reaction, target).catch((err) => { + target.runtime.error?.( + `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`, + ); + }); + } else if (message) { + // Route messages through debouncer to coalesce rapid-fire events + // (e.g., text message + URL balloon arriving as separate webhooks) + const debouncer = getOrCreateDebouncer(target); + debouncer.enqueue({ message, target }).catch((err) => { + target.runtime.error?.( + `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, + ); + }); + } + } + + res.statusCode = 200; + res.end("ok"); + if (reaction) { + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`, + ); + } + } else if (message) { + if (firstTarget) { + logVerbose( + firstTarget.core, + firstTarget.runtime, + `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, + ); + } + } + return true; +} + +async function processMessage( + message: NormalizedWebhookMessage, + target: WebhookTarget, +): Promise { + const { account, config, runtime, core, statusSink } = target; + + const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid); + const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup; + + const text = message.text.trim(); + const attachments = message.attachments ?? []; + const placeholder = buildMessagePlaceholder(message); + // Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format + // For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it + const tapbackContext = resolveTapbackContext(message); + const tapbackParsed = parseTapbackText({ + text, + emojiHint: tapbackContext?.emojiHint, + actionHint: tapbackContext?.actionHint, + requireQuoted: !tapbackContext, + }); + const isTapbackMessage = Boolean(tapbackParsed); + const rawBody = tapbackParsed + ? tapbackParsed.action === "removed" + ? `removed ${tapbackParsed.emoji} reaction` + : `reacted with ${tapbackParsed.emoji}` + : text || placeholder; + + const cacheMessageId = message.messageId?.trim(); + let messageShortId: string | undefined; + const cacheInboundMessage = () => { + if (!cacheMessageId) { + return; + } + const cacheEntry = rememberBlueBubblesReplyCache({ + accountId: account.accountId, + messageId: cacheMessageId, + chatGuid: message.chatGuid, + chatIdentifier: message.chatIdentifier, + chatId: message.chatId, + senderLabel: message.fromMe ? "me" : message.senderId, + body: rawBody, + timestamp: message.timestamp ?? Date.now(), + }); + messageShortId = cacheEntry.shortId; + }; + + if (message.fromMe) { + // Cache from-me messages so reply context can resolve sender/body. + cacheInboundMessage(); + return; + } + + if (!rawBody) { + logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); + return; + } + logVerbose( + core, + runtime, + `msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`, + ); + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry)); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("bluebubbles") + .catch(() => []); + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom] + .map((entry) => String(entry).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = [ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ] + .map((entry) => String(entry).trim()) + .filter(Boolean); + const groupAllowEntry = formatGroupAllowlistEntry({ + chatGuid: message.chatGuid, + chatId: message.chatId ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + const groupName = message.chatName?.trim() || undefined; + + if (isGroup) { + if (groupPolicy === "disabled") { + logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)"); + logGroupAllowlistHint({ + runtime, + reason: "groupPolicy=disabled", + entry: groupAllowEntry, + chatName: groupName, + accountId: account.accountId, + }); + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)"); + logGroupAllowlistHint({ + runtime, + reason: "groupPolicy=allowlist (empty allowlist)", + entry: groupAllowEntry, + chatName: groupName, + accountId: account.accountId, + }); + return; + } + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + if (!allowed) { + logVerbose( + core, + runtime, + `Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`, + ); + logVerbose( + core, + runtime, + `drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`, + ); + logGroupAllowlistHint({ + runtime, + reason: "groupPolicy=allowlist (not allowlisted)", + entry: groupAllowEntry, + chatName: groupName, + accountId: account.accountId, + }); + return; + } + } + } else { + if (dmPolicy === "disabled") { + logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`); + logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`); + return; + } + if (dmPolicy !== "open") { + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }); + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "bluebubbles", + id: message.senderId, + meta: { name: message.senderName }, + }); + runtime.log?.( + `[bluebubbles] pairing request sender=${message.senderId} created=${created}`, + ); + if (created) { + logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`); + try { + await sendMessageBlueBubbles( + message.senderId, + core.channel.pairing.buildPairingReply({ + channel: "bluebubbles", + idLine: `Your BlueBubbles sender id: ${message.senderId}`, + code, + }), + { cfg: config, accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose( + core, + runtime, + `bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`, + ); + runtime.error?.( + `[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`, + ); + } + } + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`, + ); + logVerbose( + core, + runtime, + `drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`, + ); + } + return; + } + } + } + + const chatId = message.chatId ?? undefined; + const chatGuid = message.chatGuid ?? undefined; + const chatIdentifier = message.chatIdentifier ?? undefined; + const peerId = isGroup + ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")) + : message.senderId; + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: peerId, + }, + }); + + // Mention gating for group chats (parity with iMessage/WhatsApp) + const messageText = text; + const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId); + const wasMentioned = isGroup + ? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes) + : true; + const canDetectMention = mentionRegexes.length > 0; + const requireMention = core.channel.groups.resolveRequireMention({ + cfg: config, + channel: "bluebubbles", + groupId: peerId, + accountId: account.accountId, + }); + + // Command gating (parity with iMessage/WhatsApp) + const useAccessGroups = config.commands?.useAccessGroups !== false; + const hasControlCmd = core.channel.text.hasControlCommand(messageText, config); + const ownerAllowedForCommands = + effectiveAllowFrom.length > 0 + ? isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }) + : false; + const groupAllowedForCommands = + effectiveGroupAllowFrom.length > 0 + ? isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: message.senderId, + chatId: message.chatId ?? undefined, + chatGuid: message.chatGuid ?? undefined, + chatIdentifier: message.chatIdentifier ?? undefined, + }) + : false; + const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands; + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCmd, + }); + const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized; + + // Block control commands from unauthorized senders in groups + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + reason: "control command (unauthorized)", + target: message.senderId, + }); + return; + } + + // Allow control commands to bypass mention gating when authorized (parity with iMessage) + const shouldBypassMention = + isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd; + const effectiveWasMentioned = wasMentioned || shouldBypassMention; + + // Skip group messages that require mention but weren't mentioned + if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) { + logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`); + return; + } + + // Cache allowed inbound messages so later replies can resolve sender/body without + // surfacing dropped content (allowlist/mention/command gating). + cacheInboundMessage(); + + const baseUrl = account.config.serverUrl?.trim(); + const password = account.config.password?.trim(); + const maxBytes = + account.config.mediaMaxMb && account.config.mediaMaxMb > 0 + ? account.config.mediaMaxMb * 1024 * 1024 + : 8 * 1024 * 1024; + + let mediaUrls: string[] = []; + let mediaPaths: string[] = []; + let mediaTypes: string[] = []; + if (attachments.length > 0) { + if (!baseUrl || !password) { + logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)"); + } else { + for (const attachment of attachments) { + if (!attachment.guid) { + continue; + } + if (attachment.totalBytes && attachment.totalBytes > maxBytes) { + logVerbose( + core, + runtime, + `attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`, + ); + continue; + } + try { + const downloaded = await downloadBlueBubblesAttachment(attachment, { + cfg: config, + accountId: account.accountId, + maxBytes, + }); + const saved = await core.channel.media.saveMediaBuffer( + downloaded.buffer, + downloaded.contentType, + "inbound", + maxBytes, + ); + mediaPaths.push(saved.path); + mediaUrls.push(saved.path); + if (saved.contentType) { + mediaTypes.push(saved.contentType); + } + } catch (err) { + logVerbose( + core, + runtime, + `attachment download failed guid=${attachment.guid} err=${String(err)}`, + ); + } + } + } + } + let replyToId = message.replyToId; + let replyToBody = message.replyToBody; + let replyToSender = message.replyToSender; + let replyToShortId: string | undefined; + + if (isTapbackMessage && tapbackContext?.replyToId) { + replyToId = tapbackContext.replyToId; + } + + if (replyToId) { + const cached = resolveReplyContextFromCache({ + accountId: account.accountId, + replyToId, + chatGuid: message.chatGuid, + chatIdentifier: message.chatIdentifier, + chatId: message.chatId, + }); + if (cached) { + if (!replyToBody && cached.body) { + replyToBody = cached.body; + } + if (!replyToSender && cached.senderLabel) { + replyToSender = cached.senderLabel; + } + replyToShortId = cached.shortId; + if (core.logging.shouldLogVerbose()) { + const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120); + logVerbose( + core, + runtime, + `reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`, + ); + } + } + } + + // If no cached short ID, try to get one from the UUID directly + if (replyToId && !replyToShortId) { + replyToShortId = getShortIdForUuid(replyToId); + } + + // Use inline [[reply_to:N]] tag format + // For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]") + // For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome") + const replyTag = formatReplyTag({ replyToId, replyToShortId }); + const baseBody = replyTag + ? isTapbackMessage + ? `${rawBody} ${replyTag}` + : `${replyTag} ${rawBody}` + : rawBody; + const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`; + const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined; + const groupMembers = isGroup + ? formatGroupMembers({ + participants: message.participants, + fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined, + }) + : undefined; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "BlueBubbles", + from: fromLabel, + timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: baseBody, + }); + let chatGuidForActions = chatGuid; + if (!chatGuidForActions && baseUrl && password) { + const target = + isGroup && (chatId || chatIdentifier) + ? chatId + ? ({ kind: "chat_id", chatId } as const) + : ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const) + : ({ kind: "handle", address: message.senderId } as const); + if (target.kind !== "chat_identifier" || target.chatIdentifier) { + chatGuidForActions = + (await resolveChatGuidForTarget({ + baseUrl, + password, + target, + })) ?? undefined; + } + } + + const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions"; + const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false; + const ackReactionValue = resolveBlueBubblesAckReaction({ + cfg: config, + agentId: route.agentId, + core, + runtime, + }); + const shouldAckReaction = () => + Boolean( + ackReactionValue && + core.channel.reactions.shouldAckReaction({ + scope: ackReactionScope, + isDirect: !isGroup, + isGroup, + isMentionableGroup: isGroup, + requireMention: Boolean(requireMention), + canDetectMention, + effectiveWasMentioned, + shouldBypassMention, + }), + ); + const ackMessageId = message.messageId?.trim() || ""; + const ackReactionPromise = + shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue + ? sendBlueBubblesReaction({ + chatGuid: chatGuidForActions, + messageGuid: ackMessageId, + emoji: ackReactionValue, + opts: { cfg: config, accountId: account.accountId }, + }).then( + () => true, + (err) => { + logVerbose( + core, + runtime, + `ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`, + ); + return false; + }, + ) + : null; + + // Respect sendReadReceipts config (parity with WhatsApp) + const sendReadReceipts = account.config.sendReadReceipts !== false; + if (chatGuidForActions && baseUrl && password && sendReadReceipts) { + try { + await markBlueBubblesChatRead(chatGuidForActions, { + cfg: config, + accountId: account.accountId, + }); + logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`); + } catch (err) { + runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`); + } + } else if (!sendReadReceipts) { + logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)"); + } else { + logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)"); + } + + const outboundTarget = isGroup + ? formatBlueBubblesChatTarget({ + chatId, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + }) || peerId + : chatGuidForActions + ? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions }) + : message.senderId; + + const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => { + const trimmed = messageId?.trim(); + if (!trimmed || trimmed === "ok" || trimmed === "unknown") { + return; + } + // Cache outbound message to get short ID + const cacheEntry = rememberBlueBubblesReplyCache({ + accountId: account.accountId, + messageId: trimmed, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + senderLabel: "me", + body: snippet ?? "", + timestamp: Date.now(), + }); + const displayId = cacheEntry.shortId || trimmed; + const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : ""; + core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, { + sessionKey: route.sessionKey, + contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`, + }); + }; + + const ctxPayload = { + Body: body, + BodyForAgent: body, + RawBody: rawBody, + CommandBody: rawBody, + BodyForCommands: rawBody, + MediaUrl: mediaUrls[0], + MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, + MediaPath: mediaPaths[0], + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaType: mediaTypes[0], + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`, + To: `bluebubbles:${outboundTarget}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + // Use short ID for token savings (agent can use this to reference the message) + ReplyToId: replyToShortId || replyToId, + ReplyToIdFull: replyToId, + ReplyToBody: replyToBody, + ReplyToSender: replyToSender, + GroupSubject: groupSubject, + GroupMembers: groupMembers, + SenderName: message.senderName || undefined, + SenderId: message.senderId, + Provider: "bluebubbles", + Surface: "bluebubbles", + // Use short ID for token savings (agent can use this to reference the message) + MessageSid: messageShortId || message.messageId, + MessageSidFull: message.messageId, + Timestamp: message.timestamp, + OriginatingChannel: "bluebubbles", + OriginatingTo: `bluebubbles:${outboundTarget}`, + WasMentioned: effectiveWasMentioned, + CommandAuthorized: commandAuthorized, + }; + + let sentMessage = false; + try { + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + const rawReplyToId = + typeof payload.replyToId === "string" ? payload.replyToId.trim() : ""; + // Resolve short ID (e.g., "5") to full UUID + const replyToMessageGuid = rawReplyToId + ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + if (mediaList.length > 0) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : undefined; + first = false; + const result = await sendBlueBubblesMedia({ + cfg: config, + to: outboundTarget, + mediaUrl, + caption: caption ?? undefined, + replyToId: replyToMessageGuid || null, + accountId: account.accountId, + }); + const cachedBody = (caption ?? "").trim() || ""; + maybeEnqueueOutboundMessageId(result.messageId, cachedBody); + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + } + return; + } + + const textLimit = + account.config.textChunkLimit && account.config.textChunkLimit > 0 + ? account.config.textChunkLimit + : DEFAULT_TEXT_LIMIT; + const chunkMode = account.config.chunkMode ?? "length"; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const chunks = + chunkMode === "newline" + ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) + : core.channel.text.chunkMarkdownText(text, textLimit); + if (!chunks.length && text) { + chunks.push(text); + } + if (!chunks.length) { + return; + } + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + const result = await sendMessageBlueBubbles(outboundTarget, chunk, { + cfg: config, + accountId: account.accountId, + replyToMessageGuid: replyToMessageGuid || undefined, + }); + maybeEnqueueOutboundMessageId(result.messageId, chunk); + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + // In newline mode, restart typing after each chunk if more chunks remain + // Small delay allows the Apple API to finish clearing the typing state from message send + if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) { + await new Promise((r) => setTimeout(r, 150)); + sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }).catch(() => { + // Ignore typing errors + }); + } + } + }, + onReplyStart: async () => { + if (!chatGuidForActions) { + return; + } + if (!baseUrl || !password) { + return; + } + logVerbose(core, runtime, `typing start chatGuid=${chatGuidForActions}`); + try { + await sendBlueBubblesTyping(chatGuidForActions, true, { + cfg: config, + accountId: account.accountId, + }); + } catch (err) { + runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`); + } + }, + onIdle: async () => { + if (!chatGuidForActions) { + return; + } + if (!baseUrl || !password) { + return; + } + try { + await sendBlueBubblesTyping(chatGuidForActions, false, { + cfg: config, + accountId: account.accountId, + }); + } catch (err) { + logVerbose(core, runtime, `typing stop failed: ${String(err)}`); + } + }, + onError: (err, info) => { + runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyOptions: { + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, + }); + } finally { + if (sentMessage && chatGuidForActions && ackMessageId) { + core.channel.reactions.removeAckReactionAfterReply({ + removeAfterReply: removeAckAfterReply, + ackReactionPromise, + ackReactionValue: ackReactionValue ?? null, + remove: () => + sendBlueBubblesReaction({ + chatGuid: chatGuidForActions, + messageGuid: ackMessageId, + emoji: ackReactionValue ?? "", + remove: true, + opts: { cfg: config, accountId: account.accountId }, + }), + onError: (err) => { + logAckFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + target: `${chatGuidForActions}/${ackMessageId}`, + error: err, + }); + }, + }); + } + if (chatGuidForActions && baseUrl && password && !sentMessage) { + // Stop typing indicator when no message was sent (e.g., NO_REPLY) + sendBlueBubblesTyping(chatGuidForActions, false, { + cfg: config, + accountId: account.accountId, + }).catch((err) => { + logTypingFailure({ + log: (msg) => logVerbose(core, runtime, msg), + channel: "bluebubbles", + action: "stop", + target: chatGuidForActions, + error: err, + }); + }); + } + } +} + +async function processReaction( + reaction: NormalizedWebhookReaction, + target: WebhookTarget, +): Promise { + const { account, config, runtime, core } = target; + if (reaction.fromMe) { + return; + } + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const groupPolicy = account.config.groupPolicy ?? "allowlist"; + const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); + const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry)); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("bluebubbles") + .catch(() => []); + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom] + .map((entry) => String(entry).trim()) + .filter(Boolean); + const effectiveGroupAllowFrom = [ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ] + .map((entry) => String(entry).trim()) + .filter(Boolean); + + if (reaction.isGroup) { + if (groupPolicy === "disabled") { + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + return; + } + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveGroupAllowFrom, + sender: reaction.senderId, + chatId: reaction.chatId ?? undefined, + chatGuid: reaction.chatGuid ?? undefined, + chatIdentifier: reaction.chatIdentifier ?? undefined, + }); + if (!allowed) { + return; + } + } + } else { + if (dmPolicy === "disabled") { + return; + } + if (dmPolicy !== "open") { + const allowed = isAllowedBlueBubblesSender({ + allowFrom: effectiveAllowFrom, + sender: reaction.senderId, + chatId: reaction.chatId ?? undefined, + chatGuid: reaction.chatGuid ?? undefined, + chatIdentifier: reaction.chatIdentifier ?? undefined, + }); + if (!allowed) { + return; + } + } + } + + const chatId = reaction.chatId ?? undefined; + const chatGuid = reaction.chatGuid ?? undefined; + const chatIdentifier = reaction.chatIdentifier ?? undefined; + const peerId = reaction.isGroup + ? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")) + : reaction.senderId; + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + peer: { + kind: reaction.isGroup ? "group" : "dm", + id: peerId, + }, + }); + + const senderLabel = reaction.senderName || reaction.senderId; + const chatLabel = reaction.isGroup ? ` in group:${peerId}` : ""; + // Use short ID for token savings + const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId; + // Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]" + const text = + reaction.action === "removed" + ? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}` + : `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`; + core.system.enqueueSystemEvent(text, { + sessionKey: route.sessionKey, + contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`, + }); + logVerbose(core, runtime, `reaction event enqueued: ${text}`); +} + +export async function monitorBlueBubblesProvider( + options: BlueBubblesMonitorOptions, +): Promise { + const { account, config, runtime, abortSignal, statusSink } = options; + const core = getBlueBubblesRuntime(); + const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH; + + // Fetch and cache server info (for macOS version detection in action gating) + const serverInfo = await fetchBlueBubblesServerInfo({ + baseUrl: account.baseUrl, + password: account.config.password, + accountId: account.accountId, + timeoutMs: 5000, + }).catch(() => null); + if (serverInfo?.os_version) { + runtime.log?.(`[${account.accountId}] BlueBubbles server macOS ${serverInfo.os_version}`); + } + + const unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime, + core, + path, + statusSink, + }); + + return await new Promise((resolve) => { + const stop = () => { + unregister(); + resolve(); + }; + + if (abortSignal?.aborted) { + stop(); + return; + } + + abortSignal?.addEventListener("abort", stop, { once: true }); + runtime.log?.( + `[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`, + ); + }); +} + +export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string { + const raw = config?.webhookPath?.trim(); + if (raw) { + return normalizeWebhookPath(raw); + } + return DEFAULT_WEBHOOK_PATH; +} diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d68ace62fb520cd2f3a1a246b557889228d99cf --- /dev/null +++ b/extensions/bluebubbles/src/onboarding.ts @@ -0,0 +1,352 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + OpenClawConfig, + DmPolicy, + WizardPrompter, +} from "openclaw/plugin-sdk"; +import { + DEFAULT_ACCOUNT_ID, + addWildcardAllowFrom, + formatDocsLink, + normalizeAccountId, + promptAccountId, +} from "openclaw/plugin-sdk"; +import { + listBlueBubblesAccountIds, + resolveBlueBubblesAccount, + resolveDefaultBlueBubblesAccountId, +} from "./accounts.js"; +import { parseBlueBubblesAllowTarget } from "./targets.js"; +import { normalizeBlueBubblesServerUrl } from "./types.js"; + +const channel = "bluebubbles" as const; + +function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + const allowFrom = + dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.bluebubbles?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setBlueBubblesAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom: string[], +): OpenClawConfig { + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + allowFrom, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { + ...cfg.channels?.bluebubbles, + accounts: { + ...cfg.channels?.bluebubbles?.accounts, + [accountId]: { + ...cfg.channels?.bluebubbles?.accounts?.[accountId], + allowFrom, + }, + }, + }, + }, + }; +} + +function parseBlueBubblesAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptBlueBubblesAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = + params.accountId && normalizeAccountId(params.accountId) + ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) + : resolveDefaultBlueBubblesAccountId(params.cfg); + const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId }); + const existing = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist BlueBubbles DMs by handle or chat target.", + "Examples:", + "- +15555550123", + "- user@example.com", + "- chat_id:123", + "- chat_guid:iMessage;-;+15555550123", + "Multiple entries: comma- or newline-separated.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles allowlist", + ); + const entry = await params.prompter.text({ + message: "BlueBubbles allowFrom (handle or chat_id)", + placeholder: "+15555550123, user@example.com, chat_id:123", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parts = parseBlueBubblesAllowFromInput(raw); + for (const part of parts) { + if (part === "*") { + continue; + } + const parsed = parseBlueBubblesAllowTarget(part); + if (parsed.kind === "handle" && !parsed.handle) { + return `Invalid entry: ${part}`; + } + } + return undefined; + }, + }); + const parts = parseBlueBubblesAllowFromInput(String(entry)); + const unique = [...new Set(parts)]; + return setBlueBubblesAllowFrom(params.cfg, accountId, unique); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "BlueBubbles", + channel, + policyKey: "channels.bluebubbles.dmPolicy", + allowFromKey: "channels.bluebubbles.allowFrom", + getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy), + promptAllowFrom: promptBlueBubblesAllowFrom, +}; + +export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listBlueBubblesAccountIds(cfg).some((accountId) => { + const account = resolveBlueBubblesAccount({ cfg, accountId }); + return account.configured; + }); + return { + channel, + configured, + statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`], + selectionHint: configured ? "configured" : "iMessage via BlueBubbles app", + quickstartScore: configured ? 1 : 0, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const blueBubblesOverride = accountOverrides.bluebubbles?.trim(); + const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg); + let accountId = blueBubblesOverride + ? normalizeAccountId(blueBubblesOverride) + : defaultAccountId; + if (shouldPromptAccountIds && !blueBubblesOverride) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "BlueBubbles", + currentId: accountId, + listAccountIds: listBlueBubblesAccountIds, + defaultAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId }); + + // Prompt for server URL + let serverUrl = resolvedAccount.config.serverUrl?.trim(); + if (!serverUrl) { + await prompter.note( + [ + "Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).", + "Find this in the BlueBubbles Server app under Connection.", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles server URL", + ); + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }, + }); + serverUrl = String(entered).trim(); + } else { + const keepUrl = await prompter.confirm({ + message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`, + initialValue: true, + }); + if (!keepUrl) { + const entered = await prompter.text({ + message: "BlueBubbles server URL", + placeholder: "http://192.168.1.100:1234", + initialValue: serverUrl, + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + try { + const normalized = normalizeBlueBubblesServerUrl(trimmed); + new URL(normalized); + return undefined; + } catch { + return "Invalid URL format"; + } + }, + }); + serverUrl = String(entered).trim(); + } + } + + // Prompt for password + let password = resolvedAccount.config.password?.trim(); + if (!password) { + await prompter.note( + [ + "Enter the BlueBubbles server password.", + "Find this in the BlueBubbles Server app under Settings.", + ].join("\n"), + "BlueBubbles password", + ); + const entered = await prompter.text({ + message: "BlueBubbles password", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + password = String(entered).trim(); + } else { + const keepPassword = await prompter.confirm({ + message: "BlueBubbles password already set. Keep it?", + initialValue: true, + }); + if (!keepPassword) { + const entered = await prompter.text({ + message: "BlueBubbles password", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + password = String(entered).trim(); + } + } + + // Prompt for webhook path (optional) + const existingWebhookPath = resolvedAccount.config.webhookPath?.trim(); + const wantsWebhook = await prompter.confirm({ + message: "Configure a custom webhook path? (default: /bluebubbles-webhook)", + initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"), + }); + let webhookPath = "/bluebubbles-webhook"; + if (wantsWebhook) { + const entered = await prompter.text({ + message: "Webhook path", + placeholder: "/bluebubbles-webhook", + initialValue: existingWebhookPath || "/bluebubbles-webhook", + validate: (value) => { + const trimmed = String(value ?? "").trim(); + if (!trimmed) { + return "Required"; + } + if (!trimmed.startsWith("/")) { + return "Path must start with /"; + } + return undefined; + }, + }); + webhookPath = String(entered).trim(); + } + + // Apply config + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + serverUrl, + password, + webhookPath, + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + bluebubbles: { + ...next.channels?.bluebubbles, + enabled: true, + accounts: { + ...next.channels?.bluebubbles?.accounts, + [accountId]: { + ...next.channels?.bluebubbles?.accounts?.[accountId], + enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true, + serverUrl, + password, + webhookPath, + }, + }, + }, + }, + }; + } + + await prompter.note( + [ + "Configure the webhook URL in BlueBubbles Server:", + "1. Open BlueBubbles Server → Settings → Webhooks", + "2. Add your OpenClaw gateway URL + webhook path", + " Example: https://your-gateway-host:3000/bluebubbles-webhook", + "3. Enable the webhook and save", + "", + `Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`, + ].join("\n"), + "BlueBubbles next steps", + ); + + return { cfg: next, accountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false }, + }, + }), +}; diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..76e3b330e9da25024ab6891bd6967263d71c0afb --- /dev/null +++ b/extensions/bluebubbles/src/probe.ts @@ -0,0 +1,135 @@ +import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js"; + +export type BlueBubblesProbe = { + ok: boolean; + status?: number | null; + error?: string | null; +}; + +export type BlueBubblesServerInfo = { + os_version?: string; + server_version?: string; + private_api?: boolean; + helper_connected?: boolean; + proxy_service?: string; + detected_icloud?: string; + computer_id?: string; +}; + +/** Cache server info by account ID to avoid repeated API calls */ +const serverInfoCache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes + +function buildCacheKey(accountId?: string): string { + return accountId?.trim() || "default"; +} + +/** + * Fetch server info from BlueBubbles API and cache it. + * Returns cached result if available and not expired. + */ +export async function fetchBlueBubblesServerInfo(params: { + baseUrl?: string | null; + password?: string | null; + accountId?: string; + timeoutMs?: number; +}): Promise { + const baseUrl = params.baseUrl?.trim(); + const password = params.password?.trim(); + if (!baseUrl || !password) { + return null; + } + + const cacheKey = buildCacheKey(params.accountId); + const cached = serverInfoCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.info; + } + + const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/server/info", password }); + try { + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs ?? 5000); + if (!res.ok) { + return null; + } + const payload = (await res.json().catch(() => null)) as Record | null; + const data = payload?.data as BlueBubblesServerInfo | undefined; + if (data) { + serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS }); + } + return data ?? null; + } catch { + return null; + } +} + +/** + * Get cached server info synchronously (for use in listActions). + * Returns null if not cached or expired. + */ +export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null { + const cacheKey = buildCacheKey(accountId); + const cached = serverInfoCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.info; + } + return null; +} + +/** + * Parse macOS version string (e.g., "15.0.1" or "26.0") into major version number. + */ +export function parseMacOSMajorVersion(version?: string | null): number | null { + if (!version) { + return null; + } + const match = /^(\d+)/.exec(version.trim()); + return match ? Number.parseInt(match[1], 10) : null; +} + +/** + * Check if the cached server info indicates macOS 26 or higher. + * Returns false if no cached info is available (fail open for action listing). + */ +export function isMacOS26OrHigher(accountId?: string): boolean { + const info = getCachedBlueBubblesServerInfo(accountId); + if (!info?.os_version) { + return false; + } + const major = parseMacOSMajorVersion(info.os_version); + return major !== null && major >= 26; +} + +/** Clear the server info cache (for testing) */ +export function clearServerInfoCache(): void { + serverInfoCache.clear(); +} + +export async function probeBlueBubbles(params: { + baseUrl?: string | null; + password?: string | null; + timeoutMs?: number; +}): Promise { + const baseUrl = params.baseUrl?.trim(); + const password = params.password?.trim(); + if (!baseUrl) { + return { ok: false, error: "serverUrl not configured" }; + } + if (!password) { + return { ok: false, error: "password not configured" }; + } + const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password }); + try { + const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, params.timeoutMs); + if (!res.ok) { + return { ok: false, status: res.status, error: `HTTP ${res.status}` }; + } + return { ok: true, status: res.status }; + } catch (err) { + return { + ok: false, + status: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..643a926b8897f8c7e4f39c33ba032a447a1cd15f --- /dev/null +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { sendBlueBubblesReaction } from "./reactions.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("reactions", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("sendBlueBubblesReaction", () => { + it("throws when chatGuid is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "", + messageGuid: "msg-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("chatGuid"); + }); + + it("throws when messageGuid is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("messageGuid"); + }); + + it("throws when emoji is empty", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("emoji or name"); + }); + + it("throws when serverUrl is missing", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + opts: {}, + }), + ).rejects.toThrow("serverUrl is required"); + }); + + it("throws when password is missing", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + }, + }), + ).rejects.toThrow("password is required"); + }); + + it("throws for unsupported reaction type", async () => { + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "unsupported", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("Unsupported BlueBubbles reaction"); + }); + + describe("reaction type normalization", () => { + const testCases = [ + { input: "love", expected: "love" }, + { input: "like", expected: "like" }, + { input: "dislike", expected: "dislike" }, + { input: "laugh", expected: "laugh" }, + { input: "emphasize", expected: "emphasize" }, + { input: "question", expected: "question" }, + { input: "heart", expected: "love" }, + { input: "thumbs_up", expected: "like" }, + { input: "thumbs-down", expected: "dislike" }, + { input: "thumbs_down", expected: "dislike" }, + { input: "haha", expected: "laugh" }, + { input: "lol", expected: "laugh" }, + { input: "emphasis", expected: "emphasize" }, + { input: "exclaim", expected: "emphasize" }, + { input: "❤️", expected: "love" }, + { input: "❤", expected: "love" }, + { input: "♥️", expected: "love" }, + { input: "😍", expected: "love" }, + { input: "👍", expected: "like" }, + { input: "👎", expected: "dislike" }, + { input: "😂", expected: "laugh" }, + { input: "🤣", expected: "laugh" }, + { input: "😆", expected: "laugh" }, + { input: "‼️", expected: "emphasize" }, + { input: "‼", expected: "emphasize" }, + { input: "❗", expected: "emphasize" }, + { input: "❓", expected: "question" }, + { input: "❔", expected: "question" }, + { input: "LOVE", expected: "love" }, + { input: "Like", expected: "like" }, + ]; + + for (const { input, expected } of testCases) { + it(`normalizes "${input}" to "${expected}"`, async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: input, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe(expected); + }); + } + }); + + it("sends reaction successfully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "iMessage;-;+15551234567", + messageGuid: "msg-uuid-123", + emoji: "love", + opts: { + serverUrl: "http://localhost:1234", + password: "test-password", + }, + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/message/react"), + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.chatGuid).toBe("iMessage;-;+15551234567"); + expect(body.selectedMessageGuid).toBe("msg-uuid-123"); + expect(body.reaction).toBe("love"); + expect(body.partIndex).toBe(0); + }); + + it("includes password in URL query", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "like", + opts: { + serverUrl: "http://localhost:1234", + password: "my-react-password", + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("password=my-react-password"); + }); + + it("sends reaction removal with dash prefix", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "love", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + }); + + it("strips leading dash from emoji when remove flag is set", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "-love", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-love"); + }); + + it("uses custom partIndex when provided", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "laugh", + partIndex: 3, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.partIndex).toBe(3); + }); + + it("throws on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("Invalid reaction type"), + }); + + await expect( + sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "like", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }), + ).rejects.toThrow("reaction failed (400): Invalid reaction type"); + }); + + it("resolves credentials from config", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "emphasize", + opts: { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://react-server:7777", + password: "react-pass", + }, + }, + }, + }, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("react-server:7777"); + expect(calledUrl).toContain("password=react-pass"); + }); + + it("trims chatGuid and messageGuid", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: " chat-with-spaces ", + messageGuid: " msg-with-spaces ", + emoji: "question", + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.chatGuid).toBe("chat-with-spaces"); + expect(body.selectedMessageGuid).toBe("msg-with-spaces"); + }); + + describe("reaction removal aliases", () => { + it("handles emoji-based removal", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "👍", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-like"); + }); + + it("handles text alias removal", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + await sendBlueBubblesReaction({ + chatGuid: "chat-123", + messageGuid: "msg-123", + emoji: "haha", + remove: true, + opts: { + serverUrl: "http://localhost:1234", + password: "test", + }, + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.reaction).toBe("-laugh"); + }); + }); + }); +}); diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b59eda0d88ccc7ee157e3d9011990581d59aef5 --- /dev/null +++ b/extensions/bluebubbles/src/reactions.ts @@ -0,0 +1,188 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; + +export type BlueBubblesReactionOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; +}; + +const REACTION_TYPES = new Set(["love", "like", "dislike", "laugh", "emphasize", "question"]); + +const REACTION_ALIASES = new Map([ + // General + ["heart", "love"], + ["love", "love"], + ["❤", "love"], + ["❤️", "love"], + ["red_heart", "love"], + ["thumbs_up", "like"], + ["thumbsup", "like"], + ["thumbs-up", "like"], + ["thumbsup", "like"], + ["like", "like"], + ["thumb", "like"], + ["ok", "like"], + ["thumbs_down", "dislike"], + ["thumbsdown", "dislike"], + ["thumbs-down", "dislike"], + ["dislike", "dislike"], + ["boo", "dislike"], + ["no", "dislike"], + // Laugh + ["haha", "laugh"], + ["lol", "laugh"], + ["lmao", "laugh"], + ["rofl", "laugh"], + ["😂", "laugh"], + ["🤣", "laugh"], + ["xd", "laugh"], + ["laugh", "laugh"], + // Emphasize / exclaim + ["emphasis", "emphasize"], + ["emphasize", "emphasize"], + ["exclaim", "emphasize"], + ["!!", "emphasize"], + ["‼", "emphasize"], + ["‼️", "emphasize"], + ["❗", "emphasize"], + ["important", "emphasize"], + ["bang", "emphasize"], + // Question + ["question", "question"], + ["?", "question"], + ["❓", "question"], + ["❔", "question"], + ["ask", "question"], + // Apple/Messages names + ["loved", "love"], + ["liked", "like"], + ["disliked", "dislike"], + ["laughed", "laugh"], + ["emphasized", "emphasize"], + ["questioned", "question"], + // Colloquial / informal + ["fire", "love"], + ["🔥", "love"], + ["wow", "emphasize"], + ["!", "emphasize"], + // Edge: generic emoji name forms + ["heart_eyes", "love"], + ["smile", "laugh"], + ["smiley", "laugh"], + ["happy", "laugh"], + ["joy", "laugh"], +]); + +const REACTION_EMOJIS = new Map([ + // Love + ["❤️", "love"], + ["❤", "love"], + ["♥️", "love"], + ["♥", "love"], + ["😍", "love"], + ["💕", "love"], + // Like + ["👍", "like"], + ["👌", "like"], + // Dislike + ["👎", "dislike"], + ["🙅", "dislike"], + // Laugh + ["😂", "laugh"], + ["🤣", "laugh"], + ["😆", "laugh"], + ["😁", "laugh"], + ["😹", "laugh"], + // Emphasize + ["‼️", "emphasize"], + ["‼", "emphasize"], + ["!!", "emphasize"], + ["❗", "emphasize"], + ["❕", "emphasize"], + ["!", "emphasize"], + // Question + ["❓", "question"], + ["❔", "question"], + ["?", "question"], +]); + +function resolveAccount(params: BlueBubblesReactionOpts) { + const account = resolveBlueBubblesAccount({ + cfg: params.cfg ?? {}, + accountId: params.accountId, + }); + const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = params.password?.trim() || account.config.password?.trim(); + if (!baseUrl) { + throw new Error("BlueBubbles serverUrl is required"); + } + if (!password) { + throw new Error("BlueBubbles password is required"); + } + return { baseUrl, password }; +} + +export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string { + const trimmed = emoji.trim(); + if (!trimmed) { + throw new Error("BlueBubbles reaction requires an emoji or name."); + } + let raw = trimmed.toLowerCase(); + if (raw.startsWith("-")) { + raw = raw.slice(1); + } + const aliased = REACTION_ALIASES.get(raw) ?? raw; + const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased; + if (!REACTION_TYPES.has(mapped)) { + throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`); + } + return remove ? `-${mapped}` : mapped; +} + +export async function sendBlueBubblesReaction(params: { + chatGuid: string; + messageGuid: string; + emoji: string; + remove?: boolean; + partIndex?: number; + opts?: BlueBubblesReactionOpts; +}): Promise { + const chatGuid = params.chatGuid.trim(); + const messageGuid = params.messageGuid.trim(); + if (!chatGuid) { + throw new Error("BlueBubbles reaction requires chatGuid."); + } + if (!messageGuid) { + throw new Error("BlueBubbles reaction requires messageGuid."); + } + const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove); + const { baseUrl, password } = resolveAccount(params.opts ?? {}); + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/react", + password, + }); + const payload = { + chatGuid, + selectedMessageGuid: messageGuid, + reaction, + partIndex: typeof params.partIndex === "number" ? params.partIndex : 0, + }; + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + params.opts?.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`); + } +} diff --git a/extensions/bluebubbles/src/runtime.ts b/extensions/bluebubbles/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f183c74e4dc306c0ab739a8eea933847dd0fd42 --- /dev/null +++ b/extensions/bluebubbles/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setBlueBubblesRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getBlueBubblesRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("BlueBubbles runtime not initialized"); + } + return runtime; +} diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2ee393c7f3b650357d9406fe2af20e903263678 --- /dev/null +++ b/extensions/bluebubbles/src/send.test.ts @@ -0,0 +1,808 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import type { BlueBubblesSendTarget } from "./types.js"; +import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js"; + +vi.mock("./accounts.js", () => ({ + resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => { + const config = cfg?.channels?.bluebubbles ?? {}; + return { + accountId: accountId ?? "default", + enabled: config.enabled !== false, + configured: Boolean(config.serverUrl && config.password), + config, + }; + }), +})); + +const mockFetch = vi.fn(); + +describe("send", () => { + beforeEach(() => { + vi.stubGlobal("fetch", mockFetch); + mockFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe("resolveChatGuidForTarget", () => { + it("returns chatGuid directly for chat_guid target", async () => { + const target: BlueBubblesSendTarget = { + kind: "chat_guid", + chatGuid: "iMessage;-;+15551234567", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + expect(result).toBe("iMessage;-;+15551234567"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("queries chats to resolve chat_id target", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { id: 123, guid: "iMessage;-;chat123", participants: [] }, + { id: 456, guid: "iMessage;-;chat456", participants: [] }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 456 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;chat456"); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/chat/query"), + expect.objectContaining({ method: "POST" }), + ); + }); + + it("queries chats to resolve chat_identifier target", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + identifier: "chat123@group.imessage", + guid: "iMessage;-;chat123", + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "chat_identifier", + chatIdentifier: "chat123@group.imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;chat123"); + }); + + it("matches chat_identifier against the 3rd component of chat GUID", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;chat660250192681427962", + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;+;chat660250192681427962"); + }); + + it("resolves handle target by matching participant", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15559999999", + participants: [{ address: "+15559999999" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+15551234567", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;+15551234567"); + }); + + it("prefers direct chat guid when handle also appears in a group chat", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;group-123", + participants: [{ address: "+15551234567" }, { address: "+15550001111" }], + }, + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+15551234567", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;+15551234567"); + }); + + it("returns null when handle only exists in group chat (not DM)", async () => { + // This is the critical fix: if a phone number only exists as a participant in a group chat + // (no direct DM chat), we should NOT send to that group. Return null instead. + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;group-the-council", + participants: [ + { address: "+12622102921" }, + { address: "+15550001111" }, + { address: "+15550002222" }, + ], + }, + ], + }), + }) + // Empty second page to stop pagination + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+12622102921", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + // Should return null, NOT the group chat GUID + expect(result).toBeNull(); + }); + + it("returns null when chat not found", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 999 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBeNull(); + }); + + it("handles API error gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 123 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBeNull(); + }); + + it("paginates through chats to find match", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: Array(500) + .fill(null) + .map((_, i) => ({ + id: i, + guid: `chat-${i}`, + participants: [], + })), + }), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [{ id: 555, guid: "found-chat", participants: [] }], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 555 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("found-chat"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("normalizes handle addresses for matching", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;test@example.com", + participants: [{ address: "Test@Example.COM" }], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "test@example.com", + service: "auto", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("iMessage;-;test@example.com"); + }); + + it("extracts guid from various response formats", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + chatGuid: "format1-guid", + id: 100, + participants: [], + }, + ], + }), + }); + + const target: BlueBubblesSendTarget = { kind: "chat_id", chatId: 100 }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + expect(result).toBe("format1-guid"); + }); + }); + + describe("sendMessageBlueBubbles", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + it("throws when text is empty", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", "", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("requires text"); + }); + + it("throws when text is whitespace only", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", " ", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("requires text"); + }); + + it("throws when serverUrl is missing", async () => { + await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow( + "serverUrl is required", + ); + }); + + it("throws when password is missing", async () => { + await expect( + sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + }), + ).rejects.toThrow("password is required"); + }); + + it("throws when chatGuid cannot be resolved for non-handle targets", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + await expect( + sendMessageBlueBubbles("chat_id:999", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("chatGuid not found"); + }); + + it("sends message successfully", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-123" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-uuid-123"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const sendCall = mockFetch.mock.calls[1]; + expect(sendCall[0]).toContain("/api/v1/message/text"); + const body = JSON.parse(sendCall[1].body); + expect(body.chatGuid).toBe("iMessage;-;+15551234567"); + expect(body.message).toBe("Hello world!"); + expect(body.method).toBeUndefined(); + }); + + it("creates a new chat when handle target is missing", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "new-msg-guid" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("new-msg-guid"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const createCall = mockFetch.mock.calls[1]; + expect(createCall[0]).toContain("/api/v1/chat/new"); + const body = JSON.parse(createCall[1].body); + expect(body.addresses).toEqual(["+15550009999"]); + expect(body.message).toBe("Hello new chat"); + }); + + it("throws when creating a new chat requires Private API", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve("Private API not enabled"), + }); + + await expect( + sendMessageBlueBubbles("+15550008888", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("Private API must be enabled"); + }); + + it("uses private-api when reply metadata is present", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-124" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Replying", { + serverUrl: "http://localhost:1234", + password: "test", + replyToMessageGuid: "reply-guid-123", + replyToPartIndex: 1, + }); + + expect(result.messageId).toBe("msg-uuid-124"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.selectedMessageGuid).toBe("reply-guid-123"); + expect(body.partIndex).toBe(1); + }); + + it("normalizes effect names and uses private-api for effects", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-125" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + effectId: "invisible ink", + }); + + expect(result.messageId).toBe("msg-uuid-125"); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.method).toBe("private-api"); + expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink"); + }); + + it("sends message with chat_guid target directly", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { messageId: "direct-msg-123" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles( + "chat_guid:iMessage;-;direct-chat", + "Direct message", + { + serverUrl: "http://localhost:1234", + password: "test", + }, + ); + + expect(result.messageId).toBe("direct-msg-123"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("handles send failure", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + text: () => Promise.resolve("Internal server error"), + }); + + await expect( + sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("send failed (500)"); + }); + + it("handles empty response body", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(""), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("ok"); + }); + + it("handles invalid JSON response body", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve("not valid json"), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("ok"); + }); + + it("extracts messageId from various response formats", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + id: "numeric-id-456", + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("numeric-id-456"); + }); + + it("extracts messageGuid from response payload", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { messageGuid: "msg-guid-789" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("msg-guid-789"); + }); + + it("resolves credentials from config", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })), + }); + + const result = await sendMessageBlueBubbles("+15551234567", "Hello", { + cfg: { + channels: { + bluebubbles: { + serverUrl: "http://config-server:5678", + password: "config-pass", + }, + }, + }, + }); + + expect(result.messageId).toBe("msg-123"); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain("config-server:5678"); + }); + + it("includes tempGuid in request payload", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })), + }); + + await sendMessageBlueBubbles("+15551234567", "Hello", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + expect(body.tempGuid).toBeDefined(); + expect(typeof body.tempGuid).toBe("string"); + expect(body.tempGuid.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..63333556f05ed8098b2fb0cdcedc0bf454a7b8e7 --- /dev/null +++ b/extensions/bluebubbles/src/send.ts @@ -0,0 +1,467 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import crypto from "node:crypto"; +import { resolveBlueBubblesAccount } from "./accounts.js"; +import { + extractHandleFromChatGuid, + normalizeBlueBubblesHandle, + parseBlueBubblesTarget, +} from "./targets.js"; +import { + blueBubblesFetchWithTimeout, + buildBlueBubblesApiUrl, + type BlueBubblesSendTarget, +} from "./types.js"; + +export type BlueBubblesSendOpts = { + serverUrl?: string; + password?: string; + accountId?: string; + timeoutMs?: number; + cfg?: OpenClawConfig; + /** Message GUID to reply to (reply threading) */ + replyToMessageGuid?: string; + /** Part index for reply (default: 0) */ + replyToPartIndex?: number; + /** Effect ID or short name for message effects (e.g., "slam", "balloons") */ + effectId?: string; +}; + +export type BlueBubblesSendResult = { + messageId: string; +}; + +/** Maps short effect names to full Apple effect IDs */ +const EFFECT_MAP: Record = { + // Bubble effects + slam: "com.apple.MobileSMS.expressivesend.impact", + loud: "com.apple.MobileSMS.expressivesend.loud", + gentle: "com.apple.MobileSMS.expressivesend.gentle", + invisible: "com.apple.MobileSMS.expressivesend.invisibleink", + "invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink", + "invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink", + invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink", + // Screen effects + echo: "com.apple.messages.effect.CKEchoEffect", + spotlight: "com.apple.messages.effect.CKSpotlightEffect", + balloons: "com.apple.messages.effect.CKHappyBirthdayEffect", + confetti: "com.apple.messages.effect.CKConfettiEffect", + love: "com.apple.messages.effect.CKHeartEffect", + heart: "com.apple.messages.effect.CKHeartEffect", + hearts: "com.apple.messages.effect.CKHeartEffect", + lasers: "com.apple.messages.effect.CKLasersEffect", + fireworks: "com.apple.messages.effect.CKFireworksEffect", + celebration: "com.apple.messages.effect.CKSparklesEffect", +}; + +function resolveEffectId(raw?: string): string | undefined { + if (!raw) { + return undefined; + } + const trimmed = raw.trim().toLowerCase(); + if (EFFECT_MAP[trimmed]) { + return EFFECT_MAP[trimmed]; + } + const normalized = trimmed.replace(/[\s_]+/g, "-"); + if (EFFECT_MAP[normalized]) { + return EFFECT_MAP[normalized]; + } + const compact = trimmed.replace(/[\s_-]+/g, ""); + if (EFFECT_MAP[compact]) { + return EFFECT_MAP[compact]; + } + return raw; +} + +function resolveSendTarget(raw: string): BlueBubblesSendTarget { + const parsed = parseBlueBubblesTarget(raw); + if (parsed.kind === "handle") { + return { + kind: "handle", + address: normalizeBlueBubblesHandle(parsed.to), + service: parsed.service, + }; + } + if (parsed.kind === "chat_id") { + return { kind: "chat_id", chatId: parsed.chatId }; + } + if (parsed.kind === "chat_guid") { + return { kind: "chat_guid", chatGuid: parsed.chatGuid }; + } + return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier }; +} + +function extractMessageId(payload: unknown): string { + if (!payload || typeof payload !== "object") { + return "unknown"; + } + const record = payload as Record; + const data = + record.data && typeof record.data === "object" + ? (record.data as Record) + : null; + const candidates = [ + record.messageId, + record.messageGuid, + record.message_guid, + record.guid, + record.id, + data?.messageId, + data?.messageGuid, + data?.message_guid, + data?.message_id, + data?.guid, + data?.id, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return String(candidate); + } + } + return "unknown"; +} + +type BlueBubblesChatRecord = Record; + +function extractChatGuid(chat: BlueBubblesChatRecord): string | null { + const candidates = [ + chat.chatGuid, + chat.guid, + chat.chat_guid, + chat.identifier, + chat.chatIdentifier, + chat.chat_identifier, + ]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim()) { + return candidate.trim(); + } + } + return null; +} + +function extractChatId(chat: BlueBubblesChatRecord): number | null { + const candidates = [chat.chatId, chat.id, chat.chat_id]; + for (const candidate of candidates) { + if (typeof candidate === "number" && Number.isFinite(candidate)) { + return candidate; + } + } + return null; +} + +function extractChatIdentifierFromChatGuid(chatGuid: string): string | null { + const parts = chatGuid.split(";"); + if (parts.length < 3) { + return null; + } + const identifier = parts[2]?.trim(); + return identifier ? identifier : null; +} + +function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] { + const raw = + (Array.isArray(chat.participants) ? chat.participants : null) ?? + (Array.isArray(chat.handles) ? chat.handles : null) ?? + (Array.isArray(chat.participantHandles) ? chat.participantHandles : null); + if (!raw) { + return []; + } + const out: string[] = []; + for (const entry of raw) { + if (typeof entry === "string") { + out.push(entry); + continue; + } + if (entry && typeof entry === "object") { + const record = entry as Record; + const candidate = + (typeof record.address === "string" && record.address) || + (typeof record.handle === "string" && record.handle) || + (typeof record.id === "string" && record.id) || + (typeof record.identifier === "string" && record.identifier); + if (candidate) { + out.push(candidate); + } + } + } + return out; +} + +async function queryChats(params: { + baseUrl: string; + password: string; + timeoutMs?: number; + offset: number; + limit: number; +}): Promise { + const url = buildBlueBubblesApiUrl({ + baseUrl: params.baseUrl, + path: "/api/v1/chat/query", + password: params.password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + limit: params.limit, + offset: params.offset, + with: ["participants"], + }), + }, + params.timeoutMs, + ); + if (!res.ok) { + return []; + } + const payload = (await res.json().catch(() => null)) as Record | null; + const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null; + return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : []; +} + +export async function resolveChatGuidForTarget(params: { + baseUrl: string; + password: string; + timeoutMs?: number; + target: BlueBubblesSendTarget; +}): Promise { + if (params.target.kind === "chat_guid") { + return params.target.chatGuid; + } + + const normalizedHandle = + params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : ""; + const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null; + const targetChatIdentifier = + params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null; + + const limit = 500; + let participantMatch: string | null = null; + for (let offset = 0; offset < 5000; offset += limit) { + const chats = await queryChats({ + baseUrl: params.baseUrl, + password: params.password, + timeoutMs: params.timeoutMs, + offset, + limit, + }); + if (chats.length === 0) { + break; + } + for (const chat of chats) { + if (targetChatId != null) { + const chatId = extractChatId(chat); + if (chatId != null && chatId === targetChatId) { + return extractChatGuid(chat); + } + } + if (targetChatIdentifier) { + const guid = extractChatGuid(chat); + if (guid) { + // Back-compat: some callers might pass a full chat GUID. + if (guid === targetChatIdentifier) { + return guid; + } + + // Primary match: BlueBubbles `chat_identifier:*` targets correspond to the + // third component of the chat GUID: `service;(+|-) ;identifier`. + const guidIdentifier = extractChatIdentifierFromChatGuid(guid); + if (guidIdentifier && guidIdentifier === targetChatIdentifier) { + return guid; + } + } + + const identifier = + typeof chat.identifier === "string" + ? chat.identifier + : typeof chat.chatIdentifier === "string" + ? chat.chatIdentifier + : typeof chat.chat_identifier === "string" + ? chat.chat_identifier + : ""; + if (identifier && identifier === targetChatIdentifier) { + return guid ?? extractChatGuid(chat); + } + } + if (normalizedHandle) { + const guid = extractChatGuid(chat); + const directHandle = guid ? extractHandleFromChatGuid(guid) : null; + if (directHandle && directHandle === normalizedHandle) { + return guid; + } + if (!participantMatch && guid) { + // Only consider DM chats (`;-;` separator) as participant matches. + // Group chats (`;+;` separator) should never match when searching by handle/phone. + // This prevents routing "send to +1234567890" to a group chat that contains that number. + const isDmChat = guid.includes(";-;"); + if (isDmChat) { + const participants = extractParticipantAddresses(chat).map((entry) => + normalizeBlueBubblesHandle(entry), + ); + if (participants.includes(normalizedHandle)) { + participantMatch = guid; + } + } + } + } + } + } + return participantMatch; +} + +/** + * Creates a new chat (DM) and optionally sends an initial message. + * Requires Private API to be enabled in BlueBubbles. + */ +async function createNewChatWithMessage(params: { + baseUrl: string; + password: string; + address: string; + message: string; + timeoutMs?: number; +}): Promise { + const url = buildBlueBubblesApiUrl({ + baseUrl: params.baseUrl, + path: "/api/v1/chat/new", + password: params.password, + }); + const payload = { + addresses: [params.address], + message: params.message, + }; + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + params.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text(); + // Check for Private API not enabled error + if ( + res.status === 400 || + res.status === 403 || + errorText.toLowerCase().includes("private api") + ) { + throw new Error( + `BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`, + ); + } + throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`); + } + const body = await res.text(); + if (!body) { + return { messageId: "ok" }; + } + try { + const parsed = JSON.parse(body) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} + +export async function sendMessageBlueBubbles( + to: string, + text: string, + opts: BlueBubblesSendOpts = {}, +): Promise { + const trimmedText = text ?? ""; + if (!trimmedText.trim()) { + throw new Error("BlueBubbles send requires text"); + } + + const account = resolveBlueBubblesAccount({ + cfg: opts.cfg ?? {}, + accountId: opts.accountId, + }); + const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim(); + const password = opts.password?.trim() || account.config.password?.trim(); + if (!baseUrl) { + throw new Error("BlueBubbles serverUrl is required"); + } + if (!password) { + throw new Error("BlueBubbles password is required"); + } + + const target = resolveSendTarget(to); + const chatGuid = await resolveChatGuidForTarget({ + baseUrl, + password, + timeoutMs: opts.timeoutMs, + target, + }); + if (!chatGuid) { + // If target is a phone number/handle and no existing chat found, + // auto-create a new DM chat using the /api/v1/chat/new endpoint + if (target.kind === "handle") { + return createNewChatWithMessage({ + baseUrl, + password, + address: target.address, + message: trimmedText, + timeoutMs: opts.timeoutMs, + }); + } + throw new Error( + "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", + ); + } + const effectId = resolveEffectId(opts.effectId); + const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); + const payload: Record = { + chatGuid, + tempGuid: crypto.randomUUID(), + message: trimmedText, + }; + if (needsPrivateApi) { + payload.method = "private-api"; + } + + // Add reply threading support + if (opts.replyToMessageGuid) { + payload.selectedMessageGuid = opts.replyToMessageGuid; + payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0; + } + + // Add message effects support + if (effectId) { + payload.effectId = effectId; + } + + const url = buildBlueBubblesApiUrl({ + baseUrl, + path: "/api/v1/message/text", + password, + }); + const res = await blueBubblesFetchWithTimeout( + url, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + opts.timeoutMs, + ); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`); + } + const body = await res.text(); + if (!body) { + return { messageId: "ok" }; + } + try { + const parsed = JSON.parse(body) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb159b1fb75e4c6fbbc7ca99f24dadb86e5bc37f --- /dev/null +++ b/extensions/bluebubbles/src/targets.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from "vitest"; +import { + looksLikeBlueBubblesTargetId, + normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, + parseBlueBubblesAllowTarget, +} from "./targets.js"; + +describe("normalizeBlueBubblesMessagingTarget", () => { + it("normalizes chat_guid targets", () => { + expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123"); + }); + + it("normalizes group numeric targets to chat_id", () => { + expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123"); + }); + + it("strips provider prefix and normalizes handles", () => { + expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe( + "imessage:user@example.com", + ); + }); + + it("extracts handle from DM chat_guid for cross-context matching", () => { + // DM format: service;-;handle + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe( + "+19257864429", + ); + expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe( + "+15551234567", + ); + // Email handles + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe( + "user@example.com", + ); + }); + + it("preserves group chat_guid format", () => { + // Group format: service;+;groupId + expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe( + "chat_guid:iMessage;+;chat123456789", + ); + }); + + it("normalizes raw chat_guid values", () => { + expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe( + "chat_guid:iMessage;+;chat660250192681427962", + ); + expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429"); + }); + + it("normalizes chat pattern to chat_identifier format", () => { + expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe( + "chat_identifier:chat660250192681427962", + ); + expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123"); + expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789"); + }); + + it("normalizes UUID/hex chat identifiers", () => { + expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe( + "chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc", + ); + expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe( + "chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678", + ); + }); +}); + +describe("looksLikeBlueBubblesTargetId", () => { + it("accepts chat targets", () => { + expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true); + }); + + it("accepts email handles", () => { + expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true); + }); + + it("accepts phone numbers with punctuation", () => { + expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true); + }); + + it("accepts raw chat_guid values", () => { + expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true); + }); + + it("accepts chat pattern as chat_id", () => { + expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true); + expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true); + expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true); + }); + + it("accepts UUID/hex chat identifiers", () => { + expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true); + expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true); + }); + + it("rejects display names", () => { + expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false); + }); +}); + +describe("parseBlueBubblesTarget", () => { + it("parses chat pattern as chat_identifier", () => { + expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }); + expect(parseBlueBubblesTarget("chat123")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat123", + }); + expect(parseBlueBubblesTarget("Chat456789")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "Chat456789", + }); + }); + + it("parses UUID/hex chat identifiers as chat_identifier", () => { + expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", + }); + expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", + }); + }); + + it("parses explicit chat_id: prefix", () => { + expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 }); + }); + + it("parses phone numbers as handles", () => { + expect(parseBlueBubblesTarget("+19257864429")).toEqual({ + kind: "handle", + to: "+19257864429", + service: "auto", + }); + }); + + it("parses raw chat_guid format", () => { + expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({ + kind: "chat_guid", + chatGuid: "iMessage;+;chat660250192681427962", + }); + }); +}); + +describe("parseBlueBubblesAllowTarget", () => { + it("parses chat pattern as chat_identifier", () => { + expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat660250192681427962", + }); + expect(parseBlueBubblesAllowTarget("chat123")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "chat123", + }); + }); + + it("parses UUID/hex chat identifiers as chat_identifier", () => { + expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc", + }); + expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({ + kind: "chat_identifier", + chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678", + }); + }); + + it("parses explicit chat_id: prefix", () => { + expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 }); + }); + + it("parses phone numbers as handles", () => { + expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({ + kind: "handle", + handle: "+19257864429", + }); + }); +}); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts new file mode 100644 index 0000000000000000000000000000000000000000..738e144da309a176edf78bb48e56930a5294d6c7 --- /dev/null +++ b/extensions/bluebubbles/src/targets.ts @@ -0,0 +1,422 @@ +export type BlueBubblesService = "imessage" | "sms" | "auto"; + +export type BlueBubblesTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; to: string; service: BlueBubblesService }; + +export type BlueBubblesAllowTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; handle: string }; + +const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"]; +const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"]; +const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"]; +const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [ + { prefix: "imessage:", service: "imessage" }, + { prefix: "sms:", service: "sms" }, + { prefix: "auto:", service: "auto" }, +]; +const CHAT_IDENTIFIER_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const CHAT_IDENTIFIER_HEX_RE = /^[0-9a-f]{24,64}$/i; + +function parseRawChatGuid(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parts = trimmed.split(";"); + if (parts.length !== 3) { + return null; + } + const service = parts[0]?.trim(); + const separator = parts[1]?.trim(); + const identifier = parts[2]?.trim(); + if (!service || !identifier) { + return null; + } + if (separator !== "+" && separator !== "-") { + return null; + } + return `${service};${separator};${identifier}`; +} + +function stripPrefix(value: string, prefix: string): string { + return value.slice(prefix.length).trim(); +} + +function stripBlueBubblesPrefix(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (!trimmed.toLowerCase().startsWith("bluebubbles:")) { + return trimmed; + } + return trimmed.slice("bluebubbles:".length).trim(); +} + +function looksLikeRawChatIdentifier(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) { + return false; + } + if (/^chat\d+$/i.test(trimmed)) { + return true; + } + return CHAT_IDENTIFIER_UUID_RE.test(trimmed) || CHAT_IDENTIFIER_HEX_RE.test(trimmed); +} + +export function normalizeBlueBubblesHandle(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return ""; + } + const lowered = trimmed.toLowerCase(); + if (lowered.startsWith("imessage:")) { + return normalizeBlueBubblesHandle(trimmed.slice(9)); + } + if (lowered.startsWith("sms:")) { + return normalizeBlueBubblesHandle(trimmed.slice(4)); + } + if (lowered.startsWith("auto:")) { + return normalizeBlueBubblesHandle(trimmed.slice(5)); + } + if (trimmed.includes("@")) { + return trimmed.toLowerCase(); + } + return trimmed.replace(/\s+/g, ""); +} + +/** + * Extracts the handle from a chat_guid if it's a DM (1:1 chat). + * BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429") + * Group chat format: "service;+;groupId" (has "+" instead of "-") + */ +export function extractHandleFromChatGuid(chatGuid: string): string | null { + const parts = chatGuid.split(";"); + // DM format: service;-;handle (3 parts, middle is "-") + if (parts.length === 3 && parts[1] === "-") { + const handle = parts[2]?.trim(); + if (handle) { + return normalizeBlueBubblesHandle(handle); + } + } + return null; +} + +export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { + let trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + trimmed = stripBlueBubblesPrefix(trimmed); + if (!trimmed) { + return undefined; + } + try { + const parsed = parseBlueBubblesTarget(trimmed); + if (parsed.kind === "chat_id") { + return `chat_id:${parsed.chatId}`; + } + if (parsed.kind === "chat_guid") { + // For DM chat_guids, normalize to just the handle for easier comparison. + // This allows "chat_guid:iMessage;-;+1234567890" to match "+1234567890". + const handle = extractHandleFromChatGuid(parsed.chatGuid); + if (handle) { + return handle; + } + // For group chats or unrecognized formats, keep the full chat_guid + return `chat_guid:${parsed.chatGuid}`; + } + if (parsed.kind === "chat_identifier") { + return `chat_identifier:${parsed.chatIdentifier}`; + } + const handle = normalizeBlueBubblesHandle(parsed.to); + if (!handle) { + return undefined; + } + return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`; + } catch { + return trimmed; + } +} + +export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + const candidate = stripBlueBubblesPrefix(trimmed); + if (!candidate) { + return false; + } + if (parseRawChatGuid(candidate)) { + return true; + } + const lowered = candidate.toLowerCase(); + if (/^(imessage|sms|auto):/.test(lowered)) { + return true; + } + if ( + /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( + lowered, + ) + ) { + return true; + } + // Recognize chat patterns (e.g., "chat660250192681427962") as chat IDs + if (/^chat\d+$/i.test(candidate)) { + return true; + } + if (looksLikeRawChatIdentifier(candidate)) { + return true; + } + if (candidate.includes("@")) { + return true; + } + const digitsOnly = candidate.replace(/[\s().-]/g, ""); + if (/^\+?\d{3,}$/.test(digitsOnly)) { + return true; + } + if (normalized) { + const normalizedTrimmed = normalized.trim(); + if (!normalizedTrimmed) { + return false; + } + const normalizedLower = normalizedTrimmed.toLowerCase(); + if ( + /^(imessage|sms|auto):/.test(normalizedLower) || + /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) + ) { + return true; + } + } + return false; +} + +export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { + const trimmed = stripBlueBubblesPrefix(raw); + if (!trimmed) { + throw new Error("BlueBubbles target is required"); + } + const lower = trimmed.toLowerCase(); + + for (const { prefix, service } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) { + throw new Error(`${prefix} target is required`); + } + const remainderLower = remainder.toLowerCase(); + const isChatTarget = + CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) || + CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) || + remainderLower.startsWith("group:"); + if (isChatTarget) { + return parseBlueBubblesTarget(remainder); + } + return { kind: "handle", to: remainder, service }; + } + } + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (!Number.isFinite(chatId)) { + throw new Error(`Invalid chat_id: ${value}`); + } + return { kind: "chat_id", chatId }; + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) { + throw new Error("chat_guid is required"); + } + return { kind: "chat_guid", chatGuid: value }; + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (!value) { + throw new Error("chat_identifier is required"); + } + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (!value) { + throw new Error("group target is required"); + } + return { kind: "chat_guid", chatGuid: value }; + } + + const rawChatGuid = parseRawChatGuid(trimmed); + if (rawChatGuid) { + return { kind: "chat_guid", chatGuid: rawChatGuid }; + } + + // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier + // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + + // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + + return { kind: "handle", to: trimmed, service: "auto" }; +} + +export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget { + const trimmed = raw.trim(); + if (!trimmed) { + return { kind: "handle", handle: "" }; + } + const lower = trimmed.toLowerCase(); + + for (const { prefix } of SERVICE_PREFIXES) { + if (lower.startsWith(prefix)) { + const remainder = stripPrefix(trimmed, prefix); + if (!remainder) { + return { kind: "handle", handle: "" }; + } + return parseBlueBubblesAllowTarget(remainder); + } + } + + for (const prefix of CHAT_ID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + } + } + + for (const prefix of CHAT_GUID_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + } + } + + for (const prefix of CHAT_IDENTIFIER_PREFIXES) { + if (lower.startsWith(prefix)) { + const value = stripPrefix(trimmed, prefix); + if (value) { + return { kind: "chat_identifier", chatIdentifier: value }; + } + } + } + + if (lower.startsWith("group:")) { + const value = stripPrefix(trimmed, "group:"); + const chatId = Number.parseInt(value, 10); + if (Number.isFinite(chatId)) { + return { kind: "chat_id", chatId }; + } + if (value) { + return { kind: "chat_guid", chatGuid: value }; + } + } + + // Handle chat pattern (e.g., "chat660250192681427962") as chat_identifier + // These are BlueBubbles chat identifiers (the third part of a chat GUID), not numeric IDs + if (/^chat\d+$/i.test(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + + // Handle UUID/hex chat identifiers (e.g., "8b9c1a10536d4d86a336ea03ab7151cc") + if (looksLikeRawChatIdentifier(trimmed)) { + return { kind: "chat_identifier", chatIdentifier: trimmed }; + } + + return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) }; +} + +export function isAllowedBlueBubblesSender(params: { + allowFrom: Array; + sender: string; + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}): boolean { + const allowFrom = params.allowFrom.map((entry) => String(entry).trim()); + if (allowFrom.length === 0) { + return true; + } + if (allowFrom.includes("*")) { + return true; + } + + const senderNormalized = normalizeBlueBubblesHandle(params.sender); + const chatId = params.chatId ?? undefined; + const chatGuid = params.chatGuid?.trim(); + const chatIdentifier = params.chatIdentifier?.trim(); + + for (const entry of allowFrom) { + if (!entry) { + continue; + } + const parsed = parseBlueBubblesAllowTarget(entry); + if (parsed.kind === "chat_id" && chatId !== undefined) { + if (parsed.chatId === chatId) { + return true; + } + } else if (parsed.kind === "chat_guid" && chatGuid) { + if (parsed.chatGuid === chatGuid) { + return true; + } + } else if (parsed.kind === "chat_identifier" && chatIdentifier) { + if (parsed.chatIdentifier === chatIdentifier) { + return true; + } + } else if (parsed.kind === "handle" && senderNormalized) { + if (parsed.handle === senderNormalized) { + return true; + } + } + } + return false; +} + +export function formatBlueBubblesChatTarget(params: { + chatId?: number | null; + chatGuid?: string | null; + chatIdentifier?: string | null; +}): string { + if (params.chatId && Number.isFinite(params.chatId)) { + return `chat_id:${params.chatId}`; + } + const guid = params.chatGuid?.trim(); + if (guid) { + return `chat_guid:${guid}`; + } + const identifier = params.chatIdentifier?.trim(); + if (identifier) { + return `chat_identifier:${identifier}`; + } + return ""; +} diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2aeb402277bfa1679b59b32e6844a40e4434cc7 --- /dev/null +++ b/extensions/bluebubbles/src/types.ts @@ -0,0 +1,127 @@ +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; + +export type BlueBubblesGroupConfig = { + /** If true, only respond in this group when mentioned. */ + requireMention?: boolean; + /** Optional tool policy overrides for this group. */ + tools?: { allow?: string[]; deny?: string[] }; +}; + +export type BlueBubblesAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this BlueBubbles account. Default: true. */ + enabled?: boolean; + /** Base URL for the BlueBubbles API. */ + serverUrl?: string; + /** Password for BlueBubbles API authentication. */ + password?: string; + /** Webhook path for the gateway HTTP server. */ + webhookPath?: string; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + allowFrom?: Array; + /** Optional allowlist for group senders. */ + groupAllowFrom?: Array; + /** Group message handling policy. */ + groupPolicy?: GroupPolicy; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */ + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: Record; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; + /** Send read receipts for incoming messages (default: true). */ + sendReadReceipts?: boolean; + /** Per-group configuration keyed by chat GUID or identifier. */ + groups?: Record; +}; + +export type BlueBubblesActionConfig = { + reactions?: boolean; + edit?: boolean; + unsend?: boolean; + reply?: boolean; + sendWithEffect?: boolean; + renameGroup?: boolean; + addParticipant?: boolean; + removeParticipant?: boolean; + leaveGroup?: boolean; + sendAttachment?: boolean; +}; + +export type BlueBubblesConfig = { + /** Optional per-account BlueBubbles configuration (multi-account). */ + accounts?: Record; + /** Per-action tool gating (default: true for all). */ + actions?: BlueBubblesActionConfig; +} & BlueBubblesAccountConfig; + +export type BlueBubblesSendTarget = + | { kind: "chat_id"; chatId: number } + | { kind: "chat_guid"; chatGuid: string } + | { kind: "chat_identifier"; chatIdentifier: string } + | { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" }; + +export type BlueBubblesAttachment = { + guid?: string; + uti?: string; + mimeType?: string; + transferName?: string; + totalBytes?: number; + height?: number; + width?: number; + originalROWID?: number; +}; + +const DEFAULT_TIMEOUT_MS = 10_000; + +export function normalizeBlueBubblesServerUrl(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("BlueBubbles serverUrl is required"); + } + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + return withScheme.replace(/\/+$/, ""); +} + +export function buildBlueBubblesApiUrl(params: { + baseUrl: string; + path: string; + password?: string; +}): string { + const normalized = normalizeBlueBubblesServerUrl(params.baseUrl); + const url = new URL(params.path, `${normalized}/`); + if (params.password) { + url.searchParams.set("password", params.password); + } + return url.toString(); +} + +export async function blueBubblesFetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs = DEFAULT_TIMEOUT_MS, +) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} diff --git a/extensions/copilot-proxy/README.md b/extensions/copilot-proxy/README.md new file mode 100644 index 0000000000000000000000000000000000000000..07ffde465279e602effd98e62868af53a243be13 --- /dev/null +++ b/extensions/copilot-proxy/README.md @@ -0,0 +1,24 @@ +# Copilot Proxy (OpenClaw plugin) + +Provider plugin for the **Copilot Proxy** VS Code extension. + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +openclaw plugins enable copilot-proxy +``` + +Restart the Gateway after enabling. + +## Authenticate + +```bash +openclaw models auth login --provider copilot-proxy --set-default +``` + +## Notes + +- Copilot Proxy must be running in VS Code. +- Base URL must include `/v1`. diff --git a/extensions/copilot-proxy/index.ts b/extensions/copilot-proxy/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae674bd0dc39ee8a0edfbadd635b91adfec31d14 --- /dev/null +++ b/extensions/copilot-proxy/index.ts @@ -0,0 +1,148 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; + +const DEFAULT_BASE_URL = "http://localhost:3000/v1"; +const DEFAULT_API_KEY = "n/a"; +const DEFAULT_CONTEXT_WINDOW = 128_000; +const DEFAULT_MAX_TOKENS = 8192; +const DEFAULT_MODEL_IDS = [ + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5-mini", + "claude-opus-4.5", + "claude-sonnet-4.5", + "claude-haiku-4.5", + "gemini-3-pro", + "gemini-3-flash", + "grok-code-fast-1", +] as const; + +function normalizeBaseUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return DEFAULT_BASE_URL; + } + let normalized = trimmed; + while (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + if (!normalized.endsWith("/v1")) { + normalized = `${normalized}/v1`; + } + return normalized; +} + +function validateBaseUrl(value: string): string | undefined { + const normalized = normalizeBaseUrl(value); + try { + new URL(normalized); + } catch { + return "Enter a valid URL"; + } + return undefined; +} + +function parseModelIds(input: string): string[] { + const parsed = input + .split(/[\n,]/) + .map((model) => model.trim()) + .filter(Boolean); + return Array.from(new Set(parsed)); +} + +function buildModelDefinition(modelId: string) { + return { + id: modelId, + name: modelId, + api: "openai-completions", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +const copilotProxyPlugin = { + id: "copilot-proxy", + name: "Copilot Proxy", + description: "Local Copilot Proxy (VS Code LM) provider plugin", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: "copilot-proxy", + label: "Copilot Proxy", + docsPath: "/providers/models", + auth: [ + { + id: "local", + label: "Local proxy", + hint: "Configure base URL + models for the Copilot Proxy server", + kind: "custom", + run: async (ctx) => { + const baseUrlInput = await ctx.prompter.text({ + message: "Copilot Proxy base URL", + initialValue: DEFAULT_BASE_URL, + validate: validateBaseUrl, + }); + + const modelInput = await ctx.prompter.text({ + message: "Model IDs (comma-separated)", + initialValue: DEFAULT_MODEL_IDS.join(", "), + validate: (value) => + parseModelIds(value).length > 0 ? undefined : "Enter at least one model id", + }); + + const baseUrl = normalizeBaseUrl(baseUrlInput); + const modelIds = parseModelIds(modelInput); + const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0]; + const defaultModelRef = `copilot-proxy/${defaultModelId}`; + + return { + profiles: [ + { + profileId: "copilot-proxy:local", + credential: { + type: "token", + provider: "copilot-proxy", + token: DEFAULT_API_KEY, + }, + }, + ], + configPatch: { + models: { + providers: { + "copilot-proxy": { + baseUrl, + apiKey: DEFAULT_API_KEY, + api: "openai-completions", + authHeader: false, + models: modelIds.map((modelId) => buildModelDefinition(modelId)), + }, + }, + }, + agents: { + defaults: { + models: Object.fromEntries( + modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]), + ), + }, + }, + }, + defaultModel: defaultModelRef, + notes: [ + "Start the Copilot Proxy VS Code extension before using these models.", + "Copilot Proxy serves /v1/chat/completions; base URL must include /v1.", + "Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.", + ], + }; + }, + }, + ], + }); + }, +}; + +export default copilotProxyPlugin; diff --git a/extensions/copilot-proxy/openclaw.plugin.json b/extensions/copilot-proxy/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..90e2011f014edcfda87cc1a22631bc1b623fc72f --- /dev/null +++ b/extensions/copilot-proxy/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "copilot-proxy", + "providers": ["copilot-proxy"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1bb444cb3c19f721ae308b0214583d266ecc70bd --- /dev/null +++ b/extensions/copilot-proxy/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/copilot-proxy", + "version": "2026.1.30", + "description": "OpenClaw Copilot Proxy provider plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/diagnostics-otel/index.ts b/extensions/diagnostics-otel/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b9c5318deff85ddee378d6a87ffcaaa0cecb52e --- /dev/null +++ b/extensions/diagnostics-otel/index.ts @@ -0,0 +1,15 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { createDiagnosticsOtelService } from "./src/service.js"; + +const plugin = { + id: "diagnostics-otel", + name: "Diagnostics OpenTelemetry", + description: "Export diagnostics events to OpenTelemetry", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerService(createDiagnosticsOtelService()); + }, +}; + +export default plugin; diff --git a/extensions/diagnostics-otel/openclaw.plugin.json b/extensions/diagnostics-otel/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..3207d1f18b136a02df4753e2e0d17d6a69d59cf5 --- /dev/null +++ b/extensions/diagnostics-otel/openclaw.plugin.json @@ -0,0 +1,8 @@ +{ + "id": "diagnostics-otel", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f20b495f7afc0bdb52ea62ce33aad1e534b7f17c --- /dev/null +++ b/extensions/diagnostics-otel/package.json @@ -0,0 +1,27 @@ +{ + "name": "@openclaw/diagnostics-otel", + "version": "2026.1.30", + "description": "OpenClaw diagnostics OpenTelemetry exporter", + "type": "module", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api-logs": "^0.211.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.211.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.211.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", + "@opentelemetry/resources": "^2.5.0", + "@opentelemetry/sdk-logs": "^0.211.0", + "@opentelemetry/sdk-metrics": "^2.5.0", + "@opentelemetry/sdk-node": "^0.211.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fca546730443dccc5633cb7657fac1a7b3f00c91 --- /dev/null +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const registerLogTransportMock = vi.hoisted(() => vi.fn()); + +const telemetryState = vi.hoisted(() => { + const counters = new Map }>(); + const histograms = new Map }>(); + const tracer = { + startSpan: vi.fn((_name: string, _opts?: unknown) => ({ + end: vi.fn(), + setStatus: vi.fn(), + })), + }; + const meter = { + createCounter: vi.fn((name: string) => { + const counter = { add: vi.fn() }; + counters.set(name, counter); + return counter; + }), + createHistogram: vi.fn((name: string) => { + const histogram = { record: vi.fn() }; + histograms.set(name, histogram); + return histogram; + }), + }; + return { counters, histograms, tracer, meter }; +}); + +const sdkStart = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const sdkShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +const logEmit = vi.hoisted(() => vi.fn()); +const logShutdown = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); + +vi.mock("@opentelemetry/api", () => ({ + metrics: { + getMeter: () => telemetryState.meter, + }, + trace: { + getTracer: () => telemetryState.tracer, + }, + SpanStatusCode: { + ERROR: 2, + }, +})); + +vi.mock("@opentelemetry/sdk-node", () => ({ + NodeSDK: class { + start = sdkStart; + shutdown = sdkShutdown; + }, +})); + +vi.mock("@opentelemetry/exporter-metrics-otlp-http", () => ({ + OTLPMetricExporter: class {}, +})); + +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ + OTLPTraceExporter: class {}, +})); + +vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ + OTLPLogExporter: class {}, +})); + +vi.mock("@opentelemetry/sdk-logs", () => ({ + BatchLogRecordProcessor: class {}, + LoggerProvider: class { + addLogRecordProcessor = vi.fn(); + getLogger = vi.fn(() => ({ + emit: logEmit, + })); + shutdown = logShutdown; + }, +})); + +vi.mock("@opentelemetry/sdk-metrics", () => ({ + PeriodicExportingMetricReader: class {}, +})); + +vi.mock("@opentelemetry/sdk-trace-base", () => ({ + ParentBasedSampler: class {}, + TraceIdRatioBasedSampler: class {}, +})); + +vi.mock("@opentelemetry/resources", () => ({ + Resource: class { + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(_value?: unknown) {} + }, +})); + +vi.mock("@opentelemetry/semantic-conventions", () => ({ + SemanticResourceAttributes: { + SERVICE_NAME: "service.name", + }, +})); + +vi.mock("openclaw/plugin-sdk", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk"); + return { + ...actual, + registerLogTransport: registerLogTransportMock, + }; +}); + +import { emitDiagnosticEvent } from "openclaw/plugin-sdk"; +import { createDiagnosticsOtelService } from "./service.js"; + +describe("diagnostics-otel service", () => { + beforeEach(() => { + telemetryState.counters.clear(); + telemetryState.histograms.clear(); + telemetryState.tracer.startSpan.mockClear(); + telemetryState.meter.createCounter.mockClear(); + telemetryState.meter.createHistogram.mockClear(); + sdkStart.mockClear(); + sdkShutdown.mockClear(); + logEmit.mockClear(); + logShutdown.mockClear(); + registerLogTransportMock.mockReset(); + }); + + test("records message-flow metrics and spans", async () => { + const registeredTransports: Array<(logObj: Record) => void> = []; + const stopTransport = vi.fn(); + registerLogTransportMock.mockImplementation((transport) => { + registeredTransports.push(transport); + return stopTransport; + }); + + const service = createDiagnosticsOtelService(); + await service.start({ + config: { + diagnostics: { + enabled: true, + otel: { + enabled: true, + endpoint: "http://otel-collector:4318", + protocol: "http/protobuf", + traces: true, + metrics: true, + logs: true, + }, + }, + }, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + }); + + emitDiagnosticEvent({ + type: "webhook.received", + channel: "telegram", + updateType: "telegram-post", + }); + emitDiagnosticEvent({ + type: "webhook.processed", + channel: "telegram", + updateType: "telegram-post", + durationMs: 120, + }); + emitDiagnosticEvent({ + type: "message.queued", + channel: "telegram", + source: "telegram", + queueDepth: 2, + }); + emitDiagnosticEvent({ + type: "message.processed", + channel: "telegram", + outcome: "completed", + durationMs: 55, + }); + emitDiagnosticEvent({ + type: "queue.lane.dequeue", + lane: "main", + queueSize: 3, + waitMs: 10, + }); + emitDiagnosticEvent({ + type: "session.stuck", + state: "processing", + ageMs: 125_000, + }); + emitDiagnosticEvent({ + type: "run.attempt", + runId: "run-1", + attempt: 2, + }); + + expect(telemetryState.counters.get("openclaw.webhook.received")?.add).toHaveBeenCalled(); + expect( + telemetryState.histograms.get("openclaw.webhook.duration_ms")?.record, + ).toHaveBeenCalled(); + expect(telemetryState.counters.get("openclaw.message.queued")?.add).toHaveBeenCalled(); + expect(telemetryState.counters.get("openclaw.message.processed")?.add).toHaveBeenCalled(); + expect( + telemetryState.histograms.get("openclaw.message.duration_ms")?.record, + ).toHaveBeenCalled(); + expect(telemetryState.histograms.get("openclaw.queue.wait_ms")?.record).toHaveBeenCalled(); + expect(telemetryState.counters.get("openclaw.session.stuck")?.add).toHaveBeenCalled(); + expect( + telemetryState.histograms.get("openclaw.session.stuck_age_ms")?.record, + ).toHaveBeenCalled(); + expect(telemetryState.counters.get("openclaw.run.attempt")?.add).toHaveBeenCalled(); + + const spanNames = telemetryState.tracer.startSpan.mock.calls.map((call) => call[0]); + expect(spanNames).toContain("openclaw.webhook.processed"); + expect(spanNames).toContain("openclaw.message.processed"); + expect(spanNames).toContain("openclaw.session.stuck"); + + expect(registerLogTransportMock).toHaveBeenCalledTimes(1); + expect(registeredTransports).toHaveLength(1); + registeredTransports[0]?.({ + 0: '{"subsystem":"diagnostic"}', + 1: "hello", + _meta: { logLevelName: "INFO", date: new Date() }, + }); + expect(logEmit).toHaveBeenCalled(); + + await service.stop?.(); + }); +}); diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe05fe4bd4cec548425b0182c0b0bb7cfdaab1d5 --- /dev/null +++ b/extensions/diagnostics-otel/src/service.ts @@ -0,0 +1,635 @@ +import type { SeverityNumber } from "@opentelemetry/api-logs"; +import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk"; +import { metrics, trace, SpanStatusCode } from "@opentelemetry/api"; +import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; +import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { Resource } from "@opentelemetry/resources"; +import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs"; +import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"; +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base"; +import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"; +import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk"; + +const DEFAULT_SERVICE_NAME = "openclaw"; + +function normalizeEndpoint(endpoint?: string): string | undefined { + const trimmed = endpoint?.trim(); + return trimmed ? trimmed.replace(/\/+$/, "") : undefined; +} + +function resolveOtelUrl(endpoint: string | undefined, path: string): string | undefined { + if (!endpoint) { + return undefined; + } + if (endpoint.includes("/v1/")) { + return endpoint; + } + return `${endpoint}/${path}`; +} + +function resolveSampleRate(value: number | undefined): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + if (value < 0 || value > 1) { + return undefined; + } + return value; +} + +export function createDiagnosticsOtelService(): OpenClawPluginService { + let sdk: NodeSDK | null = null; + let logProvider: LoggerProvider | null = null; + let stopLogTransport: (() => void) | null = null; + let unsubscribe: (() => void) | null = null; + + return { + id: "diagnostics-otel", + async start(ctx) { + const cfg = ctx.config.diagnostics; + const otel = cfg?.otel; + if (!cfg?.enabled || !otel?.enabled) { + return; + } + + const protocol = otel.protocol ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL ?? "http/protobuf"; + if (protocol !== "http/protobuf") { + ctx.logger.warn(`diagnostics-otel: unsupported protocol ${protocol}`); + return; + } + + const endpoint = normalizeEndpoint(otel.endpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT); + const headers = otel.headers ?? undefined; + const serviceName = + otel.serviceName?.trim() || process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE_NAME; + const sampleRate = resolveSampleRate(otel.sampleRate); + + const tracesEnabled = otel.traces !== false; + const metricsEnabled = otel.metrics !== false; + const logsEnabled = otel.logs === true; + if (!tracesEnabled && !metricsEnabled && !logsEnabled) { + return; + } + + const resource = new Resource({ + [SemanticResourceAttributes.SERVICE_NAME]: serviceName, + }); + + const traceUrl = resolveOtelUrl(endpoint, "v1/traces"); + const metricUrl = resolveOtelUrl(endpoint, "v1/metrics"); + const logUrl = resolveOtelUrl(endpoint, "v1/logs"); + const traceExporter = tracesEnabled + ? new OTLPTraceExporter({ + ...(traceUrl ? { url: traceUrl } : {}), + ...(headers ? { headers } : {}), + }) + : undefined; + + const metricExporter = metricsEnabled + ? new OTLPMetricExporter({ + ...(metricUrl ? { url: metricUrl } : {}), + ...(headers ? { headers } : {}), + }) + : undefined; + + const metricReader = metricExporter + ? new PeriodicExportingMetricReader({ + exporter: metricExporter, + ...(typeof otel.flushIntervalMs === "number" + ? { exportIntervalMillis: Math.max(1000, otel.flushIntervalMs) } + : {}), + }) + : undefined; + + if (tracesEnabled || metricsEnabled) { + sdk = new NodeSDK({ + resource, + ...(traceExporter ? { traceExporter } : {}), + ...(metricReader ? { metricReader } : {}), + ...(sampleRate !== undefined + ? { + sampler: new ParentBasedSampler({ + root: new TraceIdRatioBasedSampler(sampleRate), + }), + } + : {}), + }); + + sdk.start(); + } + + const logSeverityMap: Record = { + TRACE: 1 as SeverityNumber, + DEBUG: 5 as SeverityNumber, + INFO: 9 as SeverityNumber, + WARN: 13 as SeverityNumber, + ERROR: 17 as SeverityNumber, + FATAL: 21 as SeverityNumber, + }; + + const meter = metrics.getMeter("openclaw"); + const tracer = trace.getTracer("openclaw"); + + const tokensCounter = meter.createCounter("openclaw.tokens", { + unit: "1", + description: "Token usage by type", + }); + const costCounter = meter.createCounter("openclaw.cost.usd", { + unit: "1", + description: "Estimated model cost (USD)", + }); + const durationHistogram = meter.createHistogram("openclaw.run.duration_ms", { + unit: "ms", + description: "Agent run duration", + }); + const contextHistogram = meter.createHistogram("openclaw.context.tokens", { + unit: "1", + description: "Context window size and usage", + }); + const webhookReceivedCounter = meter.createCounter("openclaw.webhook.received", { + unit: "1", + description: "Webhook requests received", + }); + const webhookErrorCounter = meter.createCounter("openclaw.webhook.error", { + unit: "1", + description: "Webhook processing errors", + }); + const webhookDurationHistogram = meter.createHistogram("openclaw.webhook.duration_ms", { + unit: "ms", + description: "Webhook processing duration", + }); + const messageQueuedCounter = meter.createCounter("openclaw.message.queued", { + unit: "1", + description: "Messages queued for processing", + }); + const messageProcessedCounter = meter.createCounter("openclaw.message.processed", { + unit: "1", + description: "Messages processed by outcome", + }); + const messageDurationHistogram = meter.createHistogram("openclaw.message.duration_ms", { + unit: "ms", + description: "Message processing duration", + }); + const queueDepthHistogram = meter.createHistogram("openclaw.queue.depth", { + unit: "1", + description: "Queue depth on enqueue/dequeue", + }); + const queueWaitHistogram = meter.createHistogram("openclaw.queue.wait_ms", { + unit: "ms", + description: "Queue wait time before execution", + }); + const laneEnqueueCounter = meter.createCounter("openclaw.queue.lane.enqueue", { + unit: "1", + description: "Command queue lane enqueue events", + }); + const laneDequeueCounter = meter.createCounter("openclaw.queue.lane.dequeue", { + unit: "1", + description: "Command queue lane dequeue events", + }); + const sessionStateCounter = meter.createCounter("openclaw.session.state", { + unit: "1", + description: "Session state transitions", + }); + const sessionStuckCounter = meter.createCounter("openclaw.session.stuck", { + unit: "1", + description: "Sessions stuck in processing", + }); + const sessionStuckAgeHistogram = meter.createHistogram("openclaw.session.stuck_age_ms", { + unit: "ms", + description: "Age of stuck sessions", + }); + const runAttemptCounter = meter.createCounter("openclaw.run.attempt", { + unit: "1", + description: "Run attempts", + }); + + if (logsEnabled) { + const logExporter = new OTLPLogExporter({ + ...(logUrl ? { url: logUrl } : {}), + ...(headers ? { headers } : {}), + }); + logProvider = new LoggerProvider({ resource }); + logProvider.addLogRecordProcessor( + new BatchLogRecordProcessor( + logExporter, + typeof otel.flushIntervalMs === "number" + ? { scheduledDelayMillis: Math.max(1000, otel.flushIntervalMs) } + : {}, + ), + ); + const otelLogger = logProvider.getLogger("openclaw"); + + stopLogTransport = registerLogTransport((logObj) => { + const safeStringify = (value: unknown) => { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + }; + const meta = (logObj as Record)._meta as + | { + logLevelName?: string; + date?: Date; + name?: string; + parentNames?: string[]; + path?: { + filePath?: string; + fileLine?: string; + fileColumn?: string; + filePathWithLine?: string; + method?: string; + }; + } + | undefined; + const logLevelName = meta?.logLevelName ?? "INFO"; + const severityNumber = logSeverityMap[logLevelName] ?? (9 as SeverityNumber); + + const numericArgs = Object.entries(logObj) + .filter(([key]) => /^\d+$/.test(key)) + .toSorted((a, b) => Number(a[0]) - Number(b[0])) + .map(([, value]) => value); + + let bindings: Record | undefined; + if (typeof numericArgs[0] === "string" && numericArgs[0].trim().startsWith("{")) { + try { + const parsed = JSON.parse(numericArgs[0]); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + bindings = parsed as Record; + numericArgs.shift(); + } + } catch { + // ignore malformed json bindings + } + } + + let message = ""; + if (numericArgs.length > 0 && typeof numericArgs[numericArgs.length - 1] === "string") { + message = String(numericArgs.pop()); + } else if (numericArgs.length === 1) { + message = safeStringify(numericArgs[0]); + numericArgs.length = 0; + } + if (!message) { + message = "log"; + } + + const attributes: Record = { + "openclaw.log.level": logLevelName, + }; + if (meta?.name) { + attributes["openclaw.logger"] = meta.name; + } + if (meta?.parentNames?.length) { + attributes["openclaw.logger.parents"] = meta.parentNames.join("."); + } + if (bindings) { + for (const [key, value] of Object.entries(bindings)) { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + attributes[`openclaw.${key}`] = value; + } else if (value != null) { + attributes[`openclaw.${key}`] = safeStringify(value); + } + } + } + if (numericArgs.length > 0) { + attributes["openclaw.log.args"] = safeStringify(numericArgs); + } + if (meta?.path?.filePath) { + attributes["code.filepath"] = meta.path.filePath; + } + if (meta?.path?.fileLine) { + attributes["code.lineno"] = Number(meta.path.fileLine); + } + if (meta?.path?.method) { + attributes["code.function"] = meta.path.method; + } + if (meta?.path?.filePathWithLine) { + attributes["openclaw.code.location"] = meta.path.filePathWithLine; + } + + otelLogger.emit({ + body: message, + severityText: logLevelName, + severityNumber, + attributes, + timestamp: meta?.date ?? new Date(), + }); + }); + } + + const spanWithDuration = ( + name: string, + attributes: Record, + durationMs?: number, + ) => { + const startTime = + typeof durationMs === "number" ? Date.now() - Math.max(0, durationMs) : undefined; + const span = tracer.startSpan(name, { + attributes, + ...(startTime ? { startTime } : {}), + }); + return span; + }; + + const recordModelUsage = (evt: Extract) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.provider": evt.provider ?? "unknown", + "openclaw.model": evt.model ?? "unknown", + }; + + const usage = evt.usage; + if (usage.input) { + tokensCounter.add(usage.input, { ...attrs, "openclaw.token": "input" }); + } + if (usage.output) { + tokensCounter.add(usage.output, { ...attrs, "openclaw.token": "output" }); + } + if (usage.cacheRead) { + tokensCounter.add(usage.cacheRead, { ...attrs, "openclaw.token": "cache_read" }); + } + if (usage.cacheWrite) { + tokensCounter.add(usage.cacheWrite, { ...attrs, "openclaw.token": "cache_write" }); + } + if (usage.promptTokens) { + tokensCounter.add(usage.promptTokens, { ...attrs, "openclaw.token": "prompt" }); + } + if (usage.total) { + tokensCounter.add(usage.total, { ...attrs, "openclaw.token": "total" }); + } + + if (evt.costUsd) { + costCounter.add(evt.costUsd, attrs); + } + if (evt.durationMs) { + durationHistogram.record(evt.durationMs, attrs); + } + if (evt.context?.limit) { + contextHistogram.record(evt.context.limit, { + ...attrs, + "openclaw.context": "limit", + }); + } + if (evt.context?.used) { + contextHistogram.record(evt.context.used, { + ...attrs, + "openclaw.context": "used", + }); + } + + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { + ...attrs, + "openclaw.sessionKey": evt.sessionKey ?? "", + "openclaw.sessionId": evt.sessionId ?? "", + "openclaw.tokens.input": usage.input ?? 0, + "openclaw.tokens.output": usage.output ?? 0, + "openclaw.tokens.cache_read": usage.cacheRead ?? 0, + "openclaw.tokens.cache_write": usage.cacheWrite ?? 0, + "openclaw.tokens.total": usage.total ?? 0, + }; + + const span = spanWithDuration("openclaw.model.usage", spanAttrs, evt.durationMs); + span.end(); + }; + + const recordWebhookReceived = ( + evt: Extract, + ) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.webhook": evt.updateType ?? "unknown", + }; + webhookReceivedCounter.add(1, attrs); + }; + + const recordWebhookProcessed = ( + evt: Extract, + ) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.webhook": evt.updateType ?? "unknown", + }; + if (typeof evt.durationMs === "number") { + webhookDurationHistogram.record(evt.durationMs, attrs); + } + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { ...attrs }; + if (evt.chatId !== undefined) { + spanAttrs["openclaw.chatId"] = String(evt.chatId); + } + const span = spanWithDuration("openclaw.webhook.processed", spanAttrs, evt.durationMs); + span.end(); + }; + + const recordWebhookError = ( + evt: Extract, + ) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.webhook": evt.updateType ?? "unknown", + }; + webhookErrorCounter.add(1, attrs); + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { + ...attrs, + "openclaw.error": evt.error, + }; + if (evt.chatId !== undefined) { + spanAttrs["openclaw.chatId"] = String(evt.chatId); + } + const span = tracer.startSpan("openclaw.webhook.error", { + attributes: spanAttrs, + }); + span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + span.end(); + }; + + const recordMessageQueued = ( + evt: Extract, + ) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.source": evt.source ?? "unknown", + }; + messageQueuedCounter.add(1, attrs); + if (typeof evt.queueDepth === "number") { + queueDepthHistogram.record(evt.queueDepth, attrs); + } + }; + + const recordMessageProcessed = ( + evt: Extract, + ) => { + const attrs = { + "openclaw.channel": evt.channel ?? "unknown", + "openclaw.outcome": evt.outcome ?? "unknown", + }; + messageProcessedCounter.add(1, attrs); + if (typeof evt.durationMs === "number") { + messageDurationHistogram.record(evt.durationMs, attrs); + } + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { ...attrs }; + if (evt.sessionKey) { + spanAttrs["openclaw.sessionKey"] = evt.sessionKey; + } + if (evt.sessionId) { + spanAttrs["openclaw.sessionId"] = evt.sessionId; + } + if (evt.chatId !== undefined) { + spanAttrs["openclaw.chatId"] = String(evt.chatId); + } + if (evt.messageId !== undefined) { + spanAttrs["openclaw.messageId"] = String(evt.messageId); + } + if (evt.reason) { + spanAttrs["openclaw.reason"] = evt.reason; + } + const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs); + if (evt.outcome === "error") { + span.setStatus({ code: SpanStatusCode.ERROR, message: evt.error }); + } + span.end(); + }; + + const recordLaneEnqueue = ( + evt: Extract, + ) => { + const attrs = { "openclaw.lane": evt.lane }; + laneEnqueueCounter.add(1, attrs); + queueDepthHistogram.record(evt.queueSize, attrs); + }; + + const recordLaneDequeue = ( + evt: Extract, + ) => { + const attrs = { "openclaw.lane": evt.lane }; + laneDequeueCounter.add(1, attrs); + queueDepthHistogram.record(evt.queueSize, attrs); + if (typeof evt.waitMs === "number") { + queueWaitHistogram.record(evt.waitMs, attrs); + } + }; + + const recordSessionState = ( + evt: Extract, + ) => { + const attrs: Record = { "openclaw.state": evt.state }; + if (evt.reason) { + attrs["openclaw.reason"] = evt.reason; + } + sessionStateCounter.add(1, attrs); + }; + + const recordSessionStuck = ( + evt: Extract, + ) => { + const attrs: Record = { "openclaw.state": evt.state }; + sessionStuckCounter.add(1, attrs); + if (typeof evt.ageMs === "number") { + sessionStuckAgeHistogram.record(evt.ageMs, attrs); + } + if (!tracesEnabled) { + return; + } + const spanAttrs: Record = { ...attrs }; + if (evt.sessionKey) { + spanAttrs["openclaw.sessionKey"] = evt.sessionKey; + } + if (evt.sessionId) { + spanAttrs["openclaw.sessionId"] = evt.sessionId; + } + spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0; + spanAttrs["openclaw.ageMs"] = evt.ageMs; + const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs }); + span.setStatus({ code: SpanStatusCode.ERROR, message: "session stuck" }); + span.end(); + }; + + const recordRunAttempt = (evt: Extract) => { + runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt }); + }; + + const recordHeartbeat = ( + evt: Extract, + ) => { + queueDepthHistogram.record(evt.queued, { "openclaw.channel": "heartbeat" }); + }; + + unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => { + switch (evt.type) { + case "model.usage": + recordModelUsage(evt); + return; + case "webhook.received": + recordWebhookReceived(evt); + return; + case "webhook.processed": + recordWebhookProcessed(evt); + return; + case "webhook.error": + recordWebhookError(evt); + return; + case "message.queued": + recordMessageQueued(evt); + return; + case "message.processed": + recordMessageProcessed(evt); + return; + case "queue.lane.enqueue": + recordLaneEnqueue(evt); + return; + case "queue.lane.dequeue": + recordLaneDequeue(evt); + return; + case "session.state": + recordSessionState(evt); + return; + case "session.stuck": + recordSessionStuck(evt); + return; + case "run.attempt": + recordRunAttempt(evt); + return; + case "diagnostic.heartbeat": + recordHeartbeat(evt); + return; + } + }); + + if (logsEnabled) { + ctx.logger.info("diagnostics-otel: logs exporter enabled (OTLP/HTTP)"); + } + }, + async stop() { + unsubscribe?.(); + unsubscribe = null; + stopLogTransport?.(); + stopLogTransport = null; + if (logProvider) { + await logProvider.shutdown().catch(() => undefined); + logProvider = null; + } + if (sdk) { + await sdk.shutdown().catch(() => undefined); + sdk = null; + } + }, + } satisfies OpenClawPluginService; +} diff --git a/extensions/discord/index.ts b/extensions/discord/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab639cbaff270357a763d9db1b5b15129e96f01c --- /dev/null +++ b/extensions/discord/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { discordPlugin } from "./src/channel.js"; +import { setDiscordRuntime } from "./src/runtime.js"; + +const plugin = { + id: "discord", + name: "Discord", + description: "Discord channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setDiscordRuntime(api.runtime); + api.registerChannel({ plugin: discordPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/discord/openclaw.plugin.json b/extensions/discord/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..62db8b8aac3cbfeeab0748ce55db9664195094d9 --- /dev/null +++ b/extensions/discord/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "discord", + "channels": ["discord"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/discord/package.json b/extensions/discord/package.json new file mode 100644 index 0000000000000000000000000000000000000000..972a5b2fb51c233729a05041dcdcb739f5ead49e --- /dev/null +++ b/extensions/discord/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/discord", + "version": "2026.1.30", + "description": "OpenClaw Discord channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..e989795dc9edde19a8c8986724a095641d98a178 --- /dev/null +++ b/extensions/discord/src/channel.ts @@ -0,0 +1,422 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + collectDiscordAuditChannelIds, + collectDiscordStatusIssues, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + discordOnboardingAdapter, + DiscordConfigSchema, + formatPairingApproveHint, + getChatChannelMeta, + listDiscordAccountIds, + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + looksLikeDiscordTargetId, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + normalizeDiscordMessagingTarget, + PAIRING_APPROVED_MESSAGE, + resolveDiscordAccount, + resolveDefaultDiscordAccountId, + resolveDiscordGroupRequireMention, + resolveDiscordGroupToolPolicy, + setAccountEnabledInConfigSection, + type ChannelMessageActionAdapter, + type ChannelPlugin, + type ResolvedDiscordAccount, +} from "openclaw/plugin-sdk"; +import { getDiscordRuntime } from "./runtime.js"; + +const meta = getChatChannelMeta("discord"); + +const discordMessageActions: ChannelMessageActionAdapter = { + listActions: (ctx) => getDiscordRuntime().channel.discord.messageActions.listActions(ctx), + extractToolSend: (ctx) => getDiscordRuntime().channel.discord.messageActions.extractToolSend(ctx), + handleAction: async (ctx) => + await getDiscordRuntime().channel.discord.messageActions.handleAction(ctx), +}; + +export const discordPlugin: ChannelPlugin = { + id: "discord", + meta: { + ...meta, + }, + onboarding: discordOnboardingAdapter, + pairing: { + idLabel: "discordUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), + notifyApproval: async ({ id }) => { + await getDiscordRuntime().channel.discord.sendMessageDiscord( + `user:${id}`, + PAIRING_APPROVED_MESSAGE, + ); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + nativeCommands: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.discord"] }, + configSchema: buildChannelConfigSchema(DiscordConfigSchema), + config: { + listAccountIds: (cfg) => listDiscordAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "discord", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "discord", + accountId, + clearBaseFields: ["token", "name"], + }), + isConfigured: (account) => Boolean(account.token?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.token?.trim()), + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveDiscordAccount({ cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.discord?.accounts?.[resolvedAccountId]); + const allowFromPath = useAccountPath + ? `channels.discord.accounts.${resolvedAccountId}.dm.` + : "channels.discord.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("discord"), + normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"), + }; + }, + collectWarnings: ({ account, cfg }) => { + const warnings: string[] = []; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open"; + const guildEntries = account.config.guilds ?? {}; + const guildsConfigured = Object.keys(guildEntries).length > 0; + const channelAllowlistConfigured = guildsConfigured; + + if (groupPolicy === "open") { + if (channelAllowlistConfigured) { + warnings.push( + `- Discord guilds: groupPolicy="open" allows any channel not explicitly denied to trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels.`, + ); + } else { + warnings.push( + `- Discord guilds: groupPolicy="open" with no guild/channel allowlist; any channel can trigger (mention-gated). Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels.`, + ); + } + } + + return warnings; + }, + }, + groups: { + resolveRequireMention: resolveDiscordGroupRequireMention, + resolveToolPolicy: resolveDiscordGroupToolPolicy, + }, + mentions: { + stripPatterns: () => ["<@!?\\d+>"], + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", + }, + messaging: { + normalizeTarget: normalizeDiscordMessagingTarget, + targetResolver: { + looksLikeId: looksLikeDiscordTargetId, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), + listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), + listPeersLive: async (params) => + getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), + listGroupsLive: async (params) => + getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), + }, + resolver: { + resolveTargets: async ({ cfg, accountId, inputs, kind }) => { + const account = resolveDiscordAccount({ cfg, accountId }); + const token = account.token?.trim(); + if (!token) { + return inputs.map((input) => ({ + input, + resolved: false, + note: "missing Discord token", + })); + } + if (kind === "group") { + const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ + token, + entries: inputs, + }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.channelId ?? entry.guildId, + name: + entry.channelName ?? + entry.guildName ?? + (entry.guildId && !entry.channelId ? entry.guildId : undefined), + note: entry.note, + })); + } + const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ + token, + entries: inputs, + }); + return resolved.map((entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.id, + name: entry.name, + note: entry.note, + })); + }, + }, + actions: discordMessageActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "discord", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "DISCORD_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token) { + return "Discord requires token (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "discord", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "discord", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + ...(input.useEnv ? {} : input.token ? { token: input.token } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + discord: { + ...next.channels?.discord, + enabled: true, + accounts: { + ...next.channels?.discord?.accounts, + [accountId]: { + ...next.channels?.discord?.accounts?.[accountId], + enabled: true, + ...(input.token ? { token: input.token } : {}), + }, + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: null, + textChunkLimit: 2000, + pollMaxOptions: 10, + sendText: async ({ to, text, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { channel: "discord", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { + const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const result = await send(to, text, { + verbose: false, + mediaUrl, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + }); + return { channel: "discord", ...result }; + }, + sendPoll: async ({ to, poll, accountId }) => + await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { + accountId: accountId ?? undefined, + }), + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectDiscordStatusIssues, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, { + includeApplication: true, + }), + auditAccount: async ({ account, timeoutMs, cfg }) => { + const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({ + cfg, + accountId: account.accountId, + }); + if (!channelIds.length && unresolvedChannels === 0) { + return undefined; + } + const botToken = account.token?.trim(); + if (!botToken) { + return { + ok: unresolvedChannels === 0, + checkedChannels: 0, + unresolvedChannels, + channels: [], + elapsedMs: 0, + }; + } + const audit = await getDiscordRuntime().channel.discord.auditChannelPermissions({ + token: botToken, + accountId: account.accountId, + channelIds, + timeoutMs, + }); + return { ...audit, unresolvedChannels }; + }, + buildAccountSnapshot: ({ account, runtime, probe, audit }) => { + const configured = Boolean(account.token?.trim()); + const app = runtime?.application ?? (probe as { application?: unknown })?.application; + const bot = runtime?.bot ?? (probe as { bot?: unknown })?.bot; + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + application: app ?? undefined, + bot: bot ?? undefined, + probe, + audit, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const token = account.token.trim(); + let discordBotLabel = ""; + try { + const probe = await getDiscordRuntime().channel.discord.probeDiscord(token, 2500, { + includeApplication: true, + }); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) { + discordBotLabel = ` (@${username})`; + } + ctx.setStatus({ + accountId: account.accountId, + bot: probe.bot, + application: probe.application, + }); + const messageContent = probe.application?.intents?.messageContent; + if (messageContent === "disabled") { + ctx.log?.warn( + `[${account.accountId}] Discord Message Content Intent is disabled; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`, + ); + } else if (messageContent === "limited") { + ctx.log?.info( + `[${account.accountId}] Discord Message Content Intent is limited; bots under 100 servers can use it without verification.`, + ); + } + } catch (err) { + if (getDiscordRuntime().logging.shouldLogVerbose()) { + ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } + } + ctx.log?.info(`[${account.accountId}] starting provider${discordBotLabel}`); + return getDiscordRuntime().channel.discord.monitorDiscordProvider({ + token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + historyLimit: account.config.historyLimit, + }); + }, + }, +}; diff --git a/extensions/discord/src/runtime.ts b/extensions/discord/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c3aa9f36762ea94440ddd01ac1c06f6cc8a9b49 --- /dev/null +++ b/extensions/discord/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setDiscordRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getDiscordRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Discord runtime not initialized"); + } + return runtime; +} diff --git a/extensions/google-antigravity-auth/README.md b/extensions/google-antigravity-auth/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4e1dee975eaf58ab1b08426ec39a7f45061298af --- /dev/null +++ b/extensions/google-antigravity-auth/README.md @@ -0,0 +1,24 @@ +# Google Antigravity Auth (OpenClaw plugin) + +OAuth provider plugin for **Google Antigravity** (Cloud Code Assist). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +openclaw plugins enable google-antigravity-auth +``` + +Restart the Gateway after enabling. + +## Authenticate + +```bash +openclaw models auth login --provider google-antigravity --set-default +``` + +## Notes + +- Antigravity uses Google Cloud project quotas. +- If requests fail, ensure Gemini for Google Cloud is enabled. diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..74f9406c48e908c92c63f35432e01378d8628db7 --- /dev/null +++ b/extensions/google-antigravity-auth/index.ts @@ -0,0 +1,461 @@ +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; + +// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync +const decode = (s: string) => Buffer.from(s, "base64").toString(); +const CLIENT_ID = decode( + "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==", +); +const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY="); +const REDIRECT_URI = "http://localhost:51121/oauth-callback"; +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const DEFAULT_PROJECT_ID = "rising-fact-p41fc"; +const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking"; + +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; + +const CODE_ASSIST_ENDPOINTS = [ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.sandbox.googleapis.com", +]; + +const RESPONSE_PAGE = ` + + + + OpenClaw Antigravity OAuth + + +
+

Authentication complete

+

You can return to the terminal.

+
+ +`; + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +function isWSL(): boolean { + if (process.platform !== "linux") { + return false; + } + try { + const release = readFileSync("/proc/version", "utf8").toLowerCase(); + return release.includes("microsoft") || release.includes("wsl"); + } catch { + return false; + } +} + +function isWSL2(): boolean { + if (!isWSL()) { + return false; + } + try { + const version = readFileSync("/proc/version", "utf8").toLowerCase(); + return version.includes("wsl2") || version.includes("microsoft-standard"); + } catch { + return false; + } +} + +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2(); +} + +function buildAuthUrl(params: { challenge: string; state: string }): string { + const url = new URL(AUTH_URL); + url.searchParams.set("client_id", CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", REDIRECT_URI); + url.searchParams.set("scope", SCOPES.join(" ")); + url.searchParams.set("code_challenge", params.challenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", params.state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("prompt", "consent"); + return url.toString(); +} + +function parseCallbackInput(input: string): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter in URL" }; + } + return { code, state }; + } catch { + return { error: "Paste the full redirect URL (not just the code)." }; + } +} + +async function startCallbackServer(params: { timeoutMs: number }) { + const redirect = new URL(REDIRECT_URI); + const port = redirect.port ? Number(redirect.port) : 51121; + + let settled = false; + let resolveCallback: (url: URL) => void; + let rejectCallback: (err: Error) => void; + + const callbackPromise = new Promise((resolve, reject) => { + resolveCallback = (url) => { + if (settled) { + return; + } + settled = true; + resolve(url); + }; + rejectCallback = (err) => { + if (settled) { + return; + } + settled = true; + reject(err); + }; + }); + + const timeout = setTimeout(() => { + rejectCallback(new Error("Timed out waiting for OAuth callback")); + }, params.timeoutMs); + timeout.unref?.(); + + const server = createServer((request, response) => { + if (!request.url) { + response.writeHead(400, { "Content-Type": "text/plain" }); + response.end("Missing URL"); + return; + } + + const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`); + if (url.pathname !== redirect.pathname) { + response.writeHead(404, { "Content-Type": "text/plain" }); + response.end("Not found"); + return; + } + + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(RESPONSE_PAGE); + resolveCallback(url); + + setImmediate(() => { + server.close(); + }); + }); + + await new Promise((resolve, reject) => { + const onError = (err: Error) => { + server.off("error", onError); + reject(err); + }; + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); + }); + + return { + waitForCallback: () => callbackPromise, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +async function exchangeCode(params: { + code: string; + verifier: string; +}): Promise<{ access: string; refresh: string; expires: number }> { + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: params.code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: params.verifier, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Token exchange failed: ${text}`); + } + + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + const access = data.access_token?.trim(); + const refresh = data.refresh_token?.trim(); + const expiresIn = data.expires_in ?? 0; + + if (!access) { + throw new Error("Token exchange returned no access_token"); + } + if (!refresh) { + throw new Error("Token exchange returned no refresh_token"); + } + + const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000; + return { access, refresh, expires }; +} + +async function fetchUserEmail(accessToken: string): Promise { + try { + const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return undefined; + } + const data = (await response.json()) as { email?: string }; + return data.email; + } catch { + return undefined; + } +} + +async function fetchProjectId(accessToken: string): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }), + }; + + for (const endpoint of CODE_ASSIST_ENDPOINTS) { + try { + const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }), + }); + + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + cloudaicompanionProject?: string | { id?: string }; + }; + + if (typeof data.cloudaicompanionProject === "string") { + return data.cloudaicompanionProject; + } + if ( + data.cloudaicompanionProject && + typeof data.cloudaicompanionProject === "object" && + data.cloudaicompanionProject.id + ) { + return data.cloudaicompanionProject.id; + } + } catch { + // ignore + } + } + + return DEFAULT_PROJECT_ID; +} + +async function loginAntigravity(params: { + isRemote: boolean; + openUrl: (url: string) => Promise; + prompt: (message: string) => Promise; + note: (message: string, title?: string) => Promise; + log: (message: string) => void; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}): Promise<{ + access: string; + refresh: string; + expires: number; + email?: string; + projectId: string; +}> { + const { verifier, challenge } = generatePkce(); + const state = randomBytes(16).toString("hex"); + const authUrl = buildAuthUrl({ challenge, state }); + + let callbackServer: Awaited> | null = null; + const needsManual = shouldUseManualOAuthFlow(params.isRemote); + if (!needsManual) { + try { + callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 }); + } catch { + callbackServer = null; + } + } + + if (!callbackServer) { + await params.note( + [ + "Open the URL in your local browser.", + "After signing in, copy the full redirect URL and paste it back here.", + "", + `Auth URL: ${authUrl}`, + `Redirect URI: ${REDIRECT_URI}`, + ].join("\n"), + "Google Antigravity OAuth", + ); + // Output raw URL below the box for easy copying (fixes #1772) + params.log(""); + params.log("Copy this URL:"); + params.log(authUrl); + params.log(""); + } + + if (!needsManual) { + params.progress.update("Opening Google sign-in…"); + try { + await params.openUrl(authUrl); + } catch { + // ignore + } + } + + let code = ""; + let returnedState = ""; + + if (callbackServer) { + params.progress.update("Waiting for OAuth callback…"); + const callback = await callbackServer.waitForCallback(); + code = callback.searchParams.get("code") ?? ""; + returnedState = callback.searchParams.get("state") ?? ""; + await callbackServer.close(); + } else { + params.progress.update("Waiting for redirect URL…"); + const input = await params.prompt("Paste the redirect URL: "); + const parsed = parseCallbackInput(input); + if ("error" in parsed) { + throw new Error(parsed.error); + } + code = parsed.code; + returnedState = parsed.state; + } + + if (!code) { + throw new Error("Missing OAuth code"); + } + if (returnedState !== state) { + throw new Error("OAuth state mismatch. Please try again."); + } + + params.progress.update("Exchanging code for tokens…"); + const tokens = await exchangeCode({ code, verifier }); + const email = await fetchUserEmail(tokens.access); + const projectId = await fetchProjectId(tokens.access); + + params.progress.stop("Antigravity OAuth complete"); + return { ...tokens, email, projectId }; +} + +const antigravityPlugin = { + id: "google-antigravity-auth", + name: "Google Antigravity Auth", + description: "OAuth flow for Google Antigravity (Cloud Code Assist)", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: "google-antigravity", + label: "Google Antigravity", + docsPath: "/providers/models", + aliases: ["antigravity"], + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx) => { + const spin = ctx.prompter.progress("Starting Antigravity OAuth…"); + try { + const result = await loginAntigravity({ + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + prompt: async (message) => String(await ctx.prompter.text({ message })), + note: ctx.prompter.note, + log: (message) => ctx.runtime.log(message), + progress: spin, + }); + + const profileId = `google-antigravity:${result.email ?? "default"}`; + return { + profiles: [ + { + profileId, + credential: { + type: "oauth", + provider: "google-antigravity", + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + projectId: result.projectId, + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + [DEFAULT_MODEL]: {}, + }, + }, + }, + }, + defaultModel: DEFAULT_MODEL, + notes: [ + "Antigravity uses Google Cloud project quotas.", + "Enable Gemini for Google Cloud on your project if requests fail.", + ], + }; + } catch (err) { + spin.stop("Antigravity OAuth failed"); + throw err; + } + }, + }, + ], + }); + }, +}; + +export default antigravityPlugin; diff --git a/extensions/google-antigravity-auth/openclaw.plugin.json b/extensions/google-antigravity-auth/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..2ef207f0486374f74195bda2c10137a0cc6298b8 --- /dev/null +++ b/extensions/google-antigravity-auth/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "google-antigravity-auth", + "providers": ["google-antigravity"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json new file mode 100644 index 0000000000000000000000000000000000000000..354bd68cbe8364cce4ca93675824bf1d1f80c666 --- /dev/null +++ b/extensions/google-antigravity-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/google-antigravity-auth", + "version": "2026.1.30", + "description": "OpenClaw Google Antigravity OAuth provider plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/google-gemini-cli-auth/README.md b/extensions/google-gemini-cli-auth/README.md new file mode 100644 index 0000000000000000000000000000000000000000..07dcd13c52a6be4bb09b8bec93197a743b315744 --- /dev/null +++ b/extensions/google-gemini-cli-auth/README.md @@ -0,0 +1,35 @@ +# Google Gemini CLI Auth (OpenClaw plugin) + +OAuth provider plugin for **Gemini CLI** (Google Code Assist). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +openclaw plugins enable google-gemini-cli-auth +``` + +Restart the Gateway after enabling. + +## Authenticate + +```bash +openclaw models auth login --provider google-gemini-cli --set-default +``` + +## Requirements + +Requires the Gemini CLI to be installed (credentials are extracted automatically): + +```bash +brew install gemini-cli +# or: npm install -g @google/gemini-cli +``` + +## Env vars (optional) + +Override auto-detected credentials with: + +- `OPENCLAW_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID` +- `OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET` diff --git a/extensions/google-gemini-cli-auth/index.ts b/extensions/google-gemini-cli-auth/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e66071ccabc61670e44309b9c887883e5bd7e462 --- /dev/null +++ b/extensions/google-gemini-cli-auth/index.ts @@ -0,0 +1,88 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { loginGeminiCliOAuth } from "./oauth.js"; + +const PROVIDER_ID = "google-gemini-cli"; +const PROVIDER_LABEL = "Gemini CLI OAuth"; +const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview"; +const ENV_VARS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; + +const geminiCliPlugin = { + id: "google-gemini-cli-auth", + name: "Google Gemini CLI Auth", + description: "OAuth flow for Gemini CLI (Google Code Assist)", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: ENV_VARS, + auth: [ + { + id: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + kind: "oauth", + run: async (ctx) => { + const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…"); + try { + const result = await loginGeminiCliOAuth({ + isRemote: ctx.isRemote, + openUrl: ctx.openUrl, + log: (msg) => ctx.runtime.log(msg), + note: ctx.prompter.note, + prompt: async (message) => String(await ctx.prompter.text({ message })), + progress: spin, + }); + + spin.stop("Gemini CLI OAuth complete"); + const profileId = `google-gemini-cli:${result.email ?? "default"}`; + return { + profiles: [ + { + profileId, + credential: { + type: "oauth", + provider: PROVIDER_ID, + access: result.access, + refresh: result.refresh, + expires: result.expires, + email: result.email, + projectId: result.projectId, + }, + }, + ], + configPatch: { + agents: { + defaults: { + models: { + [DEFAULT_MODEL]: {}, + }, + }, + }, + }, + defaultModel: DEFAULT_MODEL, + notes: ["If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID."], + }; + } catch (err) { + spin.stop("Gemini CLI OAuth failed"); + await ctx.prompter.note( + "Trouble with OAuth? Ensure your Google account has Gemini CLI access.", + "OAuth help", + ); + throw err; + } + }, + }, + ], + }); + }, +}; + +export default geminiCliPlugin; diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5831b8b1e0d08b35ac570288a94d0aabf79eaa3a --- /dev/null +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -0,0 +1,240 @@ +import { join, parse } from "node:path"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +// Mock fs module before importing the module under test +const mockExistsSync = vi.fn(); +const mockReadFileSync = vi.fn(); +const mockRealpathSync = vi.fn(); +const mockReaddirSync = vi.fn(); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (...args: Parameters) => mockExistsSync(...args), + readFileSync: (...args: Parameters) => mockReadFileSync(...args), + realpathSync: (...args: Parameters) => mockRealpathSync(...args), + readdirSync: (...args: Parameters) => mockReaddirSync(...args), + }; +}); + +describe("extractGeminiCliCredentials", () => { + const normalizePath = (value: string) => + value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); + const rootDir = parse(process.cwd()).root || "/"; + const FAKE_CLIENT_ID = "123456789-abcdef.apps.googleusercontent.com"; + const FAKE_CLIENT_SECRET = "GOCSPX-FakeSecretValue123"; + const FAKE_OAUTH2_CONTENT = ` + const clientId = "${FAKE_CLIENT_ID}"; + const clientSecret = "${FAKE_CLIENT_SECRET}"; + `; + + let originalPath: string | undefined; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it("returns null when gemini binary is not in PATH", async () => { + process.env.PATH = "/nonexistent"; + mockExistsSync.mockReturnValue(false); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("extracts credentials from oauth2.js in known path", async () => { + const fakeBinDir = join(rootDir, "fake", "bin"); + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) { + return true; + } + if (normalized === normalizePath(fakeOauth2Path)) { + return true; + } + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + const result = extractGeminiCliCredentials(); + + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + }); + + it("returns null when oauth2.js cannot be found", async () => { + const fakeBinDir = join(rootDir, "fake", "bin"); + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation( + (p: string) => normalizePath(p) === normalizePath(fakeGeminiPath), + ); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReaddirSync.mockReturnValue([]); // Empty directory for recursive search + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("returns null when oauth2.js lacks credentials", async () => { + const fakeBinDir = join(rootDir, "fake", "bin"); + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) { + return true; + } + if (normalized === normalizePath(fakeOauth2Path)) { + return true; + } + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue("// no credentials here"); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + expect(extractGeminiCliCredentials()).toBeNull(); + }); + + it("caches credentials after first extraction", async () => { + const fakeBinDir = join(rootDir, "fake", "bin"); + const fakeGeminiPath = join(fakeBinDir, "gemini"); + const fakeResolvedPath = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "dist", + "index.js", + ); + const fakeOauth2Path = join( + rootDir, + "fake", + "lib", + "node_modules", + "@google", + "gemini-cli", + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ); + + process.env.PATH = fakeBinDir; + + mockExistsSync.mockImplementation((p: string) => { + const normalized = normalizePath(p); + if (normalized === normalizePath(fakeGeminiPath)) { + return true; + } + if (normalized === normalizePath(fakeOauth2Path)) { + return true; + } + return false; + }); + mockRealpathSync.mockReturnValue(fakeResolvedPath); + mockReadFileSync.mockReturnValue(FAKE_OAUTH2_CONTENT); + + const { extractGeminiCliCredentials, clearCredentialsCache } = await import("./oauth.js"); + clearCredentialsCache(); + + // First call + const result1 = extractGeminiCliCredentials(); + expect(result1).not.toBeNull(); + + // Second call should use cache (readFileSync not called again) + const readCount = mockReadFileSync.mock.calls.length; + const result2 = extractGeminiCliCredentials(); + expect(result2).toEqual(result1); + expect(mockReadFileSync.mock.calls.length).toBe(readCount); + }); +}); diff --git a/extensions/google-gemini-cli-auth/oauth.ts b/extensions/google-gemini-cli-auth/oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d386f2109397c4bea123eaa63972f896c43664b --- /dev/null +++ b/extensions/google-gemini-cli-auth/oauth.ts @@ -0,0 +1,662 @@ +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs"; +import { createServer } from "node:http"; +import { delimiter, dirname, join } from "node:path"; + +const CLIENT_ID_KEYS = ["OPENCLAW_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"]; +const CLIENT_SECRET_KEYS = [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", +]; +const REDIRECT_URI = "http://localhost:8085/oauth2callback"; +const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"; +const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com"; +const SCOPES = [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +]; + +const TIER_FREE = "free-tier"; +const TIER_LEGACY = "legacy-tier"; +const TIER_STANDARD = "standard-tier"; + +export type GeminiCliOAuthCredentials = { + access: string; + refresh: string; + expires: number; + email?: string; + projectId: string; +}; + +export type GeminiCliOAuthContext = { + isRemote: boolean; + openUrl: (url: string) => Promise; + log: (msg: string) => void; + note: (message: string, title?: string) => Promise; + prompt: (message: string) => Promise; + progress: { update: (msg: string) => void; stop: (msg?: string) => void }; +}; + +function resolveEnv(keys: string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null; + +/** @internal */ +export function clearCredentialsCache(): void { + cachedGeminiCliCredentials = null; +} + +/** Extracts OAuth credentials from the installed Gemini CLI's bundled oauth2.js. */ +export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null { + if (cachedGeminiCliCredentials) { + return cachedGeminiCliCredentials; + } + + try { + const geminiPath = findInPath("gemini"); + if (!geminiPath) { + return null; + } + + const resolvedPath = realpathSync(geminiPath); + const geminiCliDir = dirname(dirname(resolvedPath)); + + const searchPaths = [ + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "src", + "code_assist", + "oauth2.js", + ), + join( + geminiCliDir, + "node_modules", + "@google", + "gemini-cli-core", + "dist", + "code_assist", + "oauth2.js", + ), + ]; + + let content: string | null = null; + for (const p of searchPaths) { + if (existsSync(p)) { + content = readFileSync(p, "utf8"); + break; + } + } + if (!content) { + const found = findFile(geminiCliDir, "oauth2.js", 10); + if (found) { + content = readFileSync(found, "utf8"); + } + } + if (!content) { + return null; + } + + const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/); + const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/); + if (idMatch && secretMatch) { + cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] }; + return cachedGeminiCliCredentials; + } + } catch { + // Gemini CLI not installed or extraction failed + } + return null; +} + +function findInPath(name: string): string | null { + const exts = process.platform === "win32" ? [".cmd", ".bat", ".exe", ""] : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of exts) { + const p = join(dir, name + ext); + if (existsSync(p)) { + return p; + } + } + } + return null; +} + +function findFile(dir: string, name: string, depth: number): string | null { + if (depth <= 0) { + return null; + } + try { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isFile() && e.name === name) { + return p; + } + if (e.isDirectory() && !e.name.startsWith(".")) { + const found = findFile(p, name, depth - 1); + if (found) { + return found; + } + } + } + } catch {} + return null; +} + +function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } { + // 1. Check env vars first (user override) + const envClientId = resolveEnv(CLIENT_ID_KEYS); + const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS); + if (envClientId) { + return { clientId: envClientId, clientSecret: envClientSecret }; + } + + // 2. Try to extract from installed Gemini CLI + const extracted = extractGeminiCliCredentials(); + if (extracted) { + return extracted; + } + + // 3. No credentials available + throw new Error( + "Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.", + ); +} + +function isWSL(): boolean { + if (process.platform !== "linux") { + return false; + } + try { + const release = readFileSync("/proc/version", "utf8").toLowerCase(); + return release.includes("microsoft") || release.includes("wsl"); + } catch { + return false; + } +} + +function isWSL2(): boolean { + if (!isWSL()) { + return false; + } + try { + const version = readFileSync("/proc/version", "utf8").toLowerCase(); + return version.includes("wsl2") || version.includes("microsoft-standard"); + } catch { + return false; + } +} + +function shouldUseManualOAuthFlow(isRemote: boolean): boolean { + return isRemote || isWSL2(); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +function buildAuthUrl(challenge: string, verifier: string): string { + const { clientId } = resolveOAuthClientConfig(); + const params = new URLSearchParams({ + client_id: clientId, + response_type: "code", + redirect_uri: REDIRECT_URI, + scope: SCOPES.join(" "), + code_challenge: challenge, + code_challenge_method: "S256", + state: verifier, + access_type: "offline", + prompt: "consent", + }); + return `${AUTH_URL}?${params.toString()}`; +} + +function parseCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) { + return { error: "No input provided" }; + } + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) { + return { error: "Missing 'code' parameter in URL" }; + } + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + return { code, state }; + } catch { + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +async function waitForLocalCallback(params: { + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const port = 8085; + const hostname = "localhost"; + const expectedPath = "/oauth2callback"; + + return new Promise<{ code: string; state: string }>((resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("Not found"); + return; + } + + const error = requestUrl.searchParams.get("error"); + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (error) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end(`Authentication failed: ${error}`); + finish(new Error(`OAuth error: ${error}`)); + return; + } + + if (!code || !state) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Missing code or state"); + finish(new Error("Missing OAuth code or state")); + return; + } + + if (state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain"); + res.end("Invalid state"); + finish(new Error("OAuth state mismatch")); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + "" + + "

Gemini CLI OAuth complete

" + + "

You can close this window and return to OpenClaw.

", + ); + + finish(undefined, { code, state }); + } catch (err) { + finish(err instanceof Error ? err : new Error("OAuth callback failed")); + } + }); + + const finish = (err?: Error, result?: { code: string; state: string }) => { + if (timeout) { + clearTimeout(timeout); + } + try { + server.close(); + } catch { + // ignore close errors + } + if (err) { + reject(err); + } else if (result) { + resolve(result); + } + }; + + server.once("error", (err) => { + finish(err instanceof Error ? err : new Error("OAuth callback server error")); + }); + + server.listen(port, hostname, () => { + params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`); + }); + + timeout = setTimeout(() => { + finish(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }); +} + +async function exchangeCodeForTokens( + code: string, + verifier: string, +): Promise { + const { clientId, clientSecret } = resolveOAuthClientConfig(); + const body = new URLSearchParams({ + client_id: clientId, + code, + grant_type: "authorization_code", + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }); + if (clientSecret) { + body.set("client_secret", clientSecret); + } + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Token exchange failed: ${errorText}`); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; + + if (!data.refresh_token) { + throw new Error("No refresh token received. Please try again."); + } + + const email = await getUserEmail(data.access_token); + const projectId = await discoverProject(data.access_token); + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token, + access: data.access_token, + expires: expiresAt, + projectId, + email, + }; +} + +async function getUserEmail(accessToken: string): Promise { + try { + const response = await fetch(USERINFO_URL, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (response.ok) { + const data = (await response.json()) as { email?: string }; + return data.email; + } + } catch { + // ignore + } + return undefined; +} + +async function discoverProject(accessToken: string): Promise { + const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID; + const headers = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "gl-node/openclaw", + }; + + const loadBody = { + cloudaicompanionProject: envProject, + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + duetProject: envProject, + }, + }; + + let data: { + currentTier?: { id?: string }; + cloudaicompanionProject?: string | { id?: string }; + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>; + } = {}; + + try { + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, { + method: "POST", + headers, + body: JSON.stringify(loadBody), + }); + + if (!response.ok) { + const errorPayload = await response.json().catch(() => null); + if (isVpcScAffected(errorPayload)) { + data = { currentTier: { id: TIER_STANDARD } }; + } else { + throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`); + } + } else { + data = (await response.json()) as typeof data; + } + } catch (err) { + if (err instanceof Error) { + throw err; + } + throw new Error("loadCodeAssist failed", { cause: err }); + } + + if (data.currentTier) { + const project = data.cloudaicompanionProject; + if (typeof project === "string" && project) { + return project; + } + if (typeof project === "object" && project?.id) { + return project.id; + } + if (envProject) { + return envProject; + } + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const tier = getDefaultTier(data.allowedTiers); + const tierId = tier?.id || TIER_FREE; + if (tierId !== TIER_FREE && !envProject) { + throw new Error( + "This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.", + ); + } + + const onboardBody: Record = { + tierId, + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }; + if (tierId !== TIER_FREE && envProject) { + onboardBody.cloudaicompanionProject = envProject; + (onboardBody.metadata as Record).duetProject = envProject; + } + + const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, { + method: "POST", + headers, + body: JSON.stringify(onboardBody), + }); + + if (!onboardResponse.ok) { + throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`); + } + + let lro = (await onboardResponse.json()) as { + done?: boolean; + name?: string; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + + if (!lro.done && lro.name) { + lro = await pollOperation(lro.name, headers); + } + + const projectId = lro.response?.cloudaicompanionProject?.id; + if (projectId) { + return projectId; + } + if (envProject) { + return envProject; + } + + throw new Error( + "Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.", + ); +} + +function isVpcScAffected(payload: unknown): boolean { + if (!payload || typeof payload !== "object") { + return false; + } + const error = (payload as { error?: unknown }).error; + if (!error || typeof error !== "object") { + return false; + } + const details = (error as { details?: unknown[] }).details; + if (!Array.isArray(details)) { + return false; + } + return details.some( + (item) => + typeof item === "object" && + item && + (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED", + ); +} + +function getDefaultTier( + allowedTiers?: Array<{ id?: string; isDefault?: boolean }>, +): { id?: string } | undefined { + if (!allowedTiers?.length) { + return { id: TIER_LEGACY }; + } + return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY }; +} + +async function pollOperation( + operationName: string, + headers: Record, +): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> { + for (let attempt = 0; attempt < 24; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { + headers, + }); + if (!response.ok) { + continue; + } + const data = (await response.json()) as { + done?: boolean; + response?: { cloudaicompanionProject?: { id?: string } }; + }; + if (data.done) { + return data; + } + } + throw new Error("Operation polling timeout"); +} + +export async function loginGeminiCliOAuth( + ctx: GeminiCliOAuthContext, +): Promise { + const needsManual = shouldUseManualOAuthFlow(ctx.isRemote); + await ctx.note( + needsManual + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, copy the redirect URL and paste it back here.", + ].join("\n") + : [ + "Browser will open for Google authentication.", + "Sign in with your Google account for Gemini CLI access.", + "The callback will be captured automatically on localhost:8085.", + ].join("\n"), + "Gemini CLI OAuth", + ); + + const { verifier, challenge } = generatePkce(); + const authUrl = buildAuthUrl(challenge, verifier); + + if (needsManual) { + ctx.progress.update("OAuth URL ready"); + ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); + ctx.progress.update("Waiting for you to paste the callback URL..."); + const callbackInput = await ctx.prompt("Paste the redirect URL here: "); + const parsed = parseCallbackInput(callbackInput, verifier); + if ("error" in parsed) { + throw new Error(parsed.error); + } + if (parsed.state !== verifier) { + throw new Error("OAuth state mismatch - please try again"); + } + ctx.progress.update("Exchanging authorization code for tokens..."); + return exchangeCodeForTokens(parsed.code, verifier); + } + + ctx.progress.update("Complete sign-in in browser..."); + try { + await ctx.openUrl(authUrl); + } catch { + ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`); + } + + try { + const { code } = await waitForLocalCallback({ + expectedState: verifier, + timeoutMs: 5 * 60 * 1000, + onProgress: (msg) => ctx.progress.update(msg), + }); + ctx.progress.update("Exchanging authorization code for tokens..."); + return await exchangeCodeForTokens(code, verifier); + } catch (err) { + if ( + err instanceof Error && + (err.message.includes("EADDRINUSE") || + err.message.includes("port") || + err.message.includes("listen")) + ) { + ctx.progress.update("Local callback server failed. Switching to manual mode..."); + ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`); + const callbackInput = await ctx.prompt("Paste the redirect URL here: "); + const parsed = parseCallbackInput(callbackInput, verifier); + if ("error" in parsed) { + throw new Error(parsed.error, { cause: err }); + } + if (parsed.state !== verifier) { + throw new Error("OAuth state mismatch - please try again", { cause: err }); + } + ctx.progress.update("Exchanging authorization code for tokens..."); + return exchangeCodeForTokens(parsed.code, verifier); + } + throw err; + } +} diff --git a/extensions/google-gemini-cli-auth/openclaw.plugin.json b/extensions/google-gemini-cli-auth/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..c8f632da0c88df2d5c12c43068c4b794bc75da30 --- /dev/null +++ b/extensions/google-gemini-cli-auth/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "google-gemini-cli-auth", + "providers": ["google-gemini-cli"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json new file mode 100644 index 0000000000000000000000000000000000000000..08098136b9af204bb5618d894b50ce6ccb6128d2 --- /dev/null +++ b/extensions/google-gemini-cli-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/google-gemini-cli-auth", + "version": "2026.1.30", + "description": "OpenClaw Gemini CLI OAuth provider plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/googlechat/index.ts b/extensions/googlechat/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ade57f1e71e051487146bdd317e2fc3a3270dd1 --- /dev/null +++ b/extensions/googlechat/index.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { googlechatDock, googlechatPlugin } from "./src/channel.js"; +import { handleGoogleChatWebhookRequest } from "./src/monitor.js"; +import { setGoogleChatRuntime } from "./src/runtime.js"; + +const plugin = { + id: "googlechat", + name: "Google Chat", + description: "OpenClaw Google Chat channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setGoogleChatRuntime(api.runtime); + api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock }); + api.registerHttpHandler(handleGoogleChatWebhookRequest); + }, +}; + +export default plugin; diff --git a/extensions/googlechat/openclaw.plugin.json b/extensions/googlechat/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..aac96a5858449f454faa47c88250e64933b99fa1 --- /dev/null +++ b/extensions/googlechat/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "googlechat", + "channels": ["googlechat"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json new file mode 100644 index 0000000000000000000000000000000000000000..969a1e1561f592edfbfcd9f1f21774b456d7af20 --- /dev/null +++ b/extensions/googlechat/package.json @@ -0,0 +1,39 @@ +{ + "name": "@openclaw/googlechat", + "version": "2026.1.30", + "description": "OpenClaw Google Chat channel plugin", + "type": "module", + "dependencies": { + "google-auth-library": "^10.5.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.1.26" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "googlechat", + "label": "Google Chat", + "selectionLabel": "Google Chat (Chat API)", + "detailLabel": "Google Chat", + "docsPath": "/channels/googlechat", + "docsLabel": "googlechat", + "blurb": "Google Workspace Chat app via HTTP webhooks.", + "aliases": [ + "gchat", + "google-chat" + ], + "order": 55 + }, + "install": { + "npmSpec": "@openclaw/googlechat", + "localPath": "extensions/googlechat", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a247d1417c9781d1ae6d24d7c4a0f493cb8c971 --- /dev/null +++ b/extensions/googlechat/src/accounts.ts @@ -0,0 +1,147 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { GoogleChatAccountConfig } from "./types.config.js"; + +export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none"; + +export type ResolvedGoogleChatAccount = { + accountId: string; + name?: string; + enabled: boolean; + config: GoogleChatAccountConfig; + credentialSource: GoogleChatCredentialSource; + credentials?: Record; + credentialsFile?: string; +}; + +const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; +const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const accounts = cfg.channels?.["googlechat"]?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + return Object.keys(accounts).filter(Boolean); +} + +export function listGoogleChatAccountIds(cfg: OpenClawConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultGoogleChatAccountId(cfg: OpenClawConfig): string { + const channel = cfg.channels?.["googlechat"]; + if (channel?.defaultAccount?.trim()) { + return channel.defaultAccount.trim(); + } + const ids = listGoogleChatAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): GoogleChatAccountConfig | undefined { + const accounts = cfg.channels?.["googlechat"]?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + return accounts[accountId]; +} + +function mergeGoogleChatAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): GoogleChatAccountConfig { + const raw = cfg.channels?.["googlechat"] ?? {}; + const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account } as GoogleChatAccountConfig; +} + +function parseServiceAccount(value: unknown): Record | null { + if (value && typeof value === "object") { + return value as Record; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as Record; + } catch { + return null; + } +} + +function resolveCredentialsFromConfig(params: { + accountId: string; + account: GoogleChatAccountConfig; +}): { + credentials?: Record; + credentialsFile?: string; + source: GoogleChatCredentialSource; +} { + const { account, accountId } = params; + const inline = parseServiceAccount(account.serviceAccount); + if (inline) { + return { credentials: inline, source: "inline" }; + } + + const file = account.serviceAccountFile?.trim(); + if (file) { + return { credentialsFile: file, source: "file" }; + } + + if (accountId === DEFAULT_ACCOUNT_ID) { + const envJson = process.env[ENV_SERVICE_ACCOUNT]; + const envInline = parseServiceAccount(envJson); + if (envInline) { + return { credentials: envInline, source: "env" }; + } + const envFile = process.env[ENV_SERVICE_ACCOUNT_FILE]?.trim(); + if (envFile) { + return { credentialsFile: envFile, source: "env" }; + } + } + + return { source: "none" }; +} + +export function resolveGoogleChatAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedGoogleChatAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false; + const merged = mergeGoogleChatAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const credentials = resolveCredentialsFromConfig({ accountId, account: merged }); + + return { + accountId, + name: merged.name?.trim() || undefined, + enabled, + config: merged, + credentialSource: credentials.source, + credentials: credentials.credentials, + credentialsFile: credentials.credentialsFile, + }; +} + +export function listEnabledGoogleChatAccounts(cfg: OpenClawConfig): ResolvedGoogleChatAccount[] { + return listGoogleChatAccountIds(cfg) + .map((accountId) => resolveGoogleChatAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..011eaa2918861f78f76ea50e1452abf639f0d81a --- /dev/null +++ b/extensions/googlechat/src/actions.ts @@ -0,0 +1,181 @@ +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, + OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "openclaw/plugin-sdk"; +import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js"; +import { + createGoogleChatReaction, + deleteGoogleChatReaction, + listGoogleChatReactions, + sendGoogleChatMessage, + uploadGoogleChatAttachment, +} from "./api.js"; +import { getGoogleChatRuntime } from "./runtime.js"; +import { resolveGoogleChatOutboundSpace } from "./targets.js"; + +const providerId = "googlechat"; + +function listEnabledAccounts(cfg: OpenClawConfig) { + return listEnabledGoogleChatAccounts(cfg).filter( + (account) => account.enabled && account.credentialSource !== "none", + ); +} + +function isReactionsEnabled(accounts: ReturnType, cfg: OpenClawConfig) { + for (const account of accounts) { + const gate = createActionGate( + (account.config.actions ?? + (cfg.channels?.["googlechat"] as { actions?: unknown })?.actions) as Record< + string, + boolean | undefined + >, + ); + if (gate("reactions")) { + return true; + } + } + return false; +} + +function resolveAppUserNames(account: { config: { botUser?: string | null } }) { + return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]); +} + +export const googlechatMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listEnabledAccounts(cfg); + if (accounts.length === 0) { + return []; + } + const actions = new Set([]); + actions.add("send"); + if (isReactionsEnabled(accounts, cfg)) { + actions.add("react"); + actions.add("reactions"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") { + return null; + } + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) { + return null; + } + const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; + return { to, accountId }; + }, + handleAction: async ({ action, params, cfg, accountId }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + if (account.credentialSource === "none") { + throw new Error("Google Chat credentials are missing."); + } + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo"); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + + if (mediaUrl) { + const core = getGoogleChatRuntime(); + const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024; + const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { maxBytes }); + const upload = await uploadGoogleChatAttachment({ + account, + space, + filename: loaded.filename ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + await sendGoogleChatMessage({ + account, + space, + text: content, + thread: threadId ?? undefined, + attachments: upload.attachmentUploadToken + ? [ + { + attachmentUploadToken: upload.attachmentUploadToken, + contentName: loaded.filename, + }, + ] + : undefined, + }); + return jsonResult({ ok: true, to: space }); + } + + await sendGoogleChatMessage({ + account, + space, + text: content, + thread: threadId ?? undefined, + }); + return jsonResult({ ok: true, to: space }); + } + + if (action === "react") { + const messageName = readStringParam(params, "messageId", { required: true }); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Google Chat reaction.", + }); + if (remove || isEmpty) { + const reactions = await listGoogleChatReactions({ account, messageName }); + const appUsers = resolveAppUserNames(account); + const toRemove = reactions.filter((reaction) => { + const userName = reaction.user?.name?.trim(); + if (appUsers.size > 0 && !appUsers.has(userName ?? "")) { + return false; + } + if (emoji) { + return reaction.emoji?.unicode === emoji; + } + return true; + }); + for (const reaction of toRemove) { + if (!reaction.name) { + continue; + } + await deleteGoogleChatReaction({ account, reactionName: reaction.name }); + } + return jsonResult({ ok: true, removed: toRemove.length }); + } + const reaction = await createGoogleChatReaction({ + account, + messageName, + emoji, + }); + return jsonResult({ ok: true, reaction }); + } + + if (action === "reactions") { + const messageName = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + const reactions = await listGoogleChatReactions({ + account, + messageName, + limit: limit ?? undefined, + }); + return jsonResult({ ok: true, reactions }); + } + + throw new Error(`Action ${action} is not supported for provider ${providerId}.`); + }, +}; diff --git a/extensions/googlechat/src/api.test.ts b/extensions/googlechat/src/api.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b98b247a66e257b0e5aa7a9778efc5ea3b35afb5 --- /dev/null +++ b/extensions/googlechat/src/api.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { downloadGoogleChatMedia } from "./api.js"; + +vi.mock("./auth.js", () => ({ + getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"), +})); + +const account = { + accountId: "default", + enabled: true, + credentialSource: "inline", + config: {}, +} as ResolvedGoogleChatAccount; + +describe("downloadGoogleChatMedia", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("rejects when content-length exceeds max bytes", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + const response = new Response(body, { + status: 200, + headers: { "content-length": "50", "content-type": "application/octet-stream" }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + + await expect( + downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }), + ).rejects.toThrow(/max bytes/i); + }); + + it("rejects when streamed payload exceeds max bytes", async () => { + const chunks = [new Uint8Array(6), new Uint8Array(6)]; + let index = 0; + const body = new ReadableStream({ + pull(controller) { + if (index < chunks.length) { + controller.enqueue(chunks[index++]); + } else { + controller.close(); + } + }, + }); + const response = new Response(body, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); + + await expect( + downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }), + ).rejects.toThrow(/max bytes/i); + }); +}); diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0cf0acf57f83f1ffe4e460fb8ad27b314c62079 --- /dev/null +++ b/extensions/googlechat/src/api.ts @@ -0,0 +1,282 @@ +import crypto from "node:crypto"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import type { GoogleChatReaction } from "./types.js"; +import { getGoogleChatAccessToken } from "./auth.js"; + +const CHAT_API_BASE = "https://chat.googleapis.com/v1"; +const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"; + +const headersToObject = (headers?: HeadersInit): Record => + headers instanceof Headers + ? Object.fromEntries(headers.entries()) + : Array.isArray(headers) + ? Object.fromEntries(headers) + : headers || {}; + +async function fetchJson( + account: ResolvedGoogleChatAccount, + url: string, + init: RequestInit, +): Promise { + const token = await getGoogleChatAccessToken(account); + const res = await fetch(url, { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + } + return (await res.json()) as T; +} + +async function fetchOk( + account: ResolvedGoogleChatAccount, + url: string, + init: RequestInit, +): Promise { + const token = await getGoogleChatAccessToken(account); + const res = await fetch(url, { + ...init, + headers: { + ...headersToObject(init.headers), + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + } +} + +async function fetchBuffer( + account: ResolvedGoogleChatAccount, + url: string, + init?: RequestInit, + options?: { maxBytes?: number }, +): Promise<{ buffer: Buffer; contentType?: string }> { + const token = await getGoogleChatAccessToken(account); + const res = await fetch(url, { + ...init, + headers: { + ...headersToObject(init?.headers), + Authorization: `Bearer ${token}`, + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`); + } + const maxBytes = options?.maxBytes; + const lengthHeader = res.headers.get("content-length"); + if (maxBytes && lengthHeader) { + const length = Number(lengthHeader); + if (Number.isFinite(length) && length > maxBytes) { + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + } + if (!maxBytes || !res.body) { + const buffer = Buffer.from(await res.arrayBuffer()); + const contentType = res.headers.get("content-type") ?? undefined; + return { buffer, contentType }; + } + const reader = res.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + total += value.length; + if (total > maxBytes) { + await reader.cancel(); + throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`); + } + chunks.push(Buffer.from(value)); + } + const buffer = Buffer.concat(chunks, total); + const contentType = res.headers.get("content-type") ?? undefined; + return { buffer, contentType }; +} + +export async function sendGoogleChatMessage(params: { + account: ResolvedGoogleChatAccount; + space: string; + text?: string; + thread?: string; + attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>; +}): Promise<{ messageName?: string } | null> { + const { account, space, text, thread, attachments } = params; + const body: Record = {}; + if (text) { + body.text = text; + } + if (thread) { + body.thread = { name: thread }; + } + if (attachments && attachments.length > 0) { + body.attachment = attachments.map((item) => ({ + attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken }, + ...(item.contentName ? { contentName: item.contentName } : {}), + })); + } + const url = `${CHAT_API_BASE}/${space}/messages`; + const result = await fetchJson<{ name?: string }>(account, url, { + method: "POST", + body: JSON.stringify(body), + }); + return result ? { messageName: result.name } : null; +} + +export async function updateGoogleChatMessage(params: { + account: ResolvedGoogleChatAccount; + messageName: string; + text: string; +}): Promise<{ messageName?: string }> { + const { account, messageName, text } = params; + const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`; + const result = await fetchJson<{ name?: string }>(account, url, { + method: "PATCH", + body: JSON.stringify({ text }), + }); + return { messageName: result.name }; +} + +export async function deleteGoogleChatMessage(params: { + account: ResolvedGoogleChatAccount; + messageName: string; +}): Promise { + const { account, messageName } = params; + const url = `${CHAT_API_BASE}/${messageName}`; + await fetchOk(account, url, { method: "DELETE" }); +} + +export async function uploadGoogleChatAttachment(params: { + account: ResolvedGoogleChatAccount; + space: string; + filename: string; + buffer: Buffer; + contentType?: string; +}): Promise<{ attachmentUploadToken?: string }> { + const { account, space, filename, buffer, contentType } = params; + const boundary = `openclaw-${crypto.randomUUID()}`; + const metadata = JSON.stringify({ filename }); + const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`; + const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`; + const footer = `\r\n--${boundary}--\r\n`; + const body = Buffer.concat([ + Buffer.from(header, "utf8"), + Buffer.from(mediaHeader, "utf8"), + buffer, + Buffer.from(footer, "utf8"), + ]); + + const token = await getGoogleChatAccessToken(account); + const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": `multipart/related; boundary=${boundary}`, + }, + body, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`); + } + const payload = (await res.json()) as { + attachmentDataRef?: { attachmentUploadToken?: string }; + }; + return { + attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, + }; +} + +export async function downloadGoogleChatMedia(params: { + account: ResolvedGoogleChatAccount; + resourceName: string; + maxBytes?: number; +}): Promise<{ buffer: Buffer; contentType?: string }> { + const { account, resourceName, maxBytes } = params; + const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`; + return await fetchBuffer(account, url, undefined, { maxBytes }); +} + +export async function createGoogleChatReaction(params: { + account: ResolvedGoogleChatAccount; + messageName: string; + emoji: string; +}): Promise { + const { account, messageName, emoji } = params; + const url = `${CHAT_API_BASE}/${messageName}/reactions`; + return await fetchJson(account, url, { + method: "POST", + body: JSON.stringify({ emoji: { unicode: emoji } }), + }); +} + +export async function listGoogleChatReactions(params: { + account: ResolvedGoogleChatAccount; + messageName: string; + limit?: number; +}): Promise { + const { account, messageName, limit } = params; + const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`); + if (limit && limit > 0) { + url.searchParams.set("pageSize", String(limit)); + } + const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), { + method: "GET", + }); + return result.reactions ?? []; +} + +export async function deleteGoogleChatReaction(params: { + account: ResolvedGoogleChatAccount; + reactionName: string; +}): Promise { + const { account, reactionName } = params; + const url = `${CHAT_API_BASE}/${reactionName}`; + await fetchOk(account, url, { method: "DELETE" }); +} + +export async function findGoogleChatDirectMessage(params: { + account: ResolvedGoogleChatAccount; + userName: string; +}): Promise<{ name?: string; displayName?: string } | null> { + const { account, userName } = params; + const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`); + url.searchParams.set("name", userName); + return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), { + method: "GET", + }); +} + +export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{ + ok: boolean; + status?: number; + error?: string; +}> { + try { + const url = new URL(`${CHAT_API_BASE}/spaces`); + url.searchParams.set("pageSize", "1"); + await fetchJson>(account, url.toString(), { + method: "GET", + }); + return { ok: true }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..bee093315ccea4bce6d9a776e16f4c7df0311ad0 --- /dev/null +++ b/extensions/googlechat/src/auth.ts @@ -0,0 +1,123 @@ +import { GoogleAuth, OAuth2Client } from "google-auth-library"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; + +const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; +const CHAT_ISSUER = "chat@system.gserviceaccount.com"; +// Google Workspace Add-ons use a different service account pattern +const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/; +const CHAT_CERTS_URL = + "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"; + +const authCache = new Map(); +const verifyClient = new OAuth2Client(); + +let cachedCerts: { fetchedAt: number; certs: Record } | null = null; + +function buildAuthKey(account: ResolvedGoogleChatAccount): string { + if (account.credentialsFile) { + return `file:${account.credentialsFile}`; + } + if (account.credentials) { + return `inline:${JSON.stringify(account.credentials)}`; + } + return "none"; +} + +function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { + const key = buildAuthKey(account); + const cached = authCache.get(account.accountId); + if (cached && cached.key === key) { + return cached.auth; + } + + if (account.credentialsFile) { + const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] }); + authCache.set(account.accountId, { key, auth }); + return auth; + } + + if (account.credentials) { + const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] }); + authCache.set(account.accountId, { key, auth }); + return auth; + } + + const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] }); + authCache.set(account.accountId, { key, auth }); + return auth; +} + +export async function getGoogleChatAccessToken( + account: ResolvedGoogleChatAccount, +): Promise { + const auth = getAuthInstance(account); + const client = await auth.getClient(); + const access = await client.getAccessToken(); + const token = typeof access === "string" ? access : access?.token; + if (!token) { + throw new Error("Missing Google Chat access token"); + } + return token; +} + +async function fetchChatCerts(): Promise> { + const now = Date.now(); + if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) { + return cachedCerts.certs; + } + const res = await fetch(CHAT_CERTS_URL); + if (!res.ok) { + throw new Error(`Failed to fetch Chat certs (${res.status})`); + } + const certs = (await res.json()) as Record; + cachedCerts = { fetchedAt: now, certs }; + return certs; +} + +export type GoogleChatAudienceType = "app-url" | "project-number"; + +export async function verifyGoogleChatRequest(params: { + bearer?: string | null; + audienceType?: GoogleChatAudienceType | null; + audience?: string | null; +}): Promise<{ ok: boolean; reason?: string }> { + const bearer = params.bearer?.trim(); + if (!bearer) { + return { ok: false, reason: "missing token" }; + } + const audience = params.audience?.trim(); + if (!audience) { + return { ok: false, reason: "missing audience" }; + } + const audienceType = params.audienceType ?? null; + + if (audienceType === "app-url") { + try { + const ticket = await verifyClient.verifyIdToken({ + idToken: bearer, + audience, + }); + const payload = ticket.getPayload(); + const email = payload?.email ?? ""; + const ok = + payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email)); + return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : "invalid token" }; + } + } + + if (audienceType === "project-number") { + try { + const certs = await fetchChatCerts(); + await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]); + return { ok: true }; + } catch (err) { + return { ok: false, reason: err instanceof Error ? err.message : "invalid token" }; + } + } + + return { ok: false, reason: "unsupported audience type" }; +} + +export const GOOGLE_CHAT_SCOPE = CHAT_SCOPE; diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc1cdf22560aa02527f9e7d98d161c6527b537ae --- /dev/null +++ b/extensions/googlechat/src/channel.ts @@ -0,0 +1,583 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + missingTargetError, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + resolveChannelMediaMaxBytes, + resolveGoogleChatGroupRequireMention, + setAccountEnabledInConfigSection, + type ChannelDock, + type ChannelMessageActionAdapter, + type ChannelPlugin, + type OpenClawConfig, +} from "openclaw/plugin-sdk"; +import { GoogleChatConfigSchema } from "openclaw/plugin-sdk"; +import { + listGoogleChatAccountIds, + resolveDefaultGoogleChatAccountId, + resolveGoogleChatAccount, + type ResolvedGoogleChatAccount, +} from "./accounts.js"; +import { googlechatMessageActions } from "./actions.js"; +import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js"; +import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js"; +import { googlechatOnboardingAdapter } from "./onboarding.js"; +import { getGoogleChatRuntime } from "./runtime.js"; +import { + isGoogleChatSpaceTarget, + isGoogleChatUserTarget, + normalizeGoogleChatTarget, + resolveGoogleChatOutboundSpace, +} from "./targets.js"; + +const meta = getChatChannelMeta("googlechat"); + +const formatAllowFromEntry = (entry: string) => + entry + .trim() + .replace(/^(googlechat|google-chat|gchat):/i, "") + .replace(/^user:/i, "") + .replace(/^users\//i, "") + .toLowerCase(); + +export const googlechatDock: ChannelDock = { + id: "googlechat", + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: true, + media: true, + threads: true, + blockStreaming: true, + }, + outbound: { textChunkLimit: 4000 }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => + (resolveGoogleChatAccount({ cfg: cfg, accountId }).config.dm?.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + groups: { + resolveRequireMention: resolveGoogleChatGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", + buildToolContext: ({ context, hasRepliedRef }) => { + const threadId = context.MessageThreadId ?? context.ReplyToId; + return { + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + hasRepliedRef, + }; + }, + }, +}; + +const googlechatActions: ChannelMessageActionAdapter = { + listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [], + extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null, + handleAction: async (ctx) => { + if (!googlechatMessageActions.handleAction) { + throw new Error("Google Chat actions are not available."); + } + return await googlechatMessageActions.handleAction(ctx); + }, +}; + +export const googlechatPlugin: ChannelPlugin = { + id: "googlechat", + meta: { ...meta }, + onboarding: googlechatOnboardingAdapter, + pairing: { + idLabel: "googlechatUserId", + normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), + notifyApproval: async ({ cfg, id }) => { + const account = resolveGoogleChatAccount({ cfg: cfg }); + if (account.credentialSource === "none") { + return; + } + const user = normalizeGoogleChatTarget(id) ?? id; + const target = isGoogleChatUserTarget(user) ? user : `users/${user}`; + const space = await resolveGoogleChatOutboundSpace({ account, target }); + await sendGoogleChatMessage({ + account, + space, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + reactions: true, + threads: true, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.googlechat"] }, + configSchema: buildChannelConfigSchema(GoogleChatConfigSchema), + config: { + listAccountIds: (cfg) => listGoogleChatAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg, + sectionKey: "googlechat", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg, + sectionKey: "googlechat", + accountId, + clearBaseFields: [ + "serviceAccount", + "serviceAccountFile", + "audienceType", + "audience", + "webhookPath", + "webhookUrl", + "botUser", + "name", + ], + }), + isConfigured: (account) => account.credentialSource !== "none", + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }).config.dm?.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry)) + .filter(Boolean) + .map(formatAllowFromEntry), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.["googlechat"]?.accounts?.[resolvedAccountId]); + const allowFromPath = useAccountPath + ? `channels.googlechat.accounts.${resolvedAccountId}.dm.` + : "channels.googlechat.dm."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + allowFromPath, + approveHint: formatPairingApproveHint("googlechat"), + normalizeEntry: (raw) => formatAllowFromEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const warnings: string[] = []; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "open") { + warnings.push( + `- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`, + ); + } + if (account.config.dm?.policy === "open") { + warnings.push( + `- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`, + ); + } + return warnings; + }, + }, + groups: { + resolveRequireMention: resolveGoogleChatGroupRequireMention, + }, + threading: { + resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", + }, + messaging: { + normalizeTarget: normalizeGoogleChatTarget, + targetResolver: { + looksLikeId: (raw, normalized) => { + const value = normalized ?? raw.trim(); + return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const q = query?.trim().toLowerCase() || ""; + const allowFrom = account.config.dm?.allowFrom ?? []; + const peers = Array.from( + new Set( + allowFrom + .map((entry) => String(entry).trim()) + .filter((entry) => Boolean(entry) && entry !== "*") + .map((entry) => normalizeGoogleChatTarget(entry) ?? entry), + ), + ) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + return peers; + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const groups = account.config.groups ?? {}; + const q = query?.trim().toLowerCase() || ""; + const entries = Object.keys(groups) + .filter((key) => key && key !== "*") + .filter((key) => (q ? key.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return entries; + }, + }, + resolver: { + resolveTargets: async ({ inputs, kind }) => { + const resolved = inputs.map((input) => { + const normalized = normalizeGoogleChatTarget(input); + if (!normalized) { + return { input, resolved: false, note: "empty target" }; + } + if (kind === "user" && isGoogleChatUserTarget(normalized)) { + return { input, resolved: true, id: normalized }; + } + if (kind === "group" && isGoogleChatSpaceTarget(normalized)) { + return { input, resolved: true, id: normalized }; + } + return { + input, + resolved: false, + note: "use spaces/{space} or users/{user}", + }; + }); + return resolved; + }, + }, + actions: googlechatActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "googlechat", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Google Chat requires --token (service account JSON) or --token-file."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "googlechat", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "googlechat", + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { serviceAccountFile: input.tokenFile } + : input.token + ? { serviceAccount: input.token } + : {}; + const audienceType = input.audienceType?.trim(); + const audience = input.audience?.trim(); + const webhookPath = input.webhookPath?.trim(); + const webhookUrl = input.webhookUrl?.trim(); + const configPatch = { + ...patch, + ...(audienceType ? { audienceType } : {}), + ...(audience ? { audience } : {}), + ...(webhookPath ? { webhookPath } : {}), + ...(webhookUrl ? { webhookUrl } : {}), + }; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + googlechat: { + ...next.channels?.["googlechat"], + enabled: true, + ...configPatch, + }, + }, + } as OpenClawConfig; + } + return { + ...next, + channels: { + ...next.channels, + googlechat: { + ...next.channels?.["googlechat"], + enabled: true, + accounts: { + ...next.channels?.["googlechat"]?.accounts, + [accountId]: { + ...next.channels?.["googlechat"]?.accounts?.[accountId], + enabled: true, + ...configPatch, + }, + }, + }, + }, + } as OpenClawConfig; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + resolveTarget: ({ to, allowFrom, mode }) => { + const trimmed = to?.trim() ?? ""; + const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean); + const allowList = allowListRaw + .filter((entry) => entry !== "*") + .map((entry) => normalizeGoogleChatTarget(entry)) + .filter((entry): entry is string => Boolean(entry)); + + if (trimmed) { + const normalized = normalizeGoogleChatTarget(trimmed); + if (!normalized) { + if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: missingTargetError( + "Google Chat", + " or channels.googlechat.dm.allowFrom[0]", + ), + }; + } + return { ok: true, to: normalized }; + } + + if (allowList.length > 0) { + return { ok: true, to: allowList[0] }; + } + return { + ok: false, + error: missingTargetError( + "Google Chat", + " or channels.googlechat.dm.allowFrom[0]", + ), + }; + }, + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + }); + return { + channel: "googlechat", + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { + if (!mediaUrl) { + throw new Error("Google Chat mediaUrl is required."); + } + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const runtime = getGoogleChatRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + ( + cfg.channels?.["googlechat"] as + | { accounts?: Record; mediaMaxMb?: number } + | undefined + )?.accounts?.[accountId]?.mediaMaxMb ?? + (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, + accountId, + }); + const loaded = await runtime.channel.media.fetchRemoteMedia(mediaUrl, { + maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024, + }); + const upload = await uploadGoogleChatAttachment({ + account, + space, + filename: loaded.filename ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + attachments: upload.attachmentUploadToken + ? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }] + : undefined, + }); + return { + channel: "googlechat", + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((entry) => { + const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID); + const enabled = entry.enabled !== false; + const configured = entry.configured === true; + if (!enabled || !configured) { + return []; + } + const issues = []; + if (!entry.audience) { + issues.push({ + channel: "googlechat", + accountId, + kind: "config", + message: "Google Chat audience is missing (set channels.googlechat.audience).", + fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", + }); + } + if (!entry.audienceType) { + issues.push({ + channel: "googlechat", + accountId, + kind: "config", + message: "Google Chat audienceType is missing (app-url or project-number).", + fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.", + }); + } + return issues; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + credentialSource: snapshot.credentialSource ?? "none", + audienceType: snapshot.audienceType ?? null, + audience: snapshot.audience ?? null, + webhookPath: snapshot.webhookPath ?? null, + webhookUrl: snapshot.webhookUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account }) => probeGoogleChat(account), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.credentialSource !== "none", + credentialSource: account.credentialSource, + audienceType: account.config.audienceType, + audience: account.config.audience, + webhookPath: account.config.webhookPath, + webhookUrl: account.config.webhookUrl, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dm?.policy ?? "pairing", + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`); + ctx.setStatus({ + accountId: account.accountId, + running: true, + lastStartAt: Date.now(), + webhookPath: resolveGoogleChatWebhookPath({ account }), + audienceType: account.config.audienceType, + audience: account.config.audience, + }); + const unregister = await startGoogleChatMonitor({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + webhookPath: account.config.webhookPath, + webhookUrl: account.config.webhookUrl, + statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }), + }); + return () => { + unregister?.(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }; + }, + }, +}; diff --git a/extensions/googlechat/src/monitor.test.ts b/extensions/googlechat/src/monitor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5223ba9c9fd39bee65268378ec244ca4d55ef67c --- /dev/null +++ b/extensions/googlechat/src/monitor.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { isSenderAllowed } from "./monitor.js"; + +describe("isSenderAllowed", () => { + it("matches allowlist entries with users/", () => { + expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(true); + }); + + it("matches allowlist entries with raw email", () => { + expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(true); + }); + + it("still matches user id entries", () => { + expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true); + }); + + it("rejects non-matching emails", () => { + expect(isSenderAllowed("users/123", "jane@example.com", ["users/other@example.com"])).toBe( + false, + ); + }); +}); diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5167878b8a9d3dbad0246f66b506e1c9b893433 --- /dev/null +++ b/extensions/googlechat/src/monitor.ts @@ -0,0 +1,949 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk"; +import type { + GoogleChatAnnotation, + GoogleChatAttachment, + GoogleChatEvent, + GoogleChatSpace, + GoogleChatMessage, + GoogleChatUser, +} from "./types.js"; +import { type ResolvedGoogleChatAccount } from "./accounts.js"; +import { + downloadGoogleChatMedia, + deleteGoogleChatMessage, + sendGoogleChatMessage, + updateGoogleChatMessage, +} from "./api.js"; +import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js"; +import { getGoogleChatRuntime } from "./runtime.js"; + +export type GoogleChatRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +export type GoogleChatMonitorOptions = { + account: ResolvedGoogleChatAccount; + config: OpenClawConfig; + runtime: GoogleChatRuntimeEnv; + abortSignal: AbortSignal; + webhookPath?: string; + webhookUrl?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +type GoogleChatCoreRuntime = ReturnType; + +type WebhookTarget = { + account: ResolvedGoogleChatAccount; + config: OpenClawConfig; + runtime: GoogleChatRuntimeEnv; + core: GoogleChatCoreRuntime; + path: string; + audienceType?: GoogleChatAudienceType; + audience?: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + mediaMaxMb: number; +}; + +const webhookTargets = new Map(); + +function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) { + if (core.logging.shouldLogVerbose()) { + runtime.log?.(`[googlechat] ${message}`); + } +} + +function normalizeWebhookPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return "/"; + } + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withSlash.length > 1 && withSlash.endsWith("/")) { + return withSlash.slice(0, -1); + } + return withSlash; +} + +function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null { + const trimmedPath = webhookPath?.trim(); + if (trimmedPath) { + return normalizeWebhookPath(trimmedPath); + } + if (webhookUrl?.trim()) { + try { + const parsed = new URL(webhookUrl); + return normalizeWebhookPath(parsed.pathname || "/"); + } catch { + return null; + } + } + return "/googlechat"; +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks: Buffer[] = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + let resolved = false; + const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => { + if (resolved) { + return; + } + resolved = true; + req.removeAllListeners(); + resolve(value); + }; + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + doResolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + doResolve({ ok: false, error: "empty payload" }); + return; + } + doResolve({ ok: true, value: JSON.parse(raw) as unknown }); + } catch (err) { + doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { + const key = normalizeWebhookPath(target.path); + const normalizedTarget = { ...target, path: key }; + const existing = webhookTargets.get(key) ?? []; + const next = [...existing, normalizedTarget]; + webhookTargets.set(key, next); + return () => { + const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); + if (updated.length > 0) { + webhookTargets.set(key, updated); + } else { + webhookTargets.delete(key); + } + }; +} + +function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined { + const normalized = value?.trim().toLowerCase(); + if (normalized === "app-url" || normalized === "app_url" || normalized === "app") { + return "app-url"; + } + if ( + normalized === "project-number" || + normalized === "project_number" || + normalized === "project" + ) { + return "project-number"; + } + return undefined; +} + +export async function handleGoogleChatWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + const path = normalizeWebhookPath(url.pathname); + const targets = webhookTargets.get(path); + if (!targets || targets.length === 0) { + return false; + } + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "POST"); + res.end("Method Not Allowed"); + return true; + } + + const authHeader = String(req.headers.authorization ?? ""); + const bearer = authHeader.toLowerCase().startsWith("bearer ") + ? authHeader.slice("bearer ".length) + : ""; + + const body = await readJsonBody(req, 1024 * 1024); + if (!body.ok) { + res.statusCode = body.error === "payload too large" ? 413 : 400; + res.end(body.error ?? "invalid payload"); + return true; + } + + let raw = body.value; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + res.statusCode = 400; + res.end("invalid payload"); + return true; + } + + // Transform Google Workspace Add-on format to standard Chat API format + const rawObj = raw as { + commonEventObject?: { hostApp?: string }; + chat?: { + messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage }; + user?: GoogleChatUser; + eventTime?: string; + }; + authorizationEventObject?: { systemIdToken?: string }; + }; + + if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) { + const chat = rawObj.chat; + const messagePayload = chat.messagePayload; + raw = { + type: "MESSAGE", + space: messagePayload?.space, + message: messagePayload?.message, + user: chat.user, + eventTime: chat.eventTime, + }; + + // For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken + const systemIdToken = rawObj.authorizationEventObject?.systemIdToken; + if (!bearer && systemIdToken) { + Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` }); + } + } + + const event = raw as GoogleChatEvent; + const eventType = event.type ?? (raw as { eventType?: string }).eventType; + if (typeof eventType !== "string") { + res.statusCode = 400; + res.end("invalid payload"); + return true; + } + + if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) { + res.statusCode = 400; + res.end("invalid payload"); + return true; + } + + if (eventType === "MESSAGE") { + if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) { + res.statusCode = 400; + res.end("invalid payload"); + return true; + } + } + + // Re-extract bearer in case it was updated from Add-on format + const authHeaderNow = String(req.headers.authorization ?? ""); + const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ") + ? authHeaderNow.slice("bearer ".length) + : bearer; + + let selected: WebhookTarget | undefined; + for (const target of targets) { + const audienceType = target.audienceType; + const audience = target.audience; + const verification = await verifyGoogleChatRequest({ + bearer: effectiveBearer, + audienceType, + audience, + }); + if (verification.ok) { + selected = target; + break; + } + } + + if (!selected) { + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + + selected.statusSink?.({ lastInboundAt: Date.now() }); + processGoogleChatEvent(event, selected).catch((err) => { + selected?.runtime.error?.( + `[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`, + ); + }); + + res.statusCode = 200; + res.setHeader("Content-Type", "application/json"); + res.end("{}"); + return true; +} + +async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) { + const eventType = event.type ?? (event as { eventType?: string }).eventType; + if (eventType !== "MESSAGE") { + return; + } + if (!event.message || !event.space) { + return; + } + + await processMessageWithPipeline({ + event, + account: target.account, + config: target.config, + runtime: target.runtime, + core: target.core, + statusSink: target.statusSink, + mediaMaxMb: target.mediaMaxMb, + }); +} + +function normalizeUserId(raw?: string | null): string { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) { + return ""; + } + return trimmed.replace(/^users\//i, "").toLowerCase(); +} + +export function isSenderAllowed( + senderId: string, + senderEmail: string | undefined, + allowFrom: string[], +) { + if (allowFrom.includes("*")) { + return true; + } + const normalizedSenderId = normalizeUserId(senderId); + const normalizedEmail = senderEmail?.trim().toLowerCase() ?? ""; + return allowFrom.some((entry) => { + const normalized = String(entry).trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized === normalizedSenderId) { + return true; + } + if (normalizedEmail && normalized === normalizedEmail) { + return true; + } + if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) { + return true; + } + if (normalized.replace(/^users\//i, "") === normalizedSenderId) { + return true; + } + if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) { + return true; + } + return false; + }); +} + +function resolveGroupConfig(params: { + groupId: string; + groupName?: string | null; + groups?: Record< + string, + { + requireMention?: boolean; + allow?: boolean; + enabled?: boolean; + users?: Array; + systemPrompt?: string; + } + >; +}) { + const { groupId, groupName, groups } = params; + const entries = groups ?? {}; + const keys = Object.keys(entries); + if (keys.length === 0) { + return { entry: undefined, allowlistConfigured: false }; + } + const normalizedName = groupName?.trim().toLowerCase(); + const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean); + let entry = candidates.map((candidate) => entries[candidate]).find(Boolean); + if (!entry && normalizedName) { + entry = entries[normalizedName]; + } + const fallback = entries["*"]; + return { entry: entry ?? fallback, allowlistConfigured: true, fallback }; +} + +function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) { + const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION"); + const hasAnyMention = mentionAnnotations.length > 0; + const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]); + const wasMentioned = mentionAnnotations.some((entry) => { + const userName = entry.userMention?.user?.name; + if (!userName) { + return false; + } + if (botTargets.has(userName)) { + return true; + } + return normalizeUserId(userName) === "app"; + }); + return { hasAnyMention, wasMentioned }; +} + +/** + * Resolve bot display name with fallback chain: + * 1. Account config name + * 2. Agent name from config + * 3. "OpenClaw" as generic fallback + */ +function resolveBotDisplayName(params: { + accountName?: string; + agentId: string; + config: OpenClawConfig; +}): string { + const { accountName, agentId, config } = params; + if (accountName?.trim()) { + return accountName.trim(); + } + const agent = config.agents?.list?.find((a) => a.id === agentId); + if (agent?.name?.trim()) { + return agent.name.trim(); + } + return "OpenClaw"; +} + +async function processMessageWithPipeline(params: { + event: GoogleChatEvent; + account: ResolvedGoogleChatAccount; + config: OpenClawConfig; + runtime: GoogleChatRuntimeEnv; + core: GoogleChatCoreRuntime; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + mediaMaxMb: number; +}): Promise { + const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params; + const space = event.space; + const message = event.message; + if (!space || !message) { + return; + } + + const spaceId = space.name ?? ""; + if (!spaceId) { + return; + } + const spaceType = (space.type ?? "").toUpperCase(); + const isGroup = spaceType !== "DM"; + const sender = message.sender ?? event.user; + const senderId = sender?.name ?? ""; + const senderName = sender?.displayName ?? ""; + const senderEmail = sender?.email ?? undefined; + + const allowBots = account.config.allowBots === true; + if (!allowBots) { + if (sender?.type?.toUpperCase() === "BOT") { + logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`); + return; + } + if (senderId === "users/app") { + logVerbose(core, runtime, "skip app-authored message"); + return; + } + } + + const messageText = (message.argumentText ?? message.text ?? "").trim(); + const attachments = message.attachment ?? []; + const hasMedia = attachments.length > 0; + const rawBody = messageText || (hasMedia ? "" : ""); + if (!rawBody) { + return; + } + + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupConfigResolved = resolveGroupConfig({ + groupId: spaceId, + groupName: space.displayName ?? null, + groups: account.config.groups ?? undefined, + }); + const groupEntry = groupConfigResolved.entry; + const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? []; + let effectiveWasMentioned: boolean | undefined; + + if (isGroup) { + if (groupPolicy === "disabled") { + logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`); + return; + } + const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured; + const groupAllowed = Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]); + if (groupPolicy === "allowlist") { + if (!groupAllowlistConfigured) { + logVerbose( + core, + runtime, + `drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`, + ); + return; + } + if (!groupAllowed) { + logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`); + return; + } + } + if (groupEntry?.enabled === false || groupEntry?.allow === false) { + logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`); + return; + } + + if (groupUsers.length > 0) { + const ok = isSenderAllowed( + senderId, + senderEmail, + groupUsers.map((v) => String(v)), + ); + if (!ok) { + logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`); + return; + } + } + } + + const dmPolicy = account.config.dm?.policy ?? "pairing"; + const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v)); + const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config); + const storeAllowFrom = + !isGroup && (dmPolicy !== "open" || shouldComputeAuth) + ? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => []) + : []; + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]; + const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom; + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom); + const commandAuthorized = shouldComputeAuth + ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands }, + ], + }) + : undefined; + + if (isGroup) { + const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true; + const annotations = message.annotations ?? []; + const mentionInfo = extractMentionInfo(annotations, account.config.botUser); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config, + surface: "googlechat", + }); + const mentionGate = resolveMentionGatingWithBypass({ + isGroup: true, + requireMention, + canDetectMention: true, + wasMentioned: mentionInfo.wasMentioned, + implicitMention: false, + hasAnyMention: mentionInfo.hasAnyMention, + allowTextCommands, + hasControlCommand: core.channel.text.hasControlCommand(rawBody, config), + commandAuthorized: commandAuthorized === true, + }); + effectiveWasMentioned = mentionGate.effectiveWasMentioned; + if (mentionGate.shouldSkip) { + logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`); + return; + } + } + + if (!isGroup) { + if (dmPolicy === "disabled" || account.config.dm?.enabled === false) { + logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`); + return; + } + + if (dmPolicy !== "open") { + const allowed = senderAllowedForCommands; + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "googlechat", + id: senderId, + meta: { name: senderName || undefined, email: senderEmail }, + }); + if (created) { + logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`); + try { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: core.channel.pairing.buildPairingReply({ + channel: "googlechat", + idLine: `Your Google Chat user id: ${senderId}`, + code, + }), + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } else { + logVerbose( + core, + runtime, + `Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } + } + + if ( + isGroup && + core.channel.commands.isControlCommandMessage(rawBody, config) && + commandAuthorized !== true + ) { + logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`); + return; + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "googlechat", + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: spaceId, + }, + }); + + let mediaPath: string | undefined; + let mediaType: string | undefined; + if (attachments.length > 0) { + const first = attachments[0]; + const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core); + if (attachmentData) { + mediaPath = attachmentData.path; + mediaType = attachmentData.contentType; + } + } + + const fromLabel = isGroup + ? space.displayName || `space:${spaceId}` + : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Google Chat", + from: fromLabel, + timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: `googlechat:${senderId}`, + To: `googlechat:${spaceId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "channel" : "direct", + ConversationLabel: fromLabel, + SenderName: senderName || undefined, + SenderId: senderId, + SenderUsername: senderEmail, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, + Provider: "googlechat", + Surface: "googlechat", + MessageSid: message.name, + MessageSidFull: message.name, + ReplyToId: message.thread?.name, + ReplyToIdFull: message.thread?.name, + MediaPath: mediaPath, + MediaType: mediaType, + MediaUrl: mediaPath, + GroupSpace: isGroup ? (space.displayName ?? undefined) : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + OriginatingChannel: "googlechat", + OriginatingTo: `googlechat:${spaceId}`, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`); + }); + + // Typing indicator setup + // Note: Reaction mode requires user OAuth, not available with service account auth. + // If reaction is configured, we fall back to message mode with a warning. + let typingIndicator = account.config.typingIndicator ?? "message"; + if (typingIndicator === "reaction") { + runtime.error?.( + `[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`, + ); + typingIndicator = "message"; + } + let typingMessageName: string | undefined; + + // Start typing indicator (message mode only, reaction mode not supported with app auth) + if (typingIndicator === "message") { + try { + const botName = resolveBotDisplayName({ + accountName: account.config.name, + agentId: route.agentId, + config, + }); + const result = await sendGoogleChatMessage({ + account, + space: spaceId, + text: `_${botName} is typing..._`, + thread: message.thread?.name, + }); + typingMessageName = result?.messageName; + } catch (err) { + runtime.error?.(`Failed sending typing message: ${String(err)}`); + } + } + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + await deliverGoogleChatReply({ + payload, + account, + spaceId, + runtime, + core, + config, + statusSink, + typingMessageName, + }); + // Only use typing message for first delivery + typingMessageName = undefined; + }, + onError: (err, info) => { + runtime.error?.( + `[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + }); +} + +async function downloadAttachment( + attachment: GoogleChatAttachment, + account: ResolvedGoogleChatAccount, + mediaMaxMb: number, + core: GoogleChatCoreRuntime, +): Promise<{ path: string; contentType?: string } | null> { + const resourceName = attachment.attachmentDataRef?.resourceName; + if (!resourceName) { + return null; + } + const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; + const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes }); + const saved = await core.channel.media.saveMediaBuffer( + downloaded.buffer, + downloaded.contentType ?? attachment.contentType, + "inbound", + maxBytes, + attachment.contentName, + ); + return { path: saved.path, contentType: saved.contentType }; +} + +async function deliverGoogleChatReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + account: ResolvedGoogleChatAccount; + spaceId: string; + runtime: GoogleChatRuntimeEnv; + core: GoogleChatCoreRuntime; + config: OpenClawConfig; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + typingMessageName?: string; +}): Promise { + const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = + params; + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + if (mediaList.length > 0) { + let suppressCaption = false; + if (typingMessageName) { + try { + await deleteGoogleChatMessage({ + account, + messageName: typingMessageName, + }); + } catch (err) { + runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); + const fallbackText = payload.text?.trim() + ? payload.text + : mediaList.length > 1 + ? "Sent attachments." + : "Sent attachment."; + try { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: fallbackText, + }); + suppressCaption = Boolean(payload.text?.trim()); + } catch (updateErr) { + runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); + } + } + } + let first = true; + for (const mediaUrl of mediaList) { + const caption = first && !suppressCaption ? payload.text : undefined; + first = false; + try { + const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { + maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, + }); + const upload = await uploadAttachmentForReply({ + account, + spaceId, + buffer: loaded.buffer, + contentType: loaded.contentType, + filename: loaded.filename ?? "attachment", + }); + if (!upload.attachmentUploadToken) { + throw new Error("missing attachment upload token"); + } + await sendGoogleChatMessage({ + account, + space: spaceId, + text: caption, + thread: payload.replyToId, + attachments: [ + { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }, + ], + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); + } + } + return; + } + + if (payload.text) { + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); + const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + try { + // Edit typing message with first chunk if available + if (i === 0 && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: chunk, + thread: payload.replyToId, + }); + } + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + } + } + } +} + +async function uploadAttachmentForReply(params: { + account: ResolvedGoogleChatAccount; + spaceId: string; + buffer: Buffer; + contentType?: string; + filename: string; +}) { + const { account, spaceId, buffer, contentType, filename } = params; + const { uploadGoogleChatAttachment } = await import("./api.js"); + return await uploadGoogleChatAttachment({ + account, + space: spaceId, + filename, + buffer, + contentType, + }); +} + +export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void { + const core = getGoogleChatRuntime(); + const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl); + if (!webhookPath) { + options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`); + return () => {}; + } + + const audienceType = normalizeAudienceType(options.account.config.audienceType); + const audience = options.account.config.audience?.trim(); + const mediaMaxMb = options.account.config.mediaMaxMb ?? 20; + + const unregister = registerGoogleChatWebhookTarget({ + account: options.account, + config: options.config, + runtime: options.runtime, + core, + path: webhookPath, + audienceType, + audience, + statusSink: options.statusSink, + mediaMaxMb, + }); + + return unregister; +} + +export async function startGoogleChatMonitor( + params: GoogleChatMonitorOptions, +): Promise<() => void> { + return monitorGoogleChatProvider(params); +} + +export function resolveGoogleChatWebhookPath(params: { + account: ResolvedGoogleChatAccount; +}): string { + return ( + resolveWebhookPath(params.account.config.webhookPath, params.account.config.webhookUrl) ?? + "/googlechat" + ); +} + +export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) { + return params.account.config.mediaMaxMb ?? 20; +} diff --git a/extensions/googlechat/src/onboarding.ts b/extensions/googlechat/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..263f1029bcd4f1948c1276dbc5be43e51f8b6e22 --- /dev/null +++ b/extensions/googlechat/src/onboarding.ts @@ -0,0 +1,269 @@ +import type { OpenClawConfig, DmPolicy } from "openclaw/plugin-sdk"; +import { + addWildcardAllowFrom, + formatDocsLink, + promptAccountId, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + migrateBaseNameToDefaultAccount, +} from "openclaw/plugin-sdk"; +import { + listGoogleChatAccountIds, + resolveDefaultGoogleChatAccountId, + resolveGoogleChatAccount, +} from "./accounts.js"; + +const channel = "googlechat" as const; + +const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; +const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; + +function setGoogleChatDmPolicy(cfg: OpenClawConfig, policy: DmPolicy) { + const allowFrom = + policy === "open" + ? addWildcardAllowFrom(cfg.channels?.["googlechat"]?.dm?.allowFrom) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + googlechat: { + ...cfg.channels?.["googlechat"], + dm: { + ...cfg.channels?.["googlechat"]?.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +async function promptAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? []; + const entry = await params.prompter.text({ + message: "Google Chat allowFrom (user id or email)", + placeholder: "users/123456789, name@example.com", + initialValue: current[0] ? String(current[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + const unique = [...new Set(parts)]; + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + googlechat: { + ...params.cfg.channels?.["googlechat"], + enabled: true, + dm: { + ...params.cfg.channels?.["googlechat"]?.dm, + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Google Chat", + channel, + policyKey: "channels.googlechat.dm.policy", + allowFromKey: "channels.googlechat.dm.allowFrom", + getCurrent: (cfg) => cfg.channels?.["googlechat"]?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy), + promptAllowFrom, +}; + +function applyAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; +}): OpenClawConfig { + const { cfg, accountId, patch } = params; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + googlechat: { + ...cfg.channels?.["googlechat"], + enabled: true, + ...patch, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + googlechat: { + ...cfg.channels?.["googlechat"], + enabled: true, + accounts: { + ...cfg.channels?.["googlechat"]?.accounts, + [accountId]: { + ...cfg.channels?.["googlechat"]?.accounts?.[accountId], + enabled: true, + ...patch, + }, + }, + }, + }, + }; +} + +async function promptCredentials(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const envReady = + accountId === DEFAULT_ACCOUNT_ID && + (Boolean(process.env[ENV_SERVICE_ACCOUNT]) || Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE])); + if (envReady) { + const useEnv = await prompter.confirm({ + message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?", + initialValue: true, + }); + if (useEnv) { + return applyAccountConfig({ cfg, accountId, patch: {} }); + } + } + + const method = await prompter.select({ + message: "Google Chat auth method", + options: [ + { value: "file", label: "Service account JSON file" }, + { value: "inline", label: "Paste service account JSON" }, + ], + initialValue: "file", + }); + + if (method === "file") { + const path = await prompter.text({ + message: "Service account JSON path", + placeholder: "/path/to/service-account.json", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + return applyAccountConfig({ + cfg, + accountId, + patch: { serviceAccountFile: String(path).trim() }, + }); + } + + const json = await prompter.text({ + message: "Service account JSON (single line)", + placeholder: '{"type":"service_account", ... }', + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + return applyAccountConfig({ + cfg, + accountId, + patch: { serviceAccount: String(json).trim() }, + }); +} + +async function promptAudience(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const account = resolveGoogleChatAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + const currentType = account.config.audienceType ?? "app-url"; + const currentAudience = account.config.audience ?? ""; + const audienceType = await params.prompter.select({ + message: "Webhook audience type", + options: [ + { value: "app-url", label: "App URL (recommended)" }, + { value: "project-number", label: "Project number" }, + ], + initialValue: currentType === "project-number" ? "project-number" : "app-url", + }); + const audience = await params.prompter.text({ + message: audienceType === "project-number" ? "Project number" : "App URL", + placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat", + initialValue: currentAudience || undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + return applyAccountConfig({ + cfg: params.cfg, + accountId: params.accountId, + patch: { audienceType, audience: String(audience).trim() }, + }); +} + +async function noteGoogleChatSetup(prompter: WizardPrompter) { + await prompter.note( + [ + "Google Chat apps use service-account auth and an HTTPS webhook.", + "Set the Chat API scopes in your service account and configure the Chat app URL.", + "Webhook verification requires audience type + audience value.", + `Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`, + ].join("\n"), + "Google Chat setup", + ); +} + +export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + dmPolicy, + getStatus: async ({ cfg }) => { + const configured = listGoogleChatAccountIds(cfg).some( + (accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none", + ); + return { + channel, + configured, + statusLines: [`Google Chat: ${configured ? "configured" : "needs service account"}`], + selectionHint: configured ? "configured" : "needs auth", + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const override = accountOverrides["googlechat"]?.trim(); + const defaultAccountId = resolveDefaultGoogleChatAccountId(cfg); + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "Google Chat", + currentId: accountId, + listAccountIds: listGoogleChatAccountIds, + defaultAccountId, + }); + } + + let next = cfg; + await noteGoogleChatSetup(prompter); + next = await promptCredentials({ cfg: next, prompter, accountId }); + next = await promptAudience({ cfg: next, prompter, accountId }); + + const namedConfig = migrateBaseNameToDefaultAccount({ + cfg: next, + channelKey: "googlechat", + }); + + return { cfg: namedConfig, accountId }; + }, +}; diff --git a/extensions/googlechat/src/runtime.ts b/extensions/googlechat/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..67a1917a88852940ad901d6eaeac442c5f1090ad --- /dev/null +++ b/extensions/googlechat/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setGoogleChatRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getGoogleChatRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Google Chat runtime not initialized"); + } + return runtime; +} diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb49bd0ec1fc751cc6d6090b3e385b630a0dd765 --- /dev/null +++ b/extensions/googlechat/src/targets.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + isGoogleChatSpaceTarget, + isGoogleChatUserTarget, + normalizeGoogleChatTarget, +} from "./targets.js"; + +describe("normalizeGoogleChatTarget", () => { + it("normalizes provider prefixes", () => { + expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123"); + expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA"); + expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe("users/user@example.com"); + }); + + it("normalizes email targets to users/", () => { + expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com"); + expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com"); + }); + + it("preserves space targets", () => { + expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB"); + expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC"); + }); +}); + +describe("target helpers", () => { + it("detects user and space targets", () => { + expect(isGoogleChatUserTarget("users/abc")).toBe(true); + expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true); + expect(isGoogleChatUserTarget("spaces/abc")).toBe(false); + }); +}); diff --git a/extensions/googlechat/src/targets.ts b/extensions/googlechat/src/targets.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4c5b3051ec9d06467337594dd847bae908b62bd --- /dev/null +++ b/extensions/googlechat/src/targets.ts @@ -0,0 +1,65 @@ +import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { findGoogleChatDirectMessage } from "./api.js"; + +export function normalizeGoogleChatTarget(raw?: string | null): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, ""); + const normalized = withoutPrefix + .replace(/^user:(users\/)?/i, "users/") + .replace(/^space:(spaces\/)?/i, "spaces/"); + if (isGoogleChatUserTarget(normalized)) { + const suffix = normalized.slice("users/".length); + return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized; + } + if (isGoogleChatSpaceTarget(normalized)) { + return normalized; + } + if (normalized.includes("@")) { + return `users/${normalized.toLowerCase()}`; + } + return normalized; +} + +export function isGoogleChatUserTarget(value: string): boolean { + return value.toLowerCase().startsWith("users/"); +} + +export function isGoogleChatSpaceTarget(value: string): boolean { + return value.toLowerCase().startsWith("spaces/"); +} + +function stripMessageSuffix(target: string): string { + const index = target.indexOf("/messages/"); + if (index === -1) { + return target; + } + return target.slice(0, index); +} + +export async function resolveGoogleChatOutboundSpace(params: { + account: ResolvedGoogleChatAccount; + target: string; +}): Promise { + const normalized = normalizeGoogleChatTarget(params.target); + if (!normalized) { + throw new Error("Missing Google Chat target."); + } + const base = stripMessageSuffix(normalized); + if (isGoogleChatSpaceTarget(base)) { + return base; + } + if (isGoogleChatUserTarget(base)) { + const dm = await findGoogleChatDirectMessage({ + account: params.account, + userName: base, + }); + if (!dm?.name) { + throw new Error(`No Google Chat DM found for ${base}`); + } + return dm.name; + } + return base; +} diff --git a/extensions/googlechat/src/types.config.ts b/extensions/googlechat/src/types.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..17fe1dc67d9427f6d4e580995e55b773fba90d01 --- /dev/null +++ b/extensions/googlechat/src/types.config.ts @@ -0,0 +1,3 @@ +import type { GoogleChatAccountConfig, GoogleChatConfig } from "openclaw/plugin-sdk"; + +export type { GoogleChatAccountConfig, GoogleChatConfig }; diff --git a/extensions/googlechat/src/types.ts b/extensions/googlechat/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..820c96425d43f56b34fe965cd8672114fa5749b4 --- /dev/null +++ b/extensions/googlechat/src/types.ts @@ -0,0 +1,73 @@ +export type GoogleChatSpace = { + name?: string; + displayName?: string; + type?: string; +}; + +export type GoogleChatUser = { + name?: string; + displayName?: string; + email?: string; + type?: string; +}; + +export type GoogleChatThread = { + name?: string; + threadKey?: string; +}; + +export type GoogleChatAttachmentDataRef = { + resourceName?: string; + attachmentUploadToken?: string; +}; + +export type GoogleChatAttachment = { + name?: string; + contentName?: string; + contentType?: string; + thumbnailUri?: string; + downloadUri?: string; + source?: string; + attachmentDataRef?: GoogleChatAttachmentDataRef; + driveDataRef?: Record; +}; + +export type GoogleChatUserMention = { + user?: GoogleChatUser; + type?: string; +}; + +export type GoogleChatAnnotation = { + type?: string; + startIndex?: number; + length?: number; + userMention?: GoogleChatUserMention; + slashCommand?: Record; + richLinkMetadata?: Record; + customEmojiMetadata?: Record; +}; + +export type GoogleChatMessage = { + name?: string; + text?: string; + argumentText?: string; + sender?: GoogleChatUser; + thread?: GoogleChatThread; + attachment?: GoogleChatAttachment[]; + annotations?: GoogleChatAnnotation[]; +}; + +export type GoogleChatEvent = { + type?: string; + eventType?: string; + eventTime?: string; + space?: GoogleChatSpace; + user?: GoogleChatUser; + message?: GoogleChatMessage; +}; + +export type GoogleChatReaction = { + name?: string; + user?: GoogleChatUser; + emoji?: { unicode?: string }; +}; diff --git a/extensions/imessage/index.ts b/extensions/imessage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7eb0e80b0701e35fcce9af4a7c432635ddcc38ef --- /dev/null +++ b/extensions/imessage/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { imessagePlugin } from "./src/channel.js"; +import { setIMessageRuntime } from "./src/runtime.js"; + +const plugin = { + id: "imessage", + name: "iMessage", + description: "iMessage channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setIMessageRuntime(api.runtime); + api.registerChannel({ plugin: imessagePlugin }); + }, +}; + +export default plugin; diff --git a/extensions/imessage/openclaw.plugin.json b/extensions/imessage/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..8a79eb2efecbb1ff82f9f39175546cf424b8fdb3 --- /dev/null +++ b/extensions/imessage/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "imessage", + "channels": ["imessage"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9bee0029e91bf2cbd2f5ae4c06594481610c45c5 --- /dev/null +++ b/extensions/imessage/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/imessage", + "version": "2026.1.30", + "description": "OpenClaw iMessage channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..39032261408ca2c94c90d2176da10098a2e2eaa1 --- /dev/null +++ b/extensions/imessage/src/channel.ts @@ -0,0 +1,294 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + getChatChannelMeta, + imessageOnboardingAdapter, + IMessageConfigSchema, + listIMessageAccountIds, + looksLikeIMessageTargetId, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + normalizeIMessageMessagingTarget, + PAIRING_APPROVED_MESSAGE, + resolveChannelMediaMaxBytes, + resolveDefaultIMessageAccountId, + resolveIMessageAccount, + resolveIMessageGroupRequireMention, + resolveIMessageGroupToolPolicy, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type ResolvedIMessageAccount, +} from "openclaw/plugin-sdk"; +import { getIMessageRuntime } from "./runtime.js"; + +const meta = getChatChannelMeta("imessage"); + +export const imessagePlugin: ChannelPlugin = { + id: "imessage", + meta: { + ...meta, + aliases: ["imsg"], + showConfigured: false, + }, + onboarding: imessageOnboardingAdapter, + pairing: { + idLabel: "imessageSenderId", + notifyApproval: async ({ id }) => { + await getIMessageRuntime().channel.imessage.sendMessageIMessage(id, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + }, + reload: { configPrefixes: ["channels.imessage"] }, + configSchema: buildChannelConfigSchema(IMessageConfigSchema), + config: { + listAccountIds: (cfg) => listIMessageAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultIMessageAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "imessage", + accountId, + clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => String(entry).trim()).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.imessage?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.imessage.accounts.${resolvedAccountId}.` + : "channels.imessage."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("imessage"), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + `- iMessage groups: groupPolicy="open" allows any member to trigger the bot. Set channels.imessage.groupPolicy="allowlist" + channels.imessage.groupAllowFrom to restrict senders.`, + ]; + }, + }, + groups: { + resolveRequireMention: resolveIMessageGroupRequireMention, + resolveToolPolicy: resolveIMessageGroupToolPolicy, + }, + messaging: { + normalizeTarget: normalizeIMessageMessagingTarget, + targetResolver: { + looksLikeId: looksLikeIMessageTargetId, + hint: "", + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "imessage", + accountId, + name, + }), + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "imessage", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "imessage", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + imessage: { + ...next.channels?.imessage, + enabled: true, + accounts: { + ...next.channels?.imessage?.accounts, + [accountId]: { + ...next.channels?.imessage?.accounts?.[accountId], + enabled: true, + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.dbPath ? { dbPath: input.dbPath } : {}), + ...(input.service ? { service: input.service } : {}), + ...(input.region ? { region: input.region } : {}), + }, + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), + chunkerMode: "text", + textChunkLimit: 4000, + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; + const maxBytes = resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + maxBytes, + accountId: accountId ?? undefined, + }); + return { channel: "imessage", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage; + const maxBytes = resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ?? + cfg.channels?.imessage?.mediaMaxMb, + accountId, + }); + const result = await send(to, text, { + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + }); + return { channel: "imessage", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + cliPath: null, + dbPath: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) { + return []; + } + return [ + { + channel: "imessage", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + cliPath: snapshot.cliPath ?? null, + dbPath: snapshot.dbPath ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ timeoutMs }) => + getIMessageRuntime().channel.imessage.probeIMessage(timeoutMs), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + cliPath: runtime?.cliPath ?? account.config.cliPath ?? null, + dbPath: runtime?.dbPath ?? account.config.dbPath ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + resolveAccountState: ({ enabled }) => (enabled ? "enabled" : "disabled"), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const cliPath = account.config.cliPath?.trim() || "imsg"; + const dbPath = account.config.dbPath?.trim(); + ctx.setStatus({ + accountId: account.accountId, + cliPath, + dbPath: dbPath ?? null, + }); + ctx.log?.info( + `[${account.accountId}] starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`, + ); + return getIMessageRuntime().channel.imessage.monitorIMessageProvider({ + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/extensions/imessage/src/runtime.ts b/extensions/imessage/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed41c9cb809aafcd4fc5673322809523c884c004 --- /dev/null +++ b/extensions/imessage/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setIMessageRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getIMessageRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("iMessage runtime not initialized"); + } + return runtime; +} diff --git a/extensions/line/index.ts b/extensions/line/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d90029c27ba7e1be3330366288f54d9c1c5f677 --- /dev/null +++ b/extensions/line/index.ts @@ -0,0 +1,19 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { registerLineCardCommand } from "./src/card-command.js"; +import { linePlugin } from "./src/channel.js"; +import { setLineRuntime } from "./src/runtime.js"; + +const plugin = { + id: "line", + name: "LINE", + description: "LINE Messaging API channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setLineRuntime(api.runtime); + api.registerChannel({ plugin: linePlugin }); + registerLineCardCommand(api); + }, +}; + +export default plugin; diff --git a/extensions/line/openclaw.plugin.json b/extensions/line/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..813fd1de45b9aa6be8237e45a36b44c822cfd7de --- /dev/null +++ b/extensions/line/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "line", + "channels": ["line"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/line/package.json b/extensions/line/package.json new file mode 100644 index 0000000000000000000000000000000000000000..335acbc2362c32afb3ede9c2737be1e75245eda5 --- /dev/null +++ b/extensions/line/package.json @@ -0,0 +1,29 @@ +{ + "name": "@openclaw/line", + "version": "2026.1.30", + "description": "OpenClaw LINE channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "line", + "label": "LINE", + "selectionLabel": "LINE (Messaging API)", + "docsPath": "/channels/line", + "docsLabel": "line", + "blurb": "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + "order": 75, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/line", + "localPath": "extensions/line", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff113b75e0aed8374ca00dc2288754078511f5ea --- /dev/null +++ b/extensions/line/src/card-command.ts @@ -0,0 +1,344 @@ +import type { LineChannelData, OpenClawPluginApi, ReplyPayload } from "openclaw/plugin-sdk"; +import { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + type CardAction, + type ListItem, +} from "openclaw/plugin-sdk"; + +const CARD_USAGE = `Usage: /card "title" "body" [options] + +Types: + info "Title" "Body" ["Footer"] + image "Title" "Caption" --url + action "Title" "Body" --actions "Btn1|url1,Btn2|text2" + list "Title" "Item1|Desc1,Item2|Desc2" + receipt "Title" "Item1:$10,Item2:$20" --total "$30" + confirm "Question?" --yes "Yes|data" --no "No|data" + buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2" + +Examples: + /card info "Welcome" "Thanks for joining!" + /card image "Product" "Check it out" --url https://example.com/img.jpg + /card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`; + +function buildLineReply(lineData: LineChannelData): ReplyPayload { + return { + channelData: { + line: lineData, + }, + }; +} + +/** + * Parse action string format: "Label|data,Label2|data2" + * Data can be a URL (uri action) or plain text (message action) or key=value (postback) + */ +function parseActions(actionsStr: string | undefined): CardAction[] { + if (!actionsStr) { + return []; + } + + const results: CardAction[] = []; + + for (const part of actionsStr.split(",")) { + const [label, data] = part + .trim() + .split("|") + .map((s) => s.trim()); + if (!label) { + continue; + } + + const actionData = data || label; + + if (actionData.startsWith("http://") || actionData.startsWith("https://")) { + results.push({ + label, + action: { type: "uri", label: label.slice(0, 20), uri: actionData }, + }); + } else if (actionData.includes("=")) { + results.push({ + label, + action: { + type: "postback", + label: label.slice(0, 20), + data: actionData.slice(0, 300), + displayText: label, + }, + }); + } else { + results.push({ + label, + action: { type: "message", label: label.slice(0, 20), text: actionData }, + }); + } + } + + return results; +} + +/** + * Parse list items format: "Item1|Subtitle1,Item2|Subtitle2" + */ +function parseListItems(itemsStr: string): ListItem[] { + return itemsStr + .split(",") + .map((part) => { + const [title, subtitle] = part + .trim() + .split("|") + .map((s) => s.trim()); + return { title: title || "", subtitle }; + }) + .filter((item) => item.title); +} + +/** + * Parse receipt items format: "Item1:$10,Item2:$20" + */ +function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> { + return itemsStr + .split(",") + .map((part) => { + const colonIndex = part.lastIndexOf(":"); + if (colonIndex === -1) { + return { name: part.trim(), value: "" }; + } + return { + name: part.slice(0, colonIndex).trim(), + value: part.slice(colonIndex + 1).trim(), + }; + }) + .filter((item) => item.name); +} + +/** + * Parse quoted arguments from command string + * Supports: /card type "arg1" "arg2" "arg3" --flag value + */ +function parseCardArgs(argsStr: string): { + type: string; + args: string[]; + flags: Record; +} { + const result: { type: string; args: string[]; flags: Record } = { + type: "", + args: [], + flags: {}, + }; + + // Extract type (first word) + const typeMatch = argsStr.match(/^(\w+)/); + if (typeMatch) { + result.type = typeMatch[1].toLowerCase(); + argsStr = argsStr.slice(typeMatch[0].length).trim(); + } + + // Extract quoted arguments + const quotedRegex = /"([^"]*?)"/g; + let match; + while ((match = quotedRegex.exec(argsStr)) !== null) { + result.args.push(match[1]); + } + + // Extract flags (--key value or --key "value") + const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g; + while ((match = flagRegex.exec(argsStr)) !== null) { + result.flags[match[1]] = match[2] ?? match[3]; + } + + return result; +} + +export function registerLineCardCommand(api: OpenClawPluginApi): void { + api.registerCommand({ + name: "card", + description: "Send a rich card message (LINE).", + acceptsArgs: true, + requireAuth: false, + handler: async (ctx) => { + const argsStr = ctx.args?.trim() ?? ""; + if (!argsStr) { + return { text: CARD_USAGE }; + } + + const parsed = parseCardArgs(argsStr); + const { type, args, flags } = parsed; + + if (!type) { + return { text: CARD_USAGE }; + } + + // Only LINE supports rich cards; fallback to text elsewhere. + if (ctx.channel !== "line") { + const fallbackText = args.join(" - "); + return { text: `[${type} card] ${fallbackText}`.trim() }; + } + + try { + switch (type) { + case "info": { + const [title = "Info", body = "", footer] = args; + const bubble = createInfoCard(title, body, footer); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${body}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "image": { + const [title = "Image", caption = ""] = args; + const imageUrl = flags.url || flags.image; + if (!imageUrl) { + return { text: "Error: Image card requires --url " }; + } + const bubble = createImageCard(imageUrl, title, caption); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${caption}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "action": { + const [title = "Actions", body = ""] = args; + const actions = parseActions(flags.actions); + if (actions.length === 0) { + return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' }; + } + const bubble = createActionCard(title, body, actions, { + imageUrl: flags.url || flags.image, + }); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${body}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "list": { + const [title = "List", itemsStr = ""] = args; + const items = parseListItems(itemsStr || flags.items || ""); + if (items.length === 0) { + return { + text: 'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"', + }; + } + const bubble = createListCard(title, items); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "receipt": { + const [title = "Receipt", itemsStr = ""] = args; + const items = parseReceiptItems(itemsStr || flags.items || ""); + const total = flags.total ? { label: "Total", value: flags.total } : undefined; + const footer = flags.footer; + + if (items.length === 0) { + return { + text: 'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"', + }; + } + + const bubble = createReceiptCard({ title, items, total, footer }); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice( + 0, + 400, + ), + contents: bubble, + }, + }); + } + + case "confirm": { + const [question = "Confirm?"] = args; + const yesStr = flags.yes || "Yes|yes"; + const noStr = flags.no || "No|no"; + + const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim()); + const [noLabel, noData] = noStr.split("|").map((s) => s.trim()); + + return buildLineReply({ + templateMessage: { + type: "confirm", + text: question, + confirmLabel: yesLabel || "Yes", + confirmData: yesData || "yes", + cancelLabel: noLabel || "No", + cancelData: noData || "no", + altText: question, + }, + }); + } + + case "buttons": { + const [title = "Menu", text = "Choose an option"] = args; + const actionsStr = flags.actions || ""; + const actionParts = parseActions(actionsStr); + + if (actionParts.length === 0) { + return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' }; + } + + const templateActions: Array<{ + type: "message" | "uri" | "postback"; + label: string; + data?: string; + uri?: string; + }> = actionParts.map((a) => { + const action = a.action; + const label = action.label ?? a.label; + if (action.type === "uri") { + return { type: "uri" as const, label, uri: (action as { uri: string }).uri }; + } + if (action.type === "postback") { + return { + type: "postback" as const, + label, + data: (action as { data: string }).data, + }; + } + return { + type: "message" as const, + label, + data: (action as { text: string }).text, + }; + }); + + return buildLineReply({ + templateMessage: { + type: "buttons", + title, + text, + thumbnailImageUrl: flags.url || flags.image, + actions: templateActions, + }, + }); + } + + default: + return { + text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`, + }; + } + } catch (err) { + return { text: `Error creating card: ${String(err)}` }; + } + }, + }); +} diff --git a/extensions/line/src/channel.logout.test.ts b/extensions/line/src/channel.logout.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7107d07caf8cbe93fd80f806eff8a80730fb282 --- /dev/null +++ b/extensions/line/src/channel.logout.test.ts @@ -0,0 +1,97 @@ +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { linePlugin } from "./channel.js"; +import { setLineRuntime } from "./runtime.js"; + +const DEFAULT_ACCOUNT_ID = "default"; + +type LineRuntimeMocks = { + writeConfigFile: ReturnType; + resolveLineAccount: ReturnType; +}; + +function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { + const writeConfigFile = vi.fn(async () => {}); + const resolveLineAccount = vi.fn( + ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => { + const lineConfig = (cfg.channels?.line ?? {}) as { + tokenFile?: string; + secretFile?: string; + channelAccessToken?: string; + channelSecret?: string; + accounts?: Record>; + }; + const entry = + accountId && accountId !== DEFAULT_ACCOUNT_ID + ? (lineConfig.accounts?.[accountId] ?? {}) + : lineConfig; + const hasToken = + Boolean((entry as any).channelAccessToken) || Boolean((entry as any).tokenFile); + const hasSecret = Boolean((entry as any).channelSecret) || Boolean((entry as any).secretFile); + return { tokenSource: hasToken && hasSecret ? "config" : "none" }; + }, + ); + + const runtime = { + config: { writeConfigFile }, + channel: { line: { resolveLineAccount } }, + } as unknown as PluginRuntime; + + return { runtime, mocks: { writeConfigFile, resolveLineAccount } }; +} + +describe("linePlugin gateway.logoutAccount", () => { + beforeEach(() => { + setLineRuntime(createRuntime().runtime); + }); + + it("clears tokenFile/secretFile on default account logout", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + + const cfg: OpenClawConfig = { + channels: { + line: { + tokenFile: "/tmp/token", + secretFile: "/tmp/secret", + }, + }, + }; + + const result = await linePlugin.gateway.logoutAccount({ + accountId: DEFAULT_ACCOUNT_ID, + cfg, + }); + + expect(result.cleared).toBe(true); + expect(result.loggedOut).toBe(true); + expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + }); + + it("clears tokenFile/secretFile on account logout", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + + const cfg: OpenClawConfig = { + channels: { + line: { + accounts: { + primary: { + tokenFile: "/tmp/token", + secretFile: "/tmp/secret", + }, + }, + }, + }, + }; + + const result = await linePlugin.gateway.logoutAccount({ + accountId: "primary", + cfg, + }); + + expect(result.cleared).toBe(true); + expect(result.loggedOut).toBe(true); + expect(mocks.writeConfigFile).toHaveBeenCalledWith({}); + }); +}); diff --git a/extensions/line/src/channel.sendPayload.test.ts b/extensions/line/src/channel.sendPayload.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..94bbe9e8c426fc863ece03b5f5507181f6acc5f5 --- /dev/null +++ b/extensions/line/src/channel.sendPayload.test.ts @@ -0,0 +1,306 @@ +import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import { linePlugin } from "./channel.js"; +import { setLineRuntime } from "./runtime.js"; + +type LineRuntimeMocks = { + pushMessageLine: ReturnType; + pushMessagesLine: ReturnType; + pushFlexMessage: ReturnType; + pushTemplateMessage: ReturnType; + pushLocationMessage: ReturnType; + pushTextMessageWithQuickReplies: ReturnType; + createQuickReplyItems: ReturnType; + buildTemplateMessageFromPayload: ReturnType; + sendMessageLine: ReturnType; + chunkMarkdownText: ReturnType; + resolveLineAccount: ReturnType; + resolveTextChunkLimit: ReturnType; +}; + +function createRuntime(): { runtime: PluginRuntime; mocks: LineRuntimeMocks } { + const pushMessageLine = vi.fn(async () => ({ messageId: "m-text", chatId: "c1" })); + const pushMessagesLine = vi.fn(async () => ({ messageId: "m-batch", chatId: "c1" })); + const pushFlexMessage = vi.fn(async () => ({ messageId: "m-flex", chatId: "c1" })); + const pushTemplateMessage = vi.fn(async () => ({ messageId: "m-template", chatId: "c1" })); + const pushLocationMessage = vi.fn(async () => ({ messageId: "m-loc", chatId: "c1" })); + const pushTextMessageWithQuickReplies = vi.fn(async () => ({ + messageId: "m-quick", + chatId: "c1", + })); + const createQuickReplyItems = vi.fn((labels: string[]) => ({ items: labels })); + const buildTemplateMessageFromPayload = vi.fn(() => ({ type: "buttons" })); + const sendMessageLine = vi.fn(async () => ({ messageId: "m-media", chatId: "c1" })); + const chunkMarkdownText = vi.fn((text: string) => [text]); + const resolveTextChunkLimit = vi.fn(() => 123); + const resolveLineAccount = vi.fn( + ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => { + const resolved = accountId ?? "default"; + const lineConfig = (cfg.channels?.line ?? {}) as { + accounts?: Record>; + }; + const accountConfig = resolved !== "default" ? (lineConfig.accounts?.[resolved] ?? {}) : {}; + return { + accountId: resolved, + config: { ...lineConfig, ...accountConfig }, + }; + }, + ); + + const runtime = { + channel: { + line: { + pushMessageLine, + pushMessagesLine, + pushFlexMessage, + pushTemplateMessage, + pushLocationMessage, + pushTextMessageWithQuickReplies, + createQuickReplyItems, + buildTemplateMessageFromPayload, + sendMessageLine, + resolveLineAccount, + }, + text: { + chunkMarkdownText, + resolveTextChunkLimit, + }, + }, + } as unknown as PluginRuntime; + + return { + runtime, + mocks: { + pushMessageLine, + pushMessagesLine, + pushFlexMessage, + pushTemplateMessage, + pushLocationMessage, + pushTextMessageWithQuickReplies, + createQuickReplyItems, + buildTemplateMessageFromPayload, + sendMessageLine, + chunkMarkdownText, + resolveLineAccount, + resolveTextChunkLimit, + }, + }; +} + +describe("linePlugin outbound.sendPayload", () => { + it("sends flex message without dropping text", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "Now playing:", + channelData: { + line: { + flexMessage: { + altText: "Now playing", + contents: { type: "bubble" }, + }, + }, + }, + }; + + await linePlugin.outbound.sendPayload({ + to: "line:group:1", + payload, + accountId: "default", + cfg, + }); + + expect(mocks.pushFlexMessage).toHaveBeenCalledTimes(1); + expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:group:1", "Now playing:", { + verbose: false, + accountId: "default", + }); + }); + + it("sends template message without dropping text", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "Choose one:", + channelData: { + line: { + templateMessage: { + type: "confirm", + text: "Continue?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no", + }, + }, + }, + }; + + await linePlugin.outbound.sendPayload({ + to: "line:user:1", + payload, + accountId: "default", + cfg, + }); + + expect(mocks.buildTemplateMessageFromPayload).toHaveBeenCalledTimes(1); + expect(mocks.pushTemplateMessage).toHaveBeenCalledTimes(1); + expect(mocks.pushMessageLine).toHaveBeenCalledWith("line:user:1", "Choose one:", { + verbose: false, + accountId: "default", + }); + }); + + it("attaches quick replies when no text chunks are present", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + channelData: { + line: { + quickReplies: ["One", "Two"], + flexMessage: { + altText: "Card", + contents: { type: "bubble" }, + }, + }, + }, + }; + + await linePlugin.outbound.sendPayload({ + to: "line:user:2", + payload, + accountId: "default", + cfg, + }); + + expect(mocks.pushFlexMessage).not.toHaveBeenCalled(); + expect(mocks.pushMessagesLine).toHaveBeenCalledWith( + "line:user:2", + [ + { + type: "flex", + altText: "Card", + contents: { type: "bubble" }, + quickReply: { items: ["One", "Two"] }, + }, + ], + { verbose: false, accountId: "default" }, + ); + expect(mocks.createQuickReplyItems).toHaveBeenCalledWith(["One", "Two"]); + }); + + it("sends media before quick-reply text so buttons stay visible", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: {} } } as OpenClawConfig; + + const payload = { + text: "Hello", + mediaUrl: "https://example.com/img.jpg", + channelData: { + line: { + quickReplies: ["One", "Two"], + }, + }, + }; + + await linePlugin.outbound.sendPayload({ + to: "line:user:3", + payload, + accountId: "default", + cfg, + }); + + expect(mocks.sendMessageLine).toHaveBeenCalledWith("line:user:3", "", { + verbose: false, + mediaUrl: "https://example.com/img.jpg", + accountId: "default", + }); + expect(mocks.pushTextMessageWithQuickReplies).toHaveBeenCalledWith( + "line:user:3", + "Hello", + ["One", "Two"], + { verbose: false, accountId: "default" }, + ); + const mediaOrder = mocks.sendMessageLine.mock.invocationCallOrder[0]; + const quickReplyOrder = mocks.pushTextMessageWithQuickReplies.mock.invocationCallOrder[0]; + expect(mediaOrder).toBeLessThan(quickReplyOrder); + }); + + it("uses configured text chunk limit for payloads", async () => { + const { runtime, mocks } = createRuntime(); + setLineRuntime(runtime); + const cfg = { channels: { line: { textChunkLimit: 123 } } } as OpenClawConfig; + + const payload = { + text: "Hello world", + channelData: { + line: { + flexMessage: { + altText: "Card", + contents: { type: "bubble" }, + }, + }, + }, + }; + + await linePlugin.outbound.sendPayload({ + to: "line:user:3", + payload, + accountId: "primary", + cfg, + }); + + expect(mocks.resolveTextChunkLimit).toHaveBeenCalledWith(cfg, "line", "primary", { + fallbackLimit: 5000, + }); + expect(mocks.chunkMarkdownText).toHaveBeenCalledWith("Hello world", 123); + }); +}); + +describe("linePlugin config.formatAllowFrom", () => { + it("strips line:user: prefixes without lowercasing", () => { + const formatted = linePlugin.config.formatAllowFrom({ + allowFrom: ["line:user:UABC", "line:UDEF"], + }); + expect(formatted).toEqual(["UABC", "UDEF"]); + }); +}); + +describe("linePlugin groups.resolveRequireMention", () => { + it("uses account-level group settings when provided", () => { + const { runtime } = createRuntime(); + setLineRuntime(runtime); + + const cfg = { + channels: { + line: { + groups: { + "*": { requireMention: false }, + }, + accounts: { + primary: { + groups: { + "group-1": { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + const requireMention = linePlugin.groups.resolveRequireMention({ + cfg, + accountId: "primary", + groupId: "group-1", + }); + + expect(requireMention).toBe(true); + }); +}); diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b56f42b9d128136adb4f3789cb8514db83bc34e --- /dev/null +++ b/extensions/line/src/channel.ts @@ -0,0 +1,780 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + LineConfigSchema, + processLineMessage, + type ChannelPlugin, + type ChannelStatusIssue, + type OpenClawConfig, + type LineConfig, + type LineChannelData, + type ResolvedLineAccount, +} from "openclaw/plugin-sdk"; +import { getLineRuntime } from "./runtime.js"; + +// LINE channel metadata +const meta = { + id: "line", + label: "LINE", + selectionLabel: "LINE (Messaging API)", + detailLabel: "LINE Bot", + docsPath: "/channels/line", + docsLabel: "line", + blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", + systemImage: "message.fill", +}; + +export const linePlugin: ChannelPlugin = { + id: "line", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + pairing: { + idLabel: "lineUserId", + normalizeAllowEntry: (entry) => { + // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). + return entry.replace(/^line:(?:user:)?/i, ""); + }, + notifyApproval: async ({ cfg, id }) => { + const line = getLineRuntime().channel.line; + const account = line.resolveLineAccount({ cfg }); + if (!account.channelAccessToken) { + throw new Error("LINE channel access token not configured"); + } + await line.pushMessageLine(id, "OpenClaw: your access has been approved.", { + channelAccessToken: account.channelAccessToken, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: false, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.line"] }, + configSchema: buildChannelConfigSchema(LineConfigSchema), + config: { + listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), + resolveAccount: (cfg, accountId) => + getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }), + defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => { + const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + enabled, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...lineConfig.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; + }, + deleteAccount: ({ cfg, accountId }) => { + const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + // oxlint-disable-next-line no-unused-vars + const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig; + return { + ...cfg, + channels: { + ...cfg.channels, + line: rest, + }, + }; + } + const accounts = { ...lineConfig.accounts }; + delete accounts[accountId]; + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + accounts: Object.keys(accounts).length > 0 ? accounts : undefined, + }, + }, + }; + }, + isConfigured: (account) => Boolean(account.channelAccessToken?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.channelAccessToken?.trim()), + tokenSource: account.tokenSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }).config.allowFrom ?? [] + ).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + // LINE sender IDs are case-sensitive; keep original casing. + return entry.replace(/^line:(?:user:)?/i, ""); + }), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + (cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.line.accounts.${resolvedAccountId}.` + : "channels.line."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: "openclaw pairing approve line ", + normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = (cfg.channels?.defaults as { groupPolicy?: string } | undefined) + ?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + `- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`, + ]; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId }); + const groups = account.config.groups; + if (!groups) { + return false; + } + const groupConfig = groups[groupId] ?? groups["*"]; + return groupConfig?.requireMention ?? false; + }, + }, + messaging: { + normalizeTarget: (target) => { + const trimmed = target.trim(); + if (!trimmed) { + return null; + } + return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, ""); + }, + targetResolver: { + looksLikeId: (id) => { + const trimmed = id?.trim(); + if (!trimmed) { + return false; + } + // LINE user IDs are typically U followed by 32 hex characters + // Group IDs are C followed by 32 hex characters + // Room IDs are R followed by 32 hex characters + return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async () => [], + listGroups: async () => [], + }, + setup: { + resolveAccountId: ({ accountId }) => + getLineRuntime().channel.line.normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => { + const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + name, + }, + }, + }; + } + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...lineConfig.accounts?.[accountId], + name, + }, + }, + }, + }, + }; + }, + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + name?: string; + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + enabled: true, + ...(typedInput.name ? { name: typedInput.name } : {}), + ...(typedInput.useEnv + ? {} + : typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.useEnv + ? {} + : typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + line: { + ...lineConfig, + enabled: true, + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...lineConfig.accounts?.[accountId], + enabled: true, + ...(typedInput.name ? { name: typedInput.name } : {}), + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }, + }, + }, + }; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), + textChunkLimit: 5000, // LINE allows up to 5000 characters per text message + sendPayload: async ({ to, payload, accountId, cfg }) => { + const runtime = getLineRuntime(); + const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; + const sendText = runtime.channel.line.pushMessageLine; + const sendBatch = runtime.channel.line.pushMessagesLine; + const sendFlex = runtime.channel.line.pushFlexMessage; + const sendTemplate = runtime.channel.line.pushTemplateMessage; + const sendLocation = runtime.channel.line.pushLocationMessage; + const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies; + const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload; + const createQuickReplyItems = runtime.channel.line.createQuickReplyItems; + + let lastResult: { messageId: string; chatId: string } | null = null; + const quickReplies = lineData.quickReplies ?? []; + const hasQuickReplies = quickReplies.length > 0; + const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined; + + const sendMessageBatch = async (messages: Array>) => { + if (messages.length === 0) { + return; + } + for (let i = 0; i < messages.length; i += 5) { + const result = await sendBatch(to, messages.slice(i, i + 5), { + verbose: false, + accountId: accountId ?? undefined, + }); + lastResult = { messageId: result.messageId, chatId: result.chatId }; + } + }; + + const processed = payload.text + ? processLineMessage(payload.text) + : { text: "", flexMessages: [] }; + + const chunkLimit = + runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, { + fallbackLimit: 5000, + }) ?? 5000; + + const chunks = processed.text + ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) + : []; + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; + + if (!shouldSendQuickRepliesInline) { + if (lineData.flexMessage) { + lastResult = await sendFlex( + to, + lineData.flexMessage.altText, + lineData.flexMessage.contents, + { + verbose: false, + accountId: accountId ?? undefined, + }, + ); + } + + if (lineData.templateMessage) { + const template = buildTemplate(lineData.templateMessage); + if (template) { + lastResult = await sendTemplate(to, template, { + verbose: false, + accountId: accountId ?? undefined, + }); + } + } + + if (lineData.location) { + lastResult = await sendLocation(to, lineData.location, { + verbose: false, + accountId: accountId ?? undefined, + }); + } + + for (const flexMsg of processed.flexMessages) { + lastResult = await sendFlex(to, flexMsg.altText, flexMsg.contents, { + verbose: false, + accountId: accountId ?? undefined, + }); + } + } + + const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0); + if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) { + for (const url of mediaUrls) { + lastResult = await runtime.channel.line.sendMessageLine(to, "", { + verbose: false, + mediaUrl: url, + accountId: accountId ?? undefined, + }); + } + } + + if (chunks.length > 0) { + for (let i = 0; i < chunks.length; i += 1) { + const isLast = i === chunks.length - 1; + if (isLast && hasQuickReplies) { + lastResult = await sendQuickReplies(to, chunks[i], quickReplies, { + verbose: false, + accountId: accountId ?? undefined, + }); + } else { + lastResult = await sendText(to, chunks[i], { + verbose: false, + accountId: accountId ?? undefined, + }); + } + } + } else if (shouldSendQuickRepliesInline) { + const quickReplyMessages: Array> = []; + if (lineData.flexMessage) { + quickReplyMessages.push({ + type: "flex", + altText: lineData.flexMessage.altText.slice(0, 400), + contents: lineData.flexMessage.contents, + }); + } + if (lineData.templateMessage) { + const template = buildTemplate(lineData.templateMessage); + if (template) { + quickReplyMessages.push(template); + } + } + if (lineData.location) { + quickReplyMessages.push({ + type: "location", + title: lineData.location.title.slice(0, 100), + address: lineData.location.address.slice(0, 100), + latitude: lineData.location.latitude, + longitude: lineData.location.longitude, + }); + } + for (const flexMsg of processed.flexMessages) { + quickReplyMessages.push({ + type: "flex", + altText: flexMsg.altText.slice(0, 400), + contents: flexMsg.contents, + }); + } + for (const url of mediaUrls) { + const trimmed = url?.trim(); + if (!trimmed) { + continue; + } + quickReplyMessages.push({ + type: "image", + originalContentUrl: trimmed, + previewImageUrl: trimmed, + }); + } + if (quickReplyMessages.length > 0 && quickReply) { + const lastIndex = quickReplyMessages.length - 1; + quickReplyMessages[lastIndex] = { + ...quickReplyMessages[lastIndex], + quickReply, + }; + await sendMessageBatch(quickReplyMessages); + } + } + + if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) { + for (const url of mediaUrls) { + lastResult = await runtime.channel.line.sendMessageLine(to, "", { + verbose: false, + mediaUrl: url, + accountId: accountId ?? undefined, + }); + } + } + + if (lastResult) { + return { channel: "line", ...lastResult }; + } + return { channel: "line", messageId: "empty", chatId: to }; + }, + sendText: async ({ to, text, accountId }) => { + const runtime = getLineRuntime(); + const sendText = runtime.channel.line.pushMessageLine; + const sendFlex = runtime.channel.line.pushFlexMessage; + + // Process markdown: extract tables/code blocks, strip formatting + const processed = processLineMessage(text); + + // Send cleaned text first (if non-empty) + let result: { messageId: string; chatId: string }; + if (processed.text.trim()) { + result = await sendText(to, processed.text, { + verbose: false, + accountId: accountId ?? undefined, + }); + } else { + // If text is empty after processing, still need a result + result = { messageId: "processed", chatId: to }; + } + + // Send flex messages for tables/code blocks + for (const flexMsg of processed.flexMessages) { + await sendFlex(to, flexMsg.altText, flexMsg.contents, { + verbose: false, + accountId: accountId ?? undefined, + }); + } + + return { channel: "line", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId }) => { + const send = getLineRuntime().channel.line.sendMessageLine; + const result = await send(to, text, { + verbose: false, + mediaUrl, + accountId: accountId ?? undefined, + }); + return { channel: "line", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => { + const issues: ChannelStatusIssue[] = []; + for (const account of accounts) { + const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID; + if (!account.channelAccessToken?.trim()) { + issues.push({ + channel: "line", + accountId, + kind: "config", + message: "LINE channel access token not configured", + }); + } + if (!account.channelSecret?.trim()) { + issues.push({ + channel: "line", + accountId, + kind: "config", + message: "LINE channel secret not configured", + }); + } + } + return issues; + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + tokenSource: snapshot.tokenSource ?? "none", + running: snapshot.running ?? false, + mode: snapshot.mode ?? null, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => + getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), + buildAccountSnapshot: ({ account, runtime, probe }) => { + const configured = Boolean(account.channelAccessToken?.trim()); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + tokenSource: account.tokenSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + mode: "webhook", + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const token = account.channelAccessToken.trim(); + const secret = account.channelSecret.trim(); + + let lineBotLabel = ""; + try { + const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500); + const displayName = probe.ok ? probe.bot?.displayName?.trim() : null; + if (displayName) { + lineBotLabel = ` (${displayName})`; + } + } catch (err) { + if (getLineRuntime().logging.shouldLogVerbose()) { + ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } + } + + ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`); + + return getLineRuntime().channel.line.monitorLineProvider({ + channelAccessToken: token, + channelSecret: secret, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + webhookPath: account.config.webhookPath, + }); + }, + logoutAccount: async ({ accountId, cfg }) => { + const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? ""; + const nextCfg = { ...cfg } as OpenClawConfig; + const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; + const nextLine = { ...lineConfig }; + let cleared = false; + let changed = false; + + if (accountId === DEFAULT_ACCOUNT_ID) { + if ( + nextLine.channelAccessToken || + nextLine.channelSecret || + nextLine.tokenFile || + nextLine.secretFile + ) { + delete nextLine.channelAccessToken; + delete nextLine.channelSecret; + delete nextLine.tokenFile; + delete nextLine.secretFile; + cleared = true; + changed = true; + } + } + + const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId]; + if (entry && typeof entry === "object") { + const nextEntry = { ...entry } as Record; + if ( + "channelAccessToken" in nextEntry || + "channelSecret" in nextEntry || + "tokenFile" in nextEntry || + "secretFile" in nextEntry + ) { + cleared = true; + delete nextEntry.channelAccessToken; + delete nextEntry.channelSecret; + delete nextEntry.tokenFile; + delete nextEntry.secretFile; + changed = true; + } + if (Object.keys(nextEntry).length === 0) { + delete accounts[accountId]; + changed = true; + } else { + accounts[accountId] = nextEntry as typeof entry; + } + } + } + + if (accounts) { + if (Object.keys(accounts).length === 0) { + delete nextLine.accounts; + changed = true; + } else { + nextLine.accounts = accounts; + } + } + + if (changed) { + if (Object.keys(nextLine).length > 0) { + nextCfg.channels = { ...nextCfg.channels, line: nextLine }; + } else { + const nextChannels = { ...nextCfg.channels }; + delete (nextChannels as Record).line; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels; + } else { + delete nextCfg.channels; + } + } + await getLineRuntime().config.writeConfigFile(nextCfg); + } + + const resolved = getLineRuntime().channel.line.resolveLineAccount({ + cfg: changed ? nextCfg : cfg, + accountId, + }); + const loggedOut = resolved.tokenSource === "none"; + + return { cleared, envToken: Boolean(envToken), loggedOut }; + }, + }, + agentPrompt: { + messageToolHints: () => [ + "", + "### LINE Rich Messages", + "LINE supports rich visual messages. Use these directives in your reply when appropriate:", + "", + "**Quick Replies** (bottom button suggestions):", + " [[quick_replies: Option 1, Option 2, Option 3]]", + "", + "**Location** (map pin):", + " [[location: Place Name | Address | latitude | longitude]]", + "", + "**Confirm Dialog** (yes/no prompt):", + " [[confirm: Question text? | Yes Label | No Label]]", + "", + "**Button Menu** (title + text + buttons):", + " [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]", + "", + "**Media Player Card** (music status):", + " [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]", + " - Status: 'playing' or 'paused' (optional)", + "", + "**Event Card** (calendar events, meetings):", + " [[event: Event Title | Date | Time | Location | Description]]", + " - Time, Location, Description are optional", + "", + "**Agenda Card** (multiple events/schedule):", + " [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]", + "", + "**Device Control Card** (smart devices, TVs, etc.):", + " [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]", + "", + "**Apple TV Remote** (full D-pad + transport):", + " [[appletv_remote: Apple TV | Playing]]", + "", + "**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.", + "", + "When to use rich messages:", + "- Use [[quick_replies:...]] when offering 2-4 clear options", + "- Use [[confirm:...]] for yes/no decisions", + "- Use [[buttons:...]] for menus with actions/links", + "- Use [[location:...]] when sharing a place", + "- Use [[media_player:...]] when showing what's playing", + "- Use [[event:...]] for calendar event details", + "- Use [[agenda:...]] for a day's schedule or event list", + "- Use [[device:...]] for smart device status/controls", + "- Tables/code in your response auto-convert to visual cards", + ], + }, +}; diff --git a/extensions/line/src/runtime.ts b/extensions/line/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..a352dfccdb81531d2634faa6c1142e99eceb545f --- /dev/null +++ b/extensions/line/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setLineRuntime(r: PluginRuntime): void { + runtime = r; +} + +export function getLineRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("LINE runtime not initialized - plugin not registered"); + } + return runtime; +} diff --git a/extensions/llm-task/README.md b/extensions/llm-task/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d8e5dadc6fb78ffe00fec3cd3feec7754d9fcc3e --- /dev/null +++ b/extensions/llm-task/README.md @@ -0,0 +1,97 @@ +# LLM Task (plugin) + +Adds an **optional** agent tool `llm-task` for running **JSON-only** LLM tasks +(drafting, summarizing, classifying) with optional JSON Schema validation. + +Designed to be called from workflow engines (for example, Lobster via +`openclaw.invoke --each`) without adding new OpenClaw code per workflow. + +## Enable + +1. Enable the plugin: + +```json +{ + "plugins": { + "entries": { + "llm-task": { "enabled": true } + } + } +} +``` + +2. Allowlist the tool (it is registered with `optional: true`): + +```json +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { "allow": ["llm-task"] } + } + ] + } +} +``` + +## Config (optional) + +```json +{ + "plugins": { + "entries": { + "llm-task": { + "enabled": true, + "config": { + "defaultProvider": "openai-codex", + "defaultModel": "gpt-5.2", + "defaultAuthProfileId": "main", + "allowedModels": ["openai-codex/gpt-5.2"], + "maxTokens": 800, + "timeoutMs": 30000 + } + } + } + } +} +``` + +`allowedModels` is an allowlist of `provider/model` strings. If set, any request +outside the list is rejected. + +## Tool API + +### Parameters + +- `prompt` (string, required) +- `input` (any, optional) +- `schema` (object, optional JSON Schema) +- `provider` (string, optional) +- `model` (string, optional) +- `authProfileId` (string, optional) +- `temperature` (number, optional) +- `maxTokens` (number, optional) +- `timeoutMs` (number, optional) + +### Output + +Returns `details.json` containing the parsed JSON (and validates against +`schema` when provided). + +## Notes + +- The tool is **JSON-only** and instructs the model to output only JSON + (no code fences, no commentary). +- No tools are exposed to the model for this run. +- Side effects should be handled outside this tool (for example, approvals in + Lobster) before calling tools that send messages/emails. + +## Bundled extension note + +This extension depends on OpenClaw internal modules (the embedded agent runner). +It is intended to ship as a **bundled** OpenClaw extension (like `lobster`) and +be enabled via `plugins.entries` + tool allowlists. + +It is **not** currently designed to be copied into +`~/.openclaw/extensions` as a standalone plugin directory. diff --git a/extensions/llm-task/index.ts b/extensions/llm-task/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e42634dad07cfdee4dd66b3a4fef3526793b486a --- /dev/null +++ b/extensions/llm-task/index.ts @@ -0,0 +1,6 @@ +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { createLlmTaskTool } from "./src/llm-task-tool.js"; + +export default function register(api: OpenClawPluginApi) { + api.registerTool(createLlmTaskTool(api), { optional: true }); +} diff --git a/extensions/llm-task/openclaw.plugin.json b/extensions/llm-task/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..08f8cc06778236b40a8f86a501a253cba180b8c3 --- /dev/null +++ b/extensions/llm-task/openclaw.plugin.json @@ -0,0 +1,21 @@ +{ + "id": "llm-task", + "name": "LLM Task", + "description": "Generic JSON-only LLM tool for structured tasks callable from workflows.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultProvider": { "type": "string" }, + "defaultModel": { "type": "string" }, + "defaultAuthProfileId": { "type": "string" }, + "allowedModels": { + "type": "array", + "items": { "type": "string" }, + "description": "Allowlist of provider/model keys like openai-codex/gpt-5.2." + }, + "maxTokens": { "type": "number" }, + "timeoutMs": { "type": "number" } + } + } +} diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json new file mode 100644 index 0000000000000000000000000000000000000000..165c2da4e697ef6b96c2e0ca7b2318ad1e9233b8 --- /dev/null +++ b/extensions/llm-task/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/llm-task", + "version": "2026.1.30", + "description": "OpenClaw JSON-only LLM task plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb4cf89de8d97ec74b4c2a49f2a568a2e5ee71ce --- /dev/null +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../../src/agents/pi-embedded-runner.js", () => { + return { + runEmbeddedPiAgent: vi.fn(async () => ({ + meta: { startedAt: Date.now() }, + payloads: [{ text: "{}" }], + })), + }; +}); + +import { runEmbeddedPiAgent } from "../../../src/agents/pi-embedded-runner.js"; +import { createLlmTaskTool } from "./llm-task-tool.js"; + +function fakeApi(overrides: any = {}) { + return { + id: "llm-task", + name: "llm-task", + source: "test", + config: { + agents: { defaults: { workspace: "/tmp", model: { primary: "openai-codex/gpt-5.2" } } }, + }, + pluginConfig: {}, + runtime: { version: "test" }, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerTool() {}, + ...overrides, + }; +} + +describe("llm-task tool (json-only)", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns parsed json", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ foo: "bar" }) }], + }); + const tool = createLlmTaskTool(fakeApi()); + const res = await tool.execute("id", { prompt: "return foo" }); + expect((res as any).details.json).toEqual({ foo: "bar" }); + }); + + it("strips fenced json", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: '```json\n{"ok":true}\n```' }], + }); + const tool = createLlmTaskTool(fakeApi()); + const res = await tool.execute("id", { prompt: "return ok" }); + expect((res as any).details.json).toEqual({ ok: true }); + }); + + it("validates schema", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ foo: "bar" }) }], + }); + const tool = createLlmTaskTool(fakeApi()); + const schema = { + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo"], + additionalProperties: false, + }; + const res = await tool.execute("id", { prompt: "return foo", schema }); + expect((res as any).details.json).toEqual({ foo: "bar" }); + }); + + it("throws on invalid json", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: "not-json" }], + }); + const tool = createLlmTaskTool(fakeApi()); + await expect(tool.execute("id", { prompt: "x" })).rejects.toThrow(/invalid json/i); + }); + + it("throws on schema mismatch", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ foo: 1 }) }], + }); + const tool = createLlmTaskTool(fakeApi()); + const schema = { type: "object", properties: { foo: { type: "string" } }, required: ["foo"] }; + await expect(tool.execute("id", { prompt: "x", schema })).rejects.toThrow(/match schema/i); + }); + + it("passes provider/model overrides to embedded runner", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ ok: true }) }], + }); + const tool = createLlmTaskTool(fakeApi()); + await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }); + const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + expect(call.provider).toBe("anthropic"); + expect(call.model).toBe("claude-4-sonnet"); + }); + + it("enforces allowedModels", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ ok: true }) }], + }); + const tool = createLlmTaskTool( + fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }), + ); + await expect( + tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }), + ).rejects.toThrow(/not allowed/i); + }); + + it("disables tools for embedded run", async () => { + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify({ ok: true }) }], + }); + const tool = createLlmTaskTool(fakeApi()); + await tool.execute("id", { prompt: "x" }); + const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + expect(call.disableTools).toBe(true); + }); +}); diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6f15fb09d13d6c8426869c130742e9e425785f --- /dev/null +++ b/extensions/llm-task/src/llm-task-tool.ts @@ -0,0 +1,237 @@ +import { Type } from "@sinclair/typebox"; +import Ajv from "ajv"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +// NOTE: This extension is intended to be bundled with OpenClaw. +// When running from source (tests/dev), OpenClaw internals live under src/. +// When running from a built install, internals live under dist/ (no src/ tree). +// So we resolve internal imports dynamically with src-first, dist-fallback. +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; + +type RunEmbeddedPiAgentFn = (params: Record) => Promise; + +async function loadRunEmbeddedPiAgent(): Promise { + // Source checkout (tests/dev) + try { + const mod = await import("../../../src/agents/pi-embedded-runner.js"); + if (typeof (mod as any).runEmbeddedPiAgent === "function") { + return (mod as any).runEmbeddedPiAgent; + } + } catch { + // ignore + } + + // Bundled install (built) + const mod = await import("../../../agents/pi-embedded-runner.js"); + if (typeof mod.runEmbeddedPiAgent !== "function") { + throw new Error("Internal error: runEmbeddedPiAgent not available"); + } + return mod.runEmbeddedPiAgent; +} + +function stripCodeFences(s: string): string { + const trimmed = s.trim(); + const m = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); + if (m) { + return (m[1] ?? "").trim(); + } + return trimmed; +} + +function collectText(payloads: Array<{ text?: string; isError?: boolean }> | undefined): string { + const texts = (payloads ?? []) + .filter((p) => !p.isError && typeof p.text === "string") + .map((p) => p.text ?? ""); + return texts.join("\n").trim(); +} + +function toModelKey(provider?: string, model?: string): string | undefined { + const p = provider?.trim(); + const m = model?.trim(); + if (!p || !m) { + return undefined; + } + return `${p}/${m}`; +} + +type PluginCfg = { + defaultProvider?: string; + defaultModel?: string; + defaultAuthProfileId?: string; + allowedModels?: string[]; + maxTokens?: number; + timeoutMs?: number; +}; + +export function createLlmTaskTool(api: OpenClawPluginApi) { + return { + name: "llm-task", + description: + "Run a generic JSON-only LLM task and return schema-validated JSON. Designed for orchestration from Lobster workflows via openclaw.invoke.", + parameters: Type.Object({ + prompt: Type.String({ description: "Task instruction for the LLM." }), + input: Type.Optional(Type.Unknown({ description: "Optional input payload for the task." })), + schema: Type.Optional( + Type.Unknown({ description: "Optional JSON Schema to validate the returned JSON." }), + ), + provider: Type.Optional( + Type.String({ description: "Provider override (e.g. openai-codex, anthropic)." }), + ), + model: Type.Optional(Type.String({ description: "Model id override." })), + authProfileId: Type.Optional(Type.String({ description: "Auth profile override." })), + temperature: Type.Optional(Type.Number({ description: "Best-effort temperature override." })), + maxTokens: Type.Optional(Type.Number({ description: "Best-effort maxTokens override." })), + timeoutMs: Type.Optional(Type.Number({ description: "Timeout for the LLM run." })), + }), + + async execute(_id: string, params: Record) { + const prompt = typeof params.prompt === "string" ? params.prompt : ""; + if (!prompt.trim()) { + throw new Error("prompt required"); + } + + const pluginCfg = (api.pluginConfig ?? {}) as PluginCfg; + + const primary = api.config?.agents?.defaults?.model?.primary; + const primaryProvider = typeof primary === "string" ? primary.split("/")[0] : undefined; + const primaryModel = + typeof primary === "string" ? primary.split("/").slice(1).join("/") : undefined; + + const provider = + (typeof params.provider === "string" && params.provider.trim()) || + (typeof pluginCfg.defaultProvider === "string" && pluginCfg.defaultProvider.trim()) || + primaryProvider || + undefined; + + const model = + (typeof params.model === "string" && params.model.trim()) || + (typeof pluginCfg.defaultModel === "string" && pluginCfg.defaultModel.trim()) || + primaryModel || + undefined; + + const authProfileId = + (typeof (params as any).authProfileId === "string" && + (params as any).authProfileId.trim()) || + (typeof pluginCfg.defaultAuthProfileId === "string" && + pluginCfg.defaultAuthProfileId.trim()) || + undefined; + + const modelKey = toModelKey(provider, model); + if (!provider || !model || !modelKey) { + throw new Error( + `provider/model could not be resolved (provider=${String(provider ?? "")}, model=${String(model ?? "")})`, + ); + } + + const allowed = Array.isArray(pluginCfg.allowedModels) ? pluginCfg.allowedModels : undefined; + if (allowed && allowed.length > 0 && !allowed.includes(modelKey)) { + throw new Error( + `Model not allowed by llm-task plugin config: ${modelKey}. Allowed models: ${allowed.join(", ")}`, + ); + } + + const timeoutMs = + (typeof params.timeoutMs === "number" && params.timeoutMs > 0 + ? params.timeoutMs + : undefined) || + (typeof pluginCfg.timeoutMs === "number" && pluginCfg.timeoutMs > 0 + ? pluginCfg.timeoutMs + : undefined) || + 30_000; + + const streamParams = { + temperature: typeof params.temperature === "number" ? params.temperature : undefined, + maxTokens: + typeof params.maxTokens === "number" + ? params.maxTokens + : typeof pluginCfg.maxTokens === "number" + ? pluginCfg.maxTokens + : undefined, + }; + + const input = (params as any).input as unknown; + let inputJson: string; + try { + inputJson = JSON.stringify(input ?? null, null, 2); + } catch { + throw new Error("input must be JSON-serializable"); + } + + const system = [ + "You are a JSON-only function.", + "Return ONLY a valid JSON value.", + "Do not wrap in markdown fences.", + "Do not include commentary.", + "Do not call tools.", + ].join(" "); + + const fullPrompt = `${system}\n\nTASK:\n${prompt}\n\nINPUT_JSON:\n${inputJson}\n`; + + let tmpDir: string | null = null; + try { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-llm-task-")); + const sessionId = `llm-task-${Date.now()}`; + const sessionFile = path.join(tmpDir, "session.json"); + + const runEmbeddedPiAgent = await loadRunEmbeddedPiAgent(); + + const result = await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir: api.config?.agents?.defaults?.workspace ?? process.cwd(), + config: api.config, + prompt: fullPrompt, + timeoutMs, + runId: `llm-task-${Date.now()}`, + provider, + model, + authProfileId, + authProfileIdSource: authProfileId ? "user" : "auto", + streamParams, + disableTools: true, + }); + + const text = collectText((result as any).payloads); + if (!text) { + throw new Error("LLM returned empty output"); + } + + const raw = stripCodeFences(text); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("LLM returned invalid JSON"); + } + + const schema = (params as any).schema as unknown; + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + const ajv = new Ajv({ allErrors: true, strict: false }); + const validate = ajv.compile(schema as any); + const ok = validate(parsed); + if (!ok) { + const msg = + validate.errors + ?.map((e) => `${e.instancePath || ""} ${e.message || "invalid"}`) + .join("; ") ?? "invalid"; + throw new Error(`LLM JSON did not match schema: ${msg}`); + } + } + + return { + content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }], + details: { json: parsed, provider, model }, + }; + } finally { + if (tmpDir) { + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + } + }, + }; +} diff --git a/extensions/lobster/README.md b/extensions/lobster/README.md new file mode 100644 index 0000000000000000000000000000000000000000..8a7d600f1c0341d198a15bfa5858069c663455e9 --- /dev/null +++ b/extensions/lobster/README.md @@ -0,0 +1,75 @@ +# Lobster (plugin) + +Adds the `lobster` agent tool as an **optional** plugin tool. + +## What this is + +- Lobster is a standalone workflow shell (typed JSON-first pipelines + approvals/resume). +- This plugin integrates Lobster with OpenClaw _without core changes_. + +## Enable + +Because this tool can trigger side effects (via workflows), it is registered with `optional: true`. + +Enable it in an agent allowlist: + +```json +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { + "allow": [ + "lobster" // plugin id (enables all tools from this plugin) + ] + } + } + ] + } +} +``` + +## Using `openclaw.invoke` (Lobster → OpenClaw tools) + +Some Lobster pipelines may include a `openclaw.invoke` step to call back into OpenClaw tools/plugins (for example: `gog` for Google Workspace, `gh` for GitHub, `message.send`, etc.). + +For this to work, the OpenClaw Gateway must expose the tool bridge endpoint and the target tool must be allowed by policy: + +- OpenClaw provides an HTTP endpoint: `POST /tools/invoke`. +- The request is gated by **gateway auth** (e.g. `Authorization: Bearer …` when token auth is enabled). +- The invoked tool is gated by **tool policy** (global + per-agent + provider + group policy). If the tool is not allowed, OpenClaw returns `404 Tool not available`. + +### Allowlisting recommended + +To avoid letting workflows call arbitrary tools, set a tight allowlist on the agent that will be used by `openclaw.invoke`. + +Example (allow only a small set of tools): + +```jsonc +{ + "agents": { + "list": [ + { + "id": "main", + "tools": { + "allow": ["lobster", "web_fetch", "web_search", "gog", "gh"], + "deny": ["gateway"], + }, + }, + ], + }, +} +``` + +Notes: + +- If `tools.allow` is omitted or empty, it behaves like "allow everything (except denied)". For a real allowlist, set a **non-empty** `allow`. +- Tool names depend on which plugins you have installed/enabled. + +## Security + +- Runs the `lobster` executable as a local subprocess. +- Does not manage OAuth/tokens. +- Uses timeouts, stdout caps, and strict JSON envelope parsing. +- Prefer an absolute `lobsterPath` in production to avoid PATH hijack. diff --git a/extensions/lobster/SKILL.md b/extensions/lobster/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..bac64280f7f6e1e2bce78921b398289edcf25733 --- /dev/null +++ b/extensions/lobster/SKILL.md @@ -0,0 +1,97 @@ +# Lobster + +Lobster executes multi-step workflows with approval checkpoints. Use it when: + +- User wants a repeatable automation (triage, monitor, sync) +- Actions need human approval before executing (send, post, delete) +- Multiple tool calls should run as one deterministic operation + +## When to use Lobster + +| User intent | Use Lobster? | +| ------------------------------------------------------ | --------------------------------------------- | +| "Triage my email" | Yes — multi-step, may send replies | +| "Send a message" | No — single action, use message tool directly | +| "Check my email every morning and ask before replying" | Yes — scheduled workflow with approval | +| "What's the weather?" | No — simple query | +| "Monitor this PR and notify me of changes" | Yes — stateful, recurring | + +## Basic usage + +### Run a pipeline + +```json +{ + "action": "run", + "pipeline": "gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage" +} +``` + +Returns structured result: + +```json +{ + "protocolVersion": 1, + "ok": true, + "status": "ok", + "output": [{ "summary": {...}, "items": [...] }], + "requiresApproval": null +} +``` + +### Handle approval + +If the workflow needs approval: + +```json +{ + "status": "needs_approval", + "output": [], + "requiresApproval": { + "prompt": "Send 3 draft replies?", + "items": [...], + "resumeToken": "..." + } +} +``` + +Present the prompt to the user. If they approve: + +```json +{ + "action": "resume", + "token": "", + "approve": true +} +``` + +## Example workflows + +### Email triage + +``` +gog.gmail.search --query 'newer_than:1d' --max 20 | email.triage +``` + +Fetches recent emails, classifies into buckets (needs_reply, needs_action, fyi). + +### Email triage with approval gate + +``` +gog.gmail.search --query 'newer_than:1d' | email.triage | approve --prompt 'Process these?' +``` + +Same as above, but halts for approval before returning. + +## Key behaviors + +- **Deterministic**: Same input → same output (no LLM variance in pipeline execution) +- **Approval gates**: `approve` command halts execution, returns token +- **Resumable**: Use `resume` action with token to continue +- **Structured output**: Always returns JSON envelope with `protocolVersion` + +## Don't use Lobster for + +- Simple single-action requests (just use the tool directly) +- Queries that need LLM interpretation mid-flow +- One-off tasks that won't be repeated diff --git a/extensions/lobster/index.ts b/extensions/lobster/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b01680165c435a5d8e068e794392e7d3abc5246 --- /dev/null +++ b/extensions/lobster/index.ts @@ -0,0 +1,14 @@ +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; +import { createLobsterTool } from "./src/lobster-tool.js"; + +export default function register(api: OpenClawPluginApi) { + api.registerTool( + (ctx) => { + if (ctx.sandboxed) { + return null; + } + return createLobsterTool(api); + }, + { optional: true }, + ); +} diff --git a/extensions/lobster/openclaw.plugin.json b/extensions/lobster/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..c1070a87b28e2a1e186c49240f38258c58c15f95 --- /dev/null +++ b/extensions/lobster/openclaw.plugin.json @@ -0,0 +1,10 @@ +{ + "id": "lobster", + "name": "Lobster", + "description": "Typed workflow tool with resumable approvals.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json new file mode 100644 index 0000000000000000000000000000000000000000..34ffa28b2b704c97011b857f60888e23b36ca1d6 --- /dev/null +++ b/extensions/lobster/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/lobster", + "version": "2026.1.30", + "description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0817771d4f32f301ecd0c04f6660d32e0f289086 --- /dev/null +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -0,0 +1,246 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../../../src/plugins/types.js"; +import { createLobsterTool } from "./lobster-tool.js"; + +async function writeFakeLobsterScript(scriptBody: string, prefix = "openclaw-lobster-plugin-") { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const isWindows = process.platform === "win32"; + + if (isWindows) { + const scriptPath = path.join(dir, "lobster.js"); + const cmdPath = path.join(dir, "lobster.cmd"); + await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" }); + const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`; + await fs.writeFile(cmdPath, cmd, { encoding: "utf8" }); + return { dir, binPath: cmdPath }; + } + + const binPath = path.join(dir, "lobster"); + const file = `#!/usr/bin/env node\n${scriptBody}\n`; + await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); + return { dir, binPath }; +} + +async function writeFakeLobster(params: { payload: unknown }) { + const scriptBody = + `const payload = ${JSON.stringify(params.payload)};\n` + + `process.stdout.write(JSON.stringify(payload));\n`; + return await writeFakeLobsterScript(scriptBody); +} + +function fakeApi(overrides: Partial = {}): OpenClawPluginApi { + return { + id: "lobster", + name: "lobster", + source: "test", + config: {} as any, + pluginConfig: {}, + runtime: { version: "test" } as any, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + registerTool() {}, + registerHttpHandler() {}, + registerChannel() {}, + registerGatewayMethod() {}, + registerCli() {}, + registerService() {}, + registerProvider() {}, + registerHook() {}, + registerHttpRoute() {}, + registerCommand() {}, + on() {}, + resolvePath: (p) => p, + ...overrides, + }; +} + +function fakeCtx(overrides: Partial = {}): OpenClawPluginToolContext { + return { + config: {} as any, + workspaceDir: "/tmp", + agentDir: "/tmp", + agentId: "main", + sessionKey: "main", + messageChannel: undefined, + agentAccountId: undefined, + sandboxed: false, + ...overrides, + }; +} + +describe("lobster plugin tool", () => { + it("runs lobster and returns parsed envelope in details", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + }); + + const originalPath = process.env.PATH; + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call1", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("tolerates noisy stdout before the JSON envelope", async () => { + const payload = { ok: true, status: "ok", output: [], requiresApproval: null }; + const { dir } = await writeFakeLobsterScript( + `const payload = ${JSON.stringify(payload)};\n` + + `console.log("noise before json");\n` + + `process.stdout.write(JSON.stringify(payload));\n`, + "openclaw-lobster-plugin-noisy-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + const res = await tool.execute("call-noisy", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("requires absolute lobsterPath when provided (even though it is ignored)", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + }); + + const originalPath = process.env.PATH; + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2", { + action: "run", + pipeline: "noop", + lobsterPath: "./lobster", + }), + ).rejects.toThrow(/absolute path/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("rejects lobsterPath (deprecated) when invalid", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + }); + + const originalPath = process.env.PATH; + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2b", { + action: "run", + pipeline: "noop", + lobsterPath: "/bin/bash", + }), + ).rejects.toThrow(/lobster executable/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("rejects absolute cwd", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2c", { + action: "run", + pipeline: "noop", + cwd: "/tmp", + }), + ).rejects.toThrow(/cwd must be a relative path/); + }); + + it("rejects cwd that escapes the gateway working directory", async () => { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call2d", { + action: "run", + pipeline: "noop", + cwd: "../../etc", + }), + ).rejects.toThrow(/must stay within/); + }); + + it("uses pluginConfig.lobsterPath when provided", async () => { + const fake = await writeFakeLobster({ + payload: { ok: true, status: "ok", output: [{ hello: "world" }], requiresApproval: null }, + }); + + // Ensure `lobster` is NOT discoverable via PATH, while still allowing our + // fake lobster (a Node script with `#!/usr/bin/env node`) to run. + const originalPath = process.env.PATH; + process.env.PATH = path.dirname(process.execPath); + + try { + const tool = createLobsterTool(fakeApi({ pluginConfig: { lobsterPath: fake.binPath } })); + const res = await tool.execute("call-plugin-config", { + action: "run", + pipeline: "noop", + timeoutMs: 1000, + }); + + expect(res.details).toMatchObject({ ok: true, status: "ok" }); + } finally { + process.env.PATH = originalPath; + } + }); + + it("rejects invalid JSON from lobster", async () => { + const { dir } = await writeFakeLobsterScript( + `process.stdout.write("nope");\n`, + "openclaw-lobster-plugin-bad-", + ); + + const originalPath = process.env.PATH; + process.env.PATH = `${dir}${path.delimiter}${originalPath ?? ""}`; + + try { + const tool = createLobsterTool(fakeApi()); + await expect( + tool.execute("call3", { + action: "run", + pipeline: "noop", + }), + ).rejects.toThrow(/invalid JSON/); + } finally { + process.env.PATH = originalPath; + } + }); + + it("can be gated off in sandboxed contexts", async () => { + const api = fakeApi(); + const factoryTool = (ctx: OpenClawPluginToolContext) => { + if (ctx.sandboxed) { + return null; + } + return createLobsterTool(api); + }; + + expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull(); + expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster"); + }); +}); diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..b24670eef4ca69b8dc4f1f5a4b4b8982eb55c1aa --- /dev/null +++ b/extensions/lobster/src/lobster-tool.ts @@ -0,0 +1,328 @@ +import { Type } from "@sinclair/typebox"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawPluginApi } from "../../../src/plugins/types.js"; + +type LobsterEnvelope = + | { + ok: true; + status: "ok" | "needs_approval" | "cancelled"; + output: unknown[]; + requiresApproval: null | { + type: "approval_request"; + prompt: string; + items: unknown[]; + resumeToken?: string; + }; + } + | { + ok: false; + error: { type?: string; message: string }; + }; + +function resolveExecutablePath(lobsterPathRaw: string | undefined) { + const lobsterPath = lobsterPathRaw?.trim() || "lobster"; + + // SECURITY: + // Never allow arbitrary executables (e.g. /bin/bash). If the caller overrides + // the path, it must still be the lobster binary (by name) and be absolute. + if (lobsterPath !== "lobster") { + if (!path.isAbsolute(lobsterPath)) { + throw new Error("lobsterPath must be an absolute path (or omit to use PATH)"); + } + const base = path.basename(lobsterPath).toLowerCase(); + const allowed = + process.platform === "win32" ? ["lobster.exe", "lobster.cmd", "lobster.bat"] : ["lobster"]; + if (!allowed.includes(base)) { + throw new Error("lobsterPath must point to the lobster executable"); + } + let stat: fs.Stats; + try { + stat = fs.statSync(lobsterPath); + } catch { + throw new Error("lobsterPath must exist"); + } + if (!stat.isFile()) { + throw new Error("lobsterPath must point to a file"); + } + if (process.platform !== "win32") { + try { + fs.accessSync(lobsterPath, fs.constants.X_OK); + } catch { + throw new Error("lobsterPath must be executable"); + } + } + } + + return lobsterPath; +} + +function normalizeForCwdSandbox(p: string): string { + const normalized = path.normalize(p); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function resolveCwd(cwdRaw: unknown): string { + if (typeof cwdRaw !== "string" || !cwdRaw.trim()) { + return process.cwd(); + } + const cwd = cwdRaw.trim(); + if (path.isAbsolute(cwd)) { + throw new Error("cwd must be a relative path"); + } + const base = process.cwd(); + const resolved = path.resolve(base, cwd); + + const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved)); + if (rel === "" || rel === ".") { + return resolved; + } + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error("cwd must stay within the gateway working directory"); + } + return resolved; +} + +function isWindowsSpawnErrorThatCanUseShell(err: unknown) { + if (!err || typeof err !== "object") { + return false; + } + const code = (err as { code?: unknown }).code; + + // On Windows, spawning scripts discovered on PATH (e.g. lobster.cmd) can fail + // with EINVAL, and PATH discovery itself can fail with ENOENT when the binary + // is only available via PATHEXT/script wrappers. + return code === "EINVAL" || code === "ENOENT"; +} + +async function runLobsterSubprocessOnce( + params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; + }, + useShell: boolean, +) { + const { execPath, argv, cwd } = params; + const timeoutMs = Math.max(200, params.timeoutMs); + const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); + + const env = { ...process.env, LOBSTER_MODE: "tool" } as Record; + const nodeOptions = env.NODE_OPTIONS ?? ""; + if (nodeOptions.includes("--inspect")) { + delete env.NODE_OPTIONS; + } + + return await new Promise<{ stdout: string }>((resolve, reject) => { + const child = spawn(execPath, argv, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env, + shell: useShell, + windowsHide: useShell ? true : undefined, + }); + + let stdout = ""; + let stdoutBytes = 0; + let stderr = ""; + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + + child.stdout?.on("data", (chunk) => { + const str = String(chunk); + stdoutBytes += Buffer.byteLength(str, "utf8"); + if (stdoutBytes > maxStdoutBytes) { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster output exceeded maxStdoutBytes")); + } + return; + } + stdout += str; + }); + + child.stderr?.on("data", (chunk) => { + stderr += String(chunk); + }); + + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } finally { + reject(new Error("lobster subprocess timed out")); + } + }, timeoutMs); + + child.once("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + child.once("exit", (code) => { + clearTimeout(timer); + if (code !== 0) { + reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)); + return; + } + resolve({ stdout }); + }); + }); +} + +async function runLobsterSubprocess(params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; +}) { + try { + return await runLobsterSubprocessOnce(params, false); + } catch (err) { + if (process.platform === "win32" && isWindowsSpawnErrorThatCanUseShell(err)) { + return await runLobsterSubprocessOnce(params, true); + } + throw err; + } +} + +function parseEnvelope(stdout: string): LobsterEnvelope { + const trimmed = stdout.trim(); + + const tryParse = (input: string) => { + try { + return JSON.parse(input) as unknown; + } catch { + return undefined; + } + }; + + let parsed: unknown = tryParse(trimmed); + + // Some environments can leak extra stdout (e.g. warnings/logs) before the + // final JSON envelope. Be tolerant and parse the last JSON-looking suffix. + if (parsed === undefined) { + const suffixMatch = trimmed.match(/({[\s\S]*}|\[[\s\S]*])\s*$/); + if (suffixMatch?.[1]) { + parsed = tryParse(suffixMatch[1]); + } + } + + if (parsed === undefined) { + throw new Error("lobster returned invalid JSON"); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error("lobster returned invalid JSON envelope"); + } + + const ok = (parsed as { ok?: unknown }).ok; + if (ok === true || ok === false) { + return parsed as LobsterEnvelope; + } + + throw new Error("lobster returned invalid JSON envelope"); +} + +export function createLobsterTool(api: OpenClawPluginApi) { + return { + name: "lobster", + description: + "Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).", + parameters: Type.Object({ + // NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf. + action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }), + pipeline: Type.Optional(Type.String()), + argsJson: Type.Optional(Type.String()), + token: Type.Optional(Type.String()), + approve: Type.Optional(Type.Boolean()), + // SECURITY: Do not allow the agent to choose an executable path. + // Host can configure the lobster binary via plugin config. + lobsterPath: Type.Optional( + Type.String({ description: "(deprecated) Use plugin config instead." }), + ), + cwd: Type.Optional( + Type.String({ + description: + "Relative working directory (optional). Must stay within the gateway working directory.", + }), + ), + timeoutMs: Type.Optional(Type.Number()), + maxStdoutBytes: Type.Optional(Type.Number()), + }), + async execute(_id: string, params: Record) { + const action = typeof params.action === "string" ? params.action.trim() : ""; + if (!action) { + throw new Error("action required"); + } + + // SECURITY: never allow tool callers (agent/user) to select executables. + // If a host needs to override the binary, it must do so via plugin config. + // We still validate the parameter shape to prevent reintroducing an RCE footgun. + if (typeof params.lobsterPath === "string" && params.lobsterPath.trim()) { + resolveExecutablePath(params.lobsterPath); + } + + const execPath = resolveExecutablePath( + typeof api.pluginConfig?.lobsterPath === "string" + ? api.pluginConfig.lobsterPath + : undefined, + ); + const cwd = resolveCwd(params.cwd); + const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 20_000; + const maxStdoutBytes = + typeof params.maxStdoutBytes === "number" ? params.maxStdoutBytes : 512_000; + + const argv = (() => { + if (action === "run") { + const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; + if (!pipeline.trim()) { + throw new Error("pipeline required"); + } + const argv = ["run", "--mode", "tool", pipeline]; + const argsJson = typeof params.argsJson === "string" ? params.argsJson : ""; + if (argsJson.trim()) { + argv.push("--args-json", argsJson); + } + return argv; + } + if (action === "resume") { + const token = typeof params.token === "string" ? params.token : ""; + if (!token.trim()) { + throw new Error("token required"); + } + const approve = params.approve; + if (typeof approve !== "boolean") { + throw new Error("approve required"); + } + return ["resume", "--token", token, "--approve", approve ? "yes" : "no"]; + } + throw new Error(`Unknown action: ${action}`); + })(); + + if (api.runtime?.version && api.logger?.debug) { + api.logger.debug(`lobster plugin runtime=${api.runtime.version}`); + } + + const { stdout } = await runLobsterSubprocess({ + execPath, + argv, + cwd, + timeoutMs, + maxStdoutBytes, + }); + + const envelope = parseEnvelope(stdout); + + return { + content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }], + details: envelope, + }; + }, + }; +} diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..b6d2cee5fe510d296e034baba8abf8afb4b952b7 --- /dev/null +++ b/extensions/matrix/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +## 2026.1.30 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.29 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.23 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.22 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.21 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.20 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17-1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.15 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.14 + +### Features + +- Version alignment with core OpenClaw release numbers. +- Matrix channel plugin with homeserver + user ID auth (access token or password login with device name). +- Direct messages with pairing/allowlist/open/disabled policies and allowFrom support. +- Group/room controls: allowlist policy, per-room config, mention gating, auto-reply, per-room skills/system prompts. +- Threads: replyToMode controls and thread replies (off/inbound/always). +- Messaging: text chunking, media uploads with size caps, reactions, polls, typing, and message edits/deletes. +- Actions: read messages, list/remove reactions, pin/unpin/list pins, member info, room info. +- Auto-join invites with allowlist support. +- Status + probe reporting for health checks. diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..10df32f7f79038e3995d18c9315d0762932e58b5 --- /dev/null +++ b/extensions/matrix/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { matrixPlugin } from "./src/channel.js"; +import { setMatrixRuntime } from "./src/runtime.js"; + +const plugin = { + id: "matrix", + name: "Matrix", + description: "Matrix channel plugin (matrix-js-sdk)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setMatrixRuntime(api.runtime); + api.registerChannel({ plugin: matrixPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/matrix/openclaw.plugin.json b/extensions/matrix/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..2c179d9c6a94fd81e5ad4d85a8ac909b49cfd498 --- /dev/null +++ b/extensions/matrix/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "matrix", + "channels": ["matrix"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json new file mode 100644 index 0000000000000000000000000000000000000000..e9051bc4ca0974743c1b8fb0d3d5429055f23a94 --- /dev/null +++ b/extensions/matrix/package.json @@ -0,0 +1,36 @@ +{ + "name": "@openclaw/matrix", + "version": "2026.1.30", + "description": "OpenClaw Matrix channel plugin", + "type": "module", + "dependencies": { + "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", + "@vector-im/matrix-bot-sdk": "0.8.0-element.3", + "markdown-it": "14.1.0", + "music-metadata": "^11.11.1", + "zod": "^4.3.6" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "matrix", + "label": "Matrix", + "selectionLabel": "Matrix (plugin)", + "docsPath": "/channels/matrix", + "docsLabel": "matrix", + "blurb": "open protocol; install the plugin to enable.", + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/matrix", + "localPath": "extensions/matrix", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7c219536f44ac81f77673cd8bcadfbb621cfffc --- /dev/null +++ b/extensions/matrix/src/actions.ts @@ -0,0 +1,195 @@ +import { + createActionGate, + readNumberParam, + readStringParam, + type ChannelMessageActionAdapter, + type ChannelMessageActionContext, + type ChannelMessageActionName, + type ChannelToolSend, +} from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { handleMatrixAction } from "./tool-actions.js"; + +export const matrixMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); + if (!account.enabled || !account.configured) { + return []; + } + const gate = createActionGate((cfg as CoreConfig).channels?.matrix?.actions); + const actions = new Set(["send", "poll"]); + if (gate("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (gate("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (gate("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (gate("memberInfo")) { + actions.add("member-info"); + } + if (gate("channelInfo")) { + actions.add("channel-info"); + } + return Array.from(actions); + }, + supportsAction: ({ action }) => action !== "poll", + extractToolSend: ({ args }): ChannelToolSend | null => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action !== "sendMessage") { + return null; + } + const to = typeof args.to === "string" ? args.to : undefined; + if (!to) { + return null; + } + return { to }; + }, + handleAction: async (ctx: ChannelMessageActionContext) => { + const { action, params, cfg } = ctx; + const resolveRoomId = () => + readStringParam(params, "roomId") ?? + readStringParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "media", { trim: false }); + const replyTo = readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + return await handleMatrixAction( + { + action: "sendMessage", + to, + content, + mediaUrl: mediaUrl ?? undefined, + replyToId: replyTo ?? undefined, + threadId: threadId ?? undefined, + }, + cfg, + ); + } + + if (action === "react") { + const messageId = readStringParam(params, "messageId", { required: true }); + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = typeof params.remove === "boolean" ? params.remove : undefined; + return await handleMatrixAction( + { + action: "react", + roomId: resolveRoomId(), + messageId, + emoji, + remove, + }, + cfg, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleMatrixAction( + { + action: "reactions", + roomId: resolveRoomId(), + messageId, + limit, + }, + cfg, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleMatrixAction( + { + action: "readMessages", + roomId: resolveRoomId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + }, + cfg, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleMatrixAction( + { + action: "editMessage", + roomId: resolveRoomId(), + messageId, + content, + }, + cfg, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleMatrixAction( + { + action: "deleteMessage", + roomId: resolveRoomId(), + messageId, + }, + cfg, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" + ? undefined + : readStringParam(params, "messageId", { required: true }); + return await handleMatrixAction( + { + action: + action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + roomId: resolveRoomId(), + messageId, + }, + cfg, + ); + } + + if (action === "member-info") { + const userId = readStringParam(params, "userId", { required: true }); + return await handleMatrixAction( + { + action: "memberInfo", + userId, + roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"), + }, + cfg, + ); + } + + if (action === "channel-info") { + return await handleMatrixAction( + { + action: "channelInfo", + roomId: resolveRoomId(), + }, + cfg, + ); + } + + throw new Error(`Action ${action} is not supported for provider matrix.`); + }, +}; diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb2aeacac79e4ac711eb4aa256e9b2bf1b123eb8 --- /dev/null +++ b/extensions/matrix/src/channel.directory.test.ts @@ -0,0 +1,64 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { CoreConfig } from "./types.js"; +import { matrixPlugin } from "./channel.js"; +import { setMatrixRuntime } from "./runtime.js"; + +describe("matrix directory", () => { + beforeEach(() => { + setMatrixRuntime({ + state: { + resolveStateDir: (_env, homeDir) => homeDir(), + }, + } as PluginRuntime); + }); + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + matrix: { + dm: { allowFrom: ["matrix:@alice:example.org", "bob"] }, + groupAllowFrom: ["@dana:example.org"], + groups: { + "!room1:example.org": { users: ["@carol:example.org"] }, + "#alias:example.org": { users: [] }, + }, + }, + }, + } as unknown as CoreConfig; + + expect(matrixPlugin.directory).toBeTruthy(); + expect(matrixPlugin.directory?.listPeers).toBeTruthy(); + expect(matrixPlugin.directory?.listGroups).toBeTruthy(); + + await expect( + matrixPlugin.directory!.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "user:@alice:example.org" }, + { kind: "user", id: "bob", name: "incomplete id; expected @user:server" }, + { kind: "user", id: "user:@carol:example.org" }, + { kind: "user", id: "user:@dana:example.org" }, + ]), + ); + + await expect( + matrixPlugin.directory!.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "room:!room1:example.org" }, + { kind: "group", id: "#alias:example.org" }, + ]), + ); + }); +}); diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb67c49ce699347740f92b4b598c1dc56c76455e --- /dev/null +++ b/extensions/matrix/src/channel.ts @@ -0,0 +1,443 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; +import { matrixMessageActions } from "./actions.js"; +import { MatrixConfigSchema } from "./config-schema.js"; +import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { + resolveMatrixGroupRequireMention, + resolveMatrixGroupToolPolicy, +} from "./group-mentions.js"; +import { + listMatrixAccountIds, + resolveDefaultMatrixAccountId, + resolveMatrixAccount, + type ResolvedMatrixAccount, +} from "./matrix/accounts.js"; +import { resolveMatrixAuth } from "./matrix/client.js"; +import { normalizeAllowListLower } from "./matrix/monitor/allowlist.js"; +import { probeMatrix } from "./matrix/probe.js"; +import { sendMessageMatrix } from "./matrix/send.js"; +import { matrixOnboardingAdapter } from "./onboarding.js"; +import { matrixOutbound } from "./outbound.js"; +import { resolveMatrixTargets } from "./resolve-targets.js"; + +const meta = { + id: "matrix", + label: "Matrix", + selectionLabel: "Matrix (plugin)", + docsPath: "/channels/matrix", + docsLabel: "matrix", + blurb: "open protocol; configure a homeserver + access token.", + order: 70, + quickstartAllowFrom: true, +}; + +function normalizeMatrixMessagingTarget(raw: string): string | undefined { + let normalized = raw.trim(); + if (!normalized) { + return undefined; + } + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("matrix:")) { + normalized = normalized.slice("matrix:".length).trim(); + } + const stripped = normalized.replace(/^(room|channel|user):/i, "").trim(); + return stripped || undefined; +} + +function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + +export const matrixPlugin: ChannelPlugin = { + id: "matrix", + meta, + onboarding: matrixOnboardingAdapter, + pairing: { + idLabel: "matrixUserId", + normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), + notifyApproval: async ({ id }) => { + await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group", "thread"], + polls: true, + reactions: true, + threads: true, + media: true, + }, + reload: { configPrefixes: ["channels.matrix"] }, + configSchema: buildChannelConfigSchema(MatrixConfigSchema), + config: { + listAccountIds: (cfg) => listMatrixAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultMatrixAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "matrix", + accountId, + clearBaseFields: [ + "name", + "homeserver", + "userId", + "accessToken", + "password", + "deviceName", + "initialSyncLimit", + ], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.homeserver, + }), + resolveAllowFrom: ({ cfg }) => + ((cfg as CoreConfig).channels?.matrix?.dm?.allowFrom ?? []).map((entry) => String(entry)), + formatAllowFrom: ({ allowFrom }) => normalizeAllowListLower(allowFrom), + }, + security: { + resolveDmPolicy: ({ account }) => ({ + policy: account.config.dm?.policy ?? "pairing", + allowFrom: account.config.dm?.allowFrom ?? [], + policyPath: "channels.matrix.dm.policy", + allowFromPath: "channels.matrix.dm.allowFrom", + approveHint: formatPairingApproveHint("matrix"), + normalizeEntry: (raw) => + raw + .replace(/^matrix:/i, "") + .trim() + .toLowerCase(), + }), + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = (cfg as CoreConfig).channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + '- Matrix rooms: groupPolicy="open" allows any room to trigger (mention-gated). Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms.', + ]; + }, + }, + groups: { + resolveRequireMention: resolveMatrixGroupRequireMention, + resolveToolPolicy: resolveMatrixGroupToolPolicy, + }, + threading: { + resolveReplyToMode: ({ cfg }) => (cfg as CoreConfig).channels?.matrix?.replyToMode ?? "off", + buildToolContext: ({ context, hasRepliedRef }) => { + const currentTarget = context.To; + return { + currentChannelId: currentTarget?.trim() || undefined, + currentThreadTs: + context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId, + hasRepliedRef, + }; + }, + }, + messaging: { + normalizeTarget: normalizeMatrixMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^(matrix:)?[!#@]/i.test(trimmed)) { + return true; + } + return trimmed.includes(":"); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + + for (const entry of account.config.dm?.allowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") { + continue; + } + ids.add(raw.replace(/^matrix:/i, "")); + } + + for (const entry of account.config.groupAllowFrom ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") { + continue; + } + ids.add(raw.replace(/^matrix:/i, "")); + } + + const groups = account.config.groups ?? account.config.rooms ?? {}; + for (const room of Object.values(groups)) { + for (const entry of room.users ?? []) { + const raw = String(entry).trim(); + if (!raw || raw === "*") { + continue; + } + ids.add(raw.replace(/^matrix:/i, "")); + } + } + + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const lowered = raw.toLowerCase(); + const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; + if (cleaned.startsWith("@")) { + return `user:${cleaned}`; + } + return cleaned; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => { + const raw = id.startsWith("user:") ? id.slice("user:".length) : id; + const incomplete = !raw.startsWith("@") || !raw.includes(":"); + return { + kind: "user", + id, + ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), + }; + }); + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() || ""; + const groups = account.config.groups ?? account.config.rooms ?? {}; + const ids = Object.keys(groups) + .map((raw) => raw.trim()) + .filter((raw) => Boolean(raw) && raw !== "*") + .map((raw) => raw.replace(/^matrix:/i, "")) + .map((raw) => { + const lowered = raw.toLowerCase(); + if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { + return raw; + } + if (raw.startsWith("!")) { + return `room:${raw}`; + } + return raw; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + return ids; + }, + listPeersLive: async ({ cfg, query, limit }) => + listMatrixDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listMatrixDirectoryGroupsLive({ cfg, query, limit }), + }, + resolver: { + resolveTargets: async ({ cfg, inputs, kind, runtime }) => + resolveMatrixTargets({ cfg, inputs, kind, runtime }), + }, + actions: matrixMessageActions, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = input.password?.trim(); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; + }, + applyAccountConfig: ({ cfg, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: "matrix", + accountId: DEFAULT_ACCOUNT_ID, + name: input.name, + }); + if (input.useEnv) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + matrix: { + ...namedConfig.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(namedConfig as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: input.password?.trim(), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, + }, + outbound: matrixOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) { + return []; + } + return [ + { + channel: "matrix", + accountId: account.accountId, + kind: "runtime", + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + baseUrl: snapshot.baseUrl ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ timeoutMs, cfg }) => { + try { + const auth = await resolveMatrixAuth({ cfg: cfg as CoreConfig }); + return await probeMatrix({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + userId: auth.userId, + timeoutMs, + }); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : String(err), + elapsedMs: 0, + }; + } + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + baseUrl: account.homeserver, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastProbeAt: runtime?.lastProbeAt ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.homeserver, + }); + ctx.log?.info(`[${account.accountId}] starting provider (${account.homeserver ?? "matrix"})`); + // Lazy import: the monitor pulls the reply pipeline; avoid ESM init cycles. + const { monitorMatrixProvider } = await import("./matrix/index.js"); + return monitorMatrixProvider({ + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + mediaMaxMb: account.config.mediaMaxMb, + initialSyncLimit: account.config.initialSyncLimit, + replyToMode: account.config.replyToMode, + accountId: account.accountId, + }); + }, + }, +}; diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d08fc73b137da3e2b3dc1e7346a683c36cf3f76 --- /dev/null +++ b/extensions/matrix/src/config-schema.ts @@ -0,0 +1,62 @@ +import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const matrixActionSchema = z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + }) + .optional(); + +const matrixDmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + }) + .optional(); + +const matrixRoomSchema = z + .object({ + enabled: z.boolean().optional(), + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + autoReply: z.boolean().optional(), + users: z.array(allowFromEntry).optional(), + skills: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .optional(); + +export const MatrixConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + homeserver: z.string().optional(), + userId: z.string().optional(), + accessToken: z.string().optional(), + password: z.string().optional(), + deviceName: z.string().optional(), + initialSyncLimit: z.number().optional(), + encryption: z.boolean().optional(), + allowlistOnly: z.boolean().optional(), + groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(), + replyToMode: z.enum(["off", "first", "all"]).optional(), + threadReplies: z.enum(["off", "inbound", "always"]).optional(), + textChunkLimit: z.number().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + mediaMaxMb: z.number().optional(), + autoJoin: z.enum(["always", "allowlist", "off"]).optional(), + autoJoinAllowlist: z.array(allowFromEntry).optional(), + groupAllowFrom: z.array(allowFromEntry).optional(), + dm: matrixDmSchema, + groups: z.object({}).catchall(matrixRoomSchema).optional(), + rooms: z.object({}).catchall(matrixRoomSchema).optional(), + actions: matrixActionSchema, +}); diff --git a/extensions/matrix/src/directory-live.ts b/extensions/matrix/src/directory-live.ts new file mode 100644 index 0000000000000000000000000000000000000000..e43a7c099a6cdad7c6d2ee09730313e634042157 --- /dev/null +++ b/extensions/matrix/src/directory-live.ts @@ -0,0 +1,188 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { resolveMatrixAuth } from "./matrix/client.js"; + +type MatrixUserResult = { + user_id?: string; + display_name?: string; +}; + +type MatrixUserDirectoryResponse = { + results?: MatrixUserResult[]; +}; + +type MatrixJoinedRoomsResponse = { + joined_rooms?: string[]; +}; + +type MatrixRoomNameState = { + name?: string; +}; + +type MatrixAliasLookup = { + room_id?: string; +}; + +async function fetchMatrixJson(params: { + homeserver: string; + path: string; + accessToken: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise { + const res = await fetch(`${params.homeserver}${params.path}`, { + method: params.method ?? "GET", + headers: { + Authorization: `Bearer ${params.accessToken}`, + "Content-Type": "application/json", + }, + body: params.body ? JSON.stringify(params.body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Matrix API ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim().toLowerCase() ?? ""; +} + +export async function listMatrixDirectoryPeersLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) { + return []; + } + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const res = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/user_directory/search", + method: "POST", + body: { + search_term: query, + limit: typeof params.limit === "number" && params.limit > 0 ? params.limit : 20, + }, + }); + const results = res.results ?? []; + return results + .map((entry) => { + const userId = entry.user_id?.trim(); + if (!userId) { + return null; + } + return { + kind: "user", + id: userId, + name: entry.display_name?.trim() || undefined, + handle: entry.display_name ? `@${entry.display_name.trim()}` : undefined, + raw: entry, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; +} + +async function resolveMatrixRoomAlias( + homeserver: string, + accessToken: string, + alias: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + }); + return res.room_id?.trim() || null; + } catch { + return null; + } +} + +async function fetchMatrixRoomName( + homeserver: string, + accessToken: string, + roomId: string, +): Promise { + try { + const res = await fetchMatrixJson({ + homeserver, + accessToken, + path: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`, + }); + return res.name?.trim() || null; + } catch { + return null; + } +} + +export async function listMatrixDirectoryGroupsLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) { + return []; + } + const auth = await resolveMatrixAuth({ cfg: params.cfg as never }); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + + if (query.startsWith("#")) { + const roomId = await resolveMatrixRoomAlias(auth.homeserver, auth.accessToken, query); + if (!roomId) { + return []; + } + return [ + { + kind: "group", + id: roomId, + name: query, + handle: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + if (query.startsWith("!")) { + return [ + { + kind: "group", + id: query, + name: query, + } satisfies ChannelDirectoryEntry, + ]; + } + + const joined = await fetchMatrixJson({ + homeserver: auth.homeserver, + accessToken: auth.accessToken, + path: "/_matrix/client/v3/joined_rooms", + }); + const rooms = joined.joined_rooms ?? []; + const results: ChannelDirectoryEntry[] = []; + + for (const roomId of rooms) { + const name = await fetchMatrixRoomName(auth.homeserver, auth.accessToken, roomId); + if (!name) { + continue; + } + if (!name.toLowerCase().includes(query)) { + continue; + } + results.push({ + kind: "group", + id: roomId, + name, + handle: `#${name}`, + }); + if (results.length >= limit) { + break; + } + } + + return results; +} diff --git a/extensions/matrix/src/group-mentions.ts b/extensions/matrix/src/group-mentions.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5b970021ba3244fc83140c6e6638b40f47a69ed --- /dev/null +++ b/extensions/matrix/src/group-mentions.ts @@ -0,0 +1,66 @@ +import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; +import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js"; + +export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): boolean { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + const lower = roomId.toLowerCase(); + if (lower.startsWith("matrix:")) { + roomId = roomId.slice("matrix:".length).trim(); + } + if (roomId.toLowerCase().startsWith("channel:")) { + roomId = roomId.slice("channel:".length).trim(); + } + if (roomId.toLowerCase().startsWith("room:")) { + roomId = roomId.slice("room:".length).trim(); + } + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const resolved = resolveMatrixRoomConfig({ + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; + if (resolved) { + if (resolved.autoReply === true) { + return false; + } + if (resolved.autoReply === false) { + return true; + } + if (typeof resolved.requireMention === "boolean") { + return resolved.requireMention; + } + } + return true; +} + +export function resolveMatrixGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const rawGroupId = params.groupId?.trim() ?? ""; + let roomId = rawGroupId; + const lower = roomId.toLowerCase(); + if (lower.startsWith("matrix:")) { + roomId = roomId.slice("matrix:".length).trim(); + } + if (roomId.toLowerCase().startsWith("channel:")) { + roomId = roomId.slice("channel:".length).trim(); + } + if (roomId.toLowerCase().startsWith("room:")) { + roomId = roomId.slice("room:".length).trim(); + } + const groupChannel = params.groupChannel?.trim() ?? ""; + const aliases = groupChannel ? [groupChannel] : []; + const cfg = params.cfg as CoreConfig; + const resolved = resolveMatrixRoomConfig({ + rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms, + roomId, + aliases, + name: groupChannel || undefined, + }).config; + return resolved?.tools; +} diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d453684756cdad7431a2d48e098b97ff9c3c9754 --- /dev/null +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../types.js"; +import { resolveMatrixAccount } from "./accounts.js"; + +vi.mock("./credentials.js", () => ({ + loadMatrixCredentials: () => null, + credentialsMatchConfig: () => false, +})); + +const envKeys = [ + "MATRIX_HOMESERVER", + "MATRIX_USER_ID", + "MATRIX_ACCESS_TOKEN", + "MATRIX_PASSWORD", + "MATRIX_DEVICE_NAME", +]; + +describe("resolveMatrixAccount", () => { + let prevEnv: Record = {}; + + beforeEach(() => { + prevEnv = {}; + for (const key of envKeys) { + prevEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of envKeys) { + const value = prevEnv[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it("treats access-token-only config as configured", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-access", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(true); + }); + + it("requires userId + password when no access token is set", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(false); + }); + + it("marks password auth as configured when userId is present", () => { + const cfg: CoreConfig = { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + password: "secret", + }, + }, + }; + + const account = resolveMatrixAccount({ cfg }); + expect(account.configured).toBe(true); + }); +}); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..99593b8a3c833e4752b351ba11bda05c5aff9b48 --- /dev/null +++ b/extensions/matrix/src/matrix/accounts.ts @@ -0,0 +1,65 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { CoreConfig, MatrixConfig } from "../types.js"; +import { resolveMatrixConfig } from "./client.js"; +import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; + +export type ResolvedMatrixAccount = { + accountId: string; + enabled: boolean; + name?: string; + configured: boolean; + homeserver?: string; + userId?: string; + config: MatrixConfig; +}; + +export function listMatrixAccountIds(_cfg: CoreConfig): string[] { + return [DEFAULT_ACCOUNT_ID]; +} + +export function resolveDefaultMatrixAccountId(cfg: CoreConfig): string { + const ids = listMatrixAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveMatrixAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedMatrixAccount { + const accountId = normalizeAccountId(params.accountId); + const base = params.cfg.channels?.matrix ?? {}; + const enabled = base.enabled !== false; + const resolved = resolveMatrixConfig(params.cfg, process.env); + const hasHomeserver = Boolean(resolved.homeserver); + const hasUserId = Boolean(resolved.userId); + const hasAccessToken = Boolean(resolved.accessToken); + const hasPassword = Boolean(resolved.password); + const hasPasswordAuth = hasUserId && hasPassword; + const stored = loadMatrixCredentials(process.env); + const hasStored = + stored && resolved.homeserver + ? credentialsMatchConfig(stored, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + : false; + const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored)); + return { + accountId, + enabled, + name: base.name?.trim() || undefined, + configured, + homeserver: resolved.homeserver || undefined, + userId: resolved.userId || undefined, + config: base, + }; +} + +export function listEnabledMatrixAccounts(cfg: CoreConfig): ResolvedMatrixAccount[] { + return listMatrixAccountIds(cfg) + .map((accountId) => resolveMatrixAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/matrix/src/matrix/actions.ts b/extensions/matrix/src/matrix/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..34d24b6dd3911a9f4dff49dcf69a94e26c90fdef --- /dev/null +++ b/extensions/matrix/src/matrix/actions.ts @@ -0,0 +1,15 @@ +export type { + MatrixActionClientOpts, + MatrixMessageSummary, + MatrixReactionSummary, +} from "./actions/types.js"; +export { + sendMatrixMessage, + editMatrixMessage, + deleteMatrixMessage, + readMatrixMessages, +} from "./actions/messages.js"; +export { listMatrixReactions, removeMatrixReactions } from "./actions/reactions.js"; +export { pinMatrixMessage, unpinMatrixMessage, listMatrixPins } from "./actions/pins.js"; +export { getMatrixMemberInfo, getMatrixRoomInfo } from "./actions/room.js"; +export { reactMatrixMessage } from "./send.js"; diff --git a/extensions/matrix/src/matrix/actions/client.ts b/extensions/matrix/src/matrix/actions/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9fe477db85800abff105287bac6ebda207d28b5 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/client.ts @@ -0,0 +1,57 @@ +import type { CoreConfig } from "../types.js"; +import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export async function resolveActionClient( + opts: MatrixActionClientOpts = {}, +): Promise { + ensureNodeRuntime(); + if (opts.client) { + return { client: opts.client, stopOnDone: false }; + } + const active = getActiveMatrixClient(); + if (active) { + return { client: active, stopOnDone: false }; + } + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth({ + cfg: getMatrixRuntime().config.loadConfig() as CoreConfig, + }); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off actions. + } + } + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9cfe37225960d23e063ccc74aa31cfe82f713d5 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -0,0 +1,128 @@ +import { resolveMatrixRoomId, sendMessageMatrix } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { summarizeMatrixRawEvent } from "./summary.js"; +import { + EventType, + MsgType, + RelationType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, +} from "./types.js"; + +export async function sendMatrixMessage( + to: string, + content: string, + opts: MatrixActionClientOpts & { + mediaUrl?: string; + replyToId?: string; + threadId?: string; + } = {}, +) { + return await sendMessageMatrix(to, content, { + mediaUrl: opts.mediaUrl, + replyToId: opts.replyToId, + threadId: opts.threadId, + client: opts.client, + timeoutMs: opts.timeoutMs, + }); +} + +export async function editMatrixMessage( + roomId: string, + messageId: string, + content: string, + opts: MatrixActionClientOpts = {}, +) { + const trimmed = content.trim(); + if (!trimmed) { + throw new Error("Matrix edit requires content"); + } + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const newContent = { + msgtype: MsgType.Text, + body: trimmed, + } satisfies RoomMessageEventContent; + const payload: RoomMessageEventContent = { + msgtype: MsgType.Text, + body: `* ${trimmed}`, + "m.new_content": newContent, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: messageId, + }, + }; + const eventId = await client.sendMessage(resolvedRoom, payload); + return { eventId: eventId ?? null }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function deleteMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { reason?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + await client.redactEvent(resolvedRoom, messageId, opts.reason); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function readMatrixMessages( + roomId: string, + opts: MatrixActionClientOpts & { + limit?: number; + before?: string; + after?: string; + } = {}, +): Promise<{ + messages: MatrixMessageSummary[]; + nextBatch?: string | null; + prevBatch?: string | null; +}> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 20; + const token = opts.before?.trim() || opts.after?.trim() || undefined; + const dir = opts.after ? "f" : "b"; + // @vector-im/matrix-bot-sdk uses doRequest for room messages + const res = (await client.doRequest( + "GET", + `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, + { + dir, + limit, + from: token, + }, + )) as { chunk: MatrixRawEvent[]; start?: string; end?: string }; + const messages = res.chunk + .filter((event) => event.type === EventType.RoomMessage) + .filter((event) => !event.unsigned?.redacted_because) + .map(summarizeMatrixRawEvent); + return { + messages, + nextBatch: res.end ?? null, + prevBatch: res.start ?? null, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/actions/pins.ts b/extensions/matrix/src/matrix/actions/pins.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d466db652f2f460f2384772071b9e6ef48ecb96 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/pins.ts @@ -0,0 +1,76 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { fetchEventSummary, readPinnedEvents } from "./summary.js"; +import { + EventType, + type MatrixActionClientOpts, + type MatrixMessageSummary, + type RoomPinnedEventsEventContent, +} from "./types.js"; + +export async function pinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const current = await readPinnedEvents(client, resolvedRoom); + const next = current.includes(messageId) ? current : [...current, messageId]; + const payload: RoomPinnedEventsEventContent = { pinned: next }; + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); + return { pinned: next }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function unpinMatrixMessage( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const current = await readPinnedEvents(client, resolvedRoom); + const next = current.filter((id) => id !== messageId); + const payload: RoomPinnedEventsEventContent = { pinned: next }; + await client.sendStateEvent(resolvedRoom, EventType.RoomPinnedEvents, "", payload); + return { pinned: next }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function listMatrixPins( + roomId: string, + opts: MatrixActionClientOpts = {}, +): Promise<{ pinned: string[]; events: MatrixMessageSummary[] }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const pinned = await readPinnedEvents(client, resolvedRoom); + const events = ( + await Promise.all( + pinned.map(async (eventId) => { + try { + return await fetchEventSummary(client, resolvedRoom, eventId); + } catch { + return null; + } + }), + ) + ).filter((event): event is MatrixMessageSummary => Boolean(event)); + return { pinned, events }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe80239609372cb3edc4e8cb3a0e6853bc010c41 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -0,0 +1,96 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { + EventType, + RelationType, + type MatrixActionClientOpts, + type MatrixRawEvent, + type MatrixReactionSummary, + type ReactionEventContent, +} from "./types.js"; + +export async function listMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { limit?: number } = {}, +): Promise { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const limit = + typeof opts.limit === "number" && Number.isFinite(opts.limit) + ? Math.max(1, Math.floor(opts.limit)) + : 100; + // @vector-im/matrix-bot-sdk uses doRequest for relations + const res = (await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit }, + )) as { chunk: MatrixRawEvent[] }; + const summaries = new Map(); + for (const event of res.chunk) { + const content = event.content as ReactionEventContent; + const key = content["m.relates_to"]?.key; + if (!key) { + continue; + } + const sender = event.sender ?? ""; + const entry: MatrixReactionSummary = summaries.get(key) ?? { + key, + count: 0, + users: [], + }; + entry.count += 1; + if (sender && !entry.users.includes(sender)) { + entry.users.push(sender); + } + summaries.set(key, entry); + } + return Array.from(summaries.values()); + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function removeMatrixReactions( + roomId: string, + messageId: string, + opts: MatrixActionClientOpts & { emoji?: string } = {}, +): Promise<{ removed: number }> { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + const res = (await client.doRequest( + "GET", + `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, + { dir: "b", limit: 200 }, + )) as { chunk: MatrixRawEvent[] }; + const userId = await client.getUserId(); + if (!userId) { + return { removed: 0 }; + } + const targetEmoji = opts.emoji?.trim(); + const toRemove = res.chunk + .filter((event) => event.sender === userId) + .filter((event) => { + if (!targetEmoji) { + return true; + } + const content = event.content as ReactionEventContent; + return content["m.relates_to"]?.key === targetEmoji; + }) + .map((event) => event.event_id) + .filter((id): id is string => Boolean(id)); + if (toRemove.length === 0) { + return { removed: 0 }; + } + await Promise.all(toRemove.map((id) => client.redactEvent(resolvedRoom, id))); + return { removed: toRemove.length }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1770c7bc8d989c963f51de2037c3f50ce6f4426 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -0,0 +1,85 @@ +import { resolveMatrixRoomId } from "../send.js"; +import { resolveActionClient } from "./client.js"; +import { EventType, type MatrixActionClientOpts } from "./types.js"; + +export async function getMatrixMemberInfo( + userId: string, + opts: MatrixActionClientOpts & { roomId?: string } = {}, +) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; + // @vector-im/matrix-bot-sdk uses getUserProfile + const profile = await client.getUserProfile(userId); + // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk + // We'd need to fetch room state separately if needed + return { + userId, + profile: { + displayName: profile?.displayname ?? null, + avatarUrl: profile?.avatar_url ?? null, + }, + membership: null, // Would need separate room state query + powerLevel: null, // Would need separate power levels state query + displayName: profile?.displayname ?? null, + roomId: roomId ?? null, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClientOpts = {}) { + const { client, stopOnDone } = await resolveActionClient(opts); + try { + const resolvedRoom = await resolveMatrixRoomId(client, roomId); + // @vector-im/matrix-bot-sdk uses getRoomState for state events + let name: string | null = null; + let topic: string | null = null; + let canonicalAlias: string | null = null; + let memberCount: number | null = null; + + try { + const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", ""); + name = nameState?.name ?? null; + } catch { + // ignore + } + + try { + const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, ""); + topic = topicState?.topic ?? null; + } catch { + // ignore + } + + try { + const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", ""); + canonicalAlias = aliasState?.alias ?? null; + } catch { + // ignore + } + + try { + const members = await client.getJoinedRoomMembers(resolvedRoom); + memberCount = members.length; + } catch { + // ignore + } + + return { + roomId: resolvedRoom, + name, + topic, + canonicalAlias, + altAliases: [], // Would need separate query + memberCount, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts new file mode 100644 index 0000000000000000000000000000000000000000..d200e99273737dfb0f3e9f76f83eb4889cae5b82 --- /dev/null +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -0,0 +1,75 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { + EventType, + type MatrixMessageSummary, + type MatrixRawEvent, + type RoomMessageEventContent, + type RoomPinnedEventsEventContent, +} from "./types.js"; + +export function summarizeMatrixRawEvent(event: MatrixRawEvent): MatrixMessageSummary { + const content = event.content as RoomMessageEventContent; + const relates = content["m.relates_to"]; + let relType: string | undefined; + let eventId: string | undefined; + if (relates) { + if ("rel_type" in relates) { + relType = relates.rel_type; + eventId = relates.event_id; + } else if ("m.in_reply_to" in relates) { + eventId = relates["m.in_reply_to"]?.event_id; + } + } + const relatesTo = + relType || eventId + ? { + relType, + eventId, + } + : undefined; + return { + eventId: event.event_id, + sender: event.sender, + body: content.body, + msgtype: content.msgtype, + timestamp: event.origin_server_ts, + relatesTo, + }; +} + +export async function readPinnedEvents(client: MatrixClient, roomId: string): Promise { + try { + const content = (await client.getRoomStateEvent( + roomId, + EventType.RoomPinnedEvents, + "", + )) as RoomPinnedEventsEventContent; + const pinned = content.pinned; + return pinned.filter((id) => id.trim().length > 0); + } catch (err: unknown) { + const errObj = err as { statusCode?: number; body?: { errcode?: string } }; + const httpStatus = errObj.statusCode; + const errcode = errObj.body?.errcode; + if (httpStatus === 404 || errcode === "M_NOT_FOUND") { + return []; + } + throw err; + } +} + +export async function fetchEventSummary( + client: MatrixClient, + roomId: string, + eventId: string, +): Promise { + try { + const raw = (await client.getEvent(roomId, eventId)) as MatrixRawEvent; + if (raw.unsigned?.redacted_because) { + return null; + } + return summarizeMatrixRawEvent(raw); + } catch { + // Event not found, redacted, or inaccessible - return null + return null; + } +} diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..75fddbd9cf90dcf3e31ef52b6702e8410cd373ae --- /dev/null +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -0,0 +1,84 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +export const MsgType = { + Text: "m.text", +} as const; + +export const RelationType = { + Replace: "m.replace", + Annotation: "m.annotation", +} as const; + +export const EventType = { + RoomMessage: "m.room.message", + RoomPinnedEvents: "m.room.pinned_events", + RoomTopic: "m.room.topic", + Reaction: "m.reaction", +} as const; + +export type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.new_content"?: RoomMessageEventContent; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: string; + event_id: string; + key: string; + }; +}; + +export type RoomPinnedEventsEventContent = { + pinned: string[]; +}; + +export type RoomTopicEventContent = { + topic?: string; +}; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + redacted_because?: unknown; + }; +}; + +export type MatrixActionClientOpts = { + client?: MatrixClient; + timeoutMs?: number; +}; + +export type MatrixMessageSummary = { + eventId?: string; + sender?: string; + body?: string; + msgtype?: string; + timestamp?: number; + relatesTo?: { + relType?: string; + eventId?: string; + key?: string; + }; +}; + +export type MatrixReactionSummary = { + key: string; + count: number; + users: string[]; +}; + +export type MatrixActionClient = { + client: MatrixClient; + stopOnDone: boolean; +}; diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ff54092673d1f8e58236b41ae3f098f871b7710 --- /dev/null +++ b/extensions/matrix/src/matrix/active-client.ts @@ -0,0 +1,11 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +let activeClient: MatrixClient | null = null; + +export function setActiveMatrixClient(client: MatrixClient | null): void { + activeClient = client; +} + +export function getActiveMatrixClient(): MatrixClient | null { + return activeClient; +} diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..69de112dbd5069a664e8dcf6ae8c419de7622d39 --- /dev/null +++ b/extensions/matrix/src/matrix/client.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { CoreConfig } from "../types.js"; +import { resolveMatrixConfig } from "./client.js"; + +describe("resolveMatrixConfig", () => { + it("prefers config over env", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://cfg.example.org", + userId: "@cfg:example.org", + accessToken: "cfg-token", + password: "cfg-pass", + deviceName: "CfgDevice", + initialSyncLimit: 5, + }, + }, + } as CoreConfig; + const env = { + MATRIX_HOMESERVER: "https://env.example.org", + MATRIX_USER_ID: "@env:example.org", + MATRIX_ACCESS_TOKEN: "env-token", + MATRIX_PASSWORD: "env-pass", + MATRIX_DEVICE_NAME: "EnvDevice", + } as NodeJS.ProcessEnv; + const resolved = resolveMatrixConfig(cfg, env); + expect(resolved).toEqual({ + homeserver: "https://cfg.example.org", + userId: "@cfg:example.org", + accessToken: "cfg-token", + password: "cfg-pass", + deviceName: "CfgDevice", + initialSyncLimit: 5, + encryption: false, + }); + }); + + it("uses env when config is missing", () => { + const cfg = {} as CoreConfig; + const env = { + MATRIX_HOMESERVER: "https://env.example.org", + MATRIX_USER_ID: "@env:example.org", + MATRIX_ACCESS_TOKEN: "env-token", + MATRIX_PASSWORD: "env-pass", + MATRIX_DEVICE_NAME: "EnvDevice", + } as NodeJS.ProcessEnv; + const resolved = resolveMatrixConfig(cfg, env); + expect(resolved.homeserver).toBe("https://env.example.org"); + expect(resolved.userId).toBe("@env:example.org"); + expect(resolved.accessToken).toBe("env-token"); + expect(resolved.password).toBe("env-pass"); + expect(resolved.deviceName).toBe("EnvDevice"); + expect(resolved.initialSyncLimit).toBeUndefined(); + expect(resolved.encryption).toBe(false); + }); +}); diff --git a/extensions/matrix/src/matrix/client.ts b/extensions/matrix/src/matrix/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d35cde2e2947b9ac3d84e924bf6313388fe84d9 --- /dev/null +++ b/extensions/matrix/src/matrix/client.ts @@ -0,0 +1,5 @@ +export type { MatrixAuth, MatrixResolvedConfig } from "./client/types.js"; +export { isBunRuntime } from "./client/runtime.js"; +export { resolveMatrixConfig, resolveMatrixAuth } from "./client/config.js"; +export { createMatrixClient } from "./client/create-client.js"; +export { resolveSharedMatrixClient, waitForMatrixSync, stopSharedClient } from "./client/shared.js"; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c6c0da66b5fcef80193e64e53a9c8d30e1aa5c8 --- /dev/null +++ b/extensions/matrix/src/matrix/client/config.ts @@ -0,0 +1,160 @@ +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { CoreConfig } from "../types.js"; +import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; + +function clean(value?: string): string { + return value?.trim() ?? ""; +} + +export function resolveMatrixConfig( + cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig, + env: NodeJS.ProcessEnv = process.env, +): MatrixResolvedConfig { + const matrix = cfg.channels?.matrix ?? {}; + const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER); + const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID); + const accessToken = clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined; + const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined; + const deviceName = clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined; + const initialSyncLimit = + typeof matrix.initialSyncLimit === "number" + ? Math.max(0, Math.floor(matrix.initialSyncLimit)) + : undefined; + const encryption = matrix.encryption ?? false; + return { + homeserver, + userId, + accessToken, + password, + deviceName, + initialSyncLimit, + encryption, + }; +} + +export async function resolveMatrixAuth(params?: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; +}): Promise { + const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig); + const env = params?.env ?? process.env; + const resolved = resolveMatrixConfig(cfg, env); + if (!resolved.homeserver) { + throw new Error("Matrix homeserver is required (matrix.homeserver)"); + } + + const { + loadMatrixCredentials, + saveMatrixCredentials, + credentialsMatchConfig, + touchMatrixCredentials, + } = await import("../credentials.js"); + + const cached = loadMatrixCredentials(env); + const cachedCredentials = + cached && + credentialsMatchConfig(cached, { + homeserver: resolved.homeserver, + userId: resolved.userId || "", + }) + ? cached + : null; + + // If we have an access token, we can fetch userId via whoami if not provided + if (resolved.accessToken) { + let userId = resolved.userId; + if (!userId) { + // Fetch userId from access token via whoami + ensureMatrixSdkLoggingConfigured(); + const tempClient = new MatrixClient(resolved.homeserver, resolved.accessToken); + const whoami = await tempClient.getUserId(); + userId = whoami; + // Save the credentials with the fetched userId + saveMatrixCredentials({ + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + }); + } else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) { + touchMatrixCredentials(env); + } + return { + homeserver: resolved.homeserver, + userId, + accessToken: resolved.accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + if (cachedCredentials) { + touchMatrixCredentials(env); + return { + homeserver: cachedCredentials.homeserver, + userId: cachedCredentials.userId, + accessToken: cachedCredentials.accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + } + + if (!resolved.userId) { + throw new Error("Matrix userId is required when no access token is configured (matrix.userId)"); + } + + if (!resolved.password) { + throw new Error( + "Matrix password is required when no access token is configured (matrix.password)", + ); + } + + // Login with password using HTTP API + const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { type: "m.id.user", user: resolved.userId }, + password: resolved.password, + initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway", + }), + }); + + if (!loginResponse.ok) { + const errorText = await loginResponse.text(); + throw new Error(`Matrix login failed: ${errorText}`); + } + + const login = (await loginResponse.json()) as { + access_token?: string; + user_id?: string; + device_id?: string; + }; + + const accessToken = login.access_token?.trim(); + if (!accessToken) { + throw new Error("Matrix login did not return an access token"); + } + + const auth: MatrixAuth = { + homeserver: resolved.homeserver, + userId: login.user_id ?? resolved.userId, + accessToken, + deviceName: resolved.deviceName, + initialSyncLimit: resolved.initialSyncLimit, + encryption: resolved.encryption, + }; + + saveMatrixCredentials({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + deviceId: login.device_id, + }); + + return auth; +} diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2dc7eaf84a9b86d237c6b992e2e05e2ce449a50 --- /dev/null +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -0,0 +1,123 @@ +import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; +import { + LogService, + MatrixClient, + SimpleFsStorageProvider, + RustSdkCryptoStorageProvider, +} from "@vector-im/matrix-bot-sdk"; +import fs from "node:fs"; +import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; +import { + maybeMigrateLegacyStorage, + resolveMatrixStoragePaths, + writeStorageMeta, +} from "./storage.js"; + +function sanitizeUserIdList(input: unknown, label: string): string[] { + if (input == null) { + return []; + } + if (!Array.isArray(input)) { + LogService.warn( + "MatrixClientLite", + `Expected ${label} list to be an array, got ${typeof input}`, + ); + return []; + } + const filtered = input.filter( + (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, + ); + if (filtered.length !== input.length) { + LogService.warn( + "MatrixClientLite", + `Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`, + ); + } + return filtered; +} + +export async function createMatrixClient(params: { + homeserver: string; + userId: string; + accessToken: string; + encryption?: boolean; + localTimeoutMs?: number; + accountId?: string | null; +}): Promise { + ensureMatrixSdkLoggingConfigured(); + const env = process.env; + + // Create storage provider + const storagePaths = resolveMatrixStoragePaths({ + homeserver: params.homeserver, + userId: params.userId, + accessToken: params.accessToken, + accountId: params.accountId, + env, + }); + maybeMigrateLegacyStorage({ storagePaths, env }); + fs.mkdirSync(storagePaths.rootDir, { recursive: true }); + const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath); + + // Create crypto storage if encryption is enabled + let cryptoStorage: ICryptoStorageProvider | undefined; + if (params.encryption) { + fs.mkdirSync(storagePaths.cryptoPath, { recursive: true }); + + try { + const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs"); + cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite); + } catch (err) { + LogService.warn( + "MatrixClientLite", + "Failed to initialize crypto storage, E2EE disabled:", + err, + ); + } + } + + writeStorageMeta({ + storagePaths, + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId, + }); + + const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage); + + if (client.crypto) { + const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto); + client.crypto.updateSyncData = async ( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + changedDeviceLists, + leftDeviceLists, + ) => { + const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list"); + const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list"); + try { + return await originalUpdateSyncData( + toDeviceMessages, + otkCounts, + unusedFallbackKeyAlgs, + safeChanged, + safeLeft, + ); + } catch (err) { + const message = typeof err === "string" ? err : err instanceof Error ? err.message : ""; + if (message.includes("Expect value to be String")) { + LogService.warn( + "MatrixClientLite", + "Ignoring malformed device list entries during crypto sync", + message, + ); + return; + } + throw err; + } + }; + } + + return client; +} diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5ef702b019fa67bb871f2adf95133aa848612a6 --- /dev/null +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -0,0 +1,36 @@ +import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk"; + +let matrixSdkLoggingConfigured = false; +const matrixSdkBaseLogger = new ConsoleLogger(); + +function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean { + if (module !== "MatrixHttpClient") { + return false; + } + return messageOrObject.some((entry) => { + if (!entry || typeof entry !== "object") { + return false; + } + return (entry as { errcode?: string }).errcode === "M_NOT_FOUND"; + }); +} + +export function ensureMatrixSdkLoggingConfigured(): void { + if (matrixSdkLoggingConfigured) { + return; + } + matrixSdkLoggingConfigured = true; + + LogService.setLogger({ + trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject), + debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject), + info: (module, ...messageOrObject) => matrixSdkBaseLogger.info(module, ...messageOrObject), + warn: (module, ...messageOrObject) => matrixSdkBaseLogger.warn(module, ...messageOrObject), + error: (module, ...messageOrObject) => { + if (shouldSuppressMatrixHttpNotFound(module, messageOrObject)) { + return; + } + matrixSdkBaseLogger.error(module, ...messageOrObject); + }, + }); +} diff --git a/extensions/matrix/src/matrix/client/runtime.ts b/extensions/matrix/src/matrix/client/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..4995eaf8d5cb3b54a35d56b1cc7c04d12e24fbd5 --- /dev/null +++ b/extensions/matrix/src/matrix/client/runtime.ts @@ -0,0 +1,4 @@ +export function isBunRuntime(): boolean { + const versions = process.versions as { bun?: string }; + return typeof versions.bun === "string"; +} diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..201eb5bbdb2e2e10a589d0e0e48ce71ae9dbf2bc --- /dev/null +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -0,0 +1,170 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { LogService } from "@vector-im/matrix-bot-sdk"; +import type { CoreConfig } from "../types.js"; +import type { MatrixAuth } from "./types.js"; +import { resolveMatrixAuth } from "./config.js"; +import { createMatrixClient } from "./create-client.js"; +import { DEFAULT_ACCOUNT_KEY } from "./storage.js"; + +type SharedMatrixClientState = { + client: MatrixClient; + key: string; + started: boolean; + cryptoReady: boolean; +}; + +let sharedClientState: SharedMatrixClientState | null = null; +let sharedClientPromise: Promise | null = null; +let sharedClientStartPromise: Promise | null = null; + +function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string { + return [ + auth.homeserver, + auth.userId, + auth.accessToken, + auth.encryption ? "e2ee" : "plain", + accountId ?? DEFAULT_ACCOUNT_KEY, + ].join("|"); +} + +async function createSharedMatrixClient(params: { + auth: MatrixAuth; + timeoutMs?: number; + accountId?: string | null; +}): Promise { + const client = await createMatrixClient({ + homeserver: params.auth.homeserver, + userId: params.auth.userId, + accessToken: params.auth.accessToken, + encryption: params.auth.encryption, + localTimeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + return { + client, + key: buildSharedClientKey(params.auth, params.accountId), + started: false, + cryptoReady: false, + }; +} + +async function ensureSharedClientStarted(params: { + state: SharedMatrixClientState; + timeoutMs?: number; + initialSyncLimit?: number; + encryption?: boolean; +}): Promise { + if (params.state.started) { + return; + } + if (sharedClientStartPromise) { + await sharedClientStartPromise; + return; + } + sharedClientStartPromise = (async () => { + const client = params.state.client; + + // Initialize crypto if enabled + if (params.encryption && !params.state.cryptoReady) { + try { + const joinedRooms = await client.getJoinedRooms(); + if (client.crypto) { + await client.crypto.prepare(joinedRooms); + params.state.cryptoReady = true; + } + } catch (err) { + LogService.warn("MatrixClientLite", "Failed to prepare crypto:", err); + } + } + + await client.start(); + params.state.started = true; + })(); + try { + await sharedClientStartPromise; + } finally { + sharedClientStartPromise = null; + } +} + +export async function resolveSharedMatrixClient( + params: { + cfg?: CoreConfig; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + auth?: MatrixAuth; + startClient?: boolean; + accountId?: string | null; + } = {}, +): Promise { + const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env })); + const key = buildSharedClientKey(auth, params.accountId); + const shouldStart = params.startClient !== false; + + if (sharedClientState?.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: sharedClientState, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return sharedClientState.client; + } + + if (sharedClientPromise) { + const pending = await sharedClientPromise; + if (pending.key === key) { + if (shouldStart) { + await ensureSharedClientStarted({ + state: pending, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return pending.client; + } + pending.client.stop(); + sharedClientState = null; + sharedClientPromise = null; + } + + sharedClientPromise = createSharedMatrixClient({ + auth, + timeoutMs: params.timeoutMs, + accountId: params.accountId, + }); + try { + const created = await sharedClientPromise; + sharedClientState = created; + if (shouldStart) { + await ensureSharedClientStarted({ + state: created, + timeoutMs: params.timeoutMs, + initialSyncLimit: auth.initialSyncLimit, + encryption: auth.encryption, + }); + } + return created.client; + } finally { + sharedClientPromise = null; + } +} + +export async function waitForMatrixSync(_params: { + client: MatrixClient; + timeoutMs?: number; + abortSignal?: AbortSignal; +}): Promise { + // @vector-im/matrix-bot-sdk handles sync internally in start() + // This is kept for API compatibility but is essentially a no-op now +} + +export function stopSharedClient(): void { + if (sharedClientState) { + sharedClientState.client.stop(); + sharedClientState = null; + } +} diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c9dfbf3371014fc3923579e0de6c65d8c4ba481 --- /dev/null +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -0,0 +1,131 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { MatrixStoragePaths } from "./types.js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export const DEFAULT_ACCOUNT_KEY = "default"; +const STORAGE_META_FILENAME = "storage-meta.json"; + +function sanitizePathSegment(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/^_+|_+$/g, ""); + return cleaned || "unknown"; +} + +function resolveHomeserverKey(homeserver: string): string { + try { + const url = new URL(homeserver); + if (url.host) { + return sanitizePathSegment(url.host); + } + } catch { + // fall through + } + return sanitizePathSegment(homeserver); +} + +function hashAccessToken(accessToken: string): string { + return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16); +} + +function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): { + storagePath: string; + cryptoPath: string; +} { + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return { + storagePath: path.join(stateDir, "matrix", "bot-storage.json"), + cryptoPath: path.join(stateDir, "matrix", "crypto"), + }; +} + +export function resolveMatrixStoragePaths(params: { + homeserver: string; + userId: string; + accessToken: string; + accountId?: string | null; + env?: NodeJS.ProcessEnv; +}): MatrixStoragePaths { + const env = params.env ?? process.env; + const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir); + const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY); + const userKey = sanitizePathSegment(params.userId); + const serverKey = resolveHomeserverKey(params.homeserver); + const tokenHash = hashAccessToken(params.accessToken); + const rootDir = path.join( + stateDir, + "matrix", + "accounts", + accountKey, + `${serverKey}__${userKey}`, + tokenHash, + ); + return { + rootDir, + storagePath: path.join(rootDir, "bot-storage.json"), + cryptoPath: path.join(rootDir, "crypto"), + metaPath: path.join(rootDir, STORAGE_META_FILENAME), + accountKey, + tokenHash, + }; +} + +export function maybeMigrateLegacyStorage(params: { + storagePaths: MatrixStoragePaths; + env?: NodeJS.ProcessEnv; +}): void { + const legacy = resolveLegacyStoragePaths(params.env); + const hasLegacyStorage = fs.existsSync(legacy.storagePath); + const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath); + const hasNewStorage = + fs.existsSync(params.storagePaths.storagePath) || fs.existsSync(params.storagePaths.cryptoPath); + + if (!hasLegacyStorage && !hasLegacyCrypto) { + return; + } + if (hasNewStorage) { + return; + } + + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + if (hasLegacyStorage) { + try { + fs.renameSync(legacy.storagePath, params.storagePaths.storagePath); + } catch { + // Ignore migration failures; new store will be created. + } + } + if (hasLegacyCrypto) { + try { + fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath); + } catch { + // Ignore migration failures; new store will be created. + } + } +} + +export function writeStorageMeta(params: { + storagePaths: MatrixStoragePaths; + homeserver: string; + userId: string; + accountId?: string | null; +}): void { + try { + const payload = { + homeserver: params.homeserver, + userId: params.userId, + accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY, + accessTokenHash: params.storagePaths.tokenHash, + createdAt: new Date().toISOString(), + }; + fs.mkdirSync(params.storagePaths.rootDir, { recursive: true }); + fs.writeFileSync(params.storagePaths.metaPath, JSON.stringify(payload, null, 2), "utf-8"); + } catch { + // ignore meta write failures + } +} diff --git a/extensions/matrix/src/matrix/client/types.ts b/extensions/matrix/src/matrix/client/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec1b3002bc72953354c9806cd9d1f74f89b8e8a1 --- /dev/null +++ b/extensions/matrix/src/matrix/client/types.ts @@ -0,0 +1,34 @@ +export type MatrixResolvedConfig = { + homeserver: string; + userId: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +/** + * Authenticated Matrix configuration. + * Note: deviceId is NOT included here because it's implicit in the accessToken. + * The crypto storage assumes the device ID (and thus access token) does not change + * between restarts. If the access token becomes invalid or crypto storage is lost, + * both will need to be recreated together. + */ +export type MatrixAuth = { + homeserver: string; + userId: string; + accessToken: string; + deviceName?: string; + initialSyncLimit?: number; + encryption?: boolean; +}; + +export type MatrixStoragePaths = { + rootDir: string; + storagePath: string; + cryptoPath: string; + metaPath: string; + accountKey: string; + tokenHash: string; +}; diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts new file mode 100644 index 0000000000000000000000000000000000000000..04072dc72f1d7f49114297579519ad21487f81d1 --- /dev/null +++ b/extensions/matrix/src/matrix/credentials.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { getMatrixRuntime } from "../runtime.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; + createdAt: string; + lastUsedAt?: string; +}; + +const CREDENTIALS_FILENAME = "credentials.json"; + +export function resolveMatrixCredentialsDir( + env: NodeJS.ProcessEnv = process.env, + stateDir?: string, +): string { + const resolvedStateDir = stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir); + return path.join(resolvedStateDir, "credentials", "matrix"); +} + +export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string { + const dir = resolveMatrixCredentialsDir(env); + return path.join(dir, CREDENTIALS_FILENAME); +} + +export function loadMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, +): MatrixStoredCredentials | null { + const credPath = resolveMatrixCredentialsPath(env); + try { + if (!fs.existsSync(credPath)) { + return null; + } + const raw = fs.readFileSync(credPath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return parsed as MatrixStoredCredentials; + } catch { + return null; + } +} + +export function saveMatrixCredentials( + credentials: Omit, + env: NodeJS.ProcessEnv = process.env, +): void { + const dir = resolveMatrixCredentialsDir(env); + fs.mkdirSync(dir, { recursive: true }); + + const credPath = resolveMatrixCredentialsPath(env); + + const existing = loadMatrixCredentials(env); + const now = new Date().toISOString(); + + const toSave: MatrixStoredCredentials = { + ...credentials, + createdAt: existing?.createdAt ?? now, + lastUsedAt: now, + }; + + fs.writeFileSync(credPath, JSON.stringify(toSave, null, 2), "utf-8"); +} + +export function touchMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { + const existing = loadMatrixCredentials(env); + if (!existing) { + return; + } + + existing.lastUsedAt = new Date().toISOString(); + const credPath = resolveMatrixCredentialsPath(env); + fs.writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8"); +} + +export function clearMatrixCredentials(env: NodeJS.ProcessEnv = process.env): void { + const credPath = resolveMatrixCredentialsPath(env); + try { + if (fs.existsSync(credPath)) { + fs.unlinkSync(credPath); + } + } catch { + // ignore + } +} + +export function credentialsMatchConfig( + stored: MatrixStoredCredentials, + config: { homeserver: string; userId: string }, +): boolean { + // If userId is empty (token-based auth), only match homeserver + if (!config.userId) { + return stored.homeserver === config.homeserver; + } + return stored.homeserver === config.homeserver && stored.userId === config.userId; +} diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts new file mode 100644 index 0000000000000000000000000000000000000000..67fb5244a1136e2c7407b659ec9b46ed5d8f710e --- /dev/null +++ b/extensions/matrix/src/matrix/deps.ts @@ -0,0 +1,60 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getMatrixRuntime } from "../runtime.js"; + +const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; + +export function isMatrixSdkAvailable(): boolean { + try { + const req = createRequire(import.meta.url); + req.resolve(MATRIX_SDK_PACKAGE); + return true; + } catch { + return false; + } +} + +function resolvePluginRoot(): string { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve(currentDir, "..", ".."); +} + +export async function ensureMatrixSdkInstalled(params: { + runtime: RuntimeEnv; + confirm?: (message: string) => Promise; +}): Promise { + if (isMatrixSdkAvailable()) { + return; + } + const confirm = params.confirm; + if (confirm) { + const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?"); + if (!ok) { + throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first)."); + } + } + + const root = resolvePluginRoot(); + const command = fs.existsSync(path.join(root, "pnpm-lock.yaml")) + ? ["pnpm", "install"] + : ["npm", "install", "--omit=dev", "--silent"]; + params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`); + const result = await getMatrixRuntime().system.runCommandWithTimeout(command, { + cwd: root, + timeoutMs: 300_000, + env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, + }); + if (result.code !== 0) { + throw new Error( + result.stderr.trim() || result.stdout.trim() || "Matrix dependency install failed.", + ); + } + if (!isMatrixSdkAvailable()) { + throw new Error( + "Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.", + ); + } +} diff --git a/extensions/matrix/src/matrix/format.test.ts b/extensions/matrix/src/matrix/format.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4538c2792e24cf075f0d6404589e5277d034b64c --- /dev/null +++ b/extensions/matrix/src/matrix/format.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { markdownToMatrixHtml } from "./format.js"; + +describe("markdownToMatrixHtml", () => { + it("renders basic inline formatting", () => { + const html = markdownToMatrixHtml("hi _there_ **boss** `code`"); + expect(html).toContain("there"); + expect(html).toContain("boss"); + expect(html).toContain("code"); + }); + + it("renders links as HTML", () => { + const html = markdownToMatrixHtml("see [docs](https://example.com)"); + expect(html).toContain('docs'); + }); + + it("escapes raw HTML", () => { + const html = markdownToMatrixHtml("nope"); + expect(html).toContain("<b>nope</b>"); + expect(html).not.toContain("nope"); + }); + + it("flattens images into alt text", () => { + const html = markdownToMatrixHtml("![alt](https://example.com/img.png)"); + expect(html).toContain("alt"); + expect(html).not.toContain(" { + const html = markdownToMatrixHtml("line1\nline2"); + expect(html).toContain(" escapeHtml(tokens[idx]?.content ?? ""); + +md.renderer.rules.html_block = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); +md.renderer.rules.html_inline = (tokens, idx) => escapeHtml(tokens[idx]?.content ?? ""); + +export function markdownToMatrixHtml(markdown: string): string { + const rendered = md.render(markdown ?? ""); + return rendered.trimEnd(); +} diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cd75d8a1ae3094e6b4797fdd52c98624f53246c --- /dev/null +++ b/extensions/matrix/src/matrix/index.ts @@ -0,0 +1,11 @@ +export { monitorMatrixProvider } from "./monitor/index.js"; +export { probeMatrix } from "./probe.js"; +export { + reactMatrixMessage, + resolveMatrixRoomId, + sendReadReceiptMatrix, + sendMessageMatrix, + sendPollMatrix, + sendTypingMatrix, +} from "./send.js"; +export { resolveMatrixAuth, resolveSharedMatrixClient } from "./client.js"; diff --git a/extensions/matrix/src/matrix/monitor/allowlist.ts b/extensions/matrix/src/matrix/monitor/allowlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..b110dc9ef642e3faea2c6f572067ac25fbcddfc6 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/allowlist.ts @@ -0,0 +1,62 @@ +import type { AllowlistMatch } from "openclaw/plugin-sdk"; + +function normalizeAllowList(list?: Array) { + return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); +} + +export function normalizeAllowListLower(list?: Array) { + return normalizeAllowList(list).map((entry) => entry.toLowerCase()); +} + +function normalizeMatrixUser(raw?: string | null): string { + return (raw ?? "").trim().toLowerCase(); +} + +export type MatrixAllowListMatch = AllowlistMatch< + "wildcard" | "id" | "prefixed-id" | "prefixed-user" | "name" | "localpart" +>; + +export function resolveMatrixAllowListMatch(params: { + allowList: string[]; + userId?: string; + userName?: string; +}): MatrixAllowListMatch { + const allowList = params.allowList; + if (allowList.length === 0) { + return { allowed: false }; + } + if (allowList.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const userId = normalizeMatrixUser(params.userId); + const userName = normalizeMatrixUser(params.userName); + const localPart = userId.startsWith("@") ? (userId.slice(1).split(":")[0] ?? "") : ""; + const candidates: Array<{ value?: string; source: MatrixAllowListMatch["matchSource"] }> = [ + { value: userId, source: "id" }, + { value: userId ? `matrix:${userId}` : "", source: "prefixed-id" }, + { value: userId ? `user:${userId}` : "", source: "prefixed-user" }, + { value: userName, source: "name" }, + { value: localPart, source: "localpart" }, + ]; + for (const candidate of candidates) { + if (!candidate.value) { + continue; + } + if (allowList.includes(candidate.value)) { + return { + allowed: true, + matchKey: candidate.value, + matchSource: candidate.source, + }; + } + } + return { allowed: false }; +} + +export function resolveMatrixAllowListMatches(params: { + allowList: string[]; + userId?: string; + userName?: string; +}) { + return resolveMatrixAllowListMatch(params).allowed; +} diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fb36b93f176ee19b5e067b0611e88bd84b1abaf --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -0,0 +1,71 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk"; +import type { CoreConfig } from "../../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; + +export function registerMatrixAutoJoin(params: { + client: MatrixClient; + cfg: CoreConfig; + runtime: RuntimeEnv; +}) { + const { client, cfg, runtime } = params; + const core = getMatrixRuntime(); + const logVerbose = (message: string) => { + if (!core.logging.shouldLogVerbose()) { + return; + } + runtime.log?.(message); + }; + const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always"; + const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? []; + + if (autoJoin === "off") { + return; + } + + if (autoJoin === "always") { + // Use the built-in autojoin mixin for "always" mode + AutojoinRoomsMixin.setupOnClient(client); + logVerbose("matrix: auto-join enabled for all invites"); + return; + } + + // For "allowlist" mode, handle invites manually + client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => { + if (autoJoin !== "allowlist") { + return; + } + + // Get room alias if available + let alias: string | undefined; + let altAliases: string[] = []; + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + alias = aliasState?.alias; + altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : []; + } catch { + // Ignore errors + } + + const allowed = + autoJoinAllowlist.includes("*") || + autoJoinAllowlist.includes(roomId) || + (alias ? autoJoinAllowlist.includes(alias) : false) || + altAliases.some((value) => autoJoinAllowlist.includes(value)); + + if (!allowed) { + logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`); + return; + } + + try { + await client.joinRoom(roomId); + logVerbose(`matrix: joined room ${roomId}`); + } catch (err) { + runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`); + } + }); +} diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cd6e88758e2db6bc2ad3d82c5edabbe79e036d7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -0,0 +1,104 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +type DirectMessageCheck = { + roomId: string; + senderId?: string; + selfUserId?: string; +}; + +type DirectRoomTrackerOptions = { + log?: (message: string) => void; +}; + +const DM_CACHE_TTL_MS = 30_000; + +export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) { + const log = opts.log ?? (() => {}); + let lastDmUpdateMs = 0; + let cachedSelfUserId: string | null = null; + const memberCountCache = new Map(); + + const ensureSelfUserId = async (): Promise => { + if (cachedSelfUserId) { + return cachedSelfUserId; + } + try { + cachedSelfUserId = await client.getUserId(); + } catch { + cachedSelfUserId = null; + } + return cachedSelfUserId; + }; + + const refreshDmCache = async (): Promise => { + const now = Date.now(); + if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) { + return; + } + lastDmUpdateMs = now; + try { + await client.dms.update(); + } catch (err) { + log(`matrix: dm cache refresh failed (${String(err)})`); + } + }; + + const resolveMemberCount = async (roomId: string): Promise => { + const cached = memberCountCache.get(roomId); + const now = Date.now(); + if (cached && now - cached.ts < DM_CACHE_TTL_MS) { + return cached.count; + } + try { + const members = await client.getJoinedRoomMembers(roomId); + const count = members.length; + memberCountCache.set(roomId, { count, ts: now }); + return count; + } catch (err) { + log(`matrix: dm member count failed room=${roomId} (${String(err)})`); + return null; + } + }; + + const hasDirectFlag = async (roomId: string, userId?: string): Promise => { + const target = userId?.trim(); + if (!target) { + return false; + } + try { + const state = await client.getRoomStateEvent(roomId, "m.room.member", target); + return state?.is_direct === true; + } catch { + return false; + } + }; + + return { + isDirectMessage: async (params: DirectMessageCheck): Promise => { + const { roomId, senderId } = params; + await refreshDmCache(); + + if (client.dms.isDm(roomId)) { + log(`matrix: dm detected via m.direct room=${roomId}`); + return true; + } + + const memberCount = await resolveMemberCount(roomId); + if (memberCount === 2) { + log(`matrix: dm detected via member count room=${roomId} members=${memberCount}`); + return true; + } + + const selfUserId = params.selfUserId ?? (await ensureSelfUserId()); + const directViaState = + (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? "")); + if (directViaState) { + log(`matrix: dm detected via member state room=${roomId}`); + return true; + } + + log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`); + return false; + }, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..1faeffc819d3e838a105834f693b2a8c479572a7 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -0,0 +1,101 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import type { MatrixAuth } from "../client.js"; +import type { MatrixRawEvent } from "./types.js"; +import { EventType } from "./types.js"; + +export function registerMatrixMonitorEvents(params: { + client: MatrixClient; + auth: MatrixAuth; + logVerboseMessage: (message: string) => void; + warnedEncryptedRooms: Set; + warnedCryptoMissingRooms: Set; + logger: { warn: (meta: Record, message: string) => void }; + formatNativeDependencyHint: PluginRuntime["system"]["formatNativeDependencyHint"]; + onRoomMessage: (roomId: string, event: MatrixRawEvent) => void | Promise; +}): void { + const { + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint, + onRoomMessage, + } = params; + + client.on("room.message", onRoomMessage); + + client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: encrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on("room.decrypted_event", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const eventType = event?.type ?? "unknown"; + logVerboseMessage(`matrix: decrypted event room=${roomId} type=${eventType} id=${eventId}`); + }); + + client.on( + "room.failed_decryption", + async (roomId: string, event: MatrixRawEvent, error: Error) => { + logger.warn( + { roomId, eventId: event.event_id, error: error.message }, + "Failed to decrypt message", + ); + logVerboseMessage( + `matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} error=${error.message}`, + ); + }, + ); + + client.on("room.invite", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + const sender = event?.sender ?? "unknown"; + const isDirect = (event?.content as { is_direct?: boolean } | undefined)?.is_direct === true; + logVerboseMessage( + `matrix: invite room=${roomId} sender=${sender} direct=${String(isDirect)} id=${eventId}`, + ); + }); + + client.on("room.join", (roomId: string, event: MatrixRawEvent) => { + const eventId = event?.event_id ?? "unknown"; + logVerboseMessage(`matrix: join room=${roomId} id=${eventId}`); + }); + + client.on("room.event", (roomId: string, event: MatrixRawEvent) => { + const eventType = event?.type ?? "unknown"; + if (eventType === EventType.RoomMessageEncrypted) { + logVerboseMessage( + `matrix: encrypted raw event room=${roomId} id=${event?.event_id ?? "unknown"}`, + ); + if (auth.encryption !== true && !warnedEncryptedRooms.has(roomId)) { + warnedEncryptedRooms.add(roomId); + const warning = + "matrix: encrypted event received without encryption enabled; set channels.matrix.encryption=true and verify the device to decrypt"; + logger.warn({ roomId }, warning); + } + if (auth.encryption === true && !client.crypto && !warnedCryptoMissingRooms.has(roomId)) { + warnedCryptoMissingRooms.add(roomId); + const hint = formatNativeDependencyHint({ + packageName: "@matrix-org/matrix-sdk-crypto-nodejs", + manager: "pnpm", + downloadCommand: "node node_modules/@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js", + }); + const warning = `matrix: encryption enabled but crypto is unavailable; ${hint}`; + logger.warn({ roomId }, warning); + } + return; + } + if (eventType === EventType.RoomMember) { + const membership = (event?.content as { membership?: string } | undefined)?.membership; + const stateKey = (event as { state_key?: string }).state_key ?? ""; + logVerboseMessage( + `matrix: member event room=${roomId} stateKey=${stateKey} membership=${membership ?? "unknown"}`, + ); + } + }); +} diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f45f5ed38fbe14356e3a90fdee9a48663007bd8 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -0,0 +1,670 @@ +import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { + createReplyPrefixContext, + createTypingCallbacks, + formatAllowlistMatchMeta, + logInboundDrop, + logTypingFailure, + resolveControlCommandGate, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; +import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js"; +import { + formatPollAsText, + isPollStartType, + parsePollStartContent, + type PollStartContent, +} from "../poll-types.js"; +import { + reactMatrixMessage, + sendMessageMatrix, + sendReadReceiptMatrix, + sendTypingMatrix, +} from "../send.js"; +import { + resolveMatrixAllowListMatch, + resolveMatrixAllowListMatches, + normalizeAllowListLower, +} from "./allowlist.js"; +import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js"; +import { downloadMatrixMedia } from "./media.js"; +import { resolveMentions } from "./mentions.js"; +import { deliverMatrixReplies } from "./replies.js"; +import { resolveMatrixRoomConfig } from "./rooms.js"; +import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js"; +import { EventType, RelationType } from "./types.js"; + +export type MatrixMonitorHandlerParams = { + client: MatrixClient; + core: { + logging: { + shouldLogVerbose: () => boolean; + }; + channel: (typeof import("openclaw/plugin-sdk"))["channel"]; + system: { + enqueueSystemEvent: ( + text: string, + meta: { sessionKey?: string | null; contextKey?: string | null }, + ) => void; + }; + }; + cfg: CoreConfig; + runtime: RuntimeEnv; + logger: { + info: (message: string | Record, ...meta: unknown[]) => void; + warn: (meta: Record, message: string) => void; + }; + logVerboseMessage: (message: string) => void; + allowFrom: string[]; + roomsConfig: CoreConfig["channels"] extends { matrix?: infer MatrixConfig } + ? MatrixConfig extends { groups?: infer Groups } + ? Groups + : Record | undefined + : Record | undefined; + mentionRegexes: ReturnType< + (typeof import("openclaw/plugin-sdk"))["channel"]["mentions"]["buildMentionRegexes"] + >; + groupPolicy: "open" | "allowlist" | "disabled"; + replyToMode: ReplyToMode; + threadReplies: "off" | "inbound" | "always"; + dmEnabled: boolean; + dmPolicy: "open" | "pairing" | "allowlist" | "disabled"; + textLimit: number; + mediaMaxBytes: number; + startupMs: number; + startupGraceMs: number; + directTracker: { + isDirectMessage: (params: { + roomId: string; + senderId: string; + selfUserId: string; + }) => Promise; + }; + getRoomInfo: ( + roomId: string, + ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>; + getMemberDisplayName: (roomId: string, userId: string) => Promise; +}; + +export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) { + const { + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + } = params; + + return async (roomId: string, event: MatrixRawEvent) => { + try { + const eventType = event.type; + if (eventType === EventType.RoomMessageEncrypted) { + // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled + return; + } + + const isPollEvent = isPollStartType(eventType); + const locationContent = event.content as LocationMessageEventContent; + const isLocationEvent = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location); + if (eventType !== EventType.RoomMessage && !isPollEvent && !isLocationEvent) { + return; + } + logVerboseMessage( + `matrix: room.message recv room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`, + ); + if (event.unsigned?.redacted_because) { + return; + } + const senderId = event.sender; + if (!senderId) { + return; + } + const selfUserId = await client.getUserId(); + if (senderId === selfUserId) { + return; + } + const eventTs = event.origin_server_ts; + const eventAge = event.unsigned?.age; + if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) { + return; + } + if ( + typeof eventTs !== "number" && + typeof eventAge === "number" && + eventAge > startupGraceMs + ) { + return; + } + + const roomInfo = await getRoomInfo(roomId); + const roomName = roomInfo.name; + const roomAliases = [roomInfo.canonicalAlias ?? "", ...roomInfo.altAliases].filter(Boolean); + + let content = event.content as RoomMessageEventContent; + if (isPollEvent) { + const pollStartContent = event.content as PollStartContent; + const pollSummary = parsePollStartContent(pollStartContent); + if (pollSummary) { + pollSummary.eventId = event.event_id ?? ""; + pollSummary.roomId = roomId; + pollSummary.sender = senderId; + const senderDisplayName = await getMemberDisplayName(roomId, senderId); + pollSummary.senderName = senderDisplayName; + const pollText = formatPollAsText(pollSummary); + content = { + msgtype: "m.text", + body: pollText, + } as unknown as RoomMessageEventContent; + } else { + return; + } + } + + const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({ + eventType, + content: content as LocationMessageEventContent, + }); + + const relates = content["m.relates_to"]; + if (relates && "rel_type" in relates) { + if (relates.rel_type === RelationType.Replace) { + return; + } + } + + const isDirectMessage = await directTracker.isDirectMessage({ + roomId, + senderId, + selfUserId, + }); + const isRoom = !isDirectMessage; + + if (isRoom && groupPolicy === "disabled") { + return; + } + + const roomConfigInfo = isRoom + ? resolveMatrixRoomConfig({ + rooms: roomsConfig, + roomId, + aliases: roomAliases, + name: roomName, + }) + : undefined; + const roomConfig = roomConfigInfo?.config; + const roomMatchMeta = roomConfigInfo + ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${ + roomConfigInfo.matchSource ?? "none" + }` + : "matchKey=none matchSource=none"; + + if (isRoom && roomConfig && !roomConfigInfo?.allowed) { + logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`); + return; + } + if (isRoom && groupPolicy === "allowlist") { + if (!roomConfigInfo?.allowlistConfigured) { + logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`); + return; + } + if (!roomConfig) { + logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`); + return; + } + } + + const senderName = await getMemberDisplayName(roomId, senderId); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore("matrix") + .catch(() => []); + const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]); + const groupAllowFrom = cfg.channels?.matrix?.groupAllowFrom ?? []; + const effectiveGroupAllowFrom = normalizeAllowListLower([ + ...groupAllowFrom, + ...storeAllowFrom, + ]); + const groupAllowConfigured = effectiveGroupAllowFrom.length > 0; + + if (isDirectMessage) { + if (!dmEnabled || dmPolicy === "disabled") { + return; + } + if (dmPolicy !== "open") { + const allowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveAllowFrom, + userId: senderId, + userName: senderName, + }); + const allowMatchMeta = formatAllowlistMatchMeta(allowMatch); + if (!allowMatch.allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "matrix", + id: senderId, + meta: { name: senderName }, + }); + if (created) { + logVerboseMessage( + `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`, + ); + try { + await sendMessageMatrix( + `room:${roomId}`, + [ + "OpenClaw: access not configured.", + "", + `Pairing code: ${code}`, + "", + "Ask the bot owner to approve with:", + "openclaw pairing approve matrix ", + ].join("\n"), + { client }, + ); + } catch (err) { + logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } + if (dmPolicy !== "pairing") { + logVerboseMessage( + `matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`, + ); + } + return; + } + } + } + + const roomUsers = roomConfig?.users ?? []; + if (isRoom && roomUsers.length > 0) { + const userMatch = resolveMatrixAllowListMatch({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }); + if (!userMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + userMatch, + )})`, + ); + return; + } + } + if (isRoom && groupPolicy === "allowlist" && roomUsers.length === 0 && groupAllowConfigured) { + const groupAllowMatch = resolveMatrixAllowListMatch({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }); + if (!groupAllowMatch.allowed) { + logVerboseMessage( + `matrix: blocked sender ${senderId} (groupAllowFrom, ${roomMatchMeta}, ${formatAllowlistMatchMeta( + groupAllowMatch, + )})`, + ); + return; + } + } + if (isRoom) { + logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`); + } + + const rawBody = + locationPayload?.text ?? (typeof content.body === "string" ? content.body.trim() : ""); + let media: { + path: string; + contentType?: string; + placeholder: string; + } | null = null; + const contentUrl = + "url" in content && typeof content.url === "string" ? content.url : undefined; + const contentFile = + "file" in content && content.file && typeof content.file === "object" + ? content.file + : undefined; + const mediaUrl = contentUrl ?? contentFile?.url; + if (!rawBody && !mediaUrl) { + return; + } + + const contentInfo = + "info" in content && content.info && typeof content.info === "object" + ? (content.info as { mimetype?: string; size?: number }) + : undefined; + const contentType = contentInfo?.mimetype; + const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined; + if (mediaUrl?.startsWith("mxc://")) { + try { + media = await downloadMatrixMedia({ + client, + mxcUrl: mediaUrl, + contentType, + sizeBytes: contentSize, + maxBytes: mediaMaxBytes, + file: contentFile, + }); + } catch (err) { + logVerboseMessage(`matrix: media download failed: ${String(err)}`); + } + } + + const bodyText = rawBody || media?.placeholder || ""; + if (!bodyText) { + return; + } + + const { wasMentioned, hasExplicitMention } = resolveMentions({ + content, + userId: selfUserId, + text: bodyText, + mentionRegexes, + }); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "matrix", + }); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveMatrixAllowListMatches({ + allowList: effectiveAllowFrom, + userId: senderId, + userName: senderName, + }); + const senderAllowedForGroup = groupAllowConfigured + ? resolveMatrixAllowListMatches({ + allowList: effectiveGroupAllowFrom, + userId: senderId, + userName: senderName, + }) + : false; + const senderAllowedForRoomUsers = + isRoom && roomUsers.length > 0 + ? resolveMatrixAllowListMatches({ + allowList: normalizeAllowListLower(roomUsers), + userId: senderId, + userName: senderName, + }) + : false; + const hasControlCommandInMessage = core.channel.text.hasControlCommand(bodyText, cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { configured: roomUsers.length > 0, allowed: senderAllowedForRoomUsers }, + { configured: groupAllowConfigured, allowed: senderAllowedForGroup }, + ], + allowTextCommands, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = commandGate.commandAuthorized; + if (isRoom && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "matrix", + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + const shouldRequireMention = isRoom + ? roomConfig?.autoReply === true + ? false + : roomConfig?.autoReply === false + ? true + : typeof roomConfig?.requireMention === "boolean" + ? roomConfig?.requireMention + : true + : false; + const shouldBypassMention = + allowTextCommands && + isRoom && + shouldRequireMention && + !wasMentioned && + !hasExplicitMention && + commandAuthorized && + hasControlCommandInMessage; + const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention; + if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) { + logger.info({ roomId, reason: "no-mention" }, "skipping room message"); + return; + } + + const messageId = event.event_id ?? ""; + const replyToEventId = content["m.relates_to"]?.["m.in_reply_to"]?.event_id; + const threadRootId = resolveMatrixThreadRootId({ event, content }); + const threadTarget = resolveMatrixThreadTarget({ + threadReplies, + messageId, + threadRootId, + isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available + }); + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "matrix", + peer: { + kind: isDirectMessage ? "dm" : "channel", + id: isDirectMessage ? senderId : roomId, + }, + }); + const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId); + const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Matrix", + from: envelopeFrom, + timestamp: eventTs ?? undefined, + previousTimestamp, + envelope: envelopeOptions, + body: textWithId, + }); + + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: bodyText, + CommandBody: bodyText, + From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, + To: `room:${roomId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : "channel", + ConversationLabel: envelopeFrom, + SenderName: senderName, + SenderId: senderId, + SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), + GroupSubject: isRoom ? (roomName ?? roomId) : undefined, + GroupChannel: isRoom ? (roomInfo.canonicalAlias ?? roomId) : undefined, + GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, + Provider: "matrix" as const, + Surface: "matrix" as const, + WasMentioned: isRoom ? wasMentioned : undefined, + MessageSid: messageId, + ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined), + MessageThreadId: threadTarget, + Timestamp: eventTs ?? undefined, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + ...locationPayload?.context, + CommandAuthorized: commandAuthorized, + CommandSource: "text" as const, + OriginatingChannel: "matrix" as const, + OriginatingTo: `room:${roomId}`, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + updateLastRoute: isDirectMessage + ? { + sessionKey: route.mainSessionKey, + channel: "matrix", + to: `room:${roomId}`, + accountId: route.accountId, + } + : undefined, + onRecordError: (err) => { + logger.warn( + { + error: String(err), + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + }, + "failed updating session meta", + ); + }, + }); + + const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`); + + const ackReaction = (cfg.messages?.ackReaction ?? "").trim(); + const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + const shouldAckReaction = () => + Boolean( + ackReaction && + core.channel.reactions.shouldAckReaction({ + scope: ackScope, + isDirect: isDirectMessage, + isGroup: isRoom, + isMentionableGroup: isRoom, + requireMention: Boolean(shouldRequireMention), + canDetectMention, + effectiveWasMentioned: wasMentioned || shouldBypassMention, + shouldBypassMention, + }), + ); + if (shouldAckReaction() && messageId) { + reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => { + logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`); + }); + } + + const replyTarget = ctxPayload.To; + if (!replyTarget) { + runtime.error?.("matrix: missing reply target"); + return; + } + + if (messageId) { + sendReadReceiptMatrix(roomId, messageId, client).catch((err) => { + logVerboseMessage( + `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`, + ); + }); + } + + let didSendReply = false; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: route.accountId, + }); + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingMatrix(roomId, true, undefined, client), + stop: () => sendTypingMatrix(roomId, false, undefined, client), + onStartError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "start", + target: roomId, + error: err, + }); + }, + onStopError: (err) => { + logTypingFailure({ + log: logVerboseMessage, + channel: "matrix", + action: "stop", + target: roomId, + error: err, + }); + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload) => { + await deliverMatrixReplies({ + replies: [payload], + roomId, + client, + runtime, + textLimit, + replyToMode, + threadId: threadTarget, + accountId: route.accountId, + tableMode, + }); + didSendReply = true; + }, + onError: (err, info) => { + runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks.onReplyStart, + onIdle: typingCallbacks.onIdle, + }); + + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + skillFilter: roomConfig?.skills, + onModelSelected: prefixContext.onModelSelected, + }, + }); + markDispatchIdle(); + if (!queuedFinal) { + return; + } + didSendReply = true; + const finalCount = counts.final; + logVerboseMessage( + `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`, + ); + if (didSendReply) { + const previewText = bodyText.replace(/\s+/g, " ").slice(0, 160); + core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${previewText}`, { + sessionKey: route.sessionKey, + contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`, + }); + } + } catch (err) { + runtime.error?.(`matrix handler failed: ${String(err)}`); + } + }; +} diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ac87b25185c78459ce71f71565ba619562cd741 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -0,0 +1,291 @@ +import { format } from "node:util"; +import { mergeAllowlist, summarizeMapping, type RuntimeEnv } from "openclaw/plugin-sdk"; +import type { CoreConfig, ReplyToMode } from "../../types.js"; +import { resolveMatrixTargets } from "../../resolve-targets.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { setActiveMatrixClient } from "../active-client.js"; +import { + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, + stopSharedClient, +} from "../client.js"; +import { registerMatrixAutoJoin } from "./auto-join.js"; +import { createDirectRoomTracker } from "./direct.js"; +import { registerMatrixMonitorEvents } from "./events.js"; +import { createMatrixRoomMessageHandler } from "./handler.js"; +import { createMatrixRoomInfoResolver } from "./room-info.js"; + +export type MonitorMatrixOpts = { + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + mediaMaxMb?: number; + initialSyncLimit?: number; + replyToMode?: ReplyToMode; + accountId?: string | null; +}; + +const DEFAULT_MEDIA_MAX_MB = 20; + +export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promise { + if (isBunRuntime()) { + throw new Error("Matrix provider requires Node (bun runtime not supported)"); + } + const core = getMatrixRuntime(); + let cfg = core.config.loadConfig() as CoreConfig; + if (cfg.channels?.matrix?.enabled === false) { + return; + } + + const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" }); + const formatRuntimeMessage = (...args: Parameters) => format(...args); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (...args) => { + logger.info(formatRuntimeMessage(...args)); + }, + error: (...args) => { + logger.error(formatRuntimeMessage(...args)); + }, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + const logVerboseMessage = (message: string) => { + if (!core.logging.shouldLogVerbose()) { + return; + } + logger.debug(message); + }; + + const normalizeUserEntry = (raw: string) => + raw + .replace(/^matrix:/i, "") + .replace(/^user:/i, "") + .trim(); + const normalizeRoomEntry = (raw: string) => + raw + .replace(/^matrix:/i, "") + .replace(/^(room|channel):/i, "") + .trim(); + const isMatrixUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true; + let allowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + let roomsConfig = cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms; + + if (allowFrom.length > 0) { + const entries = allowFrom + .map((entry) => normalizeUserEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (entries.length > 0) { + const mapping: string[] = []; + const unresolved: string[] = []; + const additions: string[] = []; + const pending: string[] = []; + for (const entry of entries) { + if (isMatrixUserId(entry)) { + additions.push(entry); + continue; + } + pending.push(entry); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending, + kind: "user", + runtime, + }); + for (const entry of resolved) { + if (entry.resolved && entry.id) { + additions.push(entry.id); + mapping.push(`${entry.input}→${entry.id}`); + } else { + unresolved.push(entry.input); + } + } + } + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + summarizeMapping("matrix users", mapping, unresolved, runtime); + } + } + + if (roomsConfig && Object.keys(roomsConfig).length > 0) { + const entries = Object.keys(roomsConfig).filter((key) => key !== "*"); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextRooms = { ...roomsConfig }; + const pending: Array<{ input: string; query: string }> = []; + for (const entry of entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = normalizeRoomEntry(trimmed); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + if (!nextRooms[cleaned]) { + nextRooms[cleaned] = roomsConfig[entry]; + } + mapping.push(`${entry}→${cleaned}`); + continue; + } + pending.push({ input: entry, query: trimmed }); + } + if (pending.length > 0) { + const resolved = await resolveMatrixTargets({ + cfg, + inputs: pending.map((entry) => entry.query), + kind: "group", + runtime, + }); + resolved.forEach((entry, index) => { + const source = pending[index]; + if (!source) { + return; + } + if (entry.resolved && entry.id) { + if (!nextRooms[entry.id]) { + nextRooms[entry.id] = roomsConfig[source.input]; + } + mapping.push(`${source.input}→${entry.id}`); + } else { + unresolved.push(source.input); + } + }); + } + roomsConfig = nextRooms; + summarizeMapping("matrix rooms", mapping, unresolved, runtime); + } + + cfg = { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + allowFrom, + }, + ...(roomsConfig ? { groups: roomsConfig } : {}), + }, + }, + }; + + const auth = await resolveMatrixAuth({ cfg }); + const resolvedInitialSyncLimit = + typeof opts.initialSyncLimit === "number" + ? Math.max(0, Math.floor(opts.initialSyncLimit)) + : auth.initialSyncLimit; + const authWithLimit = + resolvedInitialSyncLimit === auth.initialSyncLimit + ? auth + : { ...auth, initialSyncLimit: resolvedInitialSyncLimit }; + const client = await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + startClient: false, + accountId: opts.accountId, + }); + setActiveMatrixClient(client); + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg); + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const groupPolicy = allowlistOnly && groupPolicyRaw === "open" ? "allowlist" : groupPolicyRaw; + const replyToMode = opts.replyToMode ?? cfg.channels?.matrix?.replyToMode ?? "off"; + const threadReplies = cfg.channels?.matrix?.threadReplies ?? "inbound"; + const dmConfig = cfg.channels?.matrix?.dm; + const dmEnabled = dmConfig?.enabled ?? true; + const dmPolicyRaw = dmConfig?.policy ?? "pairing"; + const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw; + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix"); + const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB; + const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024; + const startupMs = Date.now(); + const startupGraceMs = 0; + const directTracker = createDirectRoomTracker(client, { log: logVerboseMessage }); + registerMatrixAutoJoin({ client, cfg, runtime }); + const warnedEncryptedRooms = new Set(); + const warnedCryptoMissingRooms = new Set(); + + const { getRoomInfo, getMemberDisplayName } = createMatrixRoomInfoResolver(client); + const handleRoomMessage = createMatrixRoomMessageHandler({ + client, + core, + cfg, + runtime, + logger, + logVerboseMessage, + allowFrom, + roomsConfig, + mentionRegexes, + groupPolicy, + replyToMode, + threadReplies, + dmEnabled, + dmPolicy, + textLimit, + mediaMaxBytes, + startupMs, + startupGraceMs, + directTracker, + getRoomInfo, + getMemberDisplayName, + }); + + registerMatrixMonitorEvents({ + client, + auth, + logVerboseMessage, + warnedEncryptedRooms, + warnedCryptoMissingRooms, + logger, + formatNativeDependencyHint: core.system.formatNativeDependencyHint, + onRoomMessage: handleRoomMessage, + }); + + logVerboseMessage("matrix: starting client"); + await resolveSharedMatrixClient({ + cfg, + auth: authWithLimit, + accountId: opts.accountId, + }); + logVerboseMessage("matrix: client started"); + + // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient + logger.info(`matrix: logged in as ${auth.userId}`); + + // If E2EE is enabled, trigger device verification + if (auth.encryption && client.crypto) { + try { + // Request verification from other sessions + const verificationRequest = await client.crypto.requestOwnUserVerification(); + if (verificationRequest) { + logger.info("matrix: device verification requested - please verify in another client"); + } + } catch (err) { + logger.debug( + { error: String(err) }, + "Device verification request failed (may already be verified)", + ); + } + } + + await new Promise((resolve) => { + const onAbort = () => { + try { + logVerboseMessage("matrix: stopping client"); + stopSharedClient(); + } finally { + setActiveMatrixClient(null); + resolve(); + } + }; + if (opts.abortSignal?.aborted) { + onAbort(); + return; + } + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts new file mode 100644 index 0000000000000000000000000000000000000000..41c91aecc165c9121901b5b610b6343d3da4e8de --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -0,0 +1,100 @@ +import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk"; +import { + formatLocationText, + toLocationContext, + type NormalizedLocation, +} from "openclaw/plugin-sdk"; +import { EventType } from "./types.js"; + +export type MatrixLocationPayload = { + text: string; + context: ReturnType; +}; + +type GeoUriParams = { + latitude: number; + longitude: number; + accuracy?: number; +}; + +function parseGeoUri(value: string): GeoUriParams | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (!trimmed.toLowerCase().startsWith("geo:")) { + return null; + } + const payload = trimmed.slice(4); + const [coordsPart, ...paramParts] = payload.split(";"); + const coords = coordsPart.split(","); + if (coords.length < 2) { + return null; + } + const latitude = Number.parseFloat(coords[0] ?? ""); + const longitude = Number.parseFloat(coords[1] ?? ""); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + const params = new Map(); + for (const part of paramParts) { + const segment = part.trim(); + if (!segment) { + continue; + } + const eqIndex = segment.indexOf("="); + const rawKey = eqIndex === -1 ? segment : segment.slice(0, eqIndex); + const rawValue = eqIndex === -1 ? "" : segment.slice(eqIndex + 1); + const key = rawKey.trim().toLowerCase(); + if (!key) { + continue; + } + const valuePart = rawValue.trim(); + params.set(key, valuePart ? decodeURIComponent(valuePart) : ""); + } + + const accuracyRaw = params.get("u"); + const accuracy = accuracyRaw ? Number.parseFloat(accuracyRaw) : undefined; + + return { + latitude, + longitude, + accuracy: Number.isFinite(accuracy) ? accuracy : undefined, + }; +} + +export function resolveMatrixLocation(params: { + eventType: string; + content: LocationMessageEventContent; +}): MatrixLocationPayload | null { + const { eventType, content } = params; + const isLocation = + eventType === EventType.Location || + (eventType === EventType.RoomMessage && content.msgtype === EventType.Location); + if (!isLocation) { + return null; + } + const geoUri = typeof content.geo_uri === "string" ? content.geo_uri.trim() : ""; + if (!geoUri) { + return null; + } + const parsed = parseGeoUri(geoUri); + if (!parsed) { + return null; + } + const caption = typeof content.body === "string" ? content.body.trim() : ""; + const location: NormalizedLocation = { + latitude: parsed.latitude, + longitude: parsed.longitude, + accuracy: parsed.accuracy, + caption: caption || undefined, + source: "pin", + isLive: false, + }; + + return { + text: formatLocationText(location), + context: toLocationContext(location), + }; +} diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..590dd5148a51cd25360585121e56d58491dff4c6 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -0,0 +1,102 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../../runtime.js"; +import { downloadMatrixMedia } from "./media.js"; + +describe("downloadMatrixMedia", () => { + const saveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/media", + contentType: "image/png", + }); + + const runtimeStub = { + channel: { + media: { + saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args), + }, + }, + } as unknown as PluginRuntime; + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("decrypts encrypted media when file payloads are present", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + const result = await downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + maxBytes: 1024, + file, + }); + + // decryptMedia should be called with just the file object (it handles download internally) + expect(decryptMedia).toHaveBeenCalledWith(file); + expect(saveMediaBuffer).toHaveBeenCalledWith( + Buffer.from("decrypted"), + "image/png", + "inbound", + 1024, + ); + expect(result?.path).toBe("/tmp/media"); + }); + + it("rejects encrypted media that exceeds maxBytes before decrypting", async () => { + const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted")); + + const client = { + crypto: { decryptMedia }, + mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + + const file = { + url: "mxc://example/file", + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }; + + await expect( + downloadMatrixMedia({ + client, + mxcUrl: "mxc://example/file", + contentType: "image/png", + sizeBytes: 2048, + maxBytes: 1024, + file, + }), + ).rejects.toThrow("Matrix media exceeds configured size limit"); + + expect(decryptMedia).not.toHaveBeenCalled(); + expect(saveMediaBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..c88bfc0613be0b95b1b6f400a8b9967d71010442 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -0,0 +1,113 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { getMatrixRuntime } from "../../runtime.js"; + +// Type for encrypted file info +type EncryptedFile = { + url: string; + key: { + kty: string; + key_ops: string[]; + alg: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: Record; + v: string; +}; + +async function fetchMatrixMediaBuffer(params: { + client: MatrixClient; + mxcUrl: string; + maxBytes: number; +}): Promise<{ buffer: Buffer; headerType?: string } | null> { + // @vector-im/matrix-bot-sdk provides mxcToHttp helper + const url = params.client.mxcToHttp(params.mxcUrl); + if (!url) { + return null; + } + + // Use the client's download method which handles auth + try { + const buffer = await params.client.downloadContent(params.mxcUrl); + if (buffer.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + return { buffer: Buffer.from(buffer) }; + } catch (err) { + throw new Error(`Matrix media download failed: ${String(err)}`, { cause: err }); + } +} + +/** + * Download and decrypt encrypted media from a Matrix room. + * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption. + */ +async function fetchEncryptedMediaBuffer(params: { + client: MatrixClient; + file: EncryptedFile; + maxBytes: number; +}): Promise<{ buffer: Buffer } | null> { + if (!params.client.crypto) { + throw new Error("Cannot decrypt media: crypto not enabled"); + } + + // decryptMedia handles downloading and decrypting the encrypted content internally + const decrypted = await params.client.crypto.decryptMedia(params.file); + + if (decrypted.byteLength > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + + return { buffer: decrypted }; +} + +export async function downloadMatrixMedia(params: { + client: MatrixClient; + mxcUrl: string; + contentType?: string; + sizeBytes?: number; + maxBytes: number; + file?: EncryptedFile; +}): Promise<{ + path: string; + contentType?: string; + placeholder: string; +} | null> { + let fetched: { buffer: Buffer; headerType?: string } | null; + if (typeof params.sizeBytes === "number" && params.sizeBytes > params.maxBytes) { + throw new Error("Matrix media exceeds configured size limit"); + } + + if (params.file) { + // Encrypted media + fetched = await fetchEncryptedMediaBuffer({ + client: params.client, + file: params.file, + maxBytes: params.maxBytes, + }); + } else { + // Unencrypted media + fetched = await fetchMatrixMediaBuffer({ + client: params.client, + mxcUrl: params.mxcUrl, + maxBytes: params.maxBytes, + }); + } + + if (!fetched) { + return null; + } + const headerType = fetched.headerType ?? params.contentType ?? undefined; + const saved = await getMatrixRuntime().channel.media.saveMediaBuffer( + fetched.buffer, + headerType, + "inbound", + params.maxBytes, + ); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: "[matrix media]", + }; +} diff --git a/extensions/matrix/src/matrix/monitor/mentions.ts b/extensions/matrix/src/matrix/monitor/mentions.ts new file mode 100644 index 0000000000000000000000000000000000000000..1053b3fa17f36ab79f930bcc21833f15521f1a5e --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/mentions.ts @@ -0,0 +1,31 @@ +import { getMatrixRuntime } from "../../runtime.js"; + +// Type for room message content with mentions +type MessageContentWithMentions = { + msgtype: string; + body: string; + "m.mentions"?: { + user_ids?: string[]; + room?: boolean; + }; +}; + +export function resolveMentions(params: { + content: MessageContentWithMentions; + userId?: string | null; + text?: string; + mentionRegexes: RegExp[]; +}) { + const mentions = params.content["m.mentions"]; + const mentionedUsers = Array.isArray(mentions?.user_ids) + ? new Set(mentions.user_ids) + : new Set(); + const wasMentioned = + Boolean(mentions?.room) || + (params.userId ? mentionedUsers.has(params.userId) : false) || + getMatrixRuntime().channel.mentions.matchesMentionPatterns( + params.text ?? "", + params.mentionRegexes, + ); + return { wasMentioned, hasExplicitMention: Boolean(mentions) }; +} diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts new file mode 100644 index 0000000000000000000000000000000000000000..1193d59f80dc29be741a9bc396c71fd070b75a21 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -0,0 +1,97 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk"; +import { getMatrixRuntime } from "../../runtime.js"; +import { sendMessageMatrix } from "../send.js"; + +export async function deliverMatrixReplies(params: { + replies: ReplyPayload[]; + roomId: string; + client: MatrixClient; + runtime: RuntimeEnv; + textLimit: number; + replyToMode: "off" | "first" | "all"; + threadId?: string; + accountId?: string; + tableMode?: MarkdownTableMode; +}): Promise { + const core = getMatrixRuntime(); + const cfg = core.config.loadConfig(); + const tableMode = + params.tableMode ?? + core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: params.accountId, + }); + const logVerbose = (message: string) => { + if (core.logging.shouldLogVerbose()) { + params.runtime.log?.(message); + } + }; + const chunkLimit = Math.min(params.textLimit, 4000); + const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId); + let hasReplied = false; + for (const reply of params.replies) { + const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; + if (!reply?.text && !hasMedia) { + if (reply?.audioAsVoice) { + logVerbose("matrix reply has audioAsVoice without media/text; skipping"); + continue; + } + params.runtime.error?.("matrix reply missing text/media"); + continue; + } + const replyToIdRaw = reply.replyToId?.trim(); + const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); + const mediaList = reply.mediaUrls?.length + ? reply.mediaUrls + : reply.mediaUrl + ? [reply.mediaUrl] + : []; + + const shouldIncludeReply = (id?: string) => + Boolean(id) && (params.replyToMode === "all" || !hasReplied); + + if (mediaList.length === 0) { + for (const chunk of core.channel.text.chunkMarkdownTextWithMode( + text, + chunkLimit, + chunkMode, + )) { + const trimmed = chunk.trim(); + if (!trimmed) { + continue; + } + await sendMessageMatrix(params.roomId, trimmed, { + client: params.client, + replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, + threadId: params.threadId, + accountId: params.accountId, + }); + if (shouldIncludeReply(replyToId)) { + hasReplied = true; + } + } + continue; + } + + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : ""; + await sendMessageMatrix(params.roomId, caption, { + client: params.client, + mediaUrl, + replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + if (shouldIncludeReply(replyToId)) { + hasReplied = true; + } + first = false; + } + } +} diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..764147d35390cf2f7053a635d411fd419010aa66 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -0,0 +1,55 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; + +export type MatrixRoomInfo = { + name?: string; + canonicalAlias?: string; + altAliases: string[]; +}; + +export function createMatrixRoomInfoResolver(client: MatrixClient) { + const roomInfoCache = new Map(); + + const getRoomInfo = async (roomId: string): Promise => { + const cached = roomInfoCache.get(roomId); + if (cached) { + return cached; + } + let name: string | undefined; + let canonicalAlias: string | undefined; + let altAliases: string[] = []; + try { + const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "").catch(() => null); + name = nameState?.name; + } catch { + // ignore + } + try { + const aliasState = await client + .getRoomStateEvent(roomId, "m.room.canonical_alias", "") + .catch(() => null); + canonicalAlias = aliasState?.alias; + altAliases = aliasState?.alt_aliases ?? []; + } catch { + // ignore + } + const info = { name, canonicalAlias, altAliases }; + roomInfoCache.set(roomId, info); + return info; + }; + + const getMemberDisplayName = async (roomId: string, userId: string): Promise => { + try { + const memberState = await client + .getRoomStateEvent(roomId, "m.room.member", userId) + .catch(() => null); + return memberState?.displayname ?? userId; + } catch { + return userId; + } + }; + + return { + getRoomInfo, + getMemberDisplayName, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/rooms.ts b/extensions/matrix/src/matrix/monitor/rooms.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed705e8371a0ce1a8ee2b060350344cbdcfedeb5 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/rooms.ts @@ -0,0 +1,48 @@ +import { buildChannelKeyCandidates, resolveChannelEntryMatch } from "openclaw/plugin-sdk"; +import type { MatrixRoomConfig } from "../../types.js"; + +export type MatrixRoomConfigResolved = { + allowed: boolean; + allowlistConfigured: boolean; + config?: MatrixRoomConfig; + matchKey?: string; + matchSource?: "direct" | "wildcard"; +}; + +export function resolveMatrixRoomConfig(params: { + rooms?: Record; + roomId: string; + aliases: string[]; + name?: string | null; +}): MatrixRoomConfigResolved { + const rooms = params.rooms ?? {}; + const keys = Object.keys(rooms); + const allowlistConfigured = keys.length > 0; + const candidates = buildChannelKeyCandidates( + params.roomId, + `room:${params.roomId}`, + ...params.aliases, + params.name ?? "", + ); + const { + entry: matched, + key: matchedKey, + wildcardEntry, + wildcardKey, + } = resolveChannelEntryMatch({ + entries: rooms, + keys: candidates, + wildcardKey: "*", + }); + const resolved = matched ?? wildcardEntry; + const allowed = resolved ? resolved.enabled !== false && resolved.allow !== false : false; + const matchKey = matchedKey ?? wildcardKey; + const matchSource = matched ? "direct" : wildcardEntry ? "wildcard" : undefined; + return { + allowed, + allowlistConfigured, + config: resolved, + matchKey, + matchSource, + }; +} diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts new file mode 100644 index 0000000000000000000000000000000000000000..a384957166b32dd4aae53ffca99b6587fb717f0e --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -0,0 +1,68 @@ +// Type for raw Matrix event from @vector-im/matrix-bot-sdk +type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; +}; + +type RoomMessageEventContent = { + msgtype: string; + body: string; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; + +const RelationType = { + Thread: "m.thread", +} as const; + +export function resolveMatrixThreadTarget(params: { + threadReplies: "off" | "inbound" | "always"; + messageId: string; + threadRootId?: string; + isThreadRoot?: boolean; +}): string | undefined { + const { threadReplies, messageId, threadRootId } = params; + if (threadReplies === "off") { + return undefined; + } + const isThreadRoot = params.isThreadRoot === true; + const hasInboundThread = Boolean(threadRootId && threadRootId !== messageId && !isThreadRoot); + if (threadReplies === "inbound") { + return hasInboundThread ? threadRootId : undefined; + } + if (threadReplies === "always") { + return threadRootId ?? messageId; + } + return undefined; +} + +export function resolveMatrixThreadRootId(params: { + event: MatrixRawEvent; + content: RoomMessageEventContent; +}): string | undefined { + const relates = params.content["m.relates_to"]; + if (!relates || typeof relates !== "object") { + return undefined; + } + if ("rel_type" in relates && relates.rel_type === RelationType.Thread) { + if ("event_id" in relates && typeof relates.event_id === "string") { + return relates.event_id; + } + if ( + "m.in_reply_to" in relates && + typeof relates["m.in_reply_to"] === "object" && + relates["m.in_reply_to"] && + "event_id" in relates["m.in_reply_to"] && + typeof relates["m.in_reply_to"].event_id === "string" + ) { + return relates["m.in_reply_to"].event_id; + } + } + return undefined; +} diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c910f931fa929d48a423ce3ae251f89253e99924 --- /dev/null +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -0,0 +1,39 @@ +import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk"; + +export const EventType = { + RoomMessage: "m.room.message", + RoomMessageEncrypted: "m.room.encrypted", + RoomMember: "m.room.member", + Location: "m.location", +} as const; + +export const RelationType = { + Replace: "m.replace", + Thread: "m.thread", +} as const; + +export type MatrixRawEvent = { + event_id: string; + sender: string; + type: string; + origin_server_ts: number; + content: Record; + unsigned?: { + age?: number; + redacted_because?: unknown; + }; +}; + +export type RoomMessageEventContent = MessageEventContent & { + url?: string; + file?: EncryptedFile; + info?: { + mimetype?: string; + size?: number; + }; + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; +}; diff --git a/extensions/matrix/src/matrix/poll-types.test.ts b/extensions/matrix/src/matrix/poll-types.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f1797d99c6d55d0a88217238b94158b8cbce256 --- /dev/null +++ b/extensions/matrix/src/matrix/poll-types.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parsePollStartContent } from "./poll-types.js"; + +describe("parsePollStartContent", () => { + it("parses legacy m.poll payloads", () => { + const summary = parsePollStartContent({ + "m.poll": { + question: { "m.text": "Lunch?" }, + kind: "m.poll.disclosed", + max_selections: 1, + answers: [ + { id: "answer1", "m.text": "Yes" }, + { id: "answer2", "m.text": "No" }, + ], + }, + }); + + expect(summary?.question).toBe("Lunch?"); + expect(summary?.answers).toEqual(["Yes", "No"]); + }); +}); diff --git a/extensions/matrix/src/matrix/poll-types.ts b/extensions/matrix/src/matrix/poll-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..29897d895cd79eb402236ad4af226ff71c79de5b --- /dev/null +++ b/extensions/matrix/src/matrix/poll-types.ts @@ -0,0 +1,166 @@ +/** + * Matrix Poll Types (MSC3381) + * + * Defines types for Matrix poll events: + * - m.poll.start - Creates a new poll + * - m.poll.response - Records a vote + * - m.poll.end - Closes a poll + */ + +import type { PollInput } from "openclaw/plugin-sdk"; + +export const M_POLL_START = "m.poll.start" as const; +export const M_POLL_RESPONSE = "m.poll.response" as const; +export const M_POLL_END = "m.poll.end" as const; + +export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const; +export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const; +export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const; + +export const POLL_EVENT_TYPES = [ + M_POLL_START, + M_POLL_RESPONSE, + M_POLL_END, + ORG_POLL_START, + ORG_POLL_RESPONSE, + ORG_POLL_END, +]; + +export const POLL_START_TYPES = [M_POLL_START, ORG_POLL_START]; +export const POLL_RESPONSE_TYPES = [M_POLL_RESPONSE, ORG_POLL_RESPONSE]; +export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END]; + +export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; + +export type TextContent = { + "m.text"?: string; + "org.matrix.msc1767.text"?: string; + body?: string; +}; + +export type PollAnswer = { + id: string; +} & TextContent; + +export type PollStartSubtype = { + question: TextContent; + kind?: PollKind; + max_selections?: number; + answers: PollAnswer[]; +}; + +export type LegacyPollStartContent = { + "m.poll"?: PollStartSubtype; +}; + +export type PollStartContent = { + [M_POLL_START]?: PollStartSubtype; + [ORG_POLL_START]?: PollStartSubtype; + "m.poll"?: PollStartSubtype; + "m.text"?: string; + "org.matrix.msc1767.text"?: string; +}; + +export type PollSummary = { + eventId: string; + roomId: string; + sender: string; + senderName: string; + question: string; + answers: string[]; + kind: PollKind; + maxSelections: number; +}; + +export function isPollStartType(eventType: string): boolean { + return POLL_START_TYPES.includes(eventType); +} + +export function getTextContent(text?: TextContent): string { + if (!text) { + return ""; + } + return text["m.text"] ?? text["org.matrix.msc1767.text"] ?? text.body ?? ""; +} + +export function parsePollStartContent(content: PollStartContent): PollSummary | null { + const poll = + (content as Record)[M_POLL_START] ?? + (content as Record)[ORG_POLL_START] ?? + (content as Record)["m.poll"]; + if (!poll) { + return null; + } + + const question = getTextContent(poll.question); + if (!question) { + return null; + } + + const answers = poll.answers + .map((answer) => getTextContent(answer)) + .filter((a) => a.trim().length > 0); + + return { + eventId: "", + roomId: "", + sender: "", + senderName: "", + question, + answers, + kind: poll.kind ?? "m.poll.disclosed", + maxSelections: poll.max_selections ?? 1, + }; +} + +export function formatPollAsText(summary: PollSummary): string { + const lines = [ + "[Poll]", + summary.question, + "", + ...summary.answers.map((answer, idx) => `${idx + 1}. ${answer}`), + ]; + return lines.join("\n"); +} + +function buildTextContent(body: string): TextContent { + return { + "m.text": body, + "org.matrix.msc1767.text": body, + }; +} + +function buildPollFallbackText(question: string, answers: string[]): string { + if (answers.length === 0) { + return question; + } + return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`; +} + +export function buildPollStartContent(poll: PollInput): PollStartContent { + const question = poll.question.trim(); + const answers = poll.options + .map((option) => option.trim()) + .filter((option) => option.length > 0) + .map((option, idx) => ({ + id: `answer${idx + 1}`, + ...buildTextContent(option), + })); + + const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1; + const fallbackText = buildPollFallbackText( + question, + answers.map((answer) => getTextContent(answer)), + ); + + return { + [M_POLL_START]: { + question: buildTextContent(question), + kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed", + max_selections: maxSelections, + answers, + }, + "m.text": fallbackText, + "org.matrix.msc1767.text": fallbackText, + }; +} diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..7bd54bdc400e1fc6d9e754050cd4aae7224e44b0 --- /dev/null +++ b/extensions/matrix/src/matrix/probe.ts @@ -0,0 +1,70 @@ +import { createMatrixClient, isBunRuntime } from "./client.js"; + +export type MatrixProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs: number; + userId?: string | null; +}; + +export async function probeMatrix(params: { + homeserver: string; + accessToken: string; + userId?: string; + timeoutMs: number; +}): Promise { + const started = Date.now(); + const result: MatrixProbe = { + ok: false, + status: null, + error: null, + elapsedMs: 0, + }; + if (isBunRuntime()) { + return { + ...result, + error: "Matrix probe requires Node (bun runtime not supported)", + elapsedMs: Date.now() - started, + }; + } + if (!params.homeserver?.trim()) { + return { + ...result, + error: "missing homeserver", + elapsedMs: Date.now() - started, + }; + } + if (!params.accessToken?.trim()) { + return { + ...result, + error: "missing access token", + elapsedMs: Date.now() - started, + }; + } + try { + const client = await createMatrixClient({ + homeserver: params.homeserver, + userId: params.userId ?? "", + accessToken: params.accessToken, + localTimeoutMs: params.timeoutMs, + }); + // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally + const userId = await client.getUserId(); + result.ok = true; + result.userId = userId ?? null; + + result.elapsedMs = Date.now() - started; + return result; + } catch (err) { + return { + ...result, + status: + typeof err === "object" && err && "statusCode" in err + ? Number((err as { statusCode?: number }).statusCode) + : result.status, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - started, + }; + } +} diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ebfc826f806f0b2967df23a08c24b7ee5d3c28b --- /dev/null +++ b/extensions/matrix/src/matrix/send.test.ts @@ -0,0 +1,171 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { setMatrixRuntime } from "../runtime.js"; + +vi.mock("@vector-im/matrix-bot-sdk", () => ({ + ConsoleLogger: class { + trace = vi.fn(); + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); + }, + LogService: { + setLogger: vi.fn(), + }, + MatrixClient: vi.fn(), + SimpleFsStorageProvider: vi.fn(), + RustSdkCryptoStorageProvider: vi.fn(), +})); + +const loadWebMediaMock = vi.fn().mockResolvedValue({ + buffer: Buffer.from("media"), + fileName: "photo.png", + contentType: "image/png", + kind: "image", +}); +const getImageMetadataMock = vi.fn().mockResolvedValue(null); +const resizeToJpegMock = vi.fn(); + +const runtimeStub = { + config: { + loadConfig: () => ({}), + }, + media: { + loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), + mediaKindFromMime: () => "image", + isVoiceCompatibleAudio: () => false, + getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args), + resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args), + }, + channel: { + text: { + resolveTextChunkLimit: () => 4000, + resolveChunkMode: () => "length", + chunkMarkdownText: (text: string) => (text ? [text] : []), + chunkMarkdownTextWithMode: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, + }, + }, +} as unknown as PluginRuntime; + +let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; + +const makeClient = () => { + const sendMessage = vi.fn().mockResolvedValue("evt1"); + const uploadContent = vi.fn().mockResolvedValue("mxc://example/file"); + const client = { + sendMessage, + uploadContent, + getUserId: vi.fn().mockResolvedValue("@bot:example.org"), + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; + return { client, sendMessage, uploadContent }; +}; + +describe("sendMessageMatrix media", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("uploads media with url payloads", async () => { + const { client, sendMessage, uploadContent } = makeClient(); + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + const uploadArg = uploadContent.mock.calls[0]?.[0]; + expect(Buffer.isBuffer(uploadArg)).toBe(true); + + const content = sendMessage.mock.calls[0]?.[1] as { + url?: string; + msgtype?: string; + format?: string; + formatted_body?: string; + }; + expect(content.msgtype).toBe("m.image"); + expect(content.format).toBe("org.matrix.custom.html"); + expect(content.formatted_body).toContain("caption"); + expect(content.url).toBe("mxc://example/file"); + }); + + it("uploads encrypted media with file payloads", async () => { + const { client, sendMessage, uploadContent } = makeClient(); + (client as { crypto?: object }).crypto = { + isRoomEncrypted: vi.fn().mockResolvedValue(true), + encryptMedia: vi.fn().mockResolvedValue({ + buffer: Buffer.from("encrypted"), + file: { + key: { + kty: "oct", + key_ops: ["encrypt", "decrypt"], + alg: "A256CTR", + k: "secret", + ext: true, + }, + iv: "iv", + hashes: { sha256: "hash" }, + v: "v2", + }, + }), + }; + + await sendMessageMatrix("room:!room:example", "caption", { + client, + mediaUrl: "file:///tmp/photo.png", + }); + + const uploadArg = uploadContent.mock.calls[0]?.[0] as Buffer | undefined; + expect(uploadArg?.toString()).toBe("encrypted"); + + const content = sendMessage.mock.calls[0]?.[1] as { + url?: string; + file?: { url?: string }; + }; + expect(content.url).toBeUndefined(); + expect(content.file?.url).toBe("mxc://example/file"); + }); +}); + +describe("sendMessageMatrix threads", () => { + beforeAll(async () => { + setMatrixRuntime(runtimeStub); + ({ sendMessageMatrix } = await import("./send.js")); + }); + + beforeEach(() => { + vi.clearAllMocks(); + setMatrixRuntime(runtimeStub); + }); + + it("includes thread relation metadata when threadId is set", async () => { + const { client, sendMessage } = makeClient(); + + await sendMessageMatrix("room:!room:example", "hello thread", { + client, + threadId: "$thread", + }); + + const content = sendMessage.mock.calls[0]?.[1] as { + "m.relates_to"?: { + rel_type?: string; + event_id?: string; + "m.in_reply_to"?: { event_id?: string }; + }; + }; + + expect(content["m.relates_to"]).toMatchObject({ + rel_type: "m.thread", + event_id: "$thread", + "m.in_reply_to": { event_id: "$thread" }, + }); + }); +}); diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9bfae4fe0021da5358e6423b4718daa421de696 --- /dev/null +++ b/extensions/matrix/src/matrix/send.ts @@ -0,0 +1,260 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { PollInput } from "openclaw/plugin-sdk"; +import { getMatrixRuntime } from "../runtime.js"; +import { buildPollStartContent, M_POLL_START } from "./poll-types.js"; +import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js"; +import { + buildReplyRelation, + buildTextContent, + buildThreadRelation, + resolveMatrixMsgType, + resolveMatrixVoiceDecision, +} from "./send/formatting.js"; +import { + buildMediaContent, + prepareImageInfo, + resolveMediaDurationMs, + uploadMediaMaybeEncrypted, +} from "./send/media.js"; +import { normalizeThreadId, resolveMatrixRoomId } from "./send/targets.js"; +import { + EventType, + MsgType, + RelationType, + type MatrixOutboundContent, + type MatrixSendOpts, + type MatrixSendResult, + type ReactionEventContent, +} from "./send/types.js"; + +const MATRIX_TEXT_LIMIT = 4000; +const getCore = () => getMatrixRuntime(); + +export type { MatrixSendOpts, MatrixSendResult } from "./send/types.js"; +export { resolveMatrixRoomId } from "./send/targets.js"; + +export async function sendMessageMatrix( + to: string, + message: string, + opts: MatrixSendOpts = {}, +): Promise { + const trimmedMessage = message?.trim() ?? ""; + if (!trimmedMessage && !opts.mediaUrl) { + throw new Error("Matrix send requires text or media"); + } + const { client, stopOnDone } = await resolveMatrixClient({ + client: opts.client, + timeoutMs: opts.timeoutMs, + }); + try { + const roomId = await resolveMatrixRoomId(client, to); + const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); + const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); + const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); + const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId); + const chunks = getCore().channel.text.chunkMarkdownTextWithMode( + convertedMessage, + chunkLimit, + chunkMode, + ); + const threadId = normalizeThreadId(opts.threadId); + const relation = threadId + ? buildThreadRelation(threadId, opts.replyToId) + : buildReplyRelation(opts.replyToId); + const sendContent = async (content: MatrixOutboundContent) => { + // @vector-im/matrix-bot-sdk uses sendMessage differently + const eventId = await client.sendMessage(roomId, content); + return eventId; + }; + + let lastMessageId = ""; + if (opts.mediaUrl) { + const maxBytes = resolveMediaMaxBytes(); + const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes); + const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, { + contentType: media.contentType, + filename: media.fileName, + }); + const durationMs = await resolveMediaDurationMs({ + buffer: media.buffer, + contentType: media.contentType, + fileName: media.fileName, + kind: media.kind, + }); + const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); + const { useVoice } = resolveMatrixVoiceDecision({ + wantsVoice: opts.audioAsVoice === true, + contentType: media.contentType, + fileName: media.fileName, + }); + const msgtype = useVoice ? MsgType.Audio : baseMsgType; + const isImage = msgtype === MsgType.Image; + const imageInfo = isImage + ? await prepareImageInfo({ buffer: media.buffer, client }) + : undefined; + const [firstChunk, ...rest] = chunks; + const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); + const content = buildMediaContent({ + msgtype, + body, + url: uploaded.url, + file: uploaded.file, + filename: media.fileName, + mimetype: media.contentType, + size: media.buffer.byteLength, + durationMs, + relation, + isVoice: useVoice, + imageInfo, + }); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + const textChunks = useVoice ? chunks : rest; + const followupRelation = threadId ? relation : undefined; + for (const chunk of textChunks) { + const text = chunk.trim(); + if (!text) { + continue; + } + const followup = buildTextContent(text, followupRelation); + const followupEventId = await sendContent(followup); + lastMessageId = followupEventId ?? lastMessageId; + } + } else { + for (const chunk of chunks.length ? chunks : [""]) { + const text = chunk.trim(); + if (!text) { + continue; + } + const content = buildTextContent(text, relation); + const eventId = await sendContent(content); + lastMessageId = eventId ?? lastMessageId; + } + } + + return { + messageId: lastMessageId || "unknown", + roomId, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function sendPollMatrix( + to: string, + poll: PollInput, + opts: MatrixSendOpts = {}, +): Promise<{ eventId: string; roomId: string }> { + if (!poll.question?.trim()) { + throw new Error("Matrix poll requires a question"); + } + if (!poll.options?.length) { + throw new Error("Matrix poll requires options"); + } + const { client, stopOnDone } = await resolveMatrixClient({ + client: opts.client, + timeoutMs: opts.timeoutMs, + }); + + try { + const roomId = await resolveMatrixRoomId(client, to); + const pollContent = buildPollStartContent(poll); + const threadId = normalizeThreadId(opts.threadId); + const pollPayload = threadId + ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } + : pollContent; + // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly + const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); + + return { + eventId: eventId ?? "unknown", + roomId, + }; + } finally { + if (stopOnDone) { + client.stop(); + } + } +} + +export async function sendTypingMatrix( + roomId: string, + typing: boolean, + timeoutMs?: number, + client?: MatrixClient, +): Promise { + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + timeoutMs, + }); + try { + const resolvedTimeoutMs = typeof timeoutMs === "number" ? timeoutMs : 30_000; + await resolved.setTyping(roomId, typing, resolvedTimeoutMs); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} + +export async function sendReadReceiptMatrix( + roomId: string, + eventId: string, + client?: MatrixClient, +): Promise { + if (!eventId?.trim()) { + return; + } + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + }); + try { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + await resolved.sendReadReceipt(resolvedRoom, eventId.trim()); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} + +export async function reactMatrixMessage( + roomId: string, + messageId: string, + emoji: string, + client?: MatrixClient, +): Promise { + if (!emoji.trim()) { + throw new Error("Matrix reaction requires an emoji"); + } + const { client: resolved, stopOnDone } = await resolveMatrixClient({ + client, + }); + try { + const resolvedRoom = await resolveMatrixRoomId(resolved, roomId); + const reaction: ReactionEventContent = { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: messageId, + key: emoji, + }, + }; + await resolved.sendEvent(resolvedRoom, EventType.Reaction, reaction); + } finally { + if (stopOnDone) { + resolved.stop(); + } + } +} diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa0f3badb7985d01e6e59d0ceb6c3fb302d99120 --- /dev/null +++ b/extensions/matrix/src/matrix/send/client.ts @@ -0,0 +1,66 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import type { CoreConfig } from "../types.js"; +import { getMatrixRuntime } from "../../runtime.js"; +import { getActiveMatrixClient } from "../active-client.js"; +import { + createMatrixClient, + isBunRuntime, + resolveMatrixAuth, + resolveSharedMatrixClient, +} from "../client.js"; + +const getCore = () => getMatrixRuntime(); + +export function ensureNodeRuntime() { + if (isBunRuntime()) { + throw new Error("Matrix support requires Node (bun runtime not supported)"); + } +} + +export function resolveMediaMaxBytes(): number | undefined { + const cfg = getCore().config.loadConfig() as CoreConfig; + if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") { + return cfg.channels.matrix.mediaMaxMb * 1024 * 1024; + } + return undefined; +} + +export async function resolveMatrixClient(opts: { + client?: MatrixClient; + timeoutMs?: number; +}): Promise<{ client: MatrixClient; stopOnDone: boolean }> { + ensureNodeRuntime(); + if (opts.client) { + return { client: opts.client, stopOnDone: false }; + } + const active = getActiveMatrixClient(); + if (active) { + return { client: active, stopOnDone: false }; + } + const shouldShareClient = Boolean(process.env.OPENCLAW_GATEWAY_PORT); + if (shouldShareClient) { + const client = await resolveSharedMatrixClient({ + timeoutMs: opts.timeoutMs, + }); + return { client, stopOnDone: false }; + } + const auth = await resolveMatrixAuth(); + const client = await createMatrixClient({ + homeserver: auth.homeserver, + userId: auth.userId, + accessToken: auth.accessToken, + encryption: auth.encryption, + localTimeoutMs: opts.timeoutMs, + }); + if (auth.encryption && client.crypto) { + try { + const joinedRooms = await client.getJoinedRooms(); + await client.crypto.prepare(joinedRooms); + } catch { + // Ignore crypto prep failures for one-off sends; normal sync will retry. + } + } + // @vector-im/matrix-bot-sdk uses start() instead of startClient() + await client.start(); + return { client, stopOnDone: true }; +} diff --git a/extensions/matrix/src/matrix/send/formatting.ts b/extensions/matrix/src/matrix/send/formatting.ts new file mode 100644 index 0000000000000000000000000000000000000000..3189d1e908679475c09e5f3fe8007138fff27dd3 --- /dev/null +++ b/extensions/matrix/src/matrix/send/formatting.ts @@ -0,0 +1,89 @@ +import { getMatrixRuntime } from "../../runtime.js"; +import { markdownToMatrixHtml } from "../format.js"; +import { + MsgType, + RelationType, + type MatrixFormattedContent, + type MatrixMediaMsgType, + type MatrixRelation, + type MatrixReplyRelation, + type MatrixTextContent, + type MatrixThreadRelation, +} from "./types.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent { + const content: MatrixTextContent = relation + ? { + msgtype: MsgType.Text, + body, + "m.relates_to": relation, + } + : { + msgtype: MsgType.Text, + body, + }; + applyMatrixFormatting(content, body); + return content; +} + +export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void { + const formatted = markdownToMatrixHtml(body ?? ""); + if (!formatted) { + return; + } + content.format = "org.matrix.custom.html"; + content.formatted_body = formatted; +} + +export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined { + const trimmed = replyToId?.trim(); + if (!trimmed) { + return undefined; + } + return { "m.in_reply_to": { event_id: trimmed } }; +} + +export function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation { + const trimmed = threadId.trim(); + return { + rel_type: RelationType.Thread, + event_id: trimmed, + is_falling_back: true, + "m.in_reply_to": { event_id: replyToId?.trim() || trimmed }, + }; +} + +export function resolveMatrixMsgType(contentType?: string, _fileName?: string): MatrixMediaMsgType { + const kind = getCore().media.mediaKindFromMime(contentType ?? ""); + switch (kind) { + case "image": + return MsgType.Image; + case "audio": + return MsgType.Audio; + case "video": + return MsgType.Video; + default: + return MsgType.File; + } +} + +export function resolveMatrixVoiceDecision(opts: { + wantsVoice: boolean; + contentType?: string; + fileName?: string; +}): { useVoice: boolean } { + if (!opts.wantsVoice) { + return { useVoice: false }; + } + if ( + getCore().media.isVoiceCompatibleAudio({ + contentType: opts.contentType, + fileName: opts.fileName, + }) + ) { + return { useVoice: true }; + } + return { useVoice: false }; +} diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4339d9005738981ff3f287ba5c0563a3fdbc833 --- /dev/null +++ b/extensions/matrix/src/matrix/send/media.ts @@ -0,0 +1,229 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MatrixClient, + TimedFileInfo, + VideoFileInfo, +} from "@vector-im/matrix-bot-sdk"; +import { parseBuffer, type IFileInfo } from "music-metadata"; +import { getMatrixRuntime } from "../../runtime.js"; +import { applyMatrixFormatting } from "./formatting.js"; +import { + type MatrixMediaContent, + type MatrixMediaInfo, + type MatrixMediaMsgType, + type MatrixRelation, + type MediaKind, +} from "./types.js"; + +const getCore = () => getMatrixRuntime(); + +export function buildMatrixMediaInfo(params: { + size: number; + mimetype?: string; + durationMs?: number; + imageInfo?: DimensionalFileInfo; +}): MatrixMediaInfo | undefined { + const base: FileWithThumbnailInfo = {}; + if (Number.isFinite(params.size)) { + base.size = params.size; + } + if (params.mimetype) { + base.mimetype = params.mimetype; + } + if (params.imageInfo) { + const dimensional: DimensionalFileInfo = { + ...base, + ...params.imageInfo, + }; + if (typeof params.durationMs === "number") { + const videoInfo: VideoFileInfo = { + ...dimensional, + duration: params.durationMs, + }; + return videoInfo; + } + return dimensional; + } + if (typeof params.durationMs === "number") { + const timedInfo: TimedFileInfo = { + ...base, + duration: params.durationMs, + }; + return timedInfo; + } + if (Object.keys(base).length === 0) { + return undefined; + } + return base; +} + +export function buildMediaContent(params: { + msgtype: MatrixMediaMsgType; + body: string; + url?: string; + filename?: string; + mimetype?: string; + size: number; + relation?: MatrixRelation; + isVoice?: boolean; + durationMs?: number; + imageInfo?: DimensionalFileInfo; + file?: EncryptedFile; +}): MatrixMediaContent { + const info = buildMatrixMediaInfo({ + size: params.size, + mimetype: params.mimetype, + durationMs: params.durationMs, + imageInfo: params.imageInfo, + }); + const base: MatrixMediaContent = { + msgtype: params.msgtype, + body: params.body, + filename: params.filename, + info: info ?? undefined, + }; + // Encrypted media should only include the "file" payload, not top-level "url". + if (!params.file && params.url) { + base.url = params.url; + } + // For encrypted files, add the file object + if (params.file) { + base.file = params.file; + } + if (params.isVoice) { + base["org.matrix.msc3245.voice"] = {}; + if (typeof params.durationMs === "number") { + base["org.matrix.msc1767.audio"] = { + duration: params.durationMs, + }; + } + } + if (params.relation) { + base["m.relates_to"] = params.relation; + } + applyMatrixFormatting(base, params.body); + return base; +} + +const THUMBNAIL_MAX_SIDE = 800; +const THUMBNAIL_QUALITY = 80; + +export async function prepareImageInfo(params: { + buffer: Buffer; + client: MatrixClient; +}): Promise { + const meta = await getCore() + .media.getImageMetadata(params.buffer) + .catch(() => null); + if (!meta) { + return undefined; + } + const imageInfo: DimensionalFileInfo = { w: meta.width, h: meta.height }; + const maxDim = Math.max(meta.width, meta.height); + if (maxDim > THUMBNAIL_MAX_SIDE) { + try { + const thumbBuffer = await getCore().media.resizeToJpeg({ + buffer: params.buffer, + maxSide: THUMBNAIL_MAX_SIDE, + quality: THUMBNAIL_QUALITY, + withoutEnlargement: true, + }); + const thumbMeta = await getCore() + .media.getImageMetadata(thumbBuffer) + .catch(() => null); + const thumbUri = await params.client.uploadContent( + thumbBuffer, + "image/jpeg", + "thumbnail.jpg", + ); + imageInfo.thumbnail_url = thumbUri; + if (thumbMeta) { + imageInfo.thumbnail_info = { + w: thumbMeta.width, + h: thumbMeta.height, + mimetype: "image/jpeg", + size: thumbBuffer.byteLength, + }; + } + } catch { + // Thumbnail generation failed, continue without it + } + } + return imageInfo; +} + +export async function resolveMediaDurationMs(params: { + buffer: Buffer; + contentType?: string; + fileName?: string; + kind: MediaKind; +}): Promise { + if (params.kind !== "audio" && params.kind !== "video") { + return undefined; + } + try { + const fileInfo: IFileInfo | string | undefined = + params.contentType || params.fileName + ? { + mimeType: params.contentType, + size: params.buffer.byteLength, + path: params.fileName, + } + : undefined; + const metadata = await parseBuffer(params.buffer, fileInfo, { + duration: true, + skipCovers: true, + }); + const durationSeconds = metadata.format.duration; + if (typeof durationSeconds === "number" && Number.isFinite(durationSeconds)) { + return Math.max(0, Math.round(durationSeconds * 1000)); + } + } catch { + // Duration is optional; ignore parse failures. + } + return undefined; +} + +async function uploadFile( + client: MatrixClient, + file: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise { + return await client.uploadContent(file, params.contentType, params.filename); +} + +/** + * Upload media with optional encryption for E2EE rooms. + */ +export async function uploadMediaMaybeEncrypted( + client: MatrixClient, + roomId: string, + buffer: Buffer, + params: { + contentType?: string; + filename?: string; + }, +): Promise<{ url: string; file?: EncryptedFile }> { + // Check if room is encrypted and crypto is available + const isEncrypted = client.crypto && (await client.crypto.isRoomEncrypted(roomId)); + + if (isEncrypted && client.crypto) { + // Encrypt the media before uploading + const encrypted = await client.crypto.encryptMedia(buffer); + const mxc = await client.uploadContent(encrypted.buffer, params.contentType, params.filename); + const file: EncryptedFile = { url: mxc, ...encrypted.file }; + return { + url: mxc, + file, + }; + } + + // Upload unencrypted + const mxc = await uploadFile(client, buffer, params); + return { url: mxc }; +} diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bc90327cc82b1081bc14ce1bf0f3e1927829076 --- /dev/null +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -0,0 +1,98 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventType } from "./types.js"; + +let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; +let normalizeThreadId: typeof import("./targets.js").normalizeThreadId; + +beforeEach(async () => { + vi.resetModules(); + ({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js")); +}); + +describe("resolveMatrixRoomId", () => { + it("uses m.direct when available", async () => { + const userId = "@user:example.org"; + const client = { + getAccountData: vi.fn().mockResolvedValue({ + [userId]: ["!room:example.org"], + }), + getJoinedRooms: vi.fn(), + getJoinedRoomMembers: vi.fn(), + setAccountData: vi.fn(), + } as unknown as MatrixClient; + + const roomId = await resolveMatrixRoomId(client, userId); + + expect(roomId).toBe("!room:example.org"); + // oxlint-disable-next-line typescript/unbound-method + expect(client.getJoinedRooms).not.toHaveBeenCalled(); + // oxlint-disable-next-line typescript/unbound-method + expect(client.setAccountData).not.toHaveBeenCalled(); + }); + + it("falls back to joined rooms and persists m.direct", async () => { + const userId = "@fallback:example.org"; + const roomId = "!room:example.org"; + const setAccountData = vi.fn().mockResolvedValue(undefined); + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue([roomId]), + getJoinedRoomMembers: vi.fn().mockResolvedValue(["@bot:example.org", userId]), + setAccountData, + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + expect(setAccountData).toHaveBeenCalledWith( + EventType.Direct, + expect.objectContaining({ [userId]: [roomId] }), + ); + }); + + it("continues when a room member lookup fails", async () => { + const userId = "@continue:example.org"; + const roomId = "!good:example.org"; + const setAccountData = vi.fn().mockResolvedValue(undefined); + const getJoinedRoomMembers = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(["@bot:example.org", userId]); + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]), + getJoinedRoomMembers, + setAccountData, + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + expect(setAccountData).toHaveBeenCalled(); + }); + + it("allows larger rooms when no 1:1 match exists", async () => { + const userId = "@group:example.org"; + const roomId = "!group:example.org"; + const client = { + getAccountData: vi.fn().mockRejectedValue(new Error("nope")), + getJoinedRooms: vi.fn().mockResolvedValue([roomId]), + getJoinedRoomMembers: vi + .fn() + .mockResolvedValue(["@bot:example.org", userId, "@extra:example.org"]), + setAccountData: vi.fn().mockResolvedValue(undefined), + } as unknown as MatrixClient; + + const resolved = await resolveMatrixRoomId(client, userId); + + expect(resolved).toBe(roomId); + }); +}); + +describe("normalizeThreadId", () => { + it("returns null for empty thread ids", () => { + expect(normalizeThreadId(" ")).toBeNull(); + expect(normalizeThreadId("$thread")).toBe("$thread"); + }); +}); diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3de224eb661ac7b84cb22995b0c164116156e65 --- /dev/null +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -0,0 +1,136 @@ +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { EventType, type MatrixDirectAccountData } from "./types.js"; + +function normalizeTarget(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Matrix target is required (room: or #alias)"); + } + return trimmed; +} + +export function normalizeThreadId(raw?: string | number | null): string | null { + if (raw === undefined || raw === null) { + return null; + } + const trimmed = String(raw).trim(); + return trimmed ? trimmed : null; +} + +const directRoomCache = new Map(); + +async function persistDirectRoom( + client: MatrixClient, + userId: string, + roomId: string, +): Promise { + let directContent: MatrixDirectAccountData | null = null; + try { + directContent = await client.getAccountData(EventType.Direct); + } catch { + // Ignore fetch errors and fall back to an empty map. + } + const existing = directContent && !Array.isArray(directContent) ? directContent : {}; + const current = Array.isArray(existing[userId]) ? existing[userId] : []; + if (current[0] === roomId) { + return; + } + const next = [roomId, ...current.filter((id) => id !== roomId)]; + try { + await client.setAccountData(EventType.Direct, { + ...existing, + [userId]: next, + }); + } catch { + // Ignore persistence errors. + } +} + +async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise { + const trimmed = userId.trim(); + if (!trimmed.startsWith("@")) { + throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`); + } + + const cached = directRoomCache.get(trimmed); + if (cached) { + return cached; + } + + // 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot). + try { + const directContent = await client.getAccountData(EventType.Direct); + const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; + if (list.length > 0) { + directRoomCache.set(trimmed, list[0]); + return list[0]; + } + } catch { + // Ignore and fall back. + } + + // 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. + // Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. + let fallbackRoom: string | null = null; + try { + const rooms = await client.getJoinedRooms(); + for (const roomId of rooms) { + let members: string[]; + try { + members = await client.getJoinedRoomMembers(roomId); + } catch { + continue; + } + if (!members.includes(trimmed)) { + continue; + } + // Prefer classic 1:1 rooms, but allow larger rooms if requested. + if (members.length === 2) { + directRoomCache.set(trimmed, roomId); + await persistDirectRoom(client, trimmed, roomId); + return roomId; + } + if (!fallbackRoom) { + fallbackRoom = roomId; + } + } + } catch { + // Ignore and fall back. + } + + if (fallbackRoom) { + directRoomCache.set(trimmed, fallbackRoom); + await persistDirectRoom(client, trimmed, fallbackRoom); + return fallbackRoom; + } + + throw new Error(`No direct room found for ${trimmed} (m.direct missing)`); +} + +export async function resolveMatrixRoomId(client: MatrixClient, raw: string): Promise { + const target = normalizeTarget(raw); + const lowered = target.toLowerCase(); + if (lowered.startsWith("matrix:")) { + return await resolveMatrixRoomId(client, target.slice("matrix:".length)); + } + if (lowered.startsWith("room:")) { + return await resolveMatrixRoomId(client, target.slice("room:".length)); + } + if (lowered.startsWith("channel:")) { + return await resolveMatrixRoomId(client, target.slice("channel:".length)); + } + if (lowered.startsWith("user:")) { + return await resolveDirectRoomId(client, target.slice("user:".length)); + } + if (target.startsWith("@")) { + return await resolveDirectRoomId(client, target); + } + if (target.startsWith("#")) { + const resolved = await client.resolveRoom(target); + if (!resolved) { + throw new Error(`Matrix alias ${target} could not be resolved`); + } + return resolved; + } + return target; +} diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b91327aadb28b0a0cdb59db82600cb7f5798e09 --- /dev/null +++ b/extensions/matrix/src/matrix/send/types.ts @@ -0,0 +1,109 @@ +import type { + DimensionalFileInfo, + EncryptedFile, + FileWithThumbnailInfo, + MessageEventContent, + TextualMessageEventContent, + TimedFileInfo, + VideoFileInfo, +} from "@vector-im/matrix-bot-sdk"; + +// Message types +export const MsgType = { + Text: "m.text", + Image: "m.image", + Audio: "m.audio", + Video: "m.video", + File: "m.file", + Notice: "m.notice", +} as const; + +// Relation types +export const RelationType = { + Annotation: "m.annotation", + Replace: "m.replace", + Thread: "m.thread", +} as const; + +// Event types +export const EventType = { + Direct: "m.direct", + Reaction: "m.reaction", + RoomMessage: "m.room.message", +} as const; + +export type MatrixDirectAccountData = Record; + +export type MatrixReplyRelation = { + "m.in_reply_to": { event_id: string }; +}; + +export type MatrixThreadRelation = { + rel_type: typeof RelationType.Thread; + event_id: string; + is_falling_back?: boolean; + "m.in_reply_to"?: { event_id: string }; +}; + +export type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation; + +export type MatrixReplyMeta = { + "m.relates_to"?: MatrixRelation; +}; + +export type MatrixMediaInfo = + | FileWithThumbnailInfo + | DimensionalFileInfo + | TimedFileInfo + | VideoFileInfo; + +export type MatrixTextContent = TextualMessageEventContent & MatrixReplyMeta; + +export type MatrixMediaContent = MessageEventContent & + MatrixReplyMeta & { + info?: MatrixMediaInfo; + url?: string; + file?: EncryptedFile; + filename?: string; + "org.matrix.msc3245.voice"?: Record; + "org.matrix.msc1767.audio"?: { duration: number }; + }; + +export type MatrixOutboundContent = MatrixTextContent | MatrixMediaContent; + +export type ReactionEventContent = { + "m.relates_to": { + rel_type: typeof RelationType.Annotation; + event_id: string; + key: string; + }; +}; + +export type MatrixSendResult = { + messageId: string; + roomId: string; +}; + +export type MatrixSendOpts = { + client?: import("@vector-im/matrix-bot-sdk").MatrixClient; + mediaUrl?: string; + accountId?: string; + replyToId?: string; + threadId?: string | number | null; + timeoutMs?: number; + /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ + audioAsVoice?: boolean; +}; + +export type MatrixMediaMsgType = + | typeof MsgType.Image + | typeof MsgType.Audio + | typeof MsgType.Video + | typeof MsgType.File; + +export type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +export type MatrixFormattedContent = MessageEventContent & { + format?: string; + formatted_body?: string; +}; diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..c85f3a25ac3727533cf8117051f3c25ae0eacc98 --- /dev/null +++ b/extensions/matrix/src/onboarding.ts @@ -0,0 +1,442 @@ +import { + addWildcardAllowFrom, + formatDocsLink, + promptChannelAccessConfig, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, DmPolicy } from "./types.js"; +import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; +import { listMatrixDirectoryPeersLive } from "./directory-live.js"; +import { resolveMatrixAccount } from "./matrix/accounts.js"; +import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; + +const channel = "matrix" as const; + +function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { + const allowFrom = + policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + dm: { + ...cfg.channels?.matrix?.dm, + policy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }, + }; +} + +async function noteMatrixAuthHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "Matrix requires a homeserver URL.", + "Use an access token (recommended) or a password (logs in and stores a token).", + "With access token: user ID is fetched automatically.", + "Env vars supported: MATRIX_HOMESERVER, MATRIX_USER_ID, MATRIX_ACCESS_TOKEN, MATRIX_PASSWORD.", + `Docs: ${formatDocsLink("/channels/matrix", "channels/matrix")}`, + ].join("\n"), + "Matrix setup", + ); +} + +async function promptMatrixAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; +}): Promise { + const { cfg, prompter } = params; + const existingAllowFrom = cfg.channels?.matrix?.dm?.allowFrom ?? []; + const account = resolveMatrixAccount({ cfg }); + const canResolve = Boolean(account.configured); + + const parseInput = (raw: string) => + raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + + const isFullUserId = (value: string) => value.startsWith("@") && value.includes(":"); + + while (true) { + const entry = await prompter.text({ + message: "Matrix allowFrom (username or user id)", + placeholder: "@user:server", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseInput(String(entry)); + const resolvedIds: string[] = []; + let unresolved: string[] = []; + + for (const part of parts) { + if (isFullUserId(part)) { + resolvedIds.push(part); + continue; + } + if (!canResolve) { + unresolved.push(part); + continue; + } + const results = await listMatrixDirectoryPeersLive({ + cfg, + query: part, + limit: 5, + }).catch(() => []); + const match = results.find((result) => result.id); + if (match?.id) { + resolvedIds.push(match.id); + if (results.length > 1) { + await prompter.note( + `Multiple matches for "${part}", using ${match.id}.`, + "Matrix allowlist", + ); + } + } else { + unresolved.push(part); + } + } + + if (unresolved.length > 0) { + await prompter.note( + `Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`, + "Matrix allowlist", + ); + continue; + } + + const unique = [ + ...new Set([ + ...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean), + ...resolvedIds, + ]), + ]; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + dm: { + ...cfg.channels?.matrix?.dm, + policy: "allowlist", + allowFrom: unique, + }, + }, + }, + }; + } +} + +function setMatrixGroupPolicy(cfg: CoreConfig, groupPolicy: "open" | "allowlist" | "disabled") { + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + groupPolicy, + }, + }, + }; +} + +function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { + const groups = Object.fromEntries(roomKeys.map((key) => [key, { allow: true }])); + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...cfg.channels?.matrix, + enabled: true, + groups, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Matrix", + channel, + policyKey: "channels.matrix.dm.policy", + allowFromKey: "channels.matrix.dm.allowFrom", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.matrix?.dm?.policy ?? "pairing", + setPolicy: (cfg, policy) => setMatrixDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: promptMatrixAllowFrom, +}; + +export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); + const configured = account.configured; + const sdkReady = isMatrixSdkAvailable(); + return { + channel, + configured, + statusLines: [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ], + selectionHint: !sdkReady + ? "install @vector-im/matrix-bot-sdk" + : configured + ? "configured" + : "needs auth", + }; + }, + configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { + let next = cfg as CoreConfig; + await ensureMatrixSdkInstalled({ + runtime, + confirm: async (message) => + await prompter.confirm({ + message, + initialValue: true, + }), + }); + const existing = next.channels?.matrix ?? {}; + const account = resolveMatrixAccount({ cfg: next }); + if (!account.configured) { + await noteMatrixAuthHelp(prompter); + } + + const envHomeserver = process.env.MATRIX_HOMESERVER?.trim(); + const envUserId = process.env.MATRIX_USER_ID?.trim(); + const envAccessToken = process.env.MATRIX_ACCESS_TOKEN?.trim(); + const envPassword = process.env.MATRIX_PASSWORD?.trim(); + const envReady = Boolean(envHomeserver && (envAccessToken || (envUserId && envPassword))); + + if ( + envReady && + !existing.homeserver && + !existing.userId && + !existing.accessToken && + !existing.password + ) { + const useEnv = await prompter.confirm({ + message: "Matrix env vars detected. Use env values?", + initialValue: true, + }); + if (useEnv) { + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + }; + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + return { cfg: next }; + } + } + + const homeserver = String( + await prompter.text({ + message: "Matrix homeserver URL", + initialValue: existing.homeserver ?? envHomeserver, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!/^https?:\/\//i.test(raw)) { + return "Use a full URL (https://...)"; + } + return undefined; + }, + }), + ).trim(); + + let accessToken = existing.accessToken ?? ""; + let password = existing.password ?? ""; + let userId = existing.userId ?? ""; + + if (accessToken || password) { + const keep = await prompter.confirm({ + message: "Matrix credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + accessToken = ""; + password = ""; + userId = ""; + } + } + + if (!accessToken && !password) { + // Ask auth method FIRST before asking for user ID + const authMode = await prompter.select({ + message: "Matrix auth method", + options: [ + { value: "token", label: "Access token (user ID fetched automatically)" }, + { value: "password", label: "Password (requires user ID)" }, + ], + }); + + if (authMode === "token") { + accessToken = String( + await prompter.text({ + message: "Matrix access token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + // With access token, we can fetch the userId automatically - don't prompt for it + // The client.ts will use whoami() to get it + userId = ""; + } else { + // Password auth requires user ID upfront + userId = String( + await prompter.text({ + message: "Matrix user ID", + initialValue: existing.userId ?? envUserId, + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + if (!raw.startsWith("@")) { + return "Matrix user IDs should start with @"; + } + if (!raw.includes(":")) { + return "Matrix user IDs should include a server (:server)"; + } + return undefined; + }, + }), + ).trim(); + password = String( + await prompter.text({ + message: "Matrix password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } + + const deviceName = String( + await prompter.text({ + message: "Matrix device name (optional)", + initialValue: existing.deviceName ?? "OpenClaw Gateway", + }), + ).trim(); + + // Ask about E2EE encryption + const enableEncryption = await prompter.confirm({ + message: "Enable end-to-end encryption (E2EE)?", + initialValue: existing.encryption ?? false, + }); + + next = { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + homeserver, + userId: userId || undefined, + accessToken: accessToken || undefined, + password: password || undefined, + deviceName: deviceName || undefined, + encryption: enableEncryption || undefined, + }, + }, + }; + + if (forceAllowFrom) { + next = await promptMatrixAllowFrom({ cfg: next, prompter }); + } + + const existingGroups = next.channels?.matrix?.groups ?? next.channels?.matrix?.rooms; + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "Matrix rooms", + currentPolicy: next.channels?.matrix?.groupPolicy ?? "allowlist", + currentEntries: Object.keys(existingGroups ?? {}), + placeholder: "!roomId:server, #alias:server, Project Room", + updatePrompt: Boolean(existingGroups), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMatrixGroupPolicy(next, accessConfig.policy); + } else { + let roomKeys = accessConfig.entries; + if (accessConfig.entries.length > 0) { + try { + const resolvedIds: string[] = []; + const unresolved: string[] = []; + for (const entry of accessConfig.entries) { + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + const cleaned = trimmed.replace(/^(room|channel):/i, "").trim(); + if (cleaned.startsWith("!") && cleaned.includes(":")) { + resolvedIds.push(cleaned); + continue; + } + const matches = await listMatrixDirectoryGroupsLive({ + cfg: next, + query: trimmed, + limit: 10, + }); + const exact = matches.find( + (match) => (match.name ?? "").toLowerCase() === trimmed.toLowerCase(), + ); + const best = exact ?? matches[0]; + if (best?.id) { + resolvedIds.push(best.id); + } else { + unresolved.push(entry); + } + } + roomKeys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + if (resolvedIds.length > 0 || unresolved.length > 0) { + await prompter.note( + [ + resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined, + unresolved.length > 0 + ? `Unresolved (kept as typed): ${unresolved.join(", ")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + "Matrix rooms", + ); + } + } catch (err) { + await prompter.note( + `Room lookup failed; keeping entries as typed. ${String(err)}`, + "Matrix rooms", + ); + } + } + next = setMatrixGroupPolicy(next, "allowlist"); + next = setMatrixGroupRooms(next, roomKeys); + } + } + + return { cfg: next }; + }, + dmPolicy, + disable: (cfg) => ({ + ...(cfg as CoreConfig), + channels: { + ...(cfg as CoreConfig).channels, + matrix: { ...(cfg as CoreConfig).channels?.matrix, enabled: false }, + }, + }), +}; diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts new file mode 100644 index 0000000000000000000000000000000000000000..86e660e663d398a9549c1d32be73eb66e4eb16af --- /dev/null +++ b/extensions/matrix/src/outbound.ts @@ -0,0 +1,52 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import { sendMessageMatrix, sendPollMatrix } from "./matrix/send.js"; +import { getMatrixRuntime } from "./runtime.js"; + +export const matrixOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ to, text, deps, replyToId, threadId }) => { + const send = deps?.sendMatrix ?? sendMessageMatrix; + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await send(to, text, { + replyToId: replyToId ?? undefined, + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.messageId, + roomId: result.roomId, + }; + }, + sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId }) => { + const send = deps?.sendMatrix ?? sendMessageMatrix; + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await send(to, text, { + mediaUrl, + replyToId: replyToId ?? undefined, + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.messageId, + roomId: result.roomId, + }; + }, + sendPoll: async ({ to, poll, threadId }) => { + const resolvedThreadId = + threadId !== undefined && threadId !== null ? String(threadId) : undefined; + const result = await sendPollMatrix(to, poll, { + threadId: resolvedThreadId, + }); + return { + channel: "matrix", + messageId: result.eventId, + roomId: result.roomId, + pollId: result.eventId, + }; + }, +}; diff --git a/extensions/matrix/src/resolve-targets.ts b/extensions/matrix/src/resolve-targets.ts new file mode 100644 index 0000000000000000000000000000000000000000..a184247e1b5867df2d9068be82205ea9c9f1db5c --- /dev/null +++ b/extensions/matrix/src/resolve-targets.ts @@ -0,0 +1,89 @@ +import type { + ChannelDirectoryEntry, + ChannelResolveKind, + ChannelResolveResult, + RuntimeEnv, +} from "openclaw/plugin-sdk"; +import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js"; + +function pickBestGroupMatch( + matches: ChannelDirectoryEntry[], + query: string, +): ChannelDirectoryEntry | undefined { + if (matches.length === 0) { + return undefined; + } + const normalized = query.trim().toLowerCase(); + if (normalized) { + const exact = matches.find((match) => { + const name = match.name?.trim().toLowerCase(); + const handle = match.handle?.trim().toLowerCase(); + const id = match.id.trim().toLowerCase(); + return name === normalized || handle === normalized || id === normalized; + }); + if (exact) { + return exact; + } + } + return matches[0]; +} + +export async function resolveMatrixTargets(params: { + cfg: unknown; + inputs: string[]; + kind: ChannelResolveKind; + runtime?: RuntimeEnv; +}): Promise { + const results: ChannelResolveResult[] = []; + for (const input of params.inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (params.kind === "user") { + if (trimmed.startsWith("@") && trimmed.includes(":")) { + results.push({ input, resolved: true, id: trimmed }); + continue; + } + try { + const matches = await listMatrixDirectoryPeersLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + const best = matches[0]; + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + continue; + } + try { + const matches = await listMatrixDirectoryGroupsLive({ + cfg: params.cfg, + query: trimmed, + limit: 5, + }); + const best = pickBestGroupMatch(matches, trimmed); + results.push({ + input, + resolved: Boolean(best?.id), + id: best?.id, + name: best?.name, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }); + } catch (err) { + params.runtime?.error?.(`matrix resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; +} diff --git a/extensions/matrix/src/runtime.ts b/extensions/matrix/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..62eff71ad17ca7d0d3ab9231624962f1959e4176 --- /dev/null +++ b/extensions/matrix/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setMatrixRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMatrixRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Matrix runtime not initialized"); + } + return runtime; +} diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..83ccecd7a815954cff7d60fa1069dc4df2964c63 --- /dev/null +++ b/extensions/matrix/src/tool-actions.ts @@ -0,0 +1,164 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringParam, +} from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; +import { + deleteMatrixMessage, + editMatrixMessage, + getMatrixMemberInfo, + getMatrixRoomInfo, + listMatrixPins, + listMatrixReactions, + pinMatrixMessage, + readMatrixMessages, + removeMatrixReactions, + sendMatrixMessage, + unpinMatrixMessage, +} from "./matrix/actions.js"; +import { reactMatrixMessage } from "./matrix/send.js"; + +const messageActions = new Set(["sendMessage", "editMessage", "deleteMessage", "readMessages"]); +const reactionActions = new Set(["react", "reactions"]); +const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); + +function readRoomId(params: Record, required = true): string { + const direct = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + if (direct) { + return direct; + } + if (!required) { + return readStringParam(params, "to") ?? ""; + } + return readStringParam(params, "to", { required: true }); +} + +export async function handleMatrixAction( + params: Record, + cfg: CoreConfig, +): Promise> { + const action = readStringParam(params, "action", { required: true }); + const isActionEnabled = createActionGate(cfg.channels?.matrix?.actions); + + if (reactionActions.has(action)) { + if (!isActionEnabled("reactions")) { + throw new Error("Matrix reactions are disabled."); + } + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + if (action === "react") { + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Matrix reaction.", + }); + if (remove || isEmpty) { + const result = await removeMatrixReactions(roomId, messageId, { + emoji: remove ? emoji : undefined, + }); + return jsonResult({ ok: true, removed: result.removed }); + } + await reactMatrixMessage(roomId, messageId, emoji); + return jsonResult({ ok: true, added: emoji }); + } + const reactions = await listMatrixReactions(roomId, messageId); + return jsonResult({ ok: true, reactions }); + } + + if (messageActions.has(action)) { + if (!isActionEnabled("messages")) { + throw new Error("Matrix messages are disabled."); + } + switch (action) { + case "sendMessage": { + const to = readStringParam(params, "to", { required: true }); + const content = readStringParam(params, "content", { + required: true, + allowEmpty: true, + }); + const mediaUrl = readStringParam(params, "mediaUrl"); + const replyToId = + readStringParam(params, "replyToId") ?? readStringParam(params, "replyTo"); + const threadId = readStringParam(params, "threadId"); + const result = await sendMatrixMessage(to, content, { + mediaUrl: mediaUrl ?? undefined, + replyToId: replyToId ?? undefined, + threadId: threadId ?? undefined, + }); + return jsonResult({ ok: true, result }); + } + case "editMessage": { + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "content", { required: true }); + const result = await editMatrixMessage(roomId, messageId, content); + return jsonResult({ ok: true, result }); + } + case "deleteMessage": { + const roomId = readRoomId(params); + const messageId = readStringParam(params, "messageId", { required: true }); + const reason = readStringParam(params, "reason"); + await deleteMatrixMessage(roomId, messageId, { reason: reason ?? undefined }); + return jsonResult({ ok: true, deleted: true }); + } + case "readMessages": { + const roomId = readRoomId(params); + const limit = readNumberParam(params, "limit", { integer: true }); + const before = readStringParam(params, "before"); + const after = readStringParam(params, "after"); + const result = await readMatrixMessages(roomId, { + limit: limit ?? undefined, + before: before ?? undefined, + after: after ?? undefined, + }); + return jsonResult({ ok: true, ...result }); + } + default: + break; + } + } + + if (pinActions.has(action)) { + if (!isActionEnabled("pins")) { + throw new Error("Matrix pins are disabled."); + } + const roomId = readRoomId(params); + if (action === "pinMessage") { + const messageId = readStringParam(params, "messageId", { required: true }); + const result = await pinMatrixMessage(roomId, messageId); + return jsonResult({ ok: true, pinned: result.pinned }); + } + if (action === "unpinMessage") { + const messageId = readStringParam(params, "messageId", { required: true }); + const result = await unpinMatrixMessage(roomId, messageId); + return jsonResult({ ok: true, pinned: result.pinned }); + } + const result = await listMatrixPins(roomId); + return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); + } + + if (action === "memberInfo") { + if (!isActionEnabled("memberInfo")) { + throw new Error("Matrix member info is disabled."); + } + const userId = readStringParam(params, "userId", { required: true }); + const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); + const result = await getMatrixMemberInfo(userId, { + roomId: roomId ?? undefined, + }); + return jsonResult({ ok: true, member: result }); + } + + if (action === "channelInfo") { + if (!isActionEnabled("channelInfo")) { + throw new Error("Matrix room info is disabled."); + } + const roomId = readRoomId(params); + const result = await getMatrixRoomInfo(roomId); + return jsonResult({ ok: true, room: result }); + } + + throw new Error(`Unsupported Matrix action: ${action}`); +} diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..f037341308ae41297b01001fc3d5bd9a23713894 --- /dev/null +++ b/extensions/matrix/src/types.ts @@ -0,0 +1,95 @@ +export type ReplyToMode = "off" | "first" | "all"; +export type GroupPolicy = "open" | "disabled" | "allowlist"; +export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled"; + +export type MatrixDmConfig = { + /** If false, ignore all incoming Matrix DMs. Default: true. */ + enabled?: boolean; + /** Direct message access policy (default: pairing). */ + policy?: DmPolicy; + /** Allowlist for DM senders (matrix user IDs, localparts, or "*"). */ + allowFrom?: Array; +}; + +export type MatrixRoomConfig = { + /** If false, disable the bot in this room (alias for allow: false). */ + enabled?: boolean; + /** Legacy room allow toggle; prefer enabled. */ + allow?: boolean; + /** Require mentioning the bot to trigger replies. */ + requireMention?: boolean; + /** Optional tool policy overrides for this room. */ + tools?: { allow?: string[]; deny?: string[] }; + /** If true, reply without mention requirements. */ + autoReply?: boolean; + /** Optional allowlist for room senders (user IDs or localparts). */ + users?: Array; + /** Optional skill filter for this room. */ + skills?: string[]; + /** Optional system prompt snippet for this room. */ + systemPrompt?: string; +}; + +export type MatrixActionConfig = { + reactions?: boolean; + messages?: boolean; + pins?: boolean; + memberInfo?: boolean; + channelInfo?: boolean; +}; + +export type MatrixConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start Matrix. Default: true. */ + enabled?: boolean; + /** Matrix homeserver URL (https://matrix.example.org). */ + homeserver?: string; + /** Matrix user id (@user:server). */ + userId?: string; + /** Matrix access token. */ + accessToken?: string; + /** Matrix password (used only to fetch access token). */ + password?: string; + /** Optional device name when logging in via password. */ + deviceName?: string; + /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */ + initialSyncLimit?: number; + /** Enable end-to-end encryption (E2EE). Default: false. */ + encryption?: boolean; + /** If true, enforce allowlists for groups + DMs regardless of policy. */ + allowlistOnly?: boolean; + /** Group message policy (default: allowlist). */ + groupPolicy?: GroupPolicy; + /** Allowlist for group senders (user IDs or localparts). */ + groupAllowFrom?: Array; + /** Control reply threading when reply tags are present (off|first|all). */ + replyToMode?: ReplyToMode; + /** How to handle thread replies (off|inbound|always). */ + threadReplies?: "off" | "inbound" | "always"; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; + /** Auto-join invites (always|allowlist|off). Default: always. */ + autoJoin?: "always" | "allowlist" | "off"; + /** Allowlist for auto-join invites (room IDs, aliases). */ + autoJoinAllowlist?: Array; + /** Direct message policy + allowlist overrides. */ + dm?: MatrixDmConfig; + /** Room config allowlist keyed by room ID, alias, or name. */ + groups?: Record; + /** Room config allowlist keyed by room ID, alias, or name. Legacy; use groups. */ + rooms?: Record; + /** Per-action tool gating (default: true for all). */ + actions?: MatrixActionConfig; +}; + +export type CoreConfig = { + channels?: { + matrix?: MatrixConfig; + }; + [key: string]: unknown; +}; diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..276c5d01871a1df8add9d370d5dc61eb6d97b860 --- /dev/null +++ b/extensions/mattermost/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { mattermostPlugin } from "./src/channel.js"; +import { setMattermostRuntime } from "./src/runtime.js"; + +const plugin = { + id: "mattermost", + name: "Mattermost", + description: "Mattermost channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setMattermostRuntime(api.runtime); + api.registerChannel({ plugin: mattermostPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/mattermost/openclaw.plugin.json b/extensions/mattermost/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..91bbca1f210d493ace98fb21806db6a417e953a2 --- /dev/null +++ b/extensions/mattermost/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "mattermost", + "channels": ["mattermost"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f73af201a9cb13172556e531b5884711dd05099e --- /dev/null +++ b/extensions/mattermost/package.json @@ -0,0 +1,28 @@ +{ + "name": "@openclaw/mattermost", + "version": "2026.1.30", + "description": "OpenClaw Mattermost channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "mattermost", + "label": "Mattermost", + "selectionLabel": "Mattermost (plugin)", + "docsPath": "/channels/mattermost", + "docsLabel": "mattermost", + "blurb": "self-hosted Slack-style chat; install the plugin to enable.", + "order": 65 + }, + "install": { + "npmSpec": "@openclaw/mattermost", + "localPath": "extensions/mattermost", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..118d6dfb67005c9847b5210e88d6bb8a37ebb9e0 --- /dev/null +++ b/extensions/mattermost/src/channel.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { mattermostPlugin } from "./channel.js"; + +describe("mattermostPlugin", () => { + describe("messaging", () => { + it("keeps @username targets", () => { + const normalize = mattermostPlugin.messaging?.normalizeTarget; + if (!normalize) { + return; + } + + expect(normalize("@Alice")).toBe("@Alice"); + expect(normalize("@alice")).toBe("@alice"); + }); + + it("normalizes mattermost: prefix to user:", () => { + const normalize = mattermostPlugin.messaging?.normalizeTarget; + if (!normalize) { + return; + } + + expect(normalize("mattermost:USER123")).toBe("user:USER123"); + }); + }); + + describe("pairing", () => { + it("normalizes allowlist entries", () => { + const normalize = mattermostPlugin.pairing?.normalizeAllowEntry; + if (!normalize) { + return; + } + + expect(normalize("@Alice")).toBe("alice"); + expect(normalize("user:USER123")).toBe("user123"); + }); + }); + + describe("config", () => { + it("formats allowFrom entries", () => { + const formatAllowFrom = mattermostPlugin.config.formatAllowFrom; + + const formatted = formatAllowFrom({ + allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"], + }); + expect(formatted).toEqual(["@alice", "user123", "bot999"]); + }); + }); +}); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..a658dbb04e543e0f49d0df1acc18c2237acee8eb --- /dev/null +++ b/extensions/mattermost/src/channel.ts @@ -0,0 +1,337 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + setAccountEnabledInConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; +import { MattermostConfigSchema } from "./config-schema.js"; +import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; +import { + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; +import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; +import { monitorMattermostProvider } from "./mattermost/monitor.js"; +import { probeMattermost } from "./mattermost/probe.js"; +import { sendMessageMattermost } from "./mattermost/send.js"; +import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; +import { mattermostOnboardingAdapter } from "./onboarding.js"; +import { getMattermostRuntime } from "./runtime.js"; + +const meta = { + id: "mattermost", + label: "Mattermost", + selectionLabel: "Mattermost (plugin)", + detailLabel: "Mattermost Bot", + docsPath: "/channels/mattermost", + docsLabel: "mattermost", + blurb: "self-hosted Slack-style chat; install the plugin to enable.", + systemImage: "bubble.left.and.bubble.right", + order: 65, + quickstartAllowFrom: true, +} as const; + +function normalizeAllowEntry(entry: string): string { + return entry + .trim() + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +function formatAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + return username ? `@${username.toLowerCase()}` : ""; + } + return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase(); +} + +export const mattermostPlugin: ChannelPlugin = { + id: "mattermost", + meta: { + ...meta, + }, + onboarding: mattermostOnboardingAdapter, + pairing: { + idLabel: "mattermostUserId", + normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), + notifyApproval: async ({ id }) => { + console.log(`[mattermost] User ${id} approved for pairing`); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "group", "thread"], + threads: true, + media: true, + }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, + }, + reload: { configPrefixes: ["channels.mattermost"] }, + configSchema: buildChannelConfigSchema(MattermostConfigSchema), + config: { + listAccountIds: (cfg) => listMattermostAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "mattermost", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "mattermost", + accountId, + clearBaseFields: ["botToken", "baseUrl", "name"], + }), + isConfigured: (account) => Boolean(account.botToken && account.baseUrl), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.botToken && account.baseUrl), + botTokenSource: account.botTokenSource, + baseUrl: account.baseUrl, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => formatAllowEntry(String(entry))).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.mattermost.accounts.${resolvedAccountId}.` + : "channels.mattermost."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("mattermost"), + normalizeEntry: (raw) => normalizeAllowEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + `- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`, + ]; + }, + }, + groups: { + resolveRequireMention: resolveMattermostGroupRequireMention, + }, + messaging: { + normalizeTarget: normalizeMattermostMessagingTarget, + targetResolver: { + looksLikeId: looksLikeMattermostTargetId, + hint: "", + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + resolveTarget: ({ to }) => { + const trimmed = to?.trim(); + if (!trimmed) { + return { + ok: false, + error: new Error( + "Delivering to Mattermost requires --to ", + ), + }; + } + return { ok: true, to: trimmed }; + }, + sendText: async ({ to, text, accountId, replyToId }) => { + const result = await sendMessageMattermost(to, text, { + accountId: accountId ?? undefined, + replyToId: replyToId ?? undefined, + }); + return { channel: "mattermost", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + const result = await sendMessageMattermost(to, text, { + accountId: accountId ?? undefined, + mediaUrl, + replyToId: replyToId ?? undefined, + }); + return { channel: "mattermost", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + connected: false, + lastConnectedAt: null, + lastDisconnect: null, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + botTokenSource: snapshot.botTokenSource ?? "none", + running: snapshot.running ?? false, + connected: snapshot.connected ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + baseUrl: snapshot.baseUrl ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ account, timeoutMs }) => { + const token = account.botToken?.trim(); + const baseUrl = account.baseUrl?.trim(); + if (!token || !baseUrl) { + return { ok: false, error: "bot token or baseUrl missing" }; + } + return await probeMattermost(baseUrl, token, timeoutMs); + }, + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.botToken && account.baseUrl), + botTokenSource: account.botTokenSource, + baseUrl: account.baseUrl, + running: runtime?.running ?? false, + connected: runtime?.connected ?? false, + lastConnectedAt: runtime?.lastConnectedAt ?? null, + lastDisconnect: runtime?.lastDisconnect ?? null, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: "mattermost", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Mattermost env vars can only be used for the default account."; + } + const token = input.botToken ?? input.token; + const baseUrl = input.httpUrl; + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = input.httpUrl?.trim(); + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: "mattermost", + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: "mattermost", + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + mattermost: { + ...next.channels?.mattermost, + enabled: true, + ...(input.useEnv + ? {} + : { + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + mattermost: { + ...next.channels?.mattermost, + enabled: true, + accounts: { + ...next.channels?.mattermost?.accounts, + [accountId]: { + ...next.channels?.mattermost?.accounts?.[accountId], + enabled: true, + ...(token ? { botToken: token } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }, + }, + }, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + baseUrl: account.baseUrl, + botTokenSource: account.botTokenSource, + }); + ctx.log?.info(`[${account.accountId}] starting channel`); + return monitorMattermostProvider({ + botToken: account.botToken ?? undefined, + baseUrl: account.baseUrl ?? undefined, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + }); + }, + }, +}; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f184f38027e4c9d86073abeb712e3c686779822 --- /dev/null +++ b/extensions/mattermost/src/config-schema.ts @@ -0,0 +1,55 @@ +import { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const MattermostAccountSchemaBase = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + enabled: z.boolean().optional(), + configWrites: z.boolean().optional(), + botToken: z.string().optional(), + baseUrl: z.string().optional(), + chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(), + oncharPrefixes: z.array(z.string()).optional(), + requireMention: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + }) + .strict(); + +const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + }); +}); + +export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({ + accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + }); +}); diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts new file mode 100644 index 0000000000000000000000000000000000000000..c92da2000c0df29277de016b8b7d256bd3f344e8 --- /dev/null +++ b/extensions/mattermost/src/group-mentions.ts @@ -0,0 +1,15 @@ +import type { ChannelGroupContext } from "openclaw/plugin-sdk"; +import { resolveMattermostAccount } from "./mattermost/accounts.js"; + +export function resolveMattermostGroupRequireMention( + params: ChannelGroupContext, +): boolean | undefined { + const account = resolveMattermostAccount({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (typeof account.requireMention === "boolean") { + return account.requireMention; + } + return true; +} diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4fbd34a21f02247b3d303b100a1c8dbbe629755 --- /dev/null +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -0,0 +1,128 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { MattermostAccountConfig, MattermostChatMode } from "../types.js"; +import { normalizeMattermostBaseUrl } from "./client.js"; + +export type MattermostTokenSource = "env" | "config" | "none"; +export type MattermostBaseUrlSource = "env" | "config" | "none"; + +export type ResolvedMattermostAccount = { + accountId: string; + enabled: boolean; + name?: string; + botToken?: string; + baseUrl?: string; + botTokenSource: MattermostTokenSource; + baseUrlSource: MattermostBaseUrlSource; + config: MattermostAccountConfig; + chatmode?: MattermostChatMode; + oncharPrefixes?: string[]; + requireMention?: boolean; + textChunkLimit?: number; + blockStreaming?: boolean; + blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"]; +}; + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const accounts = cfg.channels?.mattermost?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + return Object.keys(accounts).filter(Boolean); +} + +export function listMattermostAccountIds(cfg: OpenClawConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string { + const ids = listMattermostAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): MattermostAccountConfig | undefined { + const accounts = cfg.channels?.mattermost?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + return accounts[accountId] as MattermostAccountConfig | undefined; +} + +function mergeMattermostAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): MattermostAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ?? + {}) as MattermostAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined { + if (config.chatmode === "oncall") { + return true; + } + if (config.chatmode === "onmessage") { + return false; + } + if (config.chatmode === "onchar") { + return true; + } + return config.requireMention; +} + +export function resolveMattermostAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedMattermostAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false; + const merged = mergeMattermostAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined; + const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined; + const configToken = merged.botToken?.trim(); + const configUrl = merged.baseUrl?.trim(); + const botToken = configToken || envToken; + const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl); + const requireMention = resolveMattermostRequireMention(merged); + + const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none"; + const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none"; + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + botToken, + baseUrl, + botTokenSource, + baseUrlSource, + config: merged, + chatmode: merged.chatmode, + oncharPrefixes: merged.oncharPrefixes, + requireMention, + textChunkLimit: merged.textChunkLimit, + blockStreaming: merged.blockStreaming, + blockStreamingCoalesce: merged.blockStreamingCoalesce, + }; +} + +export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] { + return listMattermostAccountIds(cfg) + .map((accountId) => resolveMattermostAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3e1518341f80f11d0b0f02503703226d4d06568 --- /dev/null +++ b/extensions/mattermost/src/mattermost/client.ts @@ -0,0 +1,220 @@ +export type MattermostClient = { + baseUrl: string; + apiBaseUrl: string; + token: string; + request: (path: string, init?: RequestInit) => Promise; +}; + +export type MattermostUser = { + id: string; + username?: string | null; + nickname?: string | null; + first_name?: string | null; + last_name?: string | null; +}; + +export type MattermostChannel = { + id: string; + name?: string | null; + display_name?: string | null; + type?: string | null; + team_id?: string | null; +}; + +export type MattermostPost = { + id: string; + user_id?: string | null; + channel_id?: string | null; + message?: string | null; + file_ids?: string[] | null; + type?: string | null; + root_id?: string | null; + create_at?: number | null; + props?: Record | null; +}; + +export type MattermostFileInfo = { + id: string; + name?: string | null; + mime_type?: string | null; + size?: number | null; +}; + +export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined { + const trimmed = raw?.trim(); + if (!trimmed) { + return undefined; + } + const withoutTrailing = trimmed.replace(/\/+$/, ""); + return withoutTrailing.replace(/\/api\/v4$/i, ""); +} + +function buildMattermostApiUrl(baseUrl: string, path: string): string { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + throw new Error("Mattermost baseUrl is required"); + } + const suffix = path.startsWith("/") ? path : `/${path}`; + return `${normalized}/api/v4${suffix}`; +} + +async function readMattermostError(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await res.json()) as { message?: string } | undefined; + if (data?.message) { + return data.message; + } + return JSON.stringify(data); + } + return await res.text(); +} + +export function createMattermostClient(params: { + baseUrl: string; + botToken: string; + fetchImpl?: typeof fetch; +}): MattermostClient { + const baseUrl = normalizeMattermostBaseUrl(params.baseUrl); + if (!baseUrl) { + throw new Error("Mattermost baseUrl is required"); + } + const apiBaseUrl = `${baseUrl}/api/v4`; + const token = params.botToken.trim(); + const fetchImpl = params.fetchImpl ?? fetch; + + const request = async (path: string, init?: RequestInit): Promise => { + const url = buildMattermostApiUrl(baseUrl, path); + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${token}`); + if (typeof init?.body === "string" && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + const res = await fetchImpl(url, { ...init, headers }); + if (!res.ok) { + const detail = await readMattermostError(res); + throw new Error( + `Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`, + ); + } + return (await res.json()) as T; + }; + + return { baseUrl, apiBaseUrl, token, request }; +} + +export async function fetchMattermostMe(client: MattermostClient): Promise { + return await client.request("/users/me"); +} + +export async function fetchMattermostUser( + client: MattermostClient, + userId: string, +): Promise { + return await client.request(`/users/${userId}`); +} + +export async function fetchMattermostUserByUsername( + client: MattermostClient, + username: string, +): Promise { + return await client.request(`/users/username/${encodeURIComponent(username)}`); +} + +export async function fetchMattermostChannel( + client: MattermostClient, + channelId: string, +): Promise { + return await client.request(`/channels/${channelId}`); +} + +export async function sendMattermostTyping( + client: MattermostClient, + params: { channelId: string; parentId?: string }, +): Promise { + const payload: Record = { + channel_id: params.channelId, + }; + const parentId = params.parentId?.trim(); + if (parentId) { + payload.parent_id = parentId; + } + await client.request>("/users/me/typing", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function createMattermostDirectChannel( + client: MattermostClient, + userIds: string[], +): Promise { + return await client.request("/channels/direct", { + method: "POST", + body: JSON.stringify(userIds), + }); +} + +export async function createMattermostPost( + client: MattermostClient, + params: { + channelId: string; + message: string; + rootId?: string; + fileIds?: string[]; + }, +): Promise { + const payload: Record = { + channel_id: params.channelId, + message: params.message, + }; + if (params.rootId) { + payload.root_id = params.rootId; + } + if (params.fileIds?.length) { + (payload as Record).file_ids = params.fileIds; + } + return await client.request("/posts", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function uploadMattermostFile( + client: MattermostClient, + params: { + channelId: string; + buffer: Buffer; + fileName: string; + contentType?: string; + }, +): Promise { + const form = new FormData(); + const fileName = params.fileName?.trim() || "upload"; + const bytes = Uint8Array.from(params.buffer); + const blob = params.contentType + ? new Blob([bytes], { type: params.contentType }) + : new Blob([bytes]); + form.append("files", blob, fileName); + form.append("channel_id", params.channelId); + + const res = await fetch(`${client.apiBaseUrl}/files`, { + method: "POST", + headers: { + Authorization: `Bearer ${client.token}`, + }, + body: form, + }); + + if (!res.ok) { + const detail = await readMattermostError(res); + throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`); + } + + const data = (await res.json()) as { file_infos?: MattermostFileInfo[] }; + const info = data.file_infos?.[0]; + if (!info?.id) { + throw new Error("Mattermost file upload failed"); + } + return info; +} diff --git a/extensions/mattermost/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d09fc402c1590db28251c1f9ced37a5e391a335 --- /dev/null +++ b/extensions/mattermost/src/mattermost/index.ts @@ -0,0 +1,9 @@ +export { + listEnabledMattermostAccounts, + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, +} from "./accounts.js"; +export { monitorMattermostProvider } from "./monitor.js"; +export { probeMattermost } from "./probe.js"; +export { sendMessageMattermost } from "./send.js"; diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e483f6a46ba478e6383b735b91c6083984d7d2d --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -0,0 +1,166 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type WebSocket from "ws"; +import { Buffer } from "node:buffer"; + +export type ResponsePrefixContext = { + model?: string; + modelFull?: string; + provider?: string; + thinkingLevel?: string; + identityName?: string; +}; + +export function extractShortModelName(fullModel: string): string { + const slash = fullModel.lastIndexOf("/"); + const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel; + return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, ""); +} + +export function formatInboundFromLabel(params: { + isGroup: boolean; + groupLabel?: string; + groupId?: string; + directLabel: string; + directId?: string; + groupFallback?: string; +}): string { + if (params.isGroup) { + const label = params.groupLabel?.trim() || params.groupFallback || "Group"; + const id = params.groupId?.trim(); + return id ? `${label} id:${id}` : label; + } + + const directLabel = params.directLabel.trim(); + const directId = params.directId?.trim(); + if (!directId || directId === directLabel) { + return directLabel; + } + return `${directLabel} id:${directId}`; +} + +type DedupeCache = { + check: (key: string | undefined | null, now?: number) => boolean; +}; + +export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache { + const ttlMs = Math.max(0, options.ttlMs); + const maxSize = Math.max(0, Math.floor(options.maxSize)); + const cache = new Map(); + + const touch = (key: string, now: number) => { + cache.delete(key); + cache.set(key, now); + }; + + const prune = (now: number) => { + const cutoff = ttlMs > 0 ? now - ttlMs : undefined; + if (cutoff !== undefined) { + for (const [entryKey, entryTs] of cache) { + if (entryTs < cutoff) { + cache.delete(entryKey); + } + } + } + if (maxSize <= 0) { + cache.clear(); + return; + } + while (cache.size > maxSize) { + const oldestKey = cache.keys().next().value as string | undefined; + if (!oldestKey) { + break; + } + cache.delete(oldestKey); + } + }; + + return { + check: (key, now = Date.now()) => { + if (!key) { + return false; + } + const existing = cache.get(key); + if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) { + touch(key, now); + return true; + } + touch(key, now); + prune(now); + return false; + }, + }; +} + +export function rawDataToString( + data: WebSocket.RawData, + encoding: BufferEncoding = "utf8", +): string { + if (typeof data === "string") { + return data; + } + if (Buffer.isBuffer(data)) { + return data.toString(encoding); + } + if (Array.isArray(data)) { + return Buffer.concat(data).toString(encoding); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString(encoding); + } + return Buffer.from(String(data)).toString(encoding); +} + +function normalizeAgentId(value: string | undefined | null): string { + const trimmed = (value ?? "").trim(); + if (!trimmed) { + return "main"; + } + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) { + return trimmed; + } + return ( + trimmed + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/, "") + .replace(/-+$/, "") + .slice(0, 64) || "main" + ); +} + +type AgentEntry = NonNullable["list"]>[number]; + +function listAgents(cfg: OpenClawConfig): AgentEntry[] { + const list = cfg.agents?.list; + if (!Array.isArray(list)) { + return []; + } + return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object")); +} + +function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined { + const id = normalizeAgentId(agentId); + return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id); +} + +export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined { + const entry = resolveAgentEntry(cfg, agentId); + return entry?.identity?.name?.trim() || undefined; +} + +export function resolveThreadSessionKeys(params: { + baseSessionKey: string; + threadId?: string | null; + parentSessionKey?: string; + useSuffix?: boolean; +}): { sessionKey: string; parentSessionKey?: string } { + const threadId = (params.threadId ?? "").trim(); + if (!threadId) { + return { sessionKey: params.baseSessionKey, parentSessionKey: undefined }; + } + const useSuffix = params.useSuffix ?? true; + const sessionKey = useSuffix + ? `${params.baseSessionKey}:thread:${threadId}` + : params.baseSessionKey; + return { sessionKey, parentSessionKey: params.parentSessionKey }; +} diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d10b13f6b654b2e4545c6c7125471c253667383 --- /dev/null +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -0,0 +1,987 @@ +import type { + ChannelAccountSnapshot, + OpenClawConfig, + ReplyPayload, + RuntimeEnv, +} from "openclaw/plugin-sdk"; +import { + createReplyPrefixContext, + createTypingCallbacks, + logInboundDrop, + logTypingFailure, + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, + resolveChannelMediaMaxBytes, + type HistoryEntry, +} from "openclaw/plugin-sdk"; +import WebSocket from "ws"; +import { getMattermostRuntime } from "../runtime.js"; +import { resolveMattermostAccount } from "./accounts.js"; +import { + createMattermostClient, + fetchMattermostChannel, + fetchMattermostMe, + fetchMattermostUser, + normalizeMattermostBaseUrl, + sendMattermostTyping, + type MattermostChannel, + type MattermostPost, + type MattermostUser, +} from "./client.js"; +import { + createDedupeCache, + formatInboundFromLabel, + rawDataToString, + resolveThreadSessionKeys, +} from "./monitor-helpers.js"; +import { sendMessageMattermost } from "./send.js"; + +export type MonitorMattermostOpts = { + botToken?: string; + baseUrl?: string; + accountId?: string; + config?: OpenClawConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + statusSink?: (patch: Partial) => void; +}; + +type FetchLike = typeof fetch; +type MediaKind = "image" | "audio" | "video" | "document" | "unknown"; + +type MattermostEventPayload = { + event?: string; + data?: { + post?: string; + channel_id?: string; + channel_name?: string; + channel_display_name?: string; + channel_type?: string; + sender_name?: string; + team_id?: string; + }; + broadcast?: { + channel_id?: string; + team_id?: string; + user_id?: string; + }; +}; + +const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000; +const RECENT_MATTERMOST_MESSAGE_MAX = 2000; +const CHANNEL_CACHE_TTL_MS = 5 * 60_000; +const USER_CACHE_TTL_MS = 10 * 60_000; +const DEFAULT_ONCHAR_PREFIXES = [">", "!"]; + +const recentInboundMessages = createDedupeCache({ + ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS, + maxSize: RECENT_MATTERMOST_MESSAGE_MAX, +}); + +function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv { + return ( + opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + } + ); +} + +function normalizeMention(text: string, mention: string | undefined): string { + if (!mention) { + return text.trim(); + } + const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const re = new RegExp(`@${escaped}\\b`, "gi"); + return text.replace(re, " ").replace(/\s+/g, " ").trim(); +} + +function resolveOncharPrefixes(prefixes: string[] | undefined): string[] { + const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES; + return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES; +} + +function stripOncharPrefix( + text: string, + prefixes: string[], +): { triggered: boolean; stripped: string } { + const trimmed = text.trimStart(); + for (const prefix of prefixes) { + if (!prefix) { + continue; + } + if (trimmed.startsWith(prefix)) { + return { + triggered: true, + stripped: trimmed.slice(prefix.length).trimStart(), + }; + } + } + return { triggered: false, stripped: text }; +} + +function isSystemPost(post: MattermostPost): boolean { + const type = post.type?.trim(); + return Boolean(type); +} + +function channelKind(channelType?: string | null): "dm" | "group" | "channel" { + if (!channelType) { + return "channel"; + } + const normalized = channelType.trim().toUpperCase(); + if (normalized === "D") { + return "dm"; + } + if (normalized === "G") { + return "group"; + } + return "channel"; +} + +function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" { + if (kind === "dm") { + return "direct"; + } + if (kind === "group") { + return "group"; + } + return "channel"; +} + +function normalizeAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + return trimmed + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +function normalizeAllowList(entries: Array): string[] { + const normalized = entries.map((entry) => normalizeAllowEntry(String(entry))).filter(Boolean); + return Array.from(new Set(normalized)); +} + +function isSenderAllowed(params: { + senderId: string; + senderName?: string; + allowFrom: string[]; +}): boolean { + const allowFrom = params.allowFrom; + if (allowFrom.length === 0) { + return false; + } + if (allowFrom.includes("*")) { + return true; + } + const normalizedSenderId = normalizeAllowEntry(params.senderId); + const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; + return allowFrom.some( + (entry) => + entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), + ); +} + +type MattermostMediaInfo = { + path: string; + contentType?: string; + kind: MediaKind; +}; + +function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string { + if (mediaList.length === 0) { + return ""; + } + if (mediaList.length === 1) { + const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind; + return ``; + } + const allImages = mediaList.every((media) => media.kind === "image"); + const label = allImages ? "image" : "file"; + const suffix = mediaList.length === 1 ? label : `${label}s`; + const tag = allImages ? "" : ""; + return `${tag} (${mediaList.length} ${suffix})`; +} + +function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +} { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[]; + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined, + }; +} + +function buildMattermostWsUrl(baseUrl: string): string { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + throw new Error("Mattermost baseUrl is required"); + } + const wsBase = normalized.replace(/^http/i, "ws"); + return `${wsBase}/api/v4/websocket`; +} + +export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise { + const core = getMattermostRuntime(); + const runtime = resolveRuntime(opts); + const cfg = opts.config ?? core.config.loadConfig(); + const account = resolveMattermostAccount({ + cfg, + accountId: opts.accountId, + }); + const botToken = opts.botToken?.trim() || account.botToken?.trim(); + if (!botToken) { + throw new Error( + `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`, + ); + } + const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl); + if (!baseUrl) { + throw new Error( + `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`, + ); + } + + const client = createMattermostClient({ baseUrl, botToken }); + const botUser = await fetchMattermostMe(client); + const botUserId = botUser.id; + const botUsername = botUser.username?.trim() || undefined; + runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`); + + const channelCache = new Map(); + const userCache = new Map(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const logVerboseMessage = (message: string) => { + if (!core.logging.shouldLogVerbose()) { + return; + } + logger.debug?.(message); + }; + const mediaMaxBytes = + resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: () => undefined, + accountId: account.accountId, + }) ?? 8 * 1024 * 1024; + const historyLimit = Math.max( + 0, + cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT, + ); + const channelHistories = new Map(); + + const fetchWithAuth: FetchLike = (input, init) => { + const headers = new Headers(init?.headers); + headers.set("Authorization", `Bearer ${client.token}`); + return fetch(input, { ...init, headers }); + }; + + const resolveMattermostMedia = async ( + fileIds?: string[] | null, + ): Promise => { + const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean); + if (ids.length === 0) { + return []; + } + const out: MattermostMediaInfo[] = []; + for (const fileId of ids) { + try { + const fetched = await core.channel.media.fetchRemoteMedia({ + url: `${client.apiBaseUrl}/files/${fileId}`, + fetchImpl: fetchWithAuth, + filePathHint: fileId, + maxBytes: mediaMaxBytes, + }); + const saved = await core.channel.media.saveMediaBuffer( + fetched.buffer, + fetched.contentType ?? undefined, + "inbound", + mediaMaxBytes, + ); + const contentType = saved.contentType ?? fetched.contentType ?? undefined; + out.push({ + path: saved.path, + contentType, + kind: core.media.mediaKindFromMime(contentType), + }); + } catch (err) { + logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`); + } + } + return out; + }; + + const sendTypingIndicator = async (channelId: string, parentId?: string) => { + await sendMattermostTyping(client, { channelId, parentId }); + }; + + const resolveChannelInfo = async (channelId: string): Promise => { + const cached = channelCache.get(channelId); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + try { + const info = await fetchMattermostChannel(client, channelId); + channelCache.set(channelId, { + value: info, + expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS, + }); + return info; + } catch (err) { + logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`); + channelCache.set(channelId, { + value: null, + expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS, + }); + return null; + } + }; + + const resolveUserInfo = async (userId: string): Promise => { + const cached = userCache.get(userId); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + try { + const info = await fetchMattermostUser(client, userId); + userCache.set(userId, { + value: info, + expiresAt: Date.now() + USER_CACHE_TTL_MS, + }); + return info; + } catch (err) { + logger.debug?.(`mattermost: user lookup failed: ${String(err)}`); + userCache.set(userId, { + value: null, + expiresAt: Date.now() + USER_CACHE_TTL_MS, + }); + return null; + } + }; + + const handlePost = async ( + post: MattermostPost, + payload: MattermostEventPayload, + messageIds?: string[], + ) => { + const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id; + if (!channelId) { + return; + } + + const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : []; + if (allMessageIds.length === 0) { + return; + } + const dedupeEntries = allMessageIds.map((id) => + recentInboundMessages.check(`${account.accountId}:${id}`), + ); + if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) { + return; + } + + const senderId = post.user_id ?? payload.broadcast?.user_id; + if (!senderId) { + return; + } + if (senderId === botUserId) { + return; + } + if (isSystemPost(post)) { + return; + } + + const channelInfo = await resolveChannelInfo(channelId); + const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined; + const kind = channelKind(channelType); + const chatType = channelChatType(kind); + + const senderName = + payload.data?.sender_name?.trim() || + (await resolveUserInfo(senderId))?.username?.trim() || + senderId; + const rawText = post.message?.trim() || ""; + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); + const storeAllowFrom = normalizeAllowList( + await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + ); + const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); + const effectiveGroupAllowFrom = Array.from( + new Set([ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ]), + ); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const hasControlCommand = core.channel.text.hasControlCommand(rawText, cfg); + const isControlCommand = allowTextCommands && hasControlCommand; + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed({ + senderId, + senderName, + allowFrom: effectiveAllowFrom, + }); + const groupAllowedForCommands = isSenderAllowed({ + senderId, + senderName, + allowFrom: effectiveGroupAllowFrom, + }); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: effectiveGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); + const commandAuthorized = + kind === "dm" + ? dmPolicy === "open" || senderAllowedForCommands + : commandGate.commandAuthorized; + + if (kind === "dm") { + if (dmPolicy === "disabled") { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (dmPolicy !== "open" && !senderAllowedForCommands) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "mattermost", + id: senderId, + meta: { name: senderName }, + }); + logVerboseMessage(`mattermost: pairing request sender=${senderId} created=${created}`); + if (created) { + try { + await sendMessageMattermost( + `user:${senderId}`, + core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + opts.statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerboseMessage(`mattermost: pairing reply failed for ${senderId}: ${String(err)}`); + } + } + } else { + logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`); + } + return; + } + } else { + if (groupPolicy === "disabled") { + logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; + } + if (!groupAllowedForCommands) { + logVerboseMessage(`mattermost: drop group sender=${senderId} (not in groupAllowFrom)`); + return; + } + } + } + + if (kind !== "dm" && commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "mattermost", + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + + const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined; + const channelName = payload.data?.channel_name ?? channelInfo?.name ?? ""; + const channelDisplay = + payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName; + const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "mattermost", + accountId: account.accountId, + teamId, + peer: { + kind, + id: kind === "dm" ? senderId : channelId, + }, + }); + + const baseSessionKey = route.sessionKey; + const threadRootId = post.root_id?.trim() || undefined; + const threadKeys = resolveThreadSessionKeys({ + baseSessionKey, + threadId: threadRootId, + parentSessionKey: threadRootId ? baseSessionKey : undefined, + }); + const sessionKey = threadKeys.sessionKey; + const historyKey = kind === "dm" ? null : sessionKey; + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); + const wasMentioned = + kind !== "dm" && + ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) || + core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes)); + const pendingBody = + rawText || + (post.file_ids?.length + ? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]` + : ""); + const pendingSender = senderName; + const recordPendingHistory = () => { + const trimmed = pendingBody.trim(); + recordPendingHistoryEntryIfEnabled({ + historyMap: channelHistories, + limit: historyLimit, + historyKey: historyKey ?? "", + entry: + historyKey && trimmed + ? { + sender: pendingSender, + body: trimmed, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + messageId: post.id ?? undefined, + } + : null, + }); + }; + + const oncharEnabled = account.chatmode === "onchar" && kind !== "dm"; + const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : []; + const oncharResult = oncharEnabled + ? stripOncharPrefix(rawText, oncharPrefixes) + : { triggered: false, stripped: rawText }; + const oncharTriggered = oncharResult.triggered; + + const shouldRequireMention = + kind !== "dm" && + core.channel.groups.resolveRequireMention({ + cfg, + channel: "mattermost", + accountId: account.accountId, + groupId: channelId, + }); + const shouldBypassMention = + isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized; + const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered; + const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; + + if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) { + recordPendingHistory(); + return; + } + + if (kind !== "dm" && shouldRequireMention && canDetectMention) { + if (!effectiveWasMentioned) { + recordPendingHistory(); + return; + } + } + const mediaList = await resolveMattermostMedia(post.file_ids); + const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList); + const bodySource = oncharTriggered ? oncharResult.stripped : rawText; + const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim(); + const bodyText = normalizeMention(baseText, botUsername); + if (!bodyText) { + return; + } + + core.channel.activity.record({ + channel: "mattermost", + accountId: account.accountId, + direction: "inbound", + }); + + const fromLabel = formatInboundFromLabel({ + isGroup: kind !== "dm", + groupLabel: channelDisplay || roomLabel, + groupId: channelId, + groupFallback: roomLabel || "Channel", + directLabel: senderName, + directId: senderId, + }); + + const preview = bodyText.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = + kind === "dm" + ? `Mattermost DM from ${senderName}` + : `Mattermost message in ${roomLabel} from ${senderName}`; + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey, + contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`, + }); + + const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`; + const body = core.channel.reply.formatInboundEnvelope({ + channel: "Mattermost", + from: fromLabel, + timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + body: textWithId, + chatType, + sender: { name: senderName, id: senderId }, + }); + let combinedBody = body; + if (historyKey) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: channelHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + core.channel.reply.formatInboundEnvelope({ + channel: "Mattermost", + from: fromLabel, + timestamp: entry.timestamp, + body: `${entry.body}${ + entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : "" + }`, + chatType, + senderLabel: entry.sender, + }), + }); + } + + const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`; + const mediaPayload = buildMattermostMediaPayload(mediaList); + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: combinedBody, + RawBody: bodyText, + CommandBody: bodyText, + From: + kind === "dm" + ? `mattermost:${senderId}` + : kind === "group" + ? `mattermost:group:${channelId}` + : `mattermost:channel:${channelId}`, + To: to, + SessionKey: sessionKey, + ParentSessionKey: threadKeys.parentSessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: fromLabel, + GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined, + GroupChannel: channelName ? `#${channelName}` : undefined, + GroupSpace: teamId, + SenderName: senderName, + SenderId: senderId, + Provider: "mattermost" as const, + Surface: "mattermost" as const, + MessageSid: post.id ?? undefined, + MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined, + MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined, + MessageSidLast: + allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined, + ReplyToId: threadRootId, + MessageThreadId: threadRootId, + Timestamp: typeof post.create_at === "number" ? post.create_at : undefined, + WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined, + CommandAuthorized: commandAuthorized, + OriginatingChannel: "mattermost" as const, + OriginatingTo: to, + ...mediaPayload, + }); + + if (kind === "dm") { + const sessionCfg = cfg.session; + const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, { + agentId: route.agentId, + }); + await core.channel.session.updateLastRoute({ + storePath, + sessionKey: route.mainSessionKey, + deliveryContext: { + channel: "mattermost", + to, + accountId: route.accountId, + }, + }); + } + + const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n"); + logVerboseMessage( + `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`, + ); + + const textLimit = core.channel.text.resolveTextChunkLimit( + cfg, + "mattermost", + account.accountId, + { + fallbackLimit: account.textChunkLimit ?? 4000, + }, + ); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + + const prefixContext = createReplyPrefixContext({ cfg, agentId: route.agentId }); + + const typingCallbacks = createTypingCallbacks({ + start: () => sendTypingIndicator(channelId, threadRootId), + onStartError: (err) => { + logTypingFailure({ + log: (message) => logger.debug?.(message), + channel: "mattermost", + target: channelId, + error: err, + }); + }, + }); + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), + deliver: async (payload: ReplyPayload) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + if (mediaUrls.length === 0) { + const chunkMode = core.channel.text.resolveChunkMode( + cfg, + "mattermost", + account.accountId, + ); + const chunks = core.channel.text.chunkMarkdownTextWithMode(text, textLimit, chunkMode); + for (const chunk of chunks.length > 0 ? chunks : [text]) { + if (!chunk) { + continue; + } + await sendMessageMattermost(to, chunk, { + accountId: account.accountId, + replyToId: threadRootId, + }); + } + } else { + let first = true; + for (const mediaUrl of mediaUrls) { + const caption = first ? text : ""; + first = false; + await sendMessageMattermost(to, caption, { + accountId: account.accountId, + mediaUrl, + replyToId: threadRootId, + }); + } + } + runtime.log?.(`delivered reply to ${to}`); + }, + onError: (err, info) => { + runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`); + }, + onReplyStart: typingCallbacks.onReplyStart, + }); + + await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions: { + ...replyOptions, + disableBlockStreaming: + typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined, + onModelSelected: prefixContext.onModelSelected, + }, + }); + markDispatchIdle(); + if (historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: channelHistories, + historyKey, + limit: historyLimit, + }); + } + }; + + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "mattermost", + }); + const debouncer = core.channel.debounce.createInboundDebouncer<{ + post: MattermostPost; + payload: MattermostEventPayload; + }>({ + debounceMs: inboundDebounceMs, + buildKey: (entry) => { + const channelId = + entry.post.channel_id ?? + entry.payload.data?.channel_id ?? + entry.payload.broadcast?.channel_id; + if (!channelId) { + return null; + } + const threadId = entry.post.root_id?.trim(); + const threadKey = threadId ? `thread:${threadId}` : "channel"; + return `mattermost:${account.accountId}:${channelId}:${threadKey}`; + }, + shouldDebounce: (entry) => { + if (entry.post.file_ids && entry.post.file_ids.length > 0) { + return false; + } + const text = entry.post.message?.trim() ?? ""; + if (!text) { + return false; + } + return !core.channel.text.hasControlCommand(text, cfg); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + await handlePost(last.post, last.payload); + return; + } + const combinedText = entries + .map((entry) => entry.post.message?.trim() ?? "") + .filter(Boolean) + .join("\n"); + const mergedPost: MattermostPost = { + ...last.post, + message: combinedText, + file_ids: [], + }; + const ids = entries.map((entry) => entry.post.id).filter(Boolean); + await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined); + }, + onError: (err) => { + runtime.error?.(`mattermost debounce flush failed: ${String(err)}`); + }, + }); + + const wsUrl = buildMattermostWsUrl(baseUrl); + let seq = 1; + + const connectOnce = async (): Promise => { + const ws = new WebSocket(wsUrl); + const onAbort = () => ws.close(); + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + + return await new Promise((resolve) => { + ws.on("open", () => { + opts.statusSink?.({ + connected: true, + lastConnectedAt: Date.now(), + lastError: null, + }); + ws.send( + JSON.stringify({ + seq: seq++, + action: "authentication_challenge", + data: { token: botToken }, + }), + ); + }); + + ws.on("message", async (data) => { + const raw = rawDataToString(data); + let payload: MattermostEventPayload; + try { + payload = JSON.parse(raw) as MattermostEventPayload; + } catch { + return; + } + if (payload.event !== "posted") { + return; + } + const postData = payload.data?.post; + if (!postData) { + return; + } + let post: MattermostPost | null = null; + if (typeof postData === "string") { + try { + post = JSON.parse(postData) as MattermostPost; + } catch { + return; + } + } else if (typeof postData === "object") { + post = postData as MattermostPost; + } + if (!post) { + return; + } + try { + await debouncer.enqueue({ post, payload }); + } catch (err) { + runtime.error?.(`mattermost handler failed: ${String(err)}`); + } + }); + + ws.on("close", (code, reason) => { + const message = reason.length > 0 ? reason.toString("utf8") : ""; + opts.statusSink?.({ + connected: false, + lastDisconnect: { + at: Date.now(), + status: code, + error: message || undefined, + }, + }); + opts.abortSignal?.removeEventListener("abort", onAbort); + resolve(); + }); + + ws.on("error", (err) => { + runtime.error?.(`mattermost websocket error: ${String(err)}`); + opts.statusSink?.({ + lastError: String(err), + }); + }); + }); + }; + + while (!opts.abortSignal?.aborted) { + await connectOnce(); + if (opts.abortSignal?.aborted) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +} diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..a02ca4935fdeaf4695beb8f2b473df330a94ef8b --- /dev/null +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -0,0 +1,74 @@ +import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js"; + +export type MattermostProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs?: number | null; + bot?: MattermostUser; +}; + +async function readMattermostError(res: Response): Promise { + const contentType = res.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + const data = (await res.json()) as { message?: string } | undefined; + if (data?.message) { + return data.message; + } + return JSON.stringify(data); + } + return await res.text(); +} + +export async function probeMattermost( + baseUrl: string, + botToken: string, + timeoutMs = 2500, +): Promise { + const normalized = normalizeMattermostBaseUrl(baseUrl); + if (!normalized) { + return { ok: false, error: "baseUrl missing" }; + } + const url = `${normalized}/api/v4/users/me`; + const start = Date.now(); + const controller = timeoutMs > 0 ? new AbortController() : undefined; + let timer: NodeJS.Timeout | null = null; + if (controller) { + timer = setTimeout(() => controller.abort(), timeoutMs); + } + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${botToken}` }, + signal: controller?.signal, + }); + const elapsedMs = Date.now() - start; + if (!res.ok) { + const detail = await readMattermostError(res); + return { + ok: false, + status: res.status, + error: detail || res.statusText, + elapsedMs, + }; + } + const bot = (await res.json()) as MattermostUser; + return { + ok: true, + status: res.status, + elapsedMs, + bot, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + status: null, + error: message, + elapsedMs: Date.now() - start, + }; + } finally { + if (timer) { + clearTimeout(timer); + } + } +} diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3e40e39ca31cfe0e6fe3b1b335308c298bf5bc1 --- /dev/null +++ b/extensions/mattermost/src/mattermost/send.ts @@ -0,0 +1,231 @@ +import { getMattermostRuntime } from "../runtime.js"; +import { resolveMattermostAccount } from "./accounts.js"; +import { + createMattermostClient, + createMattermostDirectChannel, + createMattermostPost, + fetchMattermostMe, + fetchMattermostUserByUsername, + normalizeMattermostBaseUrl, + uploadMattermostFile, + type MattermostUser, +} from "./client.js"; + +export type MattermostSendOpts = { + botToken?: string; + baseUrl?: string; + accountId?: string; + mediaUrl?: string; + replyToId?: string; +}; + +export type MattermostSendResult = { + messageId: string; + channelId: string; +}; + +type MattermostTarget = + | { kind: "channel"; id: string } + | { kind: "user"; id?: string; username?: string }; + +const botUserCache = new Map(); +const userByNameCache = new Map(); + +const getCore = () => getMattermostRuntime(); + +function cacheKey(baseUrl: string, token: string): string { + return `${baseUrl}::${token}`; +} + +function normalizeMessage(text: string, mediaUrl?: string): string { + const trimmed = text.trim(); + const media = mediaUrl?.trim(); + return [trimmed, media].filter(Boolean).join("\n"); +} + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value); +} + +function parseMattermostTarget(raw: string): MattermostTarget { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Recipient is required for Mattermost sends"); + } + const lower = trimmed.toLowerCase(); + if (lower.startsWith("channel:")) { + const id = trimmed.slice("channel:".length).trim(); + if (!id) { + throw new Error("Channel id is required for Mattermost sends"); + } + return { kind: "channel", id }; + } + if (lower.startsWith("user:")) { + const id = trimmed.slice("user:".length).trim(); + if (!id) { + throw new Error("User id is required for Mattermost sends"); + } + return { kind: "user", id }; + } + if (lower.startsWith("mattermost:")) { + const id = trimmed.slice("mattermost:".length).trim(); + if (!id) { + throw new Error("User id is required for Mattermost sends"); + } + return { kind: "user", id }; + } + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + if (!username) { + throw new Error("Username is required for Mattermost sends"); + } + return { kind: "user", username }; + } + return { kind: "channel", id: trimmed }; +} + +async function resolveBotUser(baseUrl: string, token: string): Promise { + const key = cacheKey(baseUrl, token); + const cached = botUserCache.get(key); + if (cached) { + return cached; + } + const client = createMattermostClient({ baseUrl, botToken: token }); + const user = await fetchMattermostMe(client); + botUserCache.set(key, user); + return user; +} + +async function resolveUserIdByUsername(params: { + baseUrl: string; + token: string; + username: string; +}): Promise { + const { baseUrl, token, username } = params; + const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`; + const cached = userByNameCache.get(key); + if (cached?.id) { + return cached.id; + } + const client = createMattermostClient({ baseUrl, botToken: token }); + const user = await fetchMattermostUserByUsername(client, username); + userByNameCache.set(key, user); + return user.id; +} + +async function resolveTargetChannelId(params: { + target: MattermostTarget; + baseUrl: string; + token: string; +}): Promise { + if (params.target.kind === "channel") { + return params.target.id; + } + const userId = params.target.id + ? params.target.id + : await resolveUserIdByUsername({ + baseUrl: params.baseUrl, + token: params.token, + username: params.target.username ?? "", + }); + const botUser = await resolveBotUser(params.baseUrl, params.token); + const client = createMattermostClient({ + baseUrl: params.baseUrl, + botToken: params.token, + }); + const channel = await createMattermostDirectChannel(client, [botUser.id, userId]); + return channel.id; +} + +export async function sendMessageMattermost( + to: string, + text: string, + opts: MattermostSendOpts = {}, +): Promise { + const core = getCore(); + const logger = core.logging.getChildLogger({ module: "mattermost" }); + const cfg = core.config.loadConfig(); + const account = resolveMattermostAccount({ + cfg, + accountId: opts.accountId, + }); + const token = opts.botToken?.trim() || account.botToken?.trim(); + if (!token) { + throw new Error( + `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`, + ); + } + const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl); + if (!baseUrl) { + throw new Error( + `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`, + ); + } + + const target = parseMattermostTarget(to); + const channelId = await resolveTargetChannelId({ + target, + baseUrl, + token, + }); + + const client = createMattermostClient({ baseUrl, botToken: token }); + let message = text?.trim() ?? ""; + let fileIds: string[] | undefined; + let uploadError: Error | undefined; + const mediaUrl = opts.mediaUrl?.trim(); + if (mediaUrl) { + try { + const media = await core.media.loadWebMedia(mediaUrl); + const fileInfo = await uploadMattermostFile(client, { + channelId, + buffer: media.buffer, + fileName: media.fileName ?? "upload", + contentType: media.contentType ?? undefined, + }); + fileIds = [fileInfo.id]; + } catch (err) { + uploadError = err instanceof Error ? err : new Error(String(err)); + if (core.logging.shouldLogVerbose()) { + logger.debug?.( + `mattermost send: media upload failed, falling back to URL text: ${String(err)}`, + ); + } + message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : ""); + } + } + + if (message) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + message = core.channel.text.convertMarkdownTables(message, tableMode); + } + + if (!message && (!fileIds || fileIds.length === 0)) { + if (uploadError) { + throw new Error(`Mattermost media upload failed: ${uploadError.message}`); + } + throw new Error("Mattermost message is empty"); + } + + const post = await createMattermostPost(client, { + channelId, + message, + rootId: opts.replyToId, + fileIds, + }); + + core.channel.activity.record({ + channel: "mattermost", + accountId: account.accountId, + direction: "outbound", + }); + + return { + messageId: post.id ?? "unknown", + channelId, + }; +} diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8a8ee967b7cb7d0e67df2b81bea56fb2185009f --- /dev/null +++ b/extensions/mattermost/src/normalize.ts @@ -0,0 +1,46 @@ +export function normalizeMattermostMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + const lower = trimmed.toLowerCase(); + if (lower.startsWith("channel:")) { + const id = trimmed.slice("channel:".length).trim(); + return id ? `channel:${id}` : undefined; + } + if (lower.startsWith("group:")) { + const id = trimmed.slice("group:".length).trim(); + return id ? `channel:${id}` : undefined; + } + if (lower.startsWith("user:")) { + const id = trimmed.slice("user:".length).trim(); + return id ? `user:${id}` : undefined; + } + if (lower.startsWith("mattermost:")) { + const id = trimmed.slice("mattermost:".length).trim(); + return id ? `user:${id}` : undefined; + } + if (trimmed.startsWith("@")) { + const id = trimmed.slice(1).trim(); + return id ? `@${id}` : undefined; + } + if (trimmed.startsWith("#")) { + const id = trimmed.slice(1).trim(); + return id ? `channel:${id}` : undefined; + } + return `channel:${trimmed}`; +} + +export function looksLikeMattermostTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^(user|channel|group|mattermost):/i.test(trimmed)) { + return true; + } + if (/^[@#]/.test(trimmed)) { + return true; + } + return /^[a-z0-9]{8,}$/i.test(trimmed); +} diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c3bd5f41daf719eda7b840d926937015f8c1277 --- /dev/null +++ b/extensions/mattermost/src/onboarding-helpers.ts @@ -0,0 +1,44 @@ +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; + +type PromptAccountIdParams = { + cfg: OpenClawConfig; + prompter: WizardPrompter; + label: string; + currentId?: string; + listAccountIds: (cfg: OpenClawConfig) => string[]; + defaultAccountId: string; +}; + +export async function promptAccountId(params: PromptAccountIdParams): Promise { + const existingIds = params.listAccountIds(params.cfg); + const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; + const choice = await params.prompter.select({ + message: `${params.label} account`, + options: [ + ...existingIds.map((id) => ({ + value: id, + label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, + })), + { value: "__new__", label: "Add a new account" }, + ], + initialValue: initial, + }); + + if (choice !== "__new__") { + return normalizeAccountId(choice); + } + + const entered = await params.prompter.text({ + message: `New ${params.label} account id`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const normalized = normalizeAccountId(String(entered)); + if (String(entered).trim() !== normalized) { + await params.prompter.note( + `Normalized account id to "${normalized}".`, + `${params.label} account`, + ); + } + return normalized; +} diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..2384558e14b3b10ceec00feb1432d817b879829a --- /dev/null +++ b/extensions/mattermost/src/onboarding.ts @@ -0,0 +1,186 @@ +import type { ChannelOnboardingAdapter, OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import { + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, +} from "./mattermost/accounts.js"; +import { promptAccountId } from "./onboarding-helpers.js"; + +const channel = "mattermost" as const; + +async function noteMattermostSetup(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Mattermost System Console -> Integrations -> Bot Accounts", + "2) Create a bot + copy its token", + "3) Use your server base URL (e.g., https://chat.example.com)", + "Tip: the bot must be a member of any channel you want it to monitor.", + "Docs: https://docs.openclaw.ai/channels/mattermost", + ].join("\n"), + "Mattermost bot token", + ); +} + +export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listMattermostAccountIds(cfg).some((accountId) => { + const account = resolveMattermostAccount({ cfg, accountId }); + return Boolean(account.botToken && account.baseUrl); + }); + return { + channel, + configured, + statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`], + selectionHint: configured ? "configured" : "needs setup", + quickstartScore: configured ? 2 : 1, + }; + }, + configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => { + const override = accountOverrides.mattermost?.trim(); + const defaultAccountId = resolveDefaultMattermostAccountId(cfg); + let accountId = override ? normalizeAccountId(override) : defaultAccountId; + if (shouldPromptAccountIds && !override) { + accountId = await promptAccountId({ + cfg, + prompter, + label: "Mattermost", + currentId: accountId, + listAccountIds: listMattermostAccountIds, + defaultAccountId, + }); + } + + let next = cfg; + const resolvedAccount = resolveMattermostAccount({ + cfg: next, + accountId, + }); + const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl); + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = + allowEnv && + Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) && + Boolean(process.env.MATTERMOST_URL?.trim()); + const hasConfigValues = + Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl); + + let botToken: string | null = null; + let baseUrl: string | null = null; + + if (!accountConfigured) { + await noteMattermostSetup(prompter); + } + + if (canUseEnv && !hasConfigValues) { + const keepEnv = await prompter.confirm({ + message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + mattermost: { + ...next.channels?.mattermost, + enabled: true, + }, + }, + }; + } else { + botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (accountConfigured) { + const keep = await prompter.confirm({ + message: "Mattermost credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + botToken = String( + await prompter.text({ + message: "Enter Mattermost bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + baseUrl = String( + await prompter.text({ + message: "Enter Mattermost base URL", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (botToken || baseUrl) { + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + mattermost: { + ...next.channels?.mattermost, + enabled: true, + ...(botToken ? { botToken } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + mattermost: { + ...next.channels?.mattermost, + enabled: true, + accounts: { + ...next.channels?.mattermost?.accounts, + [accountId]: { + ...next.channels?.mattermost?.accounts?.[accountId], + enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true, + ...(botToken ? { botToken } : {}), + ...(baseUrl ? { baseUrl } : {}), + }, + }, + }, + }, + }; + } + } + + return { cfg: next, accountId }; + }, + disable: (cfg: OpenClawConfig) => ({ + ...cfg, + channels: { + ...cfg.channels, + mattermost: { ...cfg.channels?.mattermost, enabled: false }, + }, + }), +}; diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..10ae1698a05ff5d7e6a9d1488ab51288fe623a14 --- /dev/null +++ b/extensions/mattermost/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setMattermostRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMattermostRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Mattermost runtime not initialized"); + } + return runtime; +} diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0af8cd33ac6cc591e2e160ef9834eabe10807d0d --- /dev/null +++ b/extensions/mattermost/src/types.ts @@ -0,0 +1,50 @@ +import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "openclaw/plugin-sdk"; + +export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; + +export type MattermostAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this Mattermost account. Default: true. */ + enabled?: boolean; + /** Bot token for Mattermost. */ + botToken?: string; + /** Base URL for the Mattermost server (e.g., https://chat.example.com). */ + baseUrl?: string; + /** + * Controls when channel messages trigger replies. + * - "oncall": only respond when mentioned + * - "onmessage": respond to every channel message + * - "onchar": respond when a trigger character prefixes the message + */ + chatmode?: MattermostChatMode; + /** Prefix characters that trigger onchar mode (default: [">", "!"]). */ + oncharPrefixes?: string[]; + /** Require @mention to respond in channels. Default: true. */ + requireMention?: boolean; + /** Direct message policy (pairing/allowlist/open/disabled). */ + dmPolicy?: DmPolicy; + /** Allowlist for direct messages (user ids or @usernames). */ + allowFrom?: Array; + /** Allowlist for group messages (user ids or @usernames). */ + groupAllowFrom?: Array; + /** Group message policy (allowlist/open/disabled). */ + groupPolicy?: GroupPolicy; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; +}; + +export type MattermostConfig = { + /** Optional per-account Mattermost configuration (multi-account). */ + accounts?: Record; +} & MattermostAccountConfig; diff --git a/extensions/memory-core/index.ts b/extensions/memory-core/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c71e046ef52e01ebbfe5993f2ad574697f5c82c9 --- /dev/null +++ b/extensions/memory-core/index.ts @@ -0,0 +1,38 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; + +const memoryCorePlugin = { + id: "memory-core", + name: "Memory (Core)", + description: "File-backed memory search tools and CLI", + kind: "memory", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + api.registerTool( + (ctx) => { + const memorySearchTool = api.runtime.tools.createMemorySearchTool({ + config: ctx.config, + agentSessionKey: ctx.sessionKey, + }); + const memoryGetTool = api.runtime.tools.createMemoryGetTool({ + config: ctx.config, + agentSessionKey: ctx.sessionKey, + }); + if (!memorySearchTool || !memoryGetTool) { + return null; + } + return [memorySearchTool, memoryGetTool]; + }, + { names: ["memory_search", "memory_get"] }, + ); + + api.registerCli( + ({ program }) => { + api.runtime.tools.registerMemoryCli(program); + }, + { commands: ["memory"] }, + ); + }, +}; + +export default memoryCorePlugin; diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..483e2d26f0225d13375b391e9a2e4e3f81d51acc --- /dev/null +++ b/extensions/memory-core/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "memory-core", + "kind": "memory", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1caa6287f48532a1ac91ad8ebe3030b6383ae351 --- /dev/null +++ b/extensions/memory-core/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openclaw/memory-core", + "version": "2026.1.30", + "description": "OpenClaw core memory search plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "peerDependencies": { + "openclaw": ">=2026.1.26" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/memory-lancedb/config.ts b/extensions/memory-lancedb/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3ab87d20df1156e66bbe33792ca4401e11d84ee --- /dev/null +++ b/extensions/memory-lancedb/config.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export type MemoryConfig = { + embedding: { + provider: "openai"; + model?: string; + apiKey: string; + }; + dbPath?: string; + autoCapture?: boolean; + autoRecall?: boolean; +}; + +export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const; +export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number]; + +const DEFAULT_MODEL = "text-embedding-3-small"; +const LEGACY_STATE_DIRS: string[] = []; + +function resolveDefaultDbPath(): string { + const home = homedir(); + const preferred = join(home, ".openclaw", "memory", "lancedb"); + try { + if (fs.existsSync(preferred)) { + return preferred; + } + } catch { + // best-effort + } + + for (const legacy of LEGACY_STATE_DIRS) { + const candidate = join(home, legacy, "memory", "lancedb"); + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // best-effort + } + } + + return preferred; +} + +const DEFAULT_DB_PATH = resolveDefaultDbPath(); + +const EMBEDDING_DIMENSIONS: Record = { + "text-embedding-3-small": 1536, + "text-embedding-3-large": 3072, +}; + +function assertAllowedKeys(value: Record, allowed: string[], label: string) { + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length === 0) { + return; + } + throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`); +} + +export function vectorDimsForModel(model: string): number { + const dims = EMBEDDING_DIMENSIONS[model]; + if (!dims) { + throw new Error(`Unsupported embedding model: ${model}`); + } + return dims; +} + +function resolveEnvVars(value: string): string { + return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => { + const envValue = process.env[envVar]; + if (!envValue) { + throw new Error(`Environment variable ${envVar} is not set`); + } + return envValue; + }); +} + +function resolveEmbeddingModel(embedding: Record): string { + const model = typeof embedding.model === "string" ? embedding.model : DEFAULT_MODEL; + vectorDimsForModel(model); + return model; +} + +export const memoryConfigSchema = { + parse(value: unknown): MemoryConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("memory config required"); + } + const cfg = value as Record; + assertAllowedKeys(cfg, ["embedding", "dbPath", "autoCapture", "autoRecall"], "memory config"); + + const embedding = cfg.embedding as Record | undefined; + if (!embedding || typeof embedding.apiKey !== "string") { + throw new Error("embedding.apiKey is required"); + } + assertAllowedKeys(embedding, ["apiKey", "model"], "embedding config"); + + const model = resolveEmbeddingModel(embedding); + + return { + embedding: { + provider: "openai", + model, + apiKey: resolveEnvVars(embedding.apiKey), + }, + dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH, + autoCapture: cfg.autoCapture !== false, + autoRecall: cfg.autoRecall !== false, + }; + }, + uiHints: { + "embedding.apiKey": { + label: "OpenAI API Key", + sensitive: true, + placeholder: "sk-proj-...", + help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})", + }, + "embedding.model": { + label: "Embedding Model", + placeholder: DEFAULT_MODEL, + help: "OpenAI embedding model to use", + }, + dbPath: { + label: "Database Path", + placeholder: "~/.openclaw/memory/lancedb", + advanced: true, + }, + autoCapture: { + label: "Auto-Capture", + help: "Automatically capture important information from conversations", + }, + autoRecall: { + label: "Auto-Recall", + help: "Automatically inject relevant memories into context", + }, + }, +}; diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f90bf95ec05e43f291a84a19375438322ba66ea8 --- /dev/null +++ b/extensions/memory-lancedb/index.test.ts @@ -0,0 +1,286 @@ +/** + * Memory Plugin E2E Tests + * + * Tests the memory plugin functionality including: + * - Plugin registration and configuration + * - Memory storage and retrieval + * - Auto-recall via hooks + * - Auto-capture filtering + */ + +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, test, expect, beforeEach, afterEach } from "vitest"; + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key"; +const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); +const liveEnabled = HAS_OPENAI_KEY && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = liveEnabled ? describe : describe.skip; + +describe("memory plugin e2e", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-test-")); + dbPath = path.join(tmpDir, "lancedb"); + }); + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("memory plugin registers and initializes correctly", async () => { + // Dynamic import to avoid loading LanceDB when not testing + const { default: memoryPlugin } = await import("./index.js"); + + expect(memoryPlugin.id).toBe("memory-lancedb"); + expect(memoryPlugin.name).toBe("Memory (LanceDB)"); + expect(memoryPlugin.kind).toBe("memory"); + expect(memoryPlugin.configSchema).toBeDefined(); + // oxlint-disable-next-line typescript/unbound-method + expect(memoryPlugin.register).toBeInstanceOf(Function); + }); + + test("config schema parses valid config", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + const config = memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath, + autoCapture: true, + autoRecall: true, + }); + + expect(config).toBeDefined(); + expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY); + expect(config?.dbPath).toBe(dbPath); + }); + + test("config schema resolves env vars", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + // Set a test env var + process.env.TEST_MEMORY_API_KEY = "test-key-123"; + + const config = memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: "${TEST_MEMORY_API_KEY}", + }, + dbPath, + }); + + expect(config?.embedding?.apiKey).toBe("test-key-123"); + + delete process.env.TEST_MEMORY_API_KEY; + }); + + test("config schema rejects missing apiKey", async () => { + const { default: memoryPlugin } = await import("./index.js"); + + expect(() => { + memoryPlugin.configSchema?.parse?.({ + embedding: {}, + dbPath, + }); + }).toThrow("embedding.apiKey is required"); + }); + + test("shouldCapture filters correctly", async () => { + // Test the capture filtering logic by checking the rules + const triggers = [ + { text: "I prefer dark mode", shouldMatch: true }, + { text: "Remember that my name is John", shouldMatch: true }, + { text: "My email is test@example.com", shouldMatch: true }, + { text: "Call me at +1234567890123", shouldMatch: true }, + { text: "We decided to use TypeScript", shouldMatch: true }, + { text: "I always want verbose output", shouldMatch: true }, + { text: "Just a random short message", shouldMatch: false }, + { text: "x", shouldMatch: false }, // Too short + { text: "injected", shouldMatch: false }, // Skip injected + ]; + + // The shouldCapture function is internal, but we can test via the capture behavior + // For now, just verify the patterns we expect to match + for (const { text, shouldMatch } of triggers) { + const hasPreference = /prefer|radši|like|love|hate|want/i.test(text); + const hasRemember = /zapamatuj|pamatuj|remember/i.test(text); + const hasEmail = /[\w.-]+@[\w.-]+\.\w+/.test(text); + const hasPhone = /\+\d{10,}/.test(text); + const hasDecision = /rozhodli|decided|will use|budeme/i.test(text); + const hasAlways = /always|never|important/i.test(text); + const isInjected = text.includes(""); + const isTooShort = text.length < 10; + + const wouldCapture = + !isTooShort && + !isInjected && + (hasPreference || hasRemember || hasEmail || hasPhone || hasDecision || hasAlways); + + if (shouldMatch) { + expect(wouldCapture).toBe(true); + } + } + }); + + test("detectCategory classifies correctly", async () => { + // Test category detection patterns + const cases = [ + { text: "I prefer dark mode", expected: "preference" }, + { text: "We decided to use React", expected: "decision" }, + { text: "My email is test@example.com", expected: "entity" }, + { text: "The server is running on port 3000", expected: "fact" }, + ]; + + for (const { text, expected } of cases) { + const lower = text.toLowerCase(); + let category: string; + + if (/prefer|radši|like|love|hate|want/i.test(lower)) { + category = "preference"; + } else if (/rozhodli|decided|will use|budeme/i.test(lower)) { + category = "decision"; + } else if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { + category = "entity"; + } else if (/is|are|has|have|je|má|jsou/i.test(lower)) { + category = "fact"; + } else { + category = "other"; + } + + expect(category).toBe(expected); + } + }); +}); + +// Live tests that require OpenAI API key and actually use LanceDB +describeLive("memory plugin live tests", () => { + let tmpDir: string; + let dbPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-live-")); + dbPath = path.join(tmpDir, "lancedb"); + }); + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("memory tools work end-to-end", async () => { + const { default: memoryPlugin } = await import("./index.js"); + const liveApiKey = process.env.OPENAI_API_KEY ?? ""; + + // Mock plugin API + const registeredTools: any[] = []; + const registeredClis: any[] = []; + const registeredServices: any[] = []; + const registeredHooks: Record = {}; + const logs: string[] = []; + + const mockApi = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + source: "test", + config: {}, + pluginConfig: { + embedding: { + apiKey: liveApiKey, + model: "text-embedding-3-small", + }, + dbPath, + autoCapture: false, + autoRecall: false, + }, + runtime: {}, + logger: { + info: (msg: string) => logs.push(`[info] ${msg}`), + warn: (msg: string) => logs.push(`[warn] ${msg}`), + error: (msg: string) => logs.push(`[error] ${msg}`), + debug: (msg: string) => logs.push(`[debug] ${msg}`), + }, + registerTool: (tool: any, opts: any) => { + registeredTools.push({ tool, opts }); + }, + registerCli: (registrar: any, opts: any) => { + registeredClis.push({ registrar, opts }); + }, + registerService: (service: any) => { + registeredServices.push(service); + }, + on: (hookName: string, handler: any) => { + if (!registeredHooks[hookName]) { + registeredHooks[hookName] = []; + } + registeredHooks[hookName].push(handler); + }, + resolvePath: (p: string) => p, + }; + + // Register plugin + memoryPlugin.register(mockApi as any); + + // Check registration + expect(registeredTools.length).toBe(3); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_recall"); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_store"); + expect(registeredTools.map((t) => t.opts?.name)).toContain("memory_forget"); + expect(registeredClis.length).toBe(1); + expect(registeredServices.length).toBe(1); + + // Get tool functions + const storeTool = registeredTools.find((t) => t.opts?.name === "memory_store")?.tool; + const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool; + const forgetTool = registeredTools.find((t) => t.opts?.name === "memory_forget")?.tool; + + // Test store + const storeResult = await storeTool.execute("test-call-1", { + text: "The user prefers dark mode for all applications", + importance: 0.8, + category: "preference", + }); + + expect(storeResult.details?.action).toBe("created"); + expect(storeResult.details?.id).toBeDefined(); + const storedId = storeResult.details?.id; + + // Test recall + const recallResult = await recallTool.execute("test-call-2", { + query: "dark mode preference", + limit: 5, + }); + + expect(recallResult.details?.count).toBeGreaterThan(0); + expect(recallResult.details?.memories?.[0]?.text).toContain("dark mode"); + + // Test duplicate detection + const duplicateResult = await storeTool.execute("test-call-3", { + text: "The user prefers dark mode for all applications", + }); + + expect(duplicateResult.details?.action).toBe("duplicate"); + + // Test forget + const forgetResult = await forgetTool.execute("test-call-4", { + memoryId: storedId, + }); + + expect(forgetResult.details?.action).toBe("deleted"); + + // Verify it's gone + const recallAfterForget = await recallTool.execute("test-call-5", { + query: "dark mode preference", + limit: 5, + }); + + expect(recallAfterForget.details?.count).toBe(0); + }, 60000); // 60s timeout for live API calls +}); diff --git a/extensions/memory-lancedb/index.ts b/extensions/memory-lancedb/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e4def80fa2ebfbd35378c027fd23d53ddc2bb79 --- /dev/null +++ b/extensions/memory-lancedb/index.ts @@ -0,0 +1,608 @@ +/** + * OpenClaw Memory (LanceDB) Plugin + * + * Long-term memory with vector search for AI conversations. + * Uses LanceDB for storage and OpenAI for embeddings. + * Provides seamless auto-recall and auto-capture via lifecycle hooks. + */ + +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import * as lancedb from "@lancedb/lancedb"; +import { Type } from "@sinclair/typebox"; +import { randomUUID } from "node:crypto"; +import OpenAI from "openai"; +import { stringEnum } from "openclaw/plugin-sdk"; +import { + MEMORY_CATEGORIES, + type MemoryCategory, + memoryConfigSchema, + vectorDimsForModel, +} from "./config.js"; + +// ============================================================================ +// Types +// ============================================================================ + +type MemoryEntry = { + id: string; + text: string; + vector: number[]; + importance: number; + category: MemoryCategory; + createdAt: number; +}; + +type MemorySearchResult = { + entry: MemoryEntry; + score: number; +}; + +// ============================================================================ +// LanceDB Provider +// ============================================================================ + +const TABLE_NAME = "memories"; + +class MemoryDB { + private db: lancedb.Connection | null = null; + private table: lancedb.Table | null = null; + private initPromise: Promise | null = null; + + constructor( + private readonly dbPath: string, + private readonly vectorDim: number, + ) {} + + private async ensureInitialized(): Promise { + if (this.table) { + return; + } + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = this.doInitialize(); + return this.initPromise; + } + + private async doInitialize(): Promise { + this.db = await lancedb.connect(this.dbPath); + const tables = await this.db.tableNames(); + + if (tables.includes(TABLE_NAME)) { + this.table = await this.db.openTable(TABLE_NAME); + } else { + this.table = await this.db.createTable(TABLE_NAME, [ + { + id: "__schema__", + text: "", + vector: Array.from({ length: this.vectorDim }).fill(0), + importance: 0, + category: "other", + createdAt: 0, + }, + ]); + await this.table.delete('id = "__schema__"'); + } + } + + async store(entry: Omit): Promise { + await this.ensureInitialized(); + + const fullEntry: MemoryEntry = { + ...entry, + id: randomUUID(), + createdAt: Date.now(), + }; + + await this.table!.add([fullEntry]); + return fullEntry; + } + + async search(vector: number[], limit = 5, minScore = 0.5): Promise { + await this.ensureInitialized(); + + const results = await this.table!.vectorSearch(vector).limit(limit).toArray(); + + // LanceDB uses L2 distance by default; convert to similarity score + const mapped = results.map((row) => { + const distance = row._distance ?? 0; + // Use inverse for a 0-1 range: sim = 1 / (1 + d) + const score = 1 / (1 + distance); + return { + entry: { + id: row.id as string, + text: row.text as string, + vector: row.vector as number[], + importance: row.importance as number, + category: row.category as MemoryEntry["category"], + createdAt: row.createdAt as number, + }, + score, + }; + }); + + return mapped.filter((r) => r.score >= minScore); + } + + async delete(id: string): Promise { + await this.ensureInitialized(); + // Validate UUID format to prevent injection + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + throw new Error(`Invalid memory ID format: ${id}`); + } + await this.table!.delete(`id = '${id}'`); + return true; + } + + async count(): Promise { + await this.ensureInitialized(); + return this.table!.countRows(); + } +} + +// ============================================================================ +// OpenAI Embeddings +// ============================================================================ + +class Embeddings { + private client: OpenAI; + + constructor( + apiKey: string, + private model: string, + ) { + this.client = new OpenAI({ apiKey }); + } + + async embed(text: string): Promise { + const response = await this.client.embeddings.create({ + model: this.model, + input: text, + }); + return response.data[0].embedding; + } +} + +// ============================================================================ +// Rule-based capture filter +// ============================================================================ + +const MEMORY_TRIGGERS = [ + /zapamatuj si|pamatuj|remember/i, + /preferuji|radši|nechci|prefer/i, + /rozhodli jsme|budeme používat/i, + /\+\d{10,}/, + /[\w.-]+@[\w.-]+\.\w+/, + /můj\s+\w+\s+je|je\s+můj/i, + /my\s+\w+\s+is|is\s+my/i, + /i (like|prefer|hate|love|want|need)/i, + /always|never|important/i, +]; + +function shouldCapture(text: string): boolean { + if (text.length < 10 || text.length > 500) { + return false; + } + // Skip injected context from memory recall + if (text.includes("")) { + return false; + } + // Skip system-generated content + if (text.startsWith("<") && text.includes(" 3) { + return false; + } + return MEMORY_TRIGGERS.some((r) => r.test(text)); +} + +function detectCategory(text: string): MemoryCategory { + const lower = text.toLowerCase(); + if (/prefer|radši|like|love|hate|want/i.test(lower)) { + return "preference"; + } + if (/rozhodli|decided|will use|budeme/i.test(lower)) { + return "decision"; + } + if (/\+\d{10,}|@[\w.-]+\.\w+|is called|jmenuje se/i.test(lower)) { + return "entity"; + } + if (/is|are|has|have|je|má|jsou/i.test(lower)) { + return "fact"; + } + return "other"; +} + +// ============================================================================ +// Plugin Definition +// ============================================================================ + +const memoryPlugin = { + id: "memory-lancedb", + name: "Memory (LanceDB)", + description: "LanceDB-backed long-term memory with auto-recall/capture", + kind: "memory" as const, + configSchema: memoryConfigSchema, + + register(api: OpenClawPluginApi) { + const cfg = memoryConfigSchema.parse(api.pluginConfig); + const resolvedDbPath = api.resolvePath(cfg.dbPath!); + const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small"); + const db = new MemoryDB(resolvedDbPath, vectorDim); + const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!); + + api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`); + + // ======================================================================== + // Tools + // ======================================================================== + + api.registerTool( + { + name: "memory_recall", + label: "Memory Recall", + description: + "Search through long-term memories. Use when you need context about user preferences, past decisions, or previously discussed topics.", + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + limit: Type.Optional(Type.Number({ description: "Max results (default: 5)" })), + }), + async execute(_toolCallId, params) { + const { query, limit = 5 } = params as { query: string; limit?: number }; + + const vector = await embeddings.embed(query); + const results = await db.search(vector, limit, 0.1); + + if (results.length === 0) { + return { + content: [{ type: "text", text: "No relevant memories found." }], + details: { count: 0 }, + }; + } + + const text = results + .map( + (r, i) => + `${i + 1}. [${r.entry.category}] ${r.entry.text} (${(r.score * 100).toFixed(0)}%)`, + ) + .join("\n"); + + // Strip vector data for serialization (typed arrays can't be cloned) + const sanitizedResults = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + importance: r.entry.importance, + score: r.score, + })); + + return { + content: [{ type: "text", text: `Found ${results.length} memories:\n\n${text}` }], + details: { count: results.length, memories: sanitizedResults }, + }; + }, + }, + { name: "memory_recall" }, + ); + + api.registerTool( + { + name: "memory_store", + label: "Memory Store", + description: + "Save important information in long-term memory. Use for preferences, facts, decisions.", + parameters: Type.Object({ + text: Type.String({ description: "Information to remember" }), + importance: Type.Optional(Type.Number({ description: "Importance 0-1 (default: 0.7)" })), + category: Type.Optional(stringEnum(MEMORY_CATEGORIES)), + }), + async execute(_toolCallId, params) { + const { + text, + importance = 0.7, + category = "other", + } = params as { + text: string; + importance?: number; + category?: MemoryEntry["category"]; + }; + + const vector = await embeddings.embed(text); + + // Check for duplicates + const existing = await db.search(vector, 1, 0.95); + if (existing.length > 0) { + return { + content: [ + { + type: "text", + text: `Similar memory already exists: "${existing[0].entry.text}"`, + }, + ], + details: { + action: "duplicate", + existingId: existing[0].entry.id, + existingText: existing[0].entry.text, + }, + }; + } + + const entry = await db.store({ + text, + vector, + importance, + category, + }); + + return { + content: [{ type: "text", text: `Stored: "${text.slice(0, 100)}..."` }], + details: { action: "created", id: entry.id }, + }; + }, + }, + { name: "memory_store" }, + ); + + api.registerTool( + { + name: "memory_forget", + label: "Memory Forget", + description: "Delete specific memories. GDPR-compliant.", + parameters: Type.Object({ + query: Type.Optional(Type.String({ description: "Search to find memory" })), + memoryId: Type.Optional(Type.String({ description: "Specific memory ID" })), + }), + async execute(_toolCallId, params) { + const { query, memoryId } = params as { query?: string; memoryId?: string }; + + if (memoryId) { + await db.delete(memoryId); + return { + content: [{ type: "text", text: `Memory ${memoryId} forgotten.` }], + details: { action: "deleted", id: memoryId }, + }; + } + + if (query) { + const vector = await embeddings.embed(query); + const results = await db.search(vector, 5, 0.7); + + if (results.length === 0) { + return { + content: [{ type: "text", text: "No matching memories found." }], + details: { found: 0 }, + }; + } + + if (results.length === 1 && results[0].score > 0.9) { + await db.delete(results[0].entry.id); + return { + content: [{ type: "text", text: `Forgotten: "${results[0].entry.text}"` }], + details: { action: "deleted", id: results[0].entry.id }, + }; + } + + const list = results + .map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`) + .join("\n"); + + // Strip vector data for serialization + const sanitizedCandidates = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + score: r.score, + })); + + return { + content: [ + { + type: "text", + text: `Found ${results.length} candidates. Specify memoryId:\n${list}`, + }, + ], + details: { action: "candidates", candidates: sanitizedCandidates }, + }; + } + + return { + content: [{ type: "text", text: "Provide query or memoryId." }], + details: { error: "missing_param" }, + }; + }, + }, + { name: "memory_forget" }, + ); + + // ======================================================================== + // CLI Commands + // ======================================================================== + + api.registerCli( + ({ program }) => { + const memory = program.command("ltm").description("LanceDB memory plugin commands"); + + memory + .command("list") + .description("List memories") + .action(async () => { + const count = await db.count(); + console.log(`Total memories: ${count}`); + }); + + memory + .command("search") + .description("Search memories") + .argument("", "Search query") + .option("--limit ", "Max results", "5") + .action(async (query, opts) => { + const vector = await embeddings.embed(query); + const results = await db.search(vector, parseInt(opts.limit), 0.3); + // Strip vectors for output + const output = results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: r.entry.category, + importance: r.entry.importance, + score: r.score, + })); + console.log(JSON.stringify(output, null, 2)); + }); + + memory + .command("stats") + .description("Show memory statistics") + .action(async () => { + const count = await db.count(); + console.log(`Total memories: ${count}`); + }); + }, + { commands: ["ltm"] }, + ); + + // ======================================================================== + // Lifecycle Hooks + // ======================================================================== + + // Auto-recall: inject relevant memories before agent starts + if (cfg.autoRecall) { + api.on("before_agent_start", async (event) => { + if (!event.prompt || event.prompt.length < 5) { + return; + } + + try { + const vector = await embeddings.embed(event.prompt); + const results = await db.search(vector, 3, 0.3); + + if (results.length === 0) { + return; + } + + const memoryContext = results + .map((r) => `- [${r.entry.category}] ${r.entry.text}`) + .join("\n"); + + api.logger.info?.(`memory-lancedb: injecting ${results.length} memories into context`); + + return { + prependContext: `\nThe following memories may be relevant to this conversation:\n${memoryContext}\n`, + }; + } catch (err) { + api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`); + } + }); + } + + // Auto-capture: analyze and store important information after agent ends + if (cfg.autoCapture) { + api.on("agent_end", async (event) => { + if (!event.success || !event.messages || event.messages.length === 0) { + return; + } + + try { + // Extract text content from messages (handling unknown[] type) + const texts: string[] = []; + for (const msg of event.messages) { + // Type guard for message object + if (!msg || typeof msg !== "object") { + continue; + } + const msgObj = msg as Record; + + // Only process user and assistant messages + const role = msgObj.role; + if (role !== "user" && role !== "assistant") { + continue; + } + + const content = msgObj.content; + + // Handle string content directly + if (typeof content === "string") { + texts.push(content); + continue; + } + + // Handle array content (content blocks) + if (Array.isArray(content)) { + for (const block of content) { + if ( + block && + typeof block === "object" && + "type" in block && + (block as Record).type === "text" && + "text" in block && + typeof (block as Record).text === "string" + ) { + texts.push((block as Record).text as string); + } + } + } + } + + // Filter for capturable content + const toCapture = texts.filter((text) => text && shouldCapture(text)); + if (toCapture.length === 0) { + return; + } + + // Store each capturable piece (limit to 3 per conversation) + let stored = 0; + for (const text of toCapture.slice(0, 3)) { + const category = detectCategory(text); + const vector = await embeddings.embed(text); + + // Check for duplicates (high similarity threshold) + const existing = await db.search(vector, 1, 0.95); + if (existing.length > 0) { + continue; + } + + await db.store({ + text, + vector, + importance: 0.7, + category, + }); + stored++; + } + + if (stored > 0) { + api.logger.info(`memory-lancedb: auto-captured ${stored} memories`); + } + } catch (err) { + api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`); + } + }); + } + + // ======================================================================== + // Service + // ======================================================================== + + api.registerService({ + id: "memory-lancedb", + start: () => { + api.logger.info( + `memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`, + ); + }, + stop: () => { + api.logger.info("memory-lancedb: stopped"); + }, + }); + }, +}; + +export default memoryPlugin; diff --git a/extensions/memory-lancedb/openclaw.plugin.json b/extensions/memory-lancedb/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..de25c49529b4db64e40a3805a8f2df8c85dcaeba --- /dev/null +++ b/extensions/memory-lancedb/openclaw.plugin.json @@ -0,0 +1,60 @@ +{ + "id": "memory-lancedb", + "kind": "memory", + "uiHints": { + "embedding.apiKey": { + "label": "OpenAI API Key", + "sensitive": true, + "placeholder": "sk-proj-...", + "help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})" + }, + "embedding.model": { + "label": "Embedding Model", + "placeholder": "text-embedding-3-small", + "help": "OpenAI embedding model to use" + }, + "dbPath": { + "label": "Database Path", + "placeholder": "~/.openclaw/memory/lancedb", + "advanced": true + }, + "autoCapture": { + "label": "Auto-Capture", + "help": "Automatically capture important information from conversations" + }, + "autoRecall": { + "label": "Auto-Recall", + "help": "Automatically inject relevant memories into context" + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "embedding": { + "type": "object", + "additionalProperties": false, + "properties": { + "apiKey": { + "type": "string" + }, + "model": { + "type": "string", + "enum": ["text-embedding-3-small", "text-embedding-3-large"] + } + }, + "required": ["apiKey"] + }, + "dbPath": { + "type": "string" + }, + "autoCapture": { + "type": "boolean" + }, + "autoRecall": { + "type": "boolean" + } + }, + "required": ["embedding"] + } +} diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json new file mode 100644 index 0000000000000000000000000000000000000000..841be278947df64cdb7bddf79478cdd822aae0b2 --- /dev/null +++ b/extensions/memory-lancedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "@openclaw/memory-lancedb", + "version": "2026.1.30", + "description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture", + "type": "module", + "dependencies": { + "@lancedb/lancedb": "^0.23.0", + "@sinclair/typebox": "0.34.48", + "openai": "^6.17.0" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/minimax-portal-auth/README.md b/extensions/minimax-portal-auth/README.md new file mode 100644 index 0000000000000000000000000000000000000000..3c29ab8ac22bb551ba09d2eb4faf754a38c4e251 --- /dev/null +++ b/extensions/minimax-portal-auth/README.md @@ -0,0 +1,33 @@ +# MiniMax OAuth (OpenClaw plugin) + +OAuth provider plugin for **MiniMax** (OAuth). + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```bash +openclaw plugins enable minimax-portal-auth +``` + +Restart the Gateway after enabling. + +```bash +openclaw gateway restart +``` + +## Authenticate + +```bash +openclaw models auth login --provider minimax-portal --set-default +``` + +You will be prompted to select an endpoint: + +- **Global** - International users, optimized for overseas access (`api.minimax.io`) +- **China** - Optimized for users in China (`api.minimaxi.com`) + +## Notes + +- MiniMax OAuth uses a user-code login flow. +- Currently, OAuth login is supported only for the Coding plan diff --git a/extensions/minimax-portal-auth/index.ts b/extensions/minimax-portal-auth/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2fd23522ed3daa1c1b01e399a1beb161436519f --- /dev/null +++ b/extensions/minimax-portal-auth/index.ts @@ -0,0 +1,155 @@ +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js"; + +const PROVIDER_ID = "minimax-portal"; +const PROVIDER_LABEL = "MiniMax"; +const DEFAULT_MODEL = "MiniMax-M2.1"; +const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; +const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; +const DEFAULT_CONTEXT_WINDOW = 200000; +const DEFAULT_MAX_TOKENS = 8192; +const OAUTH_PLACEHOLDER = "minimax-oauth"; + +function getDefaultBaseUrl(region: MiniMaxRegion): string { + return region === "cn" ? DEFAULT_BASE_URL_CN : DEFAULT_BASE_URL_GLOBAL; +} + +function modelRef(modelId: string): string { + return `${PROVIDER_ID}/${modelId}`; +} + +function buildModelDefinition(params: { + id: string; + name: string; + input: Array<"text" | "image">; +}) { + return { + id: params.id, + name: params.name, + reasoning: false, + input: params.input, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }; +} + +function createOAuthHandler(region: MiniMaxRegion) { + const defaultBaseUrl = getDefaultBaseUrl(region); + const regionLabel = region === "cn" ? "CN" : "Global"; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return async (ctx: any) => { + const progress = ctx.prompter.progress(`Starting MiniMax OAuth (${regionLabel})…`); + try { + const result = await loginMiniMaxPortalOAuth({ + openUrl: ctx.openUrl, + note: ctx.prompter.note, + progress, + region, + }); + + progress.stop("MiniMax OAuth complete"); + + if (result.notification_message) { + await ctx.prompter.note(result.notification_message, "MiniMax OAuth"); + } + + const profileId = `${PROVIDER_ID}:default`; + const baseUrl = result.resourceUrl || defaultBaseUrl; + + return { + profiles: [ + { + profileId, + credential: { + type: "oauth" as const, + provider: PROVIDER_ID, + access: result.access, + refresh: result.refresh, + expires: result.expires, + }, + }, + ], + configPatch: { + models: { + providers: { + [PROVIDER_ID]: { + baseUrl, + apiKey: OAUTH_PLACEHOLDER, + api: "anthropic-messages", + models: [ + buildModelDefinition({ + id: "MiniMax-M2.1", + name: "MiniMax M2.1", + input: ["text"], + }), + buildModelDefinition({ + id: "MiniMax-M2.1-lightning", + name: "MiniMax M2.1 Lightning", + input: ["text"], + }), + ], + }, + }, + }, + agents: { + defaults: { + models: { + [modelRef("MiniMax-M2.1")]: { alias: "minimax-m2.1" }, + [modelRef("MiniMax-M2.1-lightning")]: { alias: "minimax-m2.1-lightning" }, + }, + }, + }, + }, + defaultModel: modelRef(DEFAULT_MODEL), + notes: [ + "MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.", + `Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`, + ...(result.notification_message ? [result.notification_message] : []), + ], + }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + progress.stop(`MiniMax OAuth failed: ${errorMsg}`); + await ctx.prompter.note( + "If OAuth fails, verify your MiniMax account has portal access and try again.", + "MiniMax OAuth", + ); + throw err; + } + }; +} + +const minimaxPortalPlugin = { + id: "minimax-portal-auth", + name: "MiniMax OAuth", + description: "OAuth flow for MiniMax models", + configSchema: emptyPluginConfigSchema(), + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: PROVIDER_LABEL, + docsPath: "/providers/minimax", + aliases: ["minimax"], + auth: [ + { + id: "oauth", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + kind: "device_code", + run: createOAuthHandler("global"), + }, + { + id: "oauth-cn", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + kind: "device_code", + run: createOAuthHandler("cn"), + }, + ], + }); + }, +}; + +export default minimaxPortalPlugin; diff --git a/extensions/minimax-portal-auth/oauth.ts b/extensions/minimax-portal-auth/oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d60e79b034e8958eced831dc9823b2a201db751 --- /dev/null +++ b/extensions/minimax-portal-auth/oauth.ts @@ -0,0 +1,247 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +export type MiniMaxRegion = "cn" | "global"; + +const MINIMAX_OAUTH_CONFIG = { + cn: { + baseUrl: "https://api.minimaxi.com", + clientId: "78257093-7e40-4613-99e0-527b14b39113", + }, + global: { + baseUrl: "https://api.minimax.io", + clientId: "78257093-7e40-4613-99e0-527b14b39113", + }, +} as const; + +const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion"; +const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code"; + +function getOAuthEndpoints(region: MiniMaxRegion) { + const config = MINIMAX_OAUTH_CONFIG[region]; + return { + codeEndpoint: `${config.baseUrl}/oauth/code`, + tokenEndpoint: `${config.baseUrl}/oauth/token`, + clientId: config.clientId, + baseUrl: config.baseUrl, + }; +} + +export type MiniMaxOAuthAuthorization = { + user_code: string; + verification_uri: string; + expired_in: number; + interval?: number; + state: string; +}; + +export type MiniMaxOAuthToken = { + access: string; + refresh: string; + expires: number; + resourceUrl?: string; + notification_message?: string; +}; + +type TokenPending = { status: "pending"; message?: string }; + +type TokenResult = + | { status: "success"; token: MiniMaxOAuthToken } + | TokenPending + | { status: "error"; message: string }; + +function toFormUrlEncoded(data: Record): string { + return Object.entries(data) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join("&"); +} + +function generatePkce(): { verifier: string; challenge: string; state: string } { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + const state = randomBytes(16).toString("base64url"); + return { verifier, challenge, state }; +} + +async function requestOAuthCode(params: { + challenge: string; + state: string; + region: MiniMaxRegion; +}): Promise { + const endpoints = getOAuthEndpoints(params.region); + const response = await fetch(endpoints.codeEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "x-request-id": randomUUID(), + }, + body: toFormUrlEncoded({ + response_type: "code", + client_id: endpoints.clientId, + scope: MINIMAX_OAUTH_SCOPE, + code_challenge: params.challenge, + code_challenge_method: "S256", + state: params.state, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`); + } + + const payload = (await response.json()) as MiniMaxOAuthAuthorization & { error?: string }; + if (!payload.user_code || !payload.verification_uri) { + throw new Error( + payload.error ?? + "MiniMax OAuth authorization returned an incomplete payload (missing user_code or verification_uri).", + ); + } + if (payload.state !== params.state) { + throw new Error("MiniMax OAuth state mismatch: possible CSRF attack or session corruption."); + } + return payload; +} + +async function pollOAuthToken(params: { + userCode: string; + verifier: string; + region: MiniMaxRegion; +}): Promise { + const endpoints = getOAuthEndpoints(params.region); + const response = await fetch(endpoints.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: toFormUrlEncoded({ + grant_type: MINIMAX_OAUTH_GRANT_TYPE, + client_id: endpoints.clientId, + user_code: params.userCode, + code_verifier: params.verifier, + }), + }); + + const text = await response.text(); + let payload: + | { + status?: string; + base_resp?: { status_code?: number; status_msg?: string }; + } + | undefined; + if (text) { + try { + payload = JSON.parse(text) as typeof payload; + } catch { + payload = undefined; + } + } + + if (!response.ok) { + return { + status: "error", + message: + (payload?.base_resp?.status_msg ?? text) || "MiniMax OAuth failed to parse response.", + }; + } + + if (!payload) { + return { status: "error", message: "MiniMax OAuth failed to parse response." }; + } + + const tokenPayload = payload as { + status: string; + access_token?: string | null; + refresh_token?: string | null; + expired_in?: number | null; + token_type?: string; + resource_url?: string; + notification_message?: string; + }; + + if (tokenPayload.status === "error") { + return { status: "error", message: "An error occurred. Please try again later" }; + } + + if (tokenPayload.status != "success") { + return { status: "pending", message: "current user code is not authorized" }; + } + + if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expired_in) { + return { status: "error", message: "MiniMax OAuth returned incomplete token payload." }; + } + + return { + status: "success", + token: { + access: tokenPayload.access_token, + refresh: tokenPayload.refresh_token, + expires: tokenPayload.expired_in, + resourceUrl: tokenPayload.resource_url, + notification_message: tokenPayload.notification_message, + }, + }; +} + +export async function loginMiniMaxPortalOAuth(params: { + openUrl: (url: string) => Promise; + note: (message: string, title?: string) => Promise; + progress: { update: (message: string) => void; stop: (message?: string) => void }; + region?: MiniMaxRegion; +}): Promise { + const region = params.region ?? "global"; + const { verifier, challenge, state } = generatePkce(); + const oauth = await requestOAuthCode({ challenge, state, region }); + const verificationUrl = oauth.verification_uri; + + const noteLines = [ + `Open ${verificationUrl} to approve access.`, + `If prompted, enter the code ${oauth.user_code}.`, + `Interval: ${oauth.interval ?? "default (2000ms)"}, Expires at: ${oauth.expired_in} unix timestamp`, + ]; + await params.note(noteLines.join("\n"), "MiniMax OAuth"); + + try { + await params.openUrl(verificationUrl); + } catch { + // Fall back to manual copy/paste if browser open fails. + } + + let pollIntervalMs = oauth.interval ? oauth.interval : 2000; + const expireTimeMs = oauth.expired_in; + + while (Date.now() < expireTimeMs) { + params.progress.update("Waiting for MiniMax OAuth approval…"); + const result = await pollOAuthToken({ + userCode: oauth.user_code, + verifier, + region, + }); + + // // Debug: print poll result + // await params.note( + // `status: ${result.status}` + + // (result.status === "success" ? `\ntoken: ${JSON.stringify(result.token, null, 2)}` : "") + + // (result.status === "error" ? `\nmessage: ${result.message}` : "") + + // (result.status === "pending" && result.message ? `\nmessage: ${result.message}` : ""), + // "MiniMax OAuth Poll Result", + // ); + + if (result.status === "success") { + return result.token; + } + + if (result.status === "error") { + throw new Error(`MiniMax OAuth failed: ${result.message}`); + } + + if (result.status === "pending") { + pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error("MiniMax OAuth timed out waiting for authorization."); +} diff --git a/extensions/minimax-portal-auth/openclaw.plugin.json b/extensions/minimax-portal-auth/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..4645b6907eb5cbf5f60d69e3133b253c4821a083 --- /dev/null +++ b/extensions/minimax-portal-auth/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "minimax-portal-auth", + "providers": ["minimax-portal"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/minimax-portal-auth/package.json b/extensions/minimax-portal-auth/package.json new file mode 100644 index 0000000000000000000000000000000000000000..2634766cde6ef31c2d5b34d8985ae14b7ed48274 --- /dev/null +++ b/extensions/minimax-portal-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/minimax-portal-auth", + "version": "2026.1.30", + "description": "OpenClaw MiniMax Portal OAuth provider plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..c2788ca5fca88706ebb64e3bca84f900edf6d63f --- /dev/null +++ b/extensions/msteams/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +## 2026.1.30 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.29 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.23 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.22 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.21 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.20 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17-1 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.17 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.16 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.15 + +### Features + +- Bot Framework gateway monitor (Express + JWT auth) with configurable webhook path/port and `/api/messages` fallback. +- Onboarding flow for Azure Bot credentials (config + env var detection) and DM policy setup. +- Channel capabilities: DMs, group chats, channels, threads, media, polls, and `teams` alias. +- DM pairing/allowlist enforcement plus group policies with per-team/channel overrides and mention gating. +- Inbound debounce + history context for room/group chats; mention tag stripping and timestamp parsing. +- Proactive messaging via stored conversation references (file store with TTL/size pruning). +- Outbound text/media send with markdown chunking, 4k limit, split/inline media handling. +- Adaptive Card polls: build cards, parse votes, and persist poll state with vote tracking. +- Attachment processing: placeholders + HTML summaries, inline image extraction (including data: URLs). +- Media downloads with host allowlist, auth scope fallback, and Graph hostedContents/attachments fallback. +- Retry/backoff on transient/throttled sends with classified errors + helpful hints. diff --git a/extensions/msteams/index.ts b/extensions/msteams/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bab472367560e644c4bb544faa253974635702f --- /dev/null +++ b/extensions/msteams/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { msteamsPlugin } from "./src/channel.js"; +import { setMSTeamsRuntime } from "./src/runtime.js"; + +const plugin = { + id: "msteams", + name: "Microsoft Teams", + description: "Microsoft Teams channel plugin (Bot Framework)", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setMSTeamsRuntime(api.runtime); + api.registerChannel({ plugin: msteamsPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/msteams/openclaw.plugin.json b/extensions/msteams/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..0509a84660b64cf3610c4750ba47911d2786e3b3 --- /dev/null +++ b/extensions/msteams/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "msteams", + "channels": ["msteams"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json new file mode 100644 index 0000000000000000000000000000000000000000..ba877cc459c98e5e0e63df19af5d3f05ab47753c --- /dev/null +++ b/extensions/msteams/package.json @@ -0,0 +1,39 @@ +{ + "name": "@openclaw/msteams", + "version": "2026.1.30", + "description": "OpenClaw Microsoft Teams channel plugin", + "type": "module", + "dependencies": { + "@microsoft/agents-hosting": "^1.2.3", + "@microsoft/agents-hosting-express": "^1.2.3", + "@microsoft/agents-hosting-extensions-teams": "^1.2.3", + "express": "^5.2.1", + "openclaw": "workspace:*", + "proper-lockfile": "^4.1.2" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "msteams", + "label": "Microsoft Teams", + "selectionLabel": "Microsoft Teams (Bot Framework)", + "docsPath": "/channels/msteams", + "docsLabel": "msteams", + "blurb": "Bot Framework; enterprise support.", + "aliases": [ + "teams" + ], + "order": 60 + }, + "install": { + "npmSpec": "@openclaw/msteams", + "localPath": "extensions/msteams", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac8b635569ee321c8db7f421786091771f02cb66 --- /dev/null +++ b/extensions/msteams/src/attachments.test.ts @@ -0,0 +1,423 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const detectMimeMock = vi.fn(async () => "image/png"); +const saveMediaBufferMock = vi.fn(async () => ({ + path: "/tmp/saved.png", + contentType: "image/png", +})); + +const runtimeStub = { + media: { + detectMime: (...args: unknown[]) => detectMimeMock(...args), + }, + channel: { + media: { + saveMediaBuffer: (...args: unknown[]) => saveMediaBufferMock(...args), + }, + }, +} as unknown as PluginRuntime; + +describe("msteams attachments", () => { + const load = async () => { + return await import("./attachments.js"); + }; + + beforeEach(() => { + detectMimeMock.mockClear(); + saveMediaBufferMock.mockClear(); + setMSTeamsRuntime(runtimeStub); + }); + + describe("buildMSTeamsAttachmentPlaceholder", () => { + it("returns empty string when no attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); + expect(buildMSTeamsAttachmentPlaceholder(undefined)).toBe(""); + expect(buildMSTeamsAttachmentPlaceholder([])).toBe(""); + }); + + it("returns image placeholder for image attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); + expect( + buildMSTeamsAttachmentPlaceholder([ + { contentType: "image/png", contentUrl: "https://x/img.png" }, + ]), + ).toBe(""); + expect( + buildMSTeamsAttachmentPlaceholder([ + { contentType: "image/png", contentUrl: "https://x/1.png" }, + { contentType: "image/jpeg", contentUrl: "https://x/2.jpg" }, + ]), + ).toBe(" (2 images)"); + }); + + it("treats Teams file.download.info image attachments as images", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); + expect( + buildMSTeamsAttachmentPlaceholder([ + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { downloadUrl: "https://x/dl", fileType: "png" }, + }, + ]), + ).toBe(""); + }); + + it("returns document placeholder for non-image attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); + expect( + buildMSTeamsAttachmentPlaceholder([ + { contentType: "application/pdf", contentUrl: "https://x/x.pdf" }, + ]), + ).toBe(""); + expect( + buildMSTeamsAttachmentPlaceholder([ + { contentType: "application/pdf", contentUrl: "https://x/1.pdf" }, + { contentType: "application/pdf", contentUrl: "https://x/2.pdf" }, + ]), + ).toBe(" (2 files)"); + }); + + it("counts inline images in text/html attachments", async () => { + const { buildMSTeamsAttachmentPlaceholder } = await load(); + expect( + buildMSTeamsAttachmentPlaceholder([ + { + contentType: "text/html", + content: '

hi

', + }, + ]), + ).toBe(""); + expect( + buildMSTeamsAttachmentPlaceholder([ + { + contentType: "text/html", + content: '', + }, + ]), + ).toBe(" (2 images)"); + }); + }); + + describe("downloadMSTeamsAttachments", () => { + it("downloads and stores image contentUrl attachments", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(async () => { + return new Response(Buffer.from("png"), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const media = await downloadMSTeamsAttachments({ + attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], + maxBytes: 1024 * 1024, + allowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://x/img"); + expect(saveMediaBufferMock).toHaveBeenCalled(); + expect(media).toHaveLength(1); + expect(media[0]?.path).toBe("/tmp/saved.png"); + }); + + it("supports Teams file.download.info downloadUrl attachments", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(async () => { + return new Response(Buffer.from("png"), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const media = await downloadMSTeamsAttachments({ + attachments: [ + { + contentType: "application/vnd.microsoft.teams.file.download.info", + content: { downloadUrl: "https://x/dl", fileType: "png" }, + }, + ], + maxBytes: 1024 * 1024, + allowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://x/dl"); + expect(media).toHaveLength(1); + }); + + it("downloads non-image file attachments (PDF)", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(async () => { + return new Response(Buffer.from("pdf"), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + }); + detectMimeMock.mockResolvedValueOnce("application/pdf"); + saveMediaBufferMock.mockResolvedValueOnce({ + path: "/tmp/saved.pdf", + contentType: "application/pdf", + }); + + const media = await downloadMSTeamsAttachments({ + attachments: [{ contentType: "application/pdf", contentUrl: "https://x/doc.pdf" }], + maxBytes: 1024 * 1024, + allowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(fetchMock).toHaveBeenCalledWith("https://x/doc.pdf"); + expect(media).toHaveLength(1); + expect(media[0]?.path).toBe("/tmp/saved.pdf"); + expect(media[0]?.placeholder).toBe(""); + }); + + it("downloads inline image URLs from html attachments", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(async () => { + return new Response(Buffer.from("png"), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const media = await downloadMSTeamsAttachments({ + attachments: [ + { + contentType: "text/html", + content: '', + }, + ], + maxBytes: 1024 * 1024, + allowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledWith("https://x/inline.png"); + }); + + it("stores inline data:image base64 payloads", async () => { + const { downloadMSTeamsAttachments } = await load(); + const base64 = Buffer.from("png").toString("base64"); + const media = await downloadMSTeamsAttachments({ + attachments: [ + { + contentType: "text/html", + content: ``, + }, + ], + maxBytes: 1024 * 1024, + allowHosts: ["x"], + }); + + expect(media).toHaveLength(1); + expect(saveMediaBufferMock).toHaveBeenCalled(); + }); + + it("retries with auth when the first request is unauthorized", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(async (_url: string, opts?: RequestInit) => { + const hasAuth = Boolean( + opts && + typeof opts === "object" && + "headers" in opts && + (opts.headers as Record)?.Authorization, + ); + if (!hasAuth) { + return new Response("unauthorized", { status: 401 }); + } + return new Response(Buffer.from("png"), { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const media = await downloadMSTeamsAttachments({ + attachments: [{ contentType: "image/png", contentUrl: "https://x/img" }], + maxBytes: 1024 * 1024, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + allowHosts: ["x"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(fetchMock).toHaveBeenCalled(); + expect(media).toHaveLength(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("skips urls outside the allowlist", async () => { + const { downloadMSTeamsAttachments } = await load(); + const fetchMock = vi.fn(); + const media = await downloadMSTeamsAttachments({ + attachments: [{ contentType: "image/png", contentUrl: "https://evil.test/img" }], + maxBytes: 1024 * 1024, + allowHosts: ["graph.microsoft.com"], + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media).toHaveLength(0); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("buildMSTeamsGraphMessageUrls", () => { + it("builds channel message urls", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "channel", + conversationId: "19:thread@thread.tacv2", + messageId: "123", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }); + expect(urls[0]).toContain("/teams/team-id/channels/chan-id/messages/123"); + }); + + it("builds channel reply urls when replyToId is present", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "channel", + messageId: "reply-id", + replyToId: "root-id", + channelData: { team: { id: "team-id" }, channel: { id: "chan-id" } }, + }); + expect(urls[0]).toContain( + "/teams/team-id/channels/chan-id/messages/root-id/replies/reply-id", + ); + }); + + it("builds chat message urls", async () => { + const { buildMSTeamsGraphMessageUrls } = await load(); + const urls = buildMSTeamsGraphMessageUrls({ + conversationType: "groupChat", + conversationId: "19:chat@thread.v2", + messageId: "456", + }); + expect(urls[0]).toContain("/chats/19%3Achat%40thread.v2/messages/456"); + }); + }); + + describe("downloadMSTeamsGraphMedia", () => { + it("downloads hostedContents images", async () => { + const { downloadMSTeamsGraphMedia } = await load(); + const base64 = Buffer.from("png").toString("base64"); + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith("/hostedContents")) { + return new Response( + JSON.stringify({ + value: [ + { + id: "1", + contentType: "image/png", + contentBytes: base64, + }, + ], + }), + { status: 200 }, + ); + } + if (url.endsWith("/attachments")) { + return new Response(JSON.stringify({ value: [] }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const media = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + maxBytes: 1024 * 1024, + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media.media).toHaveLength(1); + expect(fetchMock).toHaveBeenCalled(); + expect(saveMediaBufferMock).toHaveBeenCalled(); + }); + + it("merges SharePoint reference attachments with hosted content", async () => { + const { downloadMSTeamsGraphMedia } = await load(); + const hostedBase64 = Buffer.from("png").toString("base64"); + const shareUrl = "https://contoso.sharepoint.com/site/file"; + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith("/hostedContents")) { + return new Response( + JSON.stringify({ + value: [ + { + id: "hosted-1", + contentType: "image/png", + contentBytes: hostedBase64, + }, + ], + }), + { status: 200 }, + ); + } + if (url.endsWith("/attachments")) { + return new Response( + JSON.stringify({ + value: [ + { + id: "ref-1", + contentType: "reference", + contentUrl: shareUrl, + name: "report.pdf", + }, + ], + }), + { status: 200 }, + ); + } + if (url.startsWith("https://graph.microsoft.com/v1.0/shares/")) { + return new Response(Buffer.from("pdf"), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + } + if (url.endsWith("/messages/123")) { + return new Response( + JSON.stringify({ + attachments: [ + { + id: "ref-1", + contentType: "reference", + contentUrl: shareUrl, + name: "report.pdf", + }, + ], + }), + { status: 200 }, + ); + } + return new Response("not found", { status: 404 }); + }); + + const media = await downloadMSTeamsGraphMedia({ + messageUrl: "https://graph.microsoft.com/v1.0/chats/19%3Achat/messages/123", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + maxBytes: 1024 * 1024, + fetchFn: fetchMock as unknown as typeof fetch, + }); + + expect(media.media).toHaveLength(2); + }); + }); + + describe("buildMSTeamsMediaPayload", () => { + it("returns single and multi-file fields", async () => { + const { buildMSTeamsMediaPayload } = await load(); + const payload = buildMSTeamsMediaPayload([ + { path: "/tmp/a.png", contentType: "image/png" }, + { path: "/tmp/b.png", contentType: "image/png" }, + ]); + expect(payload.MediaPath).toBe("/tmp/a.png"); + expect(payload.MediaUrl).toBe("/tmp/a.png"); + expect(payload.MediaPaths).toEqual(["/tmp/a.png", "/tmp/b.png"]); + expect(payload.MediaUrls).toEqual(["/tmp/a.png", "/tmp/b.png"]); + expect(payload.MediaTypes).toEqual(["image/png", "image/png"]); + }); + }); +}); diff --git a/extensions/msteams/src/attachments.ts b/extensions/msteams/src/attachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..d29a3ef310ff19589ffa06b3863fe084cdb1e3a9 --- /dev/null +++ b/extensions/msteams/src/attachments.ts @@ -0,0 +1,18 @@ +export { + downloadMSTeamsAttachments, + /** @deprecated Use `downloadMSTeamsAttachments` instead. */ + downloadMSTeamsImageAttachments, +} from "./attachments/download.js"; +export { buildMSTeamsGraphMessageUrls, downloadMSTeamsGraphMedia } from "./attachments/graph.js"; +export { + buildMSTeamsAttachmentPlaceholder, + summarizeMSTeamsHtmlAttachments, +} from "./attachments/html.js"; +export { buildMSTeamsMediaPayload } from "./attachments/payload.js"; +export type { + MSTeamsAccessTokenProvider, + MSTeamsAttachmentLike, + MSTeamsGraphMediaResult, + MSTeamsHtmlAttachmentSummary, + MSTeamsInboundMedia, +} from "./attachments/types.js"; diff --git a/extensions/msteams/src/attachments/download.ts b/extensions/msteams/src/attachments/download.ts new file mode 100644 index 0000000000000000000000000000000000000000..9446a259ae59620ce995caaa731905dc5535afcb --- /dev/null +++ b/extensions/msteams/src/attachments/download.ts @@ -0,0 +1,238 @@ +import type { + MSTeamsAccessTokenProvider, + MSTeamsAttachmentLike, + MSTeamsInboundMedia, +} from "./types.js"; +import { getMSTeamsRuntime } from "../runtime.js"; +import { + extractInlineImageCandidates, + inferPlaceholder, + isDownloadableAttachment, + isRecord, + isUrlAllowed, + normalizeContentType, + resolveAllowedHosts, +} from "./shared.js"; + +type DownloadCandidate = { + url: string; + fileHint?: string; + contentTypeHint?: string; + placeholder: string; +}; + +function resolveDownloadCandidate(att: MSTeamsAttachmentLike): DownloadCandidate | null { + const contentType = normalizeContentType(att.contentType); + const name = typeof att.name === "string" ? att.name.trim() : ""; + + if (contentType === "application/vnd.microsoft.teams.file.download.info") { + if (!isRecord(att.content)) { + return null; + } + const downloadUrl = + typeof att.content.downloadUrl === "string" ? att.content.downloadUrl.trim() : ""; + if (!downloadUrl) { + return null; + } + + const fileType = typeof att.content.fileType === "string" ? att.content.fileType.trim() : ""; + const uniqueId = typeof att.content.uniqueId === "string" ? att.content.uniqueId.trim() : ""; + const fileName = typeof att.content.fileName === "string" ? att.content.fileName.trim() : ""; + + const fileHint = name || fileName || (uniqueId && fileType ? `${uniqueId}.${fileType}` : ""); + return { + url: downloadUrl, + fileHint: fileHint || undefined, + contentTypeHint: undefined, + placeholder: inferPlaceholder({ + contentType, + fileName: fileHint, + fileType, + }), + }; + } + + const contentUrl = typeof att.contentUrl === "string" ? att.contentUrl.trim() : ""; + if (!contentUrl) { + return null; + } + + return { + url: contentUrl, + fileHint: name || undefined, + contentTypeHint: contentType, + placeholder: inferPlaceholder({ contentType, fileName: name }), + }; +} + +function scopeCandidatesForUrl(url: string): string[] { + try { + const host = new URL(url).hostname.toLowerCase(); + const looksLikeGraph = + host.endsWith("graph.microsoft.com") || + host.endsWith("sharepoint.com") || + host.endsWith("1drv.ms") || + host.includes("sharepoint"); + return looksLikeGraph + ? ["https://graph.microsoft.com", "https://api.botframework.com"] + : ["https://api.botframework.com", "https://graph.microsoft.com"]; + } catch { + return ["https://api.botframework.com", "https://graph.microsoft.com"]; + } +} + +async function fetchWithAuthFallback(params: { + url: string; + tokenProvider?: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const firstAttempt = await fetchFn(params.url); + if (firstAttempt.ok) { + return firstAttempt; + } + if (!params.tokenProvider) { + return firstAttempt; + } + if (firstAttempt.status !== 401 && firstAttempt.status !== 403) { + return firstAttempt; + } + + const scopes = scopeCandidatesForUrl(params.url); + for (const scope of scopes) { + try { + const token = await params.tokenProvider.getAccessToken(scope); + const res = await fetchFn(params.url, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + return res; + } + } catch { + // Try the next scope. + } + } + + return firstAttempt; +} + +/** + * Download all file attachments from a Teams message (images, documents, etc.). + * Renamed from downloadMSTeamsImageAttachments to support all file types. + */ +export async function downloadMSTeamsAttachments(params: { + attachments: MSTeamsAttachmentLike[] | undefined; + maxBytes: number; + tokenProvider?: MSTeamsAccessTokenProvider; + allowHosts?: string[]; + fetchFn?: typeof fetch; + /** When true, embeds original filename in stored path for later extraction. */ + preserveFilenames?: boolean; +}): Promise { + const list = Array.isArray(params.attachments) ? params.attachments : []; + if (list.length === 0) { + return []; + } + const allowHosts = resolveAllowedHosts(params.allowHosts); + + // Download ANY downloadable attachment (not just images) + const downloadable = list.filter(isDownloadableAttachment); + const candidates: DownloadCandidate[] = downloadable + .map(resolveDownloadCandidate) + .filter(Boolean) as DownloadCandidate[]; + + const inlineCandidates = extractInlineImageCandidates(list); + + const seenUrls = new Set(); + for (const inline of inlineCandidates) { + if (inline.kind === "url") { + if (!isUrlAllowed(inline.url, allowHosts)) { + continue; + } + if (seenUrls.has(inline.url)) { + continue; + } + seenUrls.add(inline.url); + candidates.push({ + url: inline.url, + fileHint: inline.fileHint, + contentTypeHint: inline.contentType, + placeholder: inline.placeholder, + }); + } + } + if (candidates.length === 0 && inlineCandidates.length === 0) { + return []; + } + + const out: MSTeamsInboundMedia[] = []; + for (const inline of inlineCandidates) { + if (inline.kind !== "data") { + continue; + } + if (inline.data.byteLength > params.maxBytes) { + continue; + } + try { + // Data inline candidates (base64 data URLs) don't have original filenames + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( + inline.data, + inline.contentType, + "inbound", + params.maxBytes, + ); + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inline.placeholder, + }); + } catch { + // Ignore decode failures and continue. + } + } + for (const candidate of candidates) { + if (!isUrlAllowed(candidate.url, allowHosts)) { + continue; + } + try { + const res = await fetchWithAuthFallback({ + url: candidate.url, + tokenProvider: params.tokenProvider, + fetchFn: params.fetchFn, + }); + if (!res.ok) { + continue; + } + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.byteLength > params.maxBytes) { + continue; + } + const mime = await getMSTeamsRuntime().media.detectMime({ + buffer, + headerMime: res.headers.get("content-type"), + filePath: candidate.fileHint ?? candidate.url, + }); + const originalFilename = params.preserveFilenames ? candidate.fileHint : undefined; + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( + buffer, + mime ?? candidate.contentTypeHint, + "inbound", + params.maxBytes, + originalFilename, + ); + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: candidate.placeholder, + }); + } catch { + // Ignore download failures and continue with next candidate. + } + } + return out; +} + +/** + * @deprecated Use `downloadMSTeamsAttachments` instead (supports all file types). + */ +export const downloadMSTeamsImageAttachments = downloadMSTeamsAttachments; diff --git a/extensions/msteams/src/attachments/graph.ts b/extensions/msteams/src/attachments/graph.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1f594641fee2d99ef8a67c147afb81603293c44 --- /dev/null +++ b/extensions/msteams/src/attachments/graph.ts @@ -0,0 +1,351 @@ +import type { + MSTeamsAccessTokenProvider, + MSTeamsAttachmentLike, + MSTeamsGraphMediaResult, + MSTeamsInboundMedia, +} from "./types.js"; +import { getMSTeamsRuntime } from "../runtime.js"; +import { downloadMSTeamsAttachments } from "./download.js"; +import { + GRAPH_ROOT, + inferPlaceholder, + isRecord, + normalizeContentType, + resolveAllowedHosts, +} from "./shared.js"; + +type GraphHostedContent = { + id?: string | null; + contentType?: string | null; + contentBytes?: string | null; +}; + +type GraphAttachment = { + id?: string | null; + contentType?: string | null; + contentUrl?: string | null; + name?: string | null; + thumbnailUrl?: string | null; + content?: unknown; +}; + +function readNestedString(value: unknown, keys: Array): string | undefined { + let current: unknown = value; + for (const key of keys) { + if (!isRecord(current)) { + return undefined; + } + current = current[key as keyof typeof current]; + } + return typeof current === "string" && current.trim() ? current.trim() : undefined; +} + +export function buildMSTeamsGraphMessageUrls(params: { + conversationType?: string | null; + conversationId?: string | null; + messageId?: string | null; + replyToId?: string | null; + conversationMessageId?: string | null; + channelData?: unknown; +}): string[] { + const conversationType = params.conversationType?.trim().toLowerCase() ?? ""; + const messageIdCandidates = new Set(); + const pushCandidate = (value: string | null | undefined) => { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (trimmed) { + messageIdCandidates.add(trimmed); + } + }; + + pushCandidate(params.messageId); + pushCandidate(params.conversationMessageId); + pushCandidate(readNestedString(params.channelData, ["messageId"])); + pushCandidate(readNestedString(params.channelData, ["teamsMessageId"])); + + const replyToId = typeof params.replyToId === "string" ? params.replyToId.trim() : ""; + + if (conversationType === "channel") { + const teamId = + readNestedString(params.channelData, ["team", "id"]) ?? + readNestedString(params.channelData, ["teamId"]); + const channelId = + readNestedString(params.channelData, ["channel", "id"]) ?? + readNestedString(params.channelData, ["channelId"]) ?? + readNestedString(params.channelData, ["teamsChannelId"]); + if (!teamId || !channelId) { + return []; + } + const urls: string[] = []; + if (replyToId) { + for (const candidate of messageIdCandidates) { + if (candidate === replyToId) { + continue; + } + urls.push( + `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(replyToId)}/replies/${encodeURIComponent(candidate)}`, + ); + } + } + if (messageIdCandidates.size === 0 && replyToId) { + messageIdCandidates.add(replyToId); + } + for (const candidate of messageIdCandidates) { + urls.push( + `${GRAPH_ROOT}/teams/${encodeURIComponent(teamId)}/channels/${encodeURIComponent(channelId)}/messages/${encodeURIComponent(candidate)}`, + ); + } + return Array.from(new Set(urls)); + } + + const chatId = params.conversationId?.trim() || readNestedString(params.channelData, ["chatId"]); + if (!chatId) { + return []; + } + if (messageIdCandidates.size === 0 && replyToId) { + messageIdCandidates.add(replyToId); + } + const urls = Array.from(messageIdCandidates).map( + (candidate) => + `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages/${encodeURIComponent(candidate)}`, + ); + return Array.from(new Set(urls)); +} + +async function fetchGraphCollection(params: { + url: string; + accessToken: string; + fetchFn?: typeof fetch; +}): Promise<{ status: number; items: T[] }> { + const fetchFn = params.fetchFn ?? fetch; + const res = await fetchFn(params.url, { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }); + const status = res.status; + if (!res.ok) { + return { status, items: [] }; + } + try { + const data = (await res.json()) as { value?: T[] }; + return { status, items: Array.isArray(data.value) ? data.value : [] }; + } catch { + return { status, items: [] }; + } +} + +function normalizeGraphAttachment(att: GraphAttachment): MSTeamsAttachmentLike { + let content: unknown = att.content; + if (typeof content === "string") { + try { + content = JSON.parse(content); + } catch { + // Keep as raw string if it's not JSON. + } + } + return { + contentType: normalizeContentType(att.contentType) ?? undefined, + contentUrl: att.contentUrl ?? undefined, + name: att.name ?? undefined, + thumbnailUrl: att.thumbnailUrl ?? undefined, + content, + }; +} + +/** + * Download all hosted content from a Teams message (images, documents, etc.). + * Renamed from downloadGraphHostedImages to support all file types. + */ +async function downloadGraphHostedContent(params: { + accessToken: string; + messageUrl: string; + maxBytes: number; + fetchFn?: typeof fetch; + preserveFilenames?: boolean; +}): Promise<{ media: MSTeamsInboundMedia[]; status: number; count: number }> { + const hosted = await fetchGraphCollection({ + url: `${params.messageUrl}/hostedContents`, + accessToken: params.accessToken, + fetchFn: params.fetchFn, + }); + if (hosted.items.length === 0) { + return { media: [], status: hosted.status, count: 0 }; + } + + const out: MSTeamsInboundMedia[] = []; + for (const item of hosted.items) { + const contentBytes = typeof item.contentBytes === "string" ? item.contentBytes : ""; + if (!contentBytes) { + continue; + } + let buffer: Buffer; + try { + buffer = Buffer.from(contentBytes, "base64"); + } catch { + continue; + } + if (buffer.byteLength > params.maxBytes) { + continue; + } + const mime = await getMSTeamsRuntime().media.detectMime({ + buffer, + headerMime: item.contentType ?? undefined, + }); + // Download any file type, not just images + try { + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( + buffer, + mime ?? item.contentType ?? undefined, + "inbound", + params.maxBytes, + ); + out.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder({ contentType: saved.contentType }), + }); + } catch { + // Ignore save failures. + } + } + + return { media: out, status: hosted.status, count: hosted.items.length }; +} + +export async function downloadMSTeamsGraphMedia(params: { + messageUrl?: string | null; + tokenProvider?: MSTeamsAccessTokenProvider; + maxBytes: number; + allowHosts?: string[]; + fetchFn?: typeof fetch; + /** When true, embeds original filename in stored path for later extraction. */ + preserveFilenames?: boolean; +}): Promise { + if (!params.messageUrl || !params.tokenProvider) { + return { media: [] }; + } + const allowHosts = resolveAllowedHosts(params.allowHosts); + const messageUrl = params.messageUrl; + let accessToken: string; + try { + accessToken = await params.tokenProvider.getAccessToken("https://graph.microsoft.com"); + } catch { + return { media: [], messageUrl, tokenError: true }; + } + + // Fetch the full message to get SharePoint file attachments (for group chats) + const fetchFn = params.fetchFn ?? fetch; + const sharePointMedia: MSTeamsInboundMedia[] = []; + const downloadedReferenceUrls = new Set(); + try { + const msgRes = await fetchFn(messageUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (msgRes.ok) { + const msgData = (await msgRes.json()) as { + body?: { content?: string; contentType?: string }; + attachments?: Array<{ + id?: string; + contentUrl?: string; + contentType?: string; + name?: string; + }>; + }; + + // Extract SharePoint file attachments (contentType: "reference") + // Download any file type, not just images + const spAttachments = (msgData.attachments ?? []).filter( + (a) => a.contentType === "reference" && a.contentUrl && a.name, + ); + for (const att of spAttachments) { + const name = att.name ?? "file"; + + try { + // SharePoint URLs need to be accessed via Graph shares API + const shareUrl = att.contentUrl!; + const encodedUrl = Buffer.from(shareUrl).toString("base64url"); + const sharesUrl = `${GRAPH_ROOT}/shares/u!${encodedUrl}/driveItem/content`; + + const spRes = await fetchFn(sharesUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: "follow", + }); + + if (spRes.ok) { + const buffer = Buffer.from(await spRes.arrayBuffer()); + if (buffer.byteLength <= params.maxBytes) { + const mime = await getMSTeamsRuntime().media.detectMime({ + buffer, + headerMime: spRes.headers.get("content-type") ?? undefined, + filePath: name, + }); + const originalFilename = params.preserveFilenames ? name : undefined; + const saved = await getMSTeamsRuntime().channel.media.saveMediaBuffer( + buffer, + mime ?? "application/octet-stream", + "inbound", + params.maxBytes, + originalFilename, + ); + sharePointMedia.push({ + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder({ contentType: saved.contentType, fileName: name }), + }); + downloadedReferenceUrls.add(shareUrl); + } + } + } catch { + // Ignore SharePoint download failures. + } + } + } + } catch { + // Ignore message fetch failures. + } + + const hosted = await downloadGraphHostedContent({ + accessToken, + messageUrl, + maxBytes: params.maxBytes, + fetchFn: params.fetchFn, + preserveFilenames: params.preserveFilenames, + }); + + const attachments = await fetchGraphCollection({ + url: `${messageUrl}/attachments`, + accessToken, + fetchFn: params.fetchFn, + }); + + const normalizedAttachments = attachments.items.map(normalizeGraphAttachment); + const filteredAttachments = + sharePointMedia.length > 0 + ? normalizedAttachments.filter((att) => { + const contentType = att.contentType?.toLowerCase(); + if (contentType !== "reference") { + return true; + } + const url = typeof att.contentUrl === "string" ? att.contentUrl : ""; + if (!url) { + return true; + } + return !downloadedReferenceUrls.has(url); + }) + : normalizedAttachments; + const attachmentMedia = await downloadMSTeamsAttachments({ + attachments: filteredAttachments, + maxBytes: params.maxBytes, + tokenProvider: params.tokenProvider, + allowHosts, + fetchFn: params.fetchFn, + preserveFilenames: params.preserveFilenames, + }); + + return { + media: [...sharePointMedia, ...hosted.media, ...attachmentMedia], + hostedCount: hosted.count, + attachmentCount: filteredAttachments.length + sharePointMedia.length, + hostedStatus: hosted.status, + attachmentStatus: attachments.status, + messageUrl, + }; +} diff --git a/extensions/msteams/src/attachments/html.ts b/extensions/msteams/src/attachments/html.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1983d452de2d7869b3239c6d9e3c5c234c83a80 --- /dev/null +++ b/extensions/msteams/src/attachments/html.ts @@ -0,0 +1,90 @@ +import type { MSTeamsAttachmentLike, MSTeamsHtmlAttachmentSummary } from "./types.js"; +import { + ATTACHMENT_TAG_RE, + extractHtmlFromAttachment, + extractInlineImageCandidates, + IMG_SRC_RE, + isLikelyImageAttachment, + safeHostForUrl, +} from "./shared.js"; + +export function summarizeMSTeamsHtmlAttachments( + attachments: MSTeamsAttachmentLike[] | undefined, +): MSTeamsHtmlAttachmentSummary | undefined { + const list = Array.isArray(attachments) ? attachments : []; + if (list.length === 0) { + return undefined; + } + let htmlAttachments = 0; + let imgTags = 0; + let dataImages = 0; + let cidImages = 0; + const srcHosts = new Set(); + let attachmentTags = 0; + const attachmentIds = new Set(); + + for (const att of list) { + const html = extractHtmlFromAttachment(att); + if (!html) { + continue; + } + htmlAttachments += 1; + IMG_SRC_RE.lastIndex = 0; + let match: RegExpExecArray | null = IMG_SRC_RE.exec(html); + while (match) { + imgTags += 1; + const src = match[1]?.trim(); + if (src) { + if (src.startsWith("data:")) { + dataImages += 1; + } else if (src.startsWith("cid:")) { + cidImages += 1; + } else { + srcHosts.add(safeHostForUrl(src)); + } + } + match = IMG_SRC_RE.exec(html); + } + + ATTACHMENT_TAG_RE.lastIndex = 0; + let attachmentMatch: RegExpExecArray | null = ATTACHMENT_TAG_RE.exec(html); + while (attachmentMatch) { + attachmentTags += 1; + const id = attachmentMatch[1]?.trim(); + if (id) { + attachmentIds.add(id); + } + attachmentMatch = ATTACHMENT_TAG_RE.exec(html); + } + } + + if (htmlAttachments === 0) { + return undefined; + } + return { + htmlAttachments, + imgTags, + dataImages, + cidImages, + srcHosts: Array.from(srcHosts).slice(0, 5), + attachmentTags, + attachmentIds: Array.from(attachmentIds).slice(0, 5), + }; +} + +export function buildMSTeamsAttachmentPlaceholder( + attachments: MSTeamsAttachmentLike[] | undefined, +): string { + const list = Array.isArray(attachments) ? attachments : []; + if (list.length === 0) { + return ""; + } + const imageCount = list.filter(isLikelyImageAttachment).length; + const inlineCount = extractInlineImageCandidates(list).length; + const totalImages = imageCount + inlineCount; + if (totalImages > 0) { + return `${totalImages > 1 ? ` (${totalImages} images)` : ""}`; + } + const count = list.length; + return `${count > 1 ? ` (${count} files)` : ""}`; +} diff --git a/extensions/msteams/src/attachments/payload.ts b/extensions/msteams/src/attachments/payload.ts new file mode 100644 index 0000000000000000000000000000000000000000..3887f9ee92711d3dad0638a2fe48eae540a30cf5 --- /dev/null +++ b/extensions/msteams/src/attachments/payload.ts @@ -0,0 +1,22 @@ +export function buildMSTeamsMediaPayload( + mediaList: Array<{ path: string; contentType?: string }>, +): { + MediaPath?: string; + MediaType?: string; + MediaUrl?: string; + MediaPaths?: string[]; + MediaUrls?: string[]; + MediaTypes?: string[]; +} { + const first = mediaList[0]; + const mediaPaths = mediaList.map((media) => media.path); + const mediaTypes = mediaList.map((media) => media.contentType ?? ""); + return { + MediaPath: first?.path, + MediaType: first?.contentType, + MediaUrl: first?.path, + MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined, + MediaTypes: mediaPaths.length > 0 ? mediaTypes : undefined, + }; +} diff --git a/extensions/msteams/src/attachments/shared.ts b/extensions/msteams/src/attachments/shared.ts new file mode 100644 index 0000000000000000000000000000000000000000..39a41e8d695695351ec39ca8ec67ca2a70c02e4d --- /dev/null +++ b/extensions/msteams/src/attachments/shared.ts @@ -0,0 +1,271 @@ +import type { MSTeamsAttachmentLike } from "./types.js"; + +type InlineImageCandidate = + | { + kind: "data"; + data: Buffer; + contentType?: string; + placeholder: string; + } + | { + kind: "url"; + url: string; + contentType?: string; + fileHint?: string; + placeholder: string; + }; + +export const IMAGE_EXT_RE = /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/i; + +export const IMG_SRC_RE = /]+src=["']([^"']+)["'][^>]*>/gi; +export const ATTACHMENT_TAG_RE = /]+id=["']([^"']+)["'][^>]*>/gi; + +export const DEFAULT_MEDIA_HOST_ALLOWLIST = [ + "graph.microsoft.com", + "graph.microsoft.us", + "graph.microsoft.de", + "graph.microsoft.cn", + "sharepoint.com", + "sharepoint.us", + "sharepoint.de", + "sharepoint.cn", + "sharepoint-df.com", + "1drv.ms", + "onedrive.com", + "teams.microsoft.com", + "teams.cdn.office.net", + "statics.teams.cdn.office.net", + "office.com", + "office.net", + // Azure Media Services / Skype CDN for clipboard-pasted images + "asm.skype.com", + "ams.skype.com", + "media.ams.skype.com", + // Bot Framework attachment URLs + "trafficmanager.net", + "blob.core.windows.net", + "azureedge.net", + "microsoft.com", +] as const; + +export const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; + +export function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function normalizeContentType(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +export function inferPlaceholder(params: { + contentType?: string; + fileName?: string; + fileType?: string; +}): string { + const mime = params.contentType?.toLowerCase() ?? ""; + const name = params.fileName?.toLowerCase() ?? ""; + const fileType = params.fileType?.toLowerCase() ?? ""; + + const looksLikeImage = + mime.startsWith("image/") || IMAGE_EXT_RE.test(name) || IMAGE_EXT_RE.test(`x.${fileType}`); + + return looksLikeImage ? "" : ""; +} + +export function isLikelyImageAttachment(att: MSTeamsAttachmentLike): boolean { + const contentType = normalizeContentType(att.contentType) ?? ""; + const name = typeof att.name === "string" ? att.name : ""; + if (contentType.startsWith("image/")) { + return true; + } + if (IMAGE_EXT_RE.test(name)) { + return true; + } + + if ( + contentType === "application/vnd.microsoft.teams.file.download.info" && + isRecord(att.content) + ) { + const fileType = typeof att.content.fileType === "string" ? att.content.fileType : ""; + if (fileType && IMAGE_EXT_RE.test(`x.${fileType}`)) { + return true; + } + const fileName = typeof att.content.fileName === "string" ? att.content.fileName : ""; + if (fileName && IMAGE_EXT_RE.test(fileName)) { + return true; + } + } + + return false; +} + +/** + * Returns true if the attachment can be downloaded (any file type). + * Used when downloading all files, not just images. + */ +export function isDownloadableAttachment(att: MSTeamsAttachmentLike): boolean { + const contentType = normalizeContentType(att.contentType) ?? ""; + + // Teams file download info always has a downloadUrl + if ( + contentType === "application/vnd.microsoft.teams.file.download.info" && + isRecord(att.content) && + typeof att.content.downloadUrl === "string" + ) { + return true; + } + + // Any attachment with a contentUrl can be downloaded + if (typeof att.contentUrl === "string" && att.contentUrl.trim()) { + return true; + } + + return false; +} + +function isHtmlAttachment(att: MSTeamsAttachmentLike): boolean { + const contentType = normalizeContentType(att.contentType) ?? ""; + return contentType.startsWith("text/html"); +} + +export function extractHtmlFromAttachment(att: MSTeamsAttachmentLike): string | undefined { + if (!isHtmlAttachment(att)) { + return undefined; + } + if (typeof att.content === "string") { + return att.content; + } + if (!isRecord(att.content)) { + return undefined; + } + const text = + typeof att.content.text === "string" + ? att.content.text + : typeof att.content.body === "string" + ? att.content.body + : typeof att.content.content === "string" + ? att.content.content + : undefined; + return text; +} + +function decodeDataImage(src: string): InlineImageCandidate | null { + const match = /^data:(image\/[a-z0-9.+-]+)?(;base64)?,(.*)$/i.exec(src); + if (!match) { + return null; + } + const contentType = match[1]?.toLowerCase(); + const isBase64 = Boolean(match[2]); + if (!isBase64) { + return null; + } + const payload = match[3] ?? ""; + if (!payload) { + return null; + } + try { + const data = Buffer.from(payload, "base64"); + return { kind: "data", data, contentType, placeholder: "" }; + } catch { + return null; + } +} + +function fileHintFromUrl(src: string): string | undefined { + try { + const url = new URL(src); + const name = url.pathname.split("/").pop(); + return name || undefined; + } catch { + return undefined; + } +} + +export function extractInlineImageCandidates( + attachments: MSTeamsAttachmentLike[], +): InlineImageCandidate[] { + const out: InlineImageCandidate[] = []; + for (const att of attachments) { + const html = extractHtmlFromAttachment(att); + if (!html) { + continue; + } + IMG_SRC_RE.lastIndex = 0; + let match: RegExpExecArray | null = IMG_SRC_RE.exec(html); + while (match) { + const src = match[1]?.trim(); + if (src && !src.startsWith("cid:")) { + if (src.startsWith("data:")) { + const decoded = decodeDataImage(src); + if (decoded) { + out.push(decoded); + } + } else { + out.push({ + kind: "url", + url: src, + fileHint: fileHintFromUrl(src), + placeholder: "", + }); + } + } + match = IMG_SRC_RE.exec(html); + } + } + return out; +} + +export function safeHostForUrl(url: string): string { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return "invalid-url"; + } +} + +function normalizeAllowHost(value: string): string { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return ""; + } + if (trimmed === "*") { + return "*"; + } + return trimmed.replace(/^\*\.?/, ""); +} + +export function resolveAllowedHosts(input?: string[]): string[] { + if (!Array.isArray(input) || input.length === 0) { + return DEFAULT_MEDIA_HOST_ALLOWLIST.slice(); + } + const normalized = input.map(normalizeAllowHost).filter(Boolean); + if (normalized.includes("*")) { + return ["*"]; + } + return normalized; +} + +function isHostAllowed(host: string, allowlist: string[]): boolean { + if (allowlist.includes("*")) { + return true; + } + const normalized = host.toLowerCase(); + return allowlist.some((entry) => normalized === entry || normalized.endsWith(`.${entry}`)); +} + +export function isUrlAllowed(url: string, allowlist: string[]): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== "https:") { + return false; + } + return isHostAllowed(parsed.hostname, allowlist); + } catch { + return false; + } +} diff --git a/extensions/msteams/src/attachments/types.ts b/extensions/msteams/src/attachments/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..6750c822c3baa3e537a4ecd9c035246a129ef3d3 --- /dev/null +++ b/extensions/msteams/src/attachments/types.ts @@ -0,0 +1,37 @@ +export type MSTeamsAttachmentLike = { + contentType?: string | null; + contentUrl?: string | null; + name?: string | null; + thumbnailUrl?: string | null; + content?: unknown; +}; + +export type MSTeamsAccessTokenProvider = { + getAccessToken: (scope: string) => Promise; +}; + +export type MSTeamsInboundMedia = { + path: string; + contentType?: string; + placeholder: string; +}; + +export type MSTeamsHtmlAttachmentSummary = { + htmlAttachments: number; + imgTags: number; + dataImages: number; + cidImages: number; + srcHosts: string[]; + attachmentTags: number; + attachmentIds: string[]; +}; + +export type MSTeamsGraphMediaResult = { + media: MSTeamsInboundMedia[]; + hostedCount?: number; + attachmentCount?: number; + hostedStatus?: number; + attachmentStatus?: number; + messageUrl?: string; + tokenError?: boolean; +}; diff --git a/extensions/msteams/src/channel.directory.test.ts b/extensions/msteams/src/channel.directory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e334edf9999d623dfd187b79c1f35116d8d60094 --- /dev/null +++ b/extensions/msteams/src/channel.directory.test.ts @@ -0,0 +1,48 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it } from "vitest"; +import { msteamsPlugin } from "./channel.js"; + +describe("msteams directory", () => { + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + msteams: { + allowFrom: ["alice", "user:Bob"], + dms: { carol: {}, bob: {} }, + teams: { + team1: { + channels: { + "conversation:chan1": {}, + chan2: {}, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + expect(msteamsPlugin.directory).toBeTruthy(); + expect(msteamsPlugin.directory?.listPeers).toBeTruthy(); + expect(msteamsPlugin.directory?.listGroups).toBeTruthy(); + + await expect( + msteamsPlugin.directory!.listPeers({ cfg, query: undefined, limit: undefined }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "user:alice" }, + { kind: "user", id: "user:Bob" }, + { kind: "user", id: "user:carol" }, + { kind: "user", id: "user:bob" }, + ]), + ); + + await expect( + msteamsPlugin.directory!.listGroups({ cfg, query: undefined, limit: undefined }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "conversation:chan1" }, + { kind: "group", id: "conversation:chan2" }, + ]), + ); + }); +}); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bd16bc3ab90f537f29930050cc112bb9a1fedc1 --- /dev/null +++ b/extensions/msteams/src/channel.ts @@ -0,0 +1,459 @@ +import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + MSTeamsConfigSchema, + PAIRING_APPROVED_MESSAGE, +} from "openclaw/plugin-sdk"; +import { listMSTeamsDirectoryGroupsLive, listMSTeamsDirectoryPeersLive } from "./directory-live.js"; +import { msteamsOnboardingAdapter } from "./onboarding.js"; +import { msteamsOutbound } from "./outbound.js"; +import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; +import { probeMSTeams } from "./probe.js"; +import { + normalizeMSTeamsMessagingTarget, + normalizeMSTeamsUserInput, + parseMSTeamsConversationId, + parseMSTeamsTeamChannelInput, + resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; +import { sendAdaptiveCardMSTeams, sendMessageMSTeams } from "./send.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +type ResolvedMSTeamsAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + +const meta = { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + docsLabel: "msteams", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + order: 60, +} as const; + +export const msteamsPlugin: ChannelPlugin = { + id: "msteams", + meta: { + ...meta, + }, + onboarding: msteamsOnboardingAdapter, + pairing: { + idLabel: "msteamsUserId", + normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), + notifyApproval: async ({ cfg, id }) => { + await sendMessageMSTeams({ + cfg, + to: id, + text: PAIRING_APPROVED_MESSAGE, + }); + }, + }, + capabilities: { + chatTypes: ["direct", "channel", "thread"], + polls: true, + threads: true, + media: true, + }, + agentPrompt: { + messageToolHints: () => [ + "- Adaptive Cards supported. Use `action=send` with `card={type,version,body}` to send rich cards.", + "- MSTeams targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:ID` or `user:Display Name` (requires Graph API) for DMs, `conversation:19:...@thread.tacv2` for groups/channels. Prefer IDs over display names for speed.", + ], + }, + threading: { + buildToolContext: ({ context, hasRepliedRef }) => ({ + currentChannelId: context.To?.trim() || undefined, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }), + }, + groups: { + resolveToolPolicy: resolveMSTeamsGroupToolPolicy, + }, + reload: { configPrefixes: ["channels.msteams"] }, + configSchema: buildChannelConfigSchema(MSTeamsConfigSchema), + config: { + listAccountIds: () => [DEFAULT_ACCOUNT_ID], + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.channels?.msteams?.enabled !== false, + configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), + }), + defaultAccountId: () => DEFAULT_ACCOUNT_ID, + setAccountEnabled: ({ cfg, enabled }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled, + }, + }, + }), + deleteAccount: ({ cfg }) => { + const next = { ...cfg } as OpenClawConfig; + const nextChannels = { ...cfg.channels }; + delete nextChannels.msteams; + if (Object.keys(nextChannels).length > 0) { + next.channels = nextChannels; + } else { + delete next.channels; + } + return next; + }, + isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), + describeAccount: (account) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + }), + resolveAllowFrom: ({ cfg }) => cfg.channels?.msteams?.allowFrom ?? [], + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + collectWarnings: ({ cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = cfg.channels?.msteams?.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + return [ + `- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.`, + ]; + }, + }, + setup: { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), + }, + messaging: { + normalizeTarget: normalizeMSTeamsMessagingTarget, + targetResolver: { + looksLikeId: (raw) => { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (/^conversation:/i.test(trimmed)) { + return true; + } + if (/^user:/i.test(trimmed)) { + // Only treat as ID if the value after user: looks like a UUID + const id = trimmed.slice("user:".length).trim(); + return /^[0-9a-fA-F-]{16,}$/.test(id); + } + return trimmed.includes("@thread"); + }, + hint: "", + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, query, limit }) => { + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + for (const entry of cfg.channels?.msteams?.allowFrom ?? []) { + const trimmed = String(entry).trim(); + if (trimmed && trimmed !== "*") { + ids.add(trimmed); + } + } + for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) { + const trimmed = userId.trim(); + if (trimmed) { + ids.add(trimmed); + } + } + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw) + .map((raw) => { + const lowered = raw.toLowerCase(); + if (lowered.startsWith("user:")) { + return raw; + } + if (lowered.startsWith("conversation:")) { + return raw; + } + return `user:${raw}`; + }) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id }) as const); + }, + listGroups: async ({ cfg, query, limit }) => { + const q = query?.trim().toLowerCase() || ""; + const ids = new Set(); + for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) { + for (const channelId of Object.keys(team.channels ?? {})) { + const trimmed = channelId.trim(); + if (trimmed && trimmed !== "*") { + ids.add(trimmed); + } + } + } + return Array.from(ids) + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => raw.replace(/^conversation:/i, "").trim()) + .map((id) => `conversation:${id}`) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id }) as const); + }, + listPeersLive: async ({ cfg, query, limit }) => + listMSTeamsDirectoryPeersLive({ cfg, query, limit }), + listGroupsLive: async ({ cfg, query, limit }) => + listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), + }, + resolver: { + resolveTargets: async ({ cfg, inputs, kind, runtime }) => { + const results = inputs.map((input) => ({ + input, + resolved: false, + id: undefined as string | undefined, + name: undefined as string | undefined, + note: undefined as string | undefined, + })); + + const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value); + + if (kind === "user") { + const pending: Array<{ input: string; query: string; index: number }> = []; + results.forEach((entry, index) => { + const trimmed = entry.input.trim(); + if (!trimmed) { + entry.note = "empty input"; + return; + } + const cleaned = stripPrefix(trimmed); + if (/^[0-9a-fA-F-]{16,}$/.test(cleaned) || cleaned.includes("@")) { + entry.resolved = true; + entry.id = cleaned; + return; + } + pending.push({ input: entry.input, query: cleaned, index }); + }); + + if (pending.length > 0) { + try { + const resolved = await resolveMSTeamsUserAllowlist({ + cfg, + entries: pending.map((entry) => entry.query), + }); + resolved.forEach((entry, idx) => { + const target = results[pending[idx]?.index ?? -1]; + if (!target) { + return; + } + target.resolved = entry.resolved; + target.id = entry.id; + target.name = entry.name; + target.note = entry.note; + }); + } catch (err) { + runtime.error?.(`msteams resolve failed: ${String(err)}`); + pending.forEach(({ index }) => { + const entry = results[index]; + if (entry) { + entry.note = "lookup failed"; + } + }); + } + } + + return results; + } + + const pending: Array<{ input: string; query: string; index: number }> = []; + results.forEach((entry, index) => { + const trimmed = entry.input.trim(); + if (!trimmed) { + entry.note = "empty input"; + return; + } + const conversationId = parseMSTeamsConversationId(trimmed); + if (conversationId !== null) { + entry.resolved = Boolean(conversationId); + entry.id = conversationId || undefined; + entry.note = conversationId ? "conversation id" : "empty conversation id"; + return; + } + const parsed = parseMSTeamsTeamChannelInput(trimmed); + if (!parsed.team) { + entry.note = "missing team"; + return; + } + const query = parsed.channel ? `${parsed.team}/${parsed.channel}` : parsed.team; + pending.push({ input: entry.input, query, index }); + }); + + if (pending.length > 0) { + try { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg, + entries: pending.map((entry) => entry.query), + }); + resolved.forEach((entry, idx) => { + const target = results[pending[idx]?.index ?? -1]; + if (!target) { + return; + } + if (!entry.resolved || !entry.teamId) { + target.resolved = false; + target.note = entry.note; + return; + } + target.resolved = true; + if (entry.channelId) { + target.id = `${entry.teamId}/${entry.channelId}`; + target.name = + entry.channelName && entry.teamName + ? `${entry.teamName}/${entry.channelName}` + : (entry.channelName ?? entry.teamName); + } else { + target.id = entry.teamId; + target.name = entry.teamName; + target.note = "team id"; + } + if (entry.note) { + target.note = entry.note; + } + }); + } catch (err) { + runtime.error?.(`msteams resolve failed: ${String(err)}`); + pending.forEach(({ index }) => { + const entry = results[index]; + if (entry) { + entry.note = "lookup failed"; + } + }); + } + } + + return results; + }, + }, + actions: { + listActions: ({ cfg }) => { + const enabled = + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); + if (!enabled) { + return []; + } + return ["poll"] satisfies ChannelMessageActionName[]; + }, + supportsCards: ({ cfg }) => { + return ( + cfg.channels?.msteams?.enabled !== false && + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) + ); + }, + handleAction: async (ctx) => { + // Handle send action with card parameter + if (ctx.action === "send" && ctx.params.card) { + const card = ctx.params.card as Record; + const to = + typeof ctx.params.to === "string" + ? ctx.params.to.trim() + : typeof ctx.params.target === "string" + ? ctx.params.target.trim() + : ""; + if (!to) { + return { + isError: true, + content: [{ type: "text", text: "Card send requires a target (to)." }], + }; + } + const result = await sendAdaptiveCardMSTeams({ + cfg: ctx.cfg, + to, + card, + }); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + ok: true, + channel: "msteams", + messageId: result.messageId, + conversationId: result.conversationId, + }), + }, + ], + }; + } + // Return null to fall through to default handler + return null as never; + }, + }, + outbound: msteamsOutbound, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + port: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + port: snapshot.port ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + enabled: account.enabled, + configured: account.configured, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + port: runtime?.port ?? null, + probe, + }), + }, + gateway: { + startAccount: async (ctx) => { + const { monitorMSTeamsProvider } = await import("./index.js"); + const port = ctx.cfg.channels?.msteams?.webhook?.port ?? 3978; + ctx.setStatus({ accountId: ctx.accountId, port }); + ctx.log?.info(`starting provider (port ${port})`); + return monitorMSTeamsProvider({ + cfg: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + }); + }, + }, +}; diff --git a/extensions/msteams/src/conversation-store-fs.test.ts b/extensions/msteams/src/conversation-store-fs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa8feb854139a0bf5aa5a5ce8a318a46737bc8bf --- /dev/null +++ b/extensions/msteams/src/conversation-store-fs.test.ts @@ -0,0 +1,88 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { StoredConversationReference } from "./conversation-store.js"; +import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const runtimeStub = { + state: { + resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { + const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + return override; + } + const resolvedHome = homedir ? homedir() : os.homedir(); + return path.join(resolvedHome, ".openclaw"); + }, + }, +} as unknown as PluginRuntime; + +describe("msteams conversation store (fs)", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + + it("filters and prunes expired entries (but keeps legacy ones)", async () => { + const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-store-")); + + const env: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + }; + + const store = createMSTeamsConversationStoreFs({ env, ttlMs: 1_000 }); + + const ref: StoredConversationReference = { + conversation: { id: "19:active@thread.tacv2" }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + user: { id: "u1", aadObjectId: "aad1" }, + }; + + await store.upsert("19:active@thread.tacv2", ref); + + const filePath = path.join(stateDir, "msteams-conversations.json"); + const raw = await fs.promises.readFile(filePath, "utf-8"); + const json = JSON.parse(raw) as { + version: number; + conversations: Record; + }; + + json.conversations["19:old@thread.tacv2"] = { + ...ref, + conversation: { id: "19:old@thread.tacv2" }, + lastSeenAt: new Date(Date.now() - 60_000).toISOString(), + }; + + // Legacy entry without lastSeenAt should be preserved. + json.conversations["19:legacy@thread.tacv2"] = { + ...ref, + conversation: { id: "19:legacy@thread.tacv2" }, + }; + + await fs.promises.writeFile(filePath, `${JSON.stringify(json, null, 2)}\n`); + + const list = await store.list(); + const ids = list.map((e) => e.conversationId).toSorted(); + expect(ids).toEqual(["19:active@thread.tacv2", "19:legacy@thread.tacv2"]); + + expect(await store.get("19:old@thread.tacv2")).toBeNull(); + expect(await store.get("19:legacy@thread.tacv2")).not.toBeNull(); + + await store.upsert("19:new@thread.tacv2", { + ...ref, + conversation: { id: "19:new@thread.tacv2" }, + }); + + const rawAfter = await fs.promises.readFile(filePath, "utf-8"); + const jsonAfter = JSON.parse(rawAfter) as typeof json; + expect(Object.keys(jsonAfter.conversations).toSorted()).toEqual([ + "19:active@thread.tacv2", + "19:legacy@thread.tacv2", + "19:new@thread.tacv2", + ]); + }); +}); diff --git a/extensions/msteams/src/conversation-store-fs.ts b/extensions/msteams/src/conversation-store-fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..8257114fc89214be13ce3608d291b5cb80737c33 --- /dev/null +++ b/extensions/msteams/src/conversation-store-fs.ts @@ -0,0 +1,165 @@ +import type { + MSTeamsConversationStore, + MSTeamsConversationStoreEntry, + StoredConversationReference, +} from "./conversation-store.js"; +import { resolveMSTeamsStorePath } from "./storage.js"; +import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; + +type ConversationStoreData = { + version: 1; + conversations: Record; +}; + +const STORE_FILENAME = "msteams-conversations.json"; +const MAX_CONVERSATIONS = 1000; +const CONVERSATION_TTL_MS = 365 * 24 * 60 * 60 * 1000; + +function parseTimestamp(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) { + return null; + } + return parsed; +} + +function pruneToLimit( + conversations: Record, +) { + const entries = Object.entries(conversations); + if (entries.length <= MAX_CONVERSATIONS) { + return conversations; + } + + entries.sort((a, b) => { + const aTs = parseTimestamp(a[1].lastSeenAt) ?? 0; + const bTs = parseTimestamp(b[1].lastSeenAt) ?? 0; + return aTs - bTs; + }); + + const keep = entries.slice(entries.length - MAX_CONVERSATIONS); + return Object.fromEntries(keep); +} + +function pruneExpired( + conversations: Record, + nowMs: number, + ttlMs: number, +) { + let removed = false; + const kept: typeof conversations = {}; + for (const [conversationId, reference] of Object.entries(conversations)) { + const lastSeenAt = parseTimestamp(reference.lastSeenAt); + // Preserve legacy entries that have no lastSeenAt until they're seen again. + if (lastSeenAt != null && nowMs - lastSeenAt > ttlMs) { + removed = true; + continue; + } + kept[conversationId] = reference; + } + return { conversations: kept, removed }; +} + +function normalizeConversationId(raw: string): string { + return raw.split(";")[0] ?? raw; +} + +export function createMSTeamsConversationStoreFs(params?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + ttlMs?: number; + stateDir?: string; + storePath?: string; +}): MSTeamsConversationStore { + const ttlMs = params?.ttlMs ?? CONVERSATION_TTL_MS; + const filePath = resolveMSTeamsStorePath({ + filename: STORE_FILENAME, + env: params?.env, + homedir: params?.homedir, + stateDir: params?.stateDir, + storePath: params?.storePath, + }); + + const empty: ConversationStoreData = { version: 1, conversations: {} }; + + const readStore = async (): Promise => { + const { value } = await readJsonFile(filePath, empty); + if ( + value.version !== 1 || + !value.conversations || + typeof value.conversations !== "object" || + Array.isArray(value.conversations) + ) { + return empty; + } + const nowMs = Date.now(); + const pruned = pruneExpired(value.conversations, nowMs, ttlMs).conversations; + return { version: 1, conversations: pruneToLimit(pruned) }; + }; + + const list = async (): Promise => { + const store = await readStore(); + return Object.entries(store.conversations).map(([conversationId, reference]) => ({ + conversationId, + reference, + })); + }; + + const get = async (conversationId: string): Promise => { + const store = await readStore(); + return store.conversations[normalizeConversationId(conversationId)] ?? null; + }; + + const findByUserId = async (id: string): Promise => { + const target = id.trim(); + if (!target) { + return null; + } + for (const entry of await list()) { + const { conversationId, reference } = entry; + if (reference.user?.aadObjectId === target) { + return { conversationId, reference }; + } + if (reference.user?.id === target) { + return { conversationId, reference }; + } + } + return null; + }; + + const upsert = async ( + conversationId: string, + reference: StoredConversationReference, + ): Promise => { + const normalizedId = normalizeConversationId(conversationId); + await withFileLock(filePath, empty, async () => { + const store = await readStore(); + store.conversations[normalizedId] = { + ...reference, + lastSeenAt: new Date().toISOString(), + }; + const nowMs = Date.now(); + store.conversations = pruneExpired(store.conversations, nowMs, ttlMs).conversations; + store.conversations = pruneToLimit(store.conversations); + await writeJsonFile(filePath, store); + }); + }; + + const remove = async (conversationId: string): Promise => { + const normalizedId = normalizeConversationId(conversationId); + return await withFileLock(filePath, empty, async () => { + const store = await readStore(); + if (!(normalizedId in store.conversations)) { + return false; + } + delete store.conversations[normalizedId]; + await writeJsonFile(filePath, store); + return true; + }); + }; + + return { upsert, get, list, remove, findByUserId }; +} diff --git a/extensions/msteams/src/conversation-store-memory.ts b/extensions/msteams/src/conversation-store-memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..c03ee6e7cd73838dd5d004652c5f6f04d9c914f7 --- /dev/null +++ b/extensions/msteams/src/conversation-store-memory.ts @@ -0,0 +1,47 @@ +import type { + MSTeamsConversationStore, + MSTeamsConversationStoreEntry, + StoredConversationReference, +} from "./conversation-store.js"; + +export function createMSTeamsConversationStoreMemory( + initial: MSTeamsConversationStoreEntry[] = [], +): MSTeamsConversationStore { + const map = new Map(); + for (const { conversationId, reference } of initial) { + map.set(conversationId, reference); + } + + return { + upsert: async (conversationId, reference) => { + map.set(conversationId, reference); + }, + get: async (conversationId) => { + return map.get(conversationId) ?? null; + }, + list: async () => { + return Array.from(map.entries()).map(([conversationId, reference]) => ({ + conversationId, + reference, + })); + }, + remove: async (conversationId) => { + return map.delete(conversationId); + }, + findByUserId: async (id) => { + const target = id.trim(); + if (!target) { + return null; + } + for (const [conversationId, reference] of map.entries()) { + if (reference.user?.aadObjectId === target) { + return { conversationId, reference }; + } + if (reference.user?.id === target) { + return { conversationId, reference }; + } + } + return null; + }, + }; +} diff --git a/extensions/msteams/src/conversation-store.ts b/extensions/msteams/src/conversation-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa5bc405db93547a08a40ebe431205d9eb58768f --- /dev/null +++ b/extensions/msteams/src/conversation-store.ts @@ -0,0 +1,41 @@ +/** + * Conversation store for MS Teams proactive messaging. + * + * Stores ConversationReference-like objects keyed by conversation ID so we can + * send proactive messages later (after the webhook turn has completed). + */ + +/** Minimal ConversationReference shape for proactive messaging */ +export type StoredConversationReference = { + /** Activity ID from the last message */ + activityId?: string; + /** User who sent the message */ + user?: { id?: string; name?: string; aadObjectId?: string }; + /** Agent/bot that received the message */ + agent?: { id?: string; name?: string; aadObjectId?: string } | null; + /** @deprecated legacy field (pre-Agents SDK). Prefer `agent`. */ + bot?: { id?: string; name?: string }; + /** Conversation details */ + conversation?: { id?: string; conversationType?: string; tenantId?: string }; + /** Team ID for channel messages (when available). */ + teamId?: string; + /** Channel ID (usually "msteams") */ + channelId?: string; + /** Service URL for sending messages back */ + serviceUrl?: string; + /** Locale */ + locale?: string; +}; + +export type MSTeamsConversationStoreEntry = { + conversationId: string; + reference: StoredConversationReference; +}; + +export type MSTeamsConversationStore = { + upsert: (conversationId: string, reference: StoredConversationReference) => Promise; + get: (conversationId: string) => Promise; + list: () => Promise; + remove: (conversationId: string) => Promise; + findByUserId: (id: string) => Promise; +}; diff --git a/extensions/msteams/src/directory-live.ts b/extensions/msteams/src/directory-live.ts new file mode 100644 index 0000000000000000000000000000000000000000..e885cdcbc630bd51f05358b04ca3b3139fe77063 --- /dev/null +++ b/extensions/msteams/src/directory-live.ts @@ -0,0 +1,205 @@ +import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk"; +import { GRAPH_ROOT } from "./attachments/shared.js"; +import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +type GraphUser = { + id?: string; + displayName?: string; + userPrincipalName?: string; + mail?: string; +}; + +type GraphGroup = { + id?: string; + displayName?: string; +}; + +type GraphChannel = { + id?: string; + displayName?: string; +}; + +type GraphResponse = { value?: T[] }; + +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim() ?? ""; +} + +function escapeOData(value: string): string { + return value.replace(/'/g, "''"); +} + +async function fetchGraphJson(params: { + token: string; + path: string; + headers?: Record; +}): Promise { + const res = await fetch(`${GRAPH_ROOT}${params.path}`, { + headers: { + Authorization: `Bearer ${params.token}`, + ...params.headers, + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function resolveGraphToken(cfg: unknown): Promise { + const creds = resolveMSTeamsCredentials( + (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams, + ); + if (!creds) { + throw new Error("MS Teams credentials missing"); + } + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const tokenProvider = new sdk.MsalTokenProvider(authConfig); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + const accessToken = readAccessToken(token); + if (!accessToken) { + throw new Error("MS Teams graph token unavailable"); + } + return accessToken; +} + +async function listTeamsByName(token: string, query: string): Promise { + const escaped = escapeOData(query); + const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`; + const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +async function listChannelsForTeam(token: string, teamId: string): Promise { + const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +export async function listMSTeamsDirectoryPeersLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const query = normalizeQuery(params.query); + if (!query) { + return []; + } + const token = await resolveGraphToken(params.cfg); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + + let users: GraphUser[] = []; + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token, path }); + users = res.value ?? []; + } else { + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=${limit}`; + const res = await fetchGraphJson>({ + token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + users = res.value ?? []; + } + + return users + .map((user) => { + const id = user.id?.trim(); + if (!id) { + return null; + } + const name = user.displayName?.trim(); + const handle = user.userPrincipalName?.trim() || user.mail?.trim(); + return { + kind: "user", + id: `user:${id}`, + name: name || undefined, + handle: handle ? `@${handle}` : undefined, + raw: user, + } satisfies ChannelDirectoryEntry; + }) + .filter(Boolean) as ChannelDirectoryEntry[]; +} + +export async function listMSTeamsDirectoryGroupsLive(params: { + cfg: unknown; + query?: string | null; + limit?: number | null; +}): Promise { + const rawQuery = normalizeQuery(params.query); + if (!rawQuery) { + return []; + } + const token = await resolveGraphToken(params.cfg); + const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 20; + const [teamQuery, channelQuery] = rawQuery.includes("/") + ? rawQuery + .split("/", 2) + .map((part) => part.trim()) + .filter(Boolean) + : [rawQuery, null]; + + const teams = await listTeamsByName(token, teamQuery); + const results: ChannelDirectoryEntry[] = []; + + for (const team of teams) { + const teamId = team.id?.trim(); + if (!teamId) { + continue; + } + const teamName = team.displayName?.trim() || teamQuery; + if (!channelQuery) { + results.push({ + kind: "group", + id: `team:${teamId}`, + name: teamName, + handle: teamName ? `#${teamName}` : undefined, + raw: team, + }); + if (results.length >= limit) { + return results; + } + continue; + } + const channels = await listChannelsForTeam(token, teamId); + for (const channel of channels) { + const name = channel.displayName?.trim(); + if (!name) { + continue; + } + if (!name.toLowerCase().includes(channelQuery.toLowerCase())) { + continue; + } + results.push({ + kind: "group", + id: `conversation:${channel.id}`, + name: `${teamName}/${name}`, + handle: `#${name}`, + raw: channel, + }); + if (results.length >= limit) { + return results; + } + } + } + + return results; +} diff --git a/extensions/msteams/src/errors.test.ts b/extensions/msteams/src/errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6890e1a1d2afce9a1eac0b665061c310393abcfa --- /dev/null +++ b/extensions/msteams/src/errors.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { + classifyMSTeamsSendError, + formatMSTeamsSendErrorHint, + formatUnknownError, +} from "./errors.js"; + +describe("msteams errors", () => { + it("formats unknown errors", () => { + expect(formatUnknownError("oops")).toBe("oops"); + expect(formatUnknownError(null)).toBe("null"); + }); + + it("classifies auth errors", () => { + expect(classifyMSTeamsSendError({ statusCode: 401 }).kind).toBe("auth"); + expect(classifyMSTeamsSendError({ statusCode: 403 }).kind).toBe("auth"); + }); + + it("classifies throttling errors and parses retry-after", () => { + expect(classifyMSTeamsSendError({ statusCode: 429, retryAfter: "1.5" })).toMatchObject({ + kind: "throttled", + statusCode: 429, + retryAfterMs: 1500, + }); + }); + + it("classifies transient errors", () => { + expect(classifyMSTeamsSendError({ statusCode: 503 })).toMatchObject({ + kind: "transient", + statusCode: 503, + }); + }); + + it("classifies permanent 4xx errors", () => { + expect(classifyMSTeamsSendError({ statusCode: 400 })).toMatchObject({ + kind: "permanent", + statusCode: 400, + }); + }); + + it("provides actionable hints for common cases", () => { + expect(formatMSTeamsSendErrorHint({ kind: "auth" })).toContain("msteams"); + expect(formatMSTeamsSendErrorHint({ kind: "throttled" })).toContain("throttled"); + }); +}); diff --git a/extensions/msteams/src/errors.ts b/extensions/msteams/src/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..6512f6ca31484dbe5142e82aac03648fb929f46a --- /dev/null +++ b/extensions/msteams/src/errors.ts @@ -0,0 +1,190 @@ +export function formatUnknownError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + if (typeof err === "string") { + return err; + } + if (err === null) { + return "null"; + } + if (err === undefined) { + return "undefined"; + } + if (typeof err === "number" || typeof err === "boolean" || typeof err === "bigint") { + return String(err); + } + if (typeof err === "symbol") { + return err.description ?? err.toString(); + } + if (typeof err === "function") { + return err.name ? `[function ${err.name}]` : "[function]"; + } + try { + return JSON.stringify(err) ?? "unknown error"; + } catch { + return "unknown error"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function extractStatusCode(err: unknown): number | null { + if (!isRecord(err)) { + return null; + } + const direct = err.statusCode ?? err.status; + if (typeof direct === "number" && Number.isFinite(direct)) { + return direct; + } + if (typeof direct === "string") { + const parsed = Number.parseInt(direct, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + const response = err.response; + if (isRecord(response)) { + const status = response.status; + if (typeof status === "number" && Number.isFinite(status)) { + return status; + } + if (typeof status === "string") { + const parsed = Number.parseInt(status, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + + return null; +} + +function extractRetryAfterMs(err: unknown): number | null { + if (!isRecord(err)) { + return null; + } + + const direct = err.retryAfterMs ?? err.retry_after_ms; + if (typeof direct === "number" && Number.isFinite(direct) && direct >= 0) { + return direct; + } + + const retryAfter = err.retryAfter ?? err.retry_after; + if (typeof retryAfter === "number" && Number.isFinite(retryAfter)) { + return retryAfter >= 0 ? retryAfter * 1000 : null; + } + if (typeof retryAfter === "string") { + const parsed = Number.parseFloat(retryAfter); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed * 1000; + } + } + + const response = err.response; + if (!isRecord(response)) { + return null; + } + + const headers = response.headers; + if (!headers) { + return null; + } + + if (isRecord(headers)) { + const raw = headers["retry-after"] ?? headers["Retry-After"]; + if (typeof raw === "string") { + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed * 1000; + } + } + } + + // Fetch Headers-like interface + if ( + typeof headers === "object" && + headers !== null && + "get" in headers && + typeof (headers as { get?: unknown }).get === "function" + ) { + const raw = (headers as { get: (name: string) => string | null }).get("retry-after"); + if (raw) { + const parsed = Number.parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0) { + return parsed * 1000; + } + } + } + + return null; +} + +export type MSTeamsSendErrorKind = "auth" | "throttled" | "transient" | "permanent" | "unknown"; + +export type MSTeamsSendErrorClassification = { + kind: MSTeamsSendErrorKind; + statusCode?: number; + retryAfterMs?: number; +}; + +/** + * Classify outbound send errors for safe retries and actionable logs. + * + * Important: We only mark errors as retryable when we have an explicit HTTP + * status code that indicates the message was not accepted (e.g. 429, 5xx). + * For transport-level errors where delivery is ambiguous, we prefer to avoid + * retries to reduce the chance of duplicate posts. + */ +export function classifyMSTeamsSendError(err: unknown): MSTeamsSendErrorClassification { + const statusCode = extractStatusCode(err); + const retryAfterMs = extractRetryAfterMs(err); + + if (statusCode === 401 || statusCode === 403) { + return { kind: "auth", statusCode }; + } + + if (statusCode === 429) { + return { + kind: "throttled", + statusCode, + retryAfterMs: retryAfterMs ?? undefined, + }; + } + + if (statusCode === 408 || (statusCode != null && statusCode >= 500)) { + return { + kind: "transient", + statusCode, + retryAfterMs: retryAfterMs ?? undefined, + }; + } + + if (statusCode != null && statusCode >= 400) { + return { kind: "permanent", statusCode }; + } + + return { + kind: "unknown", + statusCode: statusCode ?? undefined, + retryAfterMs: retryAfterMs ?? undefined, + }; +} + +export function formatMSTeamsSendErrorHint( + classification: MSTeamsSendErrorClassification, +): string | undefined { + if (classification.kind === "auth") { + return "check msteams appId/appPassword/tenantId (or env vars MSTEAMS_APP_ID/MSTEAMS_APP_PASSWORD/MSTEAMS_TENANT_ID)"; + } + if (classification.kind === "throttled") { + return "Teams throttled the bot; backing off may help"; + } + if (classification.kind === "transient") { + return "transient Teams/Bot Framework error; retry may succeed"; + } + return undefined; +} diff --git a/extensions/msteams/src/file-consent-helpers.test.ts b/extensions/msteams/src/file-consent-helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c781787c73a79391d6b246d6c7777bb55e3efdb5 --- /dev/null +++ b/extensions/msteams/src/file-consent-helpers.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js"; +import * as pendingUploads from "./pending-uploads.js"; + +describe("requiresFileConsent", () => { + const thresholdBytes = 4 * 1024 * 1024; // 4MB + + it("returns true for personal chat with non-image", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: "application/pdf", + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(true); + }); + + it("returns true for personal chat with large image", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: "image/png", + bufferSize: 5 * 1024 * 1024, // 5MB + thresholdBytes, + }), + ).toBe(true); + }); + + it("returns false for personal chat with small image", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: "image/png", + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(false); + }); + + it("returns false for group chat with large non-image", () => { + expect( + requiresFileConsent({ + conversationType: "groupChat", + contentType: "application/pdf", + bufferSize: 5 * 1024 * 1024, + thresholdBytes, + }), + ).toBe(false); + }); + + it("returns false for channel with large non-image", () => { + expect( + requiresFileConsent({ + conversationType: "channel", + contentType: "application/pdf", + bufferSize: 5 * 1024 * 1024, + thresholdBytes, + }), + ).toBe(false); + }); + + it("handles case-insensitive conversation type", () => { + expect( + requiresFileConsent({ + conversationType: "Personal", + contentType: "application/pdf", + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(true); + + expect( + requiresFileConsent({ + conversationType: "PERSONAL", + contentType: "application/pdf", + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(true); + }); + + it("returns false when conversationType is undefined", () => { + expect( + requiresFileConsent({ + conversationType: undefined, + contentType: "application/pdf", + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(false); + }); + + it("returns true for personal chat when contentType is undefined (non-image)", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: undefined, + bufferSize: 1000, + thresholdBytes, + }), + ).toBe(true); + }); + + it("returns true for personal chat with file exactly at threshold", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: "image/jpeg", + bufferSize: thresholdBytes, // exactly 4MB + thresholdBytes, + }), + ).toBe(true); + }); + + it("returns false for personal chat with file just below threshold", () => { + expect( + requiresFileConsent({ + conversationType: "personal", + contentType: "image/jpeg", + bufferSize: thresholdBytes - 1, // 4MB - 1 byte + thresholdBytes, + }), + ).toBe(false); + }); +}); + +describe("prepareFileConsentActivity", () => { + const mockUploadId = "test-upload-id-123"; + + beforeEach(() => { + vi.spyOn(pendingUploads, "storePendingUpload").mockReturnValue(mockUploadId); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates activity with consent card attachment", () => { + const result = prepareFileConsentActivity({ + media: { + buffer: Buffer.from("test content"), + filename: "test.pdf", + contentType: "application/pdf", + }, + conversationId: "conv123", + description: "My file", + }); + + expect(result.uploadId).toBe(mockUploadId); + expect(result.activity.type).toBe("message"); + expect(result.activity.attachments).toHaveLength(1); + + const attachment = (result.activity.attachments as unknown[])[0] as Record; + expect(attachment.contentType).toBe("application/vnd.microsoft.teams.card.file.consent"); + expect(attachment.name).toBe("test.pdf"); + }); + + it("stores pending upload with correct data", () => { + const buffer = Buffer.from("test content"); + prepareFileConsentActivity({ + media: { + buffer, + filename: "test.pdf", + contentType: "application/pdf", + }, + conversationId: "conv123", + description: "My file", + }); + + expect(pendingUploads.storePendingUpload).toHaveBeenCalledWith({ + buffer, + filename: "test.pdf", + contentType: "application/pdf", + conversationId: "conv123", + }); + }); + + it("uses default description when not provided", () => { + const result = prepareFileConsentActivity({ + media: { + buffer: Buffer.from("test"), + filename: "document.docx", + contentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + conversationId: "conv456", + }); + + const attachment = (result.activity.attachments as unknown[])[0] as Record< + string, + { description: string } + >; + expect(attachment.content.description).toBe("File: document.docx"); + }); + + it("uses provided description", () => { + const result = prepareFileConsentActivity({ + media: { + buffer: Buffer.from("test"), + filename: "report.pdf", + contentType: "application/pdf", + }, + conversationId: "conv789", + description: "Q4 Financial Report", + }); + + const attachment = (result.activity.attachments as unknown[])[0] as Record< + string, + { description: string } + >; + expect(attachment.content.description).toBe("Q4 Financial Report"); + }); + + it("includes uploadId in consent card context", () => { + const result = prepareFileConsentActivity({ + media: { + buffer: Buffer.from("test"), + filename: "file.txt", + contentType: "text/plain", + }, + conversationId: "conv000", + }); + + const attachment = (result.activity.attachments as unknown[])[0] as Record< + string, + { acceptContext: { uploadId: string } } + >; + expect(attachment.content.acceptContext.uploadId).toBe(mockUploadId); + }); + + it("handles media without contentType", () => { + const result = prepareFileConsentActivity({ + media: { + buffer: Buffer.from("binary data"), + filename: "unknown.bin", + }, + conversationId: "conv111", + }); + + expect(result.uploadId).toBe(mockUploadId); + expect(result.activity.type).toBe("message"); + }); +}); diff --git a/extensions/msteams/src/file-consent-helpers.ts b/extensions/msteams/src/file-consent-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8a1bc0c696318f99c47792f18131c33a790d46b --- /dev/null +++ b/extensions/msteams/src/file-consent-helpers.ts @@ -0,0 +1,73 @@ +/** + * Shared helpers for FileConsentCard flow in MSTeams. + * + * FileConsentCard is required for: + * - Personal (1:1) chats with large files (>=4MB) + * - Personal chats with non-image files (PDFs, documents, etc.) + * + * This module consolidates the logic used by both send.ts (proactive sends) + * and messenger.ts (reply path) to avoid duplication. + */ + +import { buildFileConsentCard } from "./file-consent.js"; +import { storePendingUpload } from "./pending-uploads.js"; + +export type FileConsentMedia = { + buffer: Buffer; + filename: string; + contentType?: string; +}; + +export type FileConsentActivityResult = { + activity: Record; + uploadId: string; +}; + +/** + * Prepare a FileConsentCard activity for large files or non-images in personal chats. + * Returns the activity object and uploadId - caller is responsible for sending. + */ +export function prepareFileConsentActivity(params: { + media: FileConsentMedia; + conversationId: string; + description?: string; +}): FileConsentActivityResult { + const { media, conversationId, description } = params; + + const uploadId = storePendingUpload({ + buffer: media.buffer, + filename: media.filename, + contentType: media.contentType, + conversationId, + }); + + const consentCard = buildFileConsentCard({ + filename: media.filename, + description: description || `File: ${media.filename}`, + sizeInBytes: media.buffer.length, + context: { uploadId }, + }); + + const activity: Record = { + type: "message", + attachments: [consentCard], + }; + + return { activity, uploadId }; +} + +/** + * Check if a file requires FileConsentCard flow. + * True for: personal chat AND (large file OR non-image) + */ +export function requiresFileConsent(params: { + conversationType: string | undefined; + contentType: string | undefined; + bufferSize: number; + thresholdBytes: number; +}): boolean { + const isPersonal = params.conversationType?.toLowerCase() === "personal"; + const isImage = params.contentType?.startsWith("image/") ?? false; + const isLargeFile = params.bufferSize >= params.thresholdBytes; + return isPersonal && (isLargeFile || !isImage); +} diff --git a/extensions/msteams/src/file-consent.ts b/extensions/msteams/src/file-consent.ts new file mode 100644 index 0000000000000000000000000000000000000000..268e82fff643cab12ec7b40c622890f2c088a7ab --- /dev/null +++ b/extensions/msteams/src/file-consent.ts @@ -0,0 +1,126 @@ +/** + * FileConsentCard utilities for MS Teams large file uploads (>4MB) in personal chats. + * + * Teams requires user consent before the bot can upload large files. This module provides + * utilities for: + * - Building FileConsentCard attachments (to request upload permission) + * - Building FileInfoCard attachments (to confirm upload completion) + * - Parsing fileConsent/invoke activities + */ + +export interface FileConsentCardParams { + filename: string; + description?: string; + sizeInBytes: number; + /** Custom context data to include in the card (passed back in the invoke) */ + context?: Record; +} + +export interface FileInfoCardParams { + filename: string; + contentUrl: string; + uniqueId: string; + fileType: string; +} + +/** + * Build a FileConsentCard attachment for requesting upload permission. + * Use this for files >= 4MB in personal (1:1) chats. + */ +export function buildFileConsentCard(params: FileConsentCardParams) { + return { + contentType: "application/vnd.microsoft.teams.card.file.consent", + name: params.filename, + content: { + description: params.description ?? `File: ${params.filename}`, + sizeInBytes: params.sizeInBytes, + acceptContext: { filename: params.filename, ...params.context }, + declineContext: { filename: params.filename, ...params.context }, + }, + }; +} + +/** + * Build a FileInfoCard attachment for confirming upload completion. + * Send this after successfully uploading the file to the consent URL. + */ +export function buildFileInfoCard(params: FileInfoCardParams) { + return { + contentType: "application/vnd.microsoft.teams.card.file.info", + contentUrl: params.contentUrl, + name: params.filename, + content: { + uniqueId: params.uniqueId, + fileType: params.fileType, + }, + }; +} + +export interface FileConsentUploadInfo { + name: string; + uploadUrl: string; + contentUrl: string; + uniqueId: string; + fileType: string; +} + +export interface FileConsentResponse { + action: "accept" | "decline"; + uploadInfo?: FileConsentUploadInfo; + context?: Record; +} + +/** + * Parse a fileConsent/invoke activity. + * Returns null if the activity is not a file consent invoke. + */ +export function parseFileConsentInvoke(activity: { + name?: string; + value?: unknown; +}): FileConsentResponse | null { + if (activity.name !== "fileConsent/invoke") { + return null; + } + + const value = activity.value as { + type?: string; + action?: string; + uploadInfo?: FileConsentUploadInfo; + context?: Record; + }; + + if (value?.type !== "fileUpload") { + return null; + } + + return { + action: value.action === "accept" ? "accept" : "decline", + uploadInfo: value.uploadInfo, + context: value.context, + }; +} + +/** + * Upload a file to the consent URL provided by Teams. + * The URL is provided in the fileConsent/invoke response after user accepts. + */ +export async function uploadToConsentUrl(params: { + url: string; + buffer: Buffer; + contentType?: string; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const res = await fetchFn(params.url, { + method: "PUT", + headers: { + "Content-Type": params.contentType ?? "application/octet-stream", + "Content-Range": `bytes 0-${params.buffer.length - 1}/${params.buffer.length}`, + }, + body: new Uint8Array(params.buffer), + }); + + if (!res.ok) { + throw new Error(`File upload to consent URL failed: ${res.status} ${res.statusText}`); + } +} diff --git a/extensions/msteams/src/graph-chat.ts b/extensions/msteams/src/graph-chat.ts new file mode 100644 index 0000000000000000000000000000000000000000..c606c307bcdaf242b912e75d902b12edd96eabf0 --- /dev/null +++ b/extensions/msteams/src/graph-chat.ts @@ -0,0 +1,53 @@ +/** + * Native Teams file card attachments for Bot Framework. + * + * The Bot Framework SDK supports `application/vnd.microsoft.teams.card.file.info` + * content type which produces native Teams file cards. + * + * @see https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bots-filesv4 + */ + +import type { DriveItemProperties } from "./graph-upload.js"; + +/** + * Build a native Teams file card attachment for Bot Framework. + * + * This uses the `application/vnd.microsoft.teams.card.file.info` content type + * which is supported by Bot Framework and produces native Teams file cards + * (the same display as when a user manually shares a file). + * + * @param file - DriveItem properties from getDriveItemProperties() + * @returns Attachment object for Bot Framework sendActivity() + */ +export function buildTeamsFileInfoCard(file: DriveItemProperties): { + contentType: string; + contentUrl: string; + name: string; + content: { + uniqueId: string; + fileType: string; + }; +} { + // Extract unique ID from eTag (remove quotes, braces, and version suffix) + // Example eTag formats: "{GUID},version" or "\"{GUID},version\"" + const rawETag = file.eTag; + const uniqueId = + rawETag + .replace(/^["']|["']$/g, "") // Remove outer quotes + .replace(/[{}]/g, "") // Remove curly braces + .split(",")[0] ?? rawETag; // Take the GUID part before comma + + // Extract file extension from filename + const lastDot = file.name.lastIndexOf("."); + const fileType = lastDot >= 0 ? file.name.slice(lastDot + 1).toLowerCase() : ""; + + return { + contentType: "application/vnd.microsoft.teams.card.file.info", + contentUrl: file.webDavUrl, + name: file.name, + content: { + uniqueId, + fileType, + }, + }; +} diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts new file mode 100644 index 0000000000000000000000000000000000000000..921b456c3a85c60aaeda5a6ac0abff0e3f1ecca0 --- /dev/null +++ b/extensions/msteams/src/graph-upload.ts @@ -0,0 +1,453 @@ +/** + * OneDrive/SharePoint upload utilities for MS Teams file sending. + * + * For group chats and channels, files are uploaded to SharePoint and shared via a link. + * This module provides utilities for: + * - Uploading files to OneDrive (personal scope - now deprecated for bot use) + * - Uploading files to SharePoint (group/channel scope) + * - Creating sharing links (organization-wide or per-user) + * - Getting chat members for per-user sharing + */ + +import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; + +const GRAPH_ROOT = "https://graph.microsoft.com/v1.0"; +const GRAPH_BETA = "https://graph.microsoft.com/beta"; +const GRAPH_SCOPE = "https://graph.microsoft.com"; + +export interface OneDriveUploadResult { + id: string; + webUrl: string; + name: string; +} + +/** + * Upload a file to the user's OneDrive root folder. + * For larger files, this uses the simple upload endpoint (up to 4MB). + * TODO: For files >4MB, implement resumable upload session. + */ +export async function uploadToOneDrive(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + // Use "OpenClawShared" folder to organize bot-uploaded files + const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; + + const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": params.contentType ?? "application/octet-stream", + }, + body: new Uint8Array(params.buffer), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`); + } + + const data = (await res.json()) as { + id?: string; + webUrl?: string; + name?: string; + }; + + if (!data.id || !data.webUrl || !data.name) { + throw new Error("OneDrive upload response missing required fields"); + } + + return { + id: data.id, + webUrl: data.webUrl, + name: data.name, + }; +} + +export interface OneDriveSharingLink { + webUrl: string; +} + +/** + * Create a sharing link for a OneDrive file. + * The link allows organization members to view the file. + */ +export async function createSharingLink(params: { + itemId: string; + tokenProvider: MSTeamsAccessTokenProvider; + /** Sharing scope: "organization" (default) or "anonymous" */ + scope?: "organization" | "anonymous"; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + const res = await fetchFn(`${GRAPH_ROOT}/me/drive/items/${params.itemId}/createLink`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "view", + scope: params.scope ?? "organization", + }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Create sharing link failed: ${res.status} ${res.statusText} - ${body}`); + } + + const data = (await res.json()) as { + link?: { webUrl?: string }; + }; + + if (!data.link?.webUrl) { + throw new Error("Create sharing link response missing webUrl"); + } + + return { + webUrl: data.link.webUrl, + }; +} + +/** + * Upload a file to OneDrive and create a sharing link. + * Convenience function for the common case. + */ +export async function uploadAndShareOneDrive(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + scope?: "organization" | "anonymous"; + fetchFn?: typeof fetch; +}): Promise<{ + itemId: string; + webUrl: string; + shareUrl: string; + name: string; +}> { + const uploaded = await uploadToOneDrive({ + buffer: params.buffer, + filename: params.filename, + contentType: params.contentType, + tokenProvider: params.tokenProvider, + fetchFn: params.fetchFn, + }); + + const shareLink = await createSharingLink({ + itemId: uploaded.id, + tokenProvider: params.tokenProvider, + scope: params.scope, + fetchFn: params.fetchFn, + }); + + return { + itemId: uploaded.id, + webUrl: uploaded.webUrl, + shareUrl: shareLink.webUrl, + name: uploaded.name, + }; +} + +// ============================================================================ +// SharePoint upload functions for group chats and channels +// ============================================================================ + +/** + * Upload a file to a SharePoint site. + * This is used for group chats and channels where /me/drive doesn't work for bots. + * + * @param params.siteId - SharePoint site ID (e.g., "contoso.sharepoint.com,guid1,guid2") + */ +export async function uploadToSharePoint(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + siteId: string; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + // Use "OpenClawShared" folder to organize bot-uploaded files + const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`; + + const res = await fetchFn( + `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": params.contentType ?? "application/octet-stream", + }, + body: new Uint8Array(params.buffer), + }, + ); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`); + } + + const data = (await res.json()) as { + id?: string; + webUrl?: string; + name?: string; + }; + + if (!data.id || !data.webUrl || !data.name) { + throw new Error("SharePoint upload response missing required fields"); + } + + return { + id: data.id, + webUrl: data.webUrl, + name: data.name, + }; +} + +export interface ChatMember { + aadObjectId: string; + displayName?: string; +} + +/** + * Properties needed for native Teams file card attachments. + * The eTag is used as the attachment ID and webDavUrl as the contentUrl. + */ +export interface DriveItemProperties { + /** The eTag of the driveItem (used as attachment ID) */ + eTag: string; + /** The WebDAV URL of the driveItem (used as contentUrl for reference attachment) */ + webDavUrl: string; + /** The filename */ + name: string; +} + +/** + * Get driveItem properties needed for native Teams file card attachments. + * This fetches the eTag and webDavUrl which are required for "reference" type attachments. + * + * @param params.siteId - SharePoint site ID + * @param params.itemId - The driveItem ID (returned from upload) + */ +export async function getDriveItemProperties(params: { + siteId: string; + itemId: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + const res = await fetchFn( + `${GRAPH_ROOT}/sites/${params.siteId}/drive/items/${params.itemId}?$select=eTag,webDavUrl,name`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Get driveItem properties failed: ${res.status} ${res.statusText} - ${body}`); + } + + const data = (await res.json()) as { + eTag?: string; + webDavUrl?: string; + name?: string; + }; + + if (!data.eTag || !data.webDavUrl || !data.name) { + throw new Error("DriveItem response missing required properties (eTag, webDavUrl, or name)"); + } + + return { + eTag: data.eTag, + webDavUrl: data.webDavUrl, + name: data.name, + }; +} + +/** + * Get members of a Teams chat for per-user sharing. + * Used to create sharing links scoped to only the chat participants. + */ +export async function getChatMembers(params: { + chatId: string; + tokenProvider: MSTeamsAccessTokenProvider; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + + const res = await fetchFn(`${GRAPH_ROOT}/chats/${params.chatId}/members`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Get chat members failed: ${res.status} ${res.statusText} - ${body}`); + } + + const data = (await res.json()) as { + value?: Array<{ + userId?: string; + displayName?: string; + }>; + }; + + return (data.value ?? []) + .map((m) => ({ + aadObjectId: m.userId ?? "", + displayName: m.displayName, + })) + .filter((m) => m.aadObjectId); +} + +/** + * Create a sharing link for a SharePoint drive item. + * For organization scope (default), uses v1.0 API. + * For per-user scope, uses beta API with recipients. + */ +export async function createSharePointSharingLink(params: { + siteId: string; + itemId: string; + tokenProvider: MSTeamsAccessTokenProvider; + /** Sharing scope: "organization" (default) or "users" (per-user with recipients) */ + scope?: "organization" | "users"; + /** Required when scope is "users": AAD object IDs of recipients */ + recipientObjectIds?: string[]; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE); + const scope = params.scope ?? "organization"; + + // Per-user sharing requires beta API + const apiRoot = scope === "users" ? GRAPH_BETA : GRAPH_ROOT; + + const body: Record = { + type: "view", + scope: scope === "users" ? "users" : "organization", + }; + + // Add recipients for per-user sharing + if (scope === "users" && params.recipientObjectIds?.length) { + body.recipients = params.recipientObjectIds.map((id) => ({ objectId: id })); + } + + const res = await fetchFn( + `${apiRoot}/sites/${params.siteId}/drive/items/${params.itemId}/createLink`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + + if (!res.ok) { + const respBody = await res.text().catch(() => ""); + throw new Error( + `Create SharePoint sharing link failed: ${res.status} ${res.statusText} - ${respBody}`, + ); + } + + const data = (await res.json()) as { + link?: { webUrl?: string }; + }; + + if (!data.link?.webUrl) { + throw new Error("Create SharePoint sharing link response missing webUrl"); + } + + return { + webUrl: data.link.webUrl, + }; +} + +/** + * Upload a file to SharePoint and create a sharing link. + * + * For group chats, this creates a per-user sharing link scoped to chat members. + * For channels, this creates an organization-wide sharing link. + * + * @param params.siteId - SharePoint site ID + * @param params.chatId - Optional chat ID for per-user sharing (group chats) + * @param params.usePerUserSharing - Whether to use per-user sharing (requires beta API + Chat.Read.All) + */ +export async function uploadAndShareSharePoint(params: { + buffer: Buffer; + filename: string; + contentType?: string; + tokenProvider: MSTeamsAccessTokenProvider; + siteId: string; + chatId?: string; + usePerUserSharing?: boolean; + fetchFn?: typeof fetch; +}): Promise<{ + itemId: string; + webUrl: string; + shareUrl: string; + name: string; +}> { + // 1. Upload file to SharePoint + const uploaded = await uploadToSharePoint({ + buffer: params.buffer, + filename: params.filename, + contentType: params.contentType, + tokenProvider: params.tokenProvider, + siteId: params.siteId, + fetchFn: params.fetchFn, + }); + + // 2. Determine sharing scope + let scope: "organization" | "users" = "organization"; + let recipientObjectIds: string[] | undefined; + + if (params.usePerUserSharing && params.chatId) { + try { + const members = await getChatMembers({ + chatId: params.chatId, + tokenProvider: params.tokenProvider, + fetchFn: params.fetchFn, + }); + + if (members.length > 0) { + scope = "users"; + recipientObjectIds = members.map((m) => m.aadObjectId); + } + } catch { + // Fall back to organization scope if we can't get chat members + // (e.g., missing Chat.Read.All permission) + } + } + + // 3. Create sharing link + const shareLink = await createSharePointSharingLink({ + siteId: params.siteId, + itemId: uploaded.id, + tokenProvider: params.tokenProvider, + scope, + recipientObjectIds, + fetchFn: params.fetchFn, + }); + + return { + itemId: uploaded.id, + webUrl: uploaded.webUrl, + shareUrl: shareLink.webUrl, + name: uploaded.name, + }; +} diff --git a/extensions/msteams/src/inbound.test.ts b/extensions/msteams/src/inbound.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecee5835b183f43a165a6dce8d0c2e3faf1ae38f --- /dev/null +++ b/extensions/msteams/src/inbound.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeMSTeamsConversationId, + parseMSTeamsActivityTimestamp, + stripMSTeamsMentionTags, + wasMSTeamsBotMentioned, +} from "./inbound.js"; + +describe("msteams inbound", () => { + describe("stripMSTeamsMentionTags", () => { + it("removes ... tags and trims", () => { + expect(stripMSTeamsMentionTags("Bot hi")).toBe("hi"); + expect(stripMSTeamsMentionTags("hi Bot")).toBe("hi"); + }); + + it("removes tags with attributes", () => { + expect(stripMSTeamsMentionTags('Bot hi')).toBe("hi"); + expect(stripMSTeamsMentionTags('hi Bot')).toBe("hi"); + }); + }); + + describe("normalizeMSTeamsConversationId", () => { + it("strips the ;messageid suffix", () => { + expect(normalizeMSTeamsConversationId("19:abc@thread.tacv2;messageid=deadbeef")).toBe( + "19:abc@thread.tacv2", + ); + }); + }); + + describe("parseMSTeamsActivityTimestamp", () => { + it("returns undefined for empty/invalid values", () => { + expect(parseMSTeamsActivityTimestamp(undefined)).toBeUndefined(); + expect(parseMSTeamsActivityTimestamp("not-a-date")).toBeUndefined(); + }); + + it("parses string timestamps", () => { + const ts = parseMSTeamsActivityTimestamp("2024-01-01T00:00:00.000Z"); + expect(ts?.toISOString()).toBe("2024-01-01T00:00:00.000Z"); + }); + + it("passes through Date instances", () => { + const d = new Date("2024-01-01T00:00:00.000Z"); + expect(parseMSTeamsActivityTimestamp(d)).toBe(d); + }); + }); + + describe("wasMSTeamsBotMentioned", () => { + it("returns true when a mention entity matches recipient.id", () => { + expect( + wasMSTeamsBotMentioned({ + recipient: { id: "bot" }, + entities: [{ type: "mention", mentioned: { id: "bot" } }], + }), + ).toBe(true); + }); + + it("returns false when there is no matching mention", () => { + expect( + wasMSTeamsBotMentioned({ + recipient: { id: "bot" }, + entities: [{ type: "mention", mentioned: { id: "other" } }], + }), + ).toBe(false); + }); + }); +}); diff --git a/extensions/msteams/src/inbound.ts b/extensions/msteams/src/inbound.ts new file mode 100644 index 0000000000000000000000000000000000000000..88e6c19a43575e79c133386aadb7e202c4729223 --- /dev/null +++ b/extensions/msteams/src/inbound.ts @@ -0,0 +1,48 @@ +export type MentionableActivity = { + recipient?: { id?: string } | null; + entities?: Array<{ + type?: string; + mentioned?: { id?: string }; + }> | null; +}; + +export function normalizeMSTeamsConversationId(raw: string): string { + return raw.split(";")[0] ?? raw; +} + +export function extractMSTeamsConversationMessageId(raw: string): string | undefined { + if (!raw) { + return undefined; + } + const match = /(?:^|;)messageid=([^;]+)/i.exec(raw); + const value = match?.[1]?.trim() ?? ""; + return value || undefined; +} + +export function parseMSTeamsActivityTimestamp(value: unknown): Date | undefined { + if (!value) { + return undefined; + } + if (value instanceof Date) { + return value; + } + if (typeof value !== "string") { + return undefined; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +export function stripMSTeamsMentionTags(text: string): string { + // Teams wraps mentions in ... tags + return text.replace(/]*>.*?<\/at>/gi, "").trim(); +} + +export function wasMSTeamsBotMentioned(activity: MentionableActivity): boolean { + const botId = activity.recipient?.id; + if (!botId) { + return false; + } + const entities = activity.entities ?? []; + return entities.some((e) => e.type === "mention" && e.mentioned?.id === botId); +} diff --git a/extensions/msteams/src/index.ts b/extensions/msteams/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..375a2bbd7afd4c066ff92ed7f13c29c256d9714e --- /dev/null +++ b/extensions/msteams/src/index.ts @@ -0,0 +1,4 @@ +export { monitorMSTeamsProvider } from "./monitor.js"; +export { probeMSTeams } from "./probe.js"; +export { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; +export { type MSTeamsCredentials, resolveMSTeamsCredentials } from "./token.js"; diff --git a/extensions/msteams/src/media-helpers.test.ts b/extensions/msteams/src/media-helpers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..27a9c08ec2d8891c89f0b77946c3ece66de268cf --- /dev/null +++ b/extensions/msteams/src/media-helpers.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js"; + +describe("msteams media-helpers", () => { + describe("getMimeType", () => { + it("detects png from URL", async () => { + expect(await getMimeType("https://example.com/image.png")).toBe("image/png"); + }); + + it("detects jpeg from URL (both extensions)", async () => { + expect(await getMimeType("https://example.com/photo.jpg")).toBe("image/jpeg"); + expect(await getMimeType("https://example.com/photo.jpeg")).toBe("image/jpeg"); + }); + + it("detects gif from URL", async () => { + expect(await getMimeType("https://example.com/anim.gif")).toBe("image/gif"); + }); + + it("detects webp from URL", async () => { + expect(await getMimeType("https://example.com/modern.webp")).toBe("image/webp"); + }); + + it("handles URLs with query strings", async () => { + expect(await getMimeType("https://example.com/image.png?v=123")).toBe("image/png"); + }); + + it("handles data URLs", async () => { + expect(await getMimeType("data:image/png;base64,iVBORw0KGgo=")).toBe("image/png"); + expect(await getMimeType("data:image/jpeg;base64,/9j/4AAQ")).toBe("image/jpeg"); + expect(await getMimeType("data:image/gif;base64,R0lGOD")).toBe("image/gif"); + }); + + it("handles data URLs without base64", async () => { + expect(await getMimeType("data:image/svg+xml,%3Csvg")).toBe("image/svg+xml"); + }); + + it("handles local paths", async () => { + expect(await getMimeType("/tmp/image.png")).toBe("image/png"); + expect(await getMimeType("/Users/test/photo.jpg")).toBe("image/jpeg"); + }); + + it("handles tilde paths", async () => { + expect(await getMimeType("~/Downloads/image.gif")).toBe("image/gif"); + }); + + it("defaults to application/octet-stream for unknown extensions", async () => { + expect(await getMimeType("https://example.com/image")).toBe("application/octet-stream"); + expect(await getMimeType("https://example.com/image.unknown")).toBe( + "application/octet-stream", + ); + }); + + it("is case-insensitive", async () => { + expect(await getMimeType("https://example.com/IMAGE.PNG")).toBe("image/png"); + expect(await getMimeType("https://example.com/Photo.JPEG")).toBe("image/jpeg"); + }); + + it("detects document types", async () => { + expect(await getMimeType("https://example.com/doc.pdf")).toBe("application/pdf"); + expect(await getMimeType("https://example.com/doc.docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + expect(await getMimeType("https://example.com/spreadsheet.xlsx")).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + }); + }); + + describe("extractFilename", () => { + it("extracts filename from URL with extension", async () => { + expect(await extractFilename("https://example.com/photo.jpg")).toBe("photo.jpg"); + }); + + it("extracts filename from URL with path", async () => { + expect(await extractFilename("https://example.com/images/2024/photo.png")).toBe("photo.png"); + }); + + it("handles URLs without extension by deriving from MIME", async () => { + // Now defaults to application/octet-stream → .bin fallback + expect(await extractFilename("https://example.com/images/photo")).toBe("photo.bin"); + }); + + it("handles data URLs", async () => { + expect(await extractFilename("data:image/png;base64,iVBORw0KGgo=")).toBe("image.png"); + expect(await extractFilename("data:image/jpeg;base64,/9j/4AAQ")).toBe("image.jpg"); + }); + + it("handles document data URLs", async () => { + expect(await extractFilename("data:application/pdf;base64,JVBERi0")).toBe("file.pdf"); + }); + + it("handles local paths", async () => { + expect(await extractFilename("/tmp/screenshot.png")).toBe("screenshot.png"); + expect(await extractFilename("/Users/test/photo.jpg")).toBe("photo.jpg"); + }); + + it("handles tilde paths", async () => { + expect(await extractFilename("~/Downloads/image.gif")).toBe("image.gif"); + }); + + it("returns fallback for empty URL", async () => { + expect(await extractFilename("")).toBe("file.bin"); + }); + + it("extracts original filename from embedded pattern", async () => { + // Pattern: {original}---{uuid}.{ext} + expect( + await extractFilename("/media/inbound/report---a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf"), + ).toBe("report.pdf"); + }); + + it("extracts original filename with uppercase UUID", async () => { + expect( + await extractFilename( + "/media/inbound/Document---A1B2C3D4-E5F6-7890-ABCD-EF1234567890.docx", + ), + ).toBe("Document.docx"); + }); + + it("falls back to UUID filename for legacy paths", async () => { + // UUID-only filename (legacy format, no embedded name) + expect(await extractFilename("/media/inbound/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf")).toBe( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf", + ); + }); + + it("handles --- in filename without valid UUID pattern", async () => { + // foo---bar.txt (bar is not a valid UUID) + expect(await extractFilename("/media/inbound/foo---bar.txt")).toBe("foo---bar.txt"); + }); + }); + + describe("isLocalPath", () => { + it("returns true for file:// URLs", () => { + expect(isLocalPath("file:///tmp/image.png")).toBe(true); + expect(isLocalPath("file://localhost/tmp/image.png")).toBe(true); + }); + + it("returns true for absolute paths", () => { + expect(isLocalPath("/tmp/image.png")).toBe(true); + expect(isLocalPath("/Users/test/photo.jpg")).toBe(true); + }); + + it("returns true for tilde paths", () => { + expect(isLocalPath("~/Downloads/image.png")).toBe(true); + }); + + it("returns false for http URLs", () => { + expect(isLocalPath("http://example.com/image.png")).toBe(false); + expect(isLocalPath("https://example.com/image.png")).toBe(false); + }); + + it("returns false for data URLs", () => { + expect(isLocalPath("data:image/png;base64,iVBORw0KGgo=")).toBe(false); + }); + }); + + describe("extractMessageId", () => { + it("extracts id from valid response", () => { + expect(extractMessageId({ id: "msg123" })).toBe("msg123"); + }); + + it("returns null for missing id", () => { + expect(extractMessageId({ foo: "bar" })).toBeNull(); + }); + + it("returns null for empty id", () => { + expect(extractMessageId({ id: "" })).toBeNull(); + }); + + it("returns null for non-string id", () => { + expect(extractMessageId({ id: 123 })).toBeNull(); + expect(extractMessageId({ id: null })).toBeNull(); + }); + + it("returns null for null response", () => { + expect(extractMessageId(null)).toBeNull(); + }); + + it("returns null for undefined response", () => { + expect(extractMessageId(undefined)).toBeNull(); + }); + + it("returns null for non-object response", () => { + expect(extractMessageId("string")).toBeNull(); + expect(extractMessageId(123)).toBeNull(); + }); + }); +}); diff --git a/extensions/msteams/src/media-helpers.ts b/extensions/msteams/src/media-helpers.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4368fb4d69da60a6727c8a6052e2116b1072885 --- /dev/null +++ b/extensions/msteams/src/media-helpers.ts @@ -0,0 +1,86 @@ +/** + * MIME type detection and filename extraction for MSTeams media attachments. + */ + +import path from "node:path"; +import { + detectMime, + extensionForMime, + extractOriginalFilename, + getFileExtension, +} from "openclaw/plugin-sdk"; + +/** + * Detect MIME type from URL extension or data URL. + * Uses shared MIME detection for consistency with core handling. + */ +export async function getMimeType(url: string): Promise { + // Handle data URLs: data:image/png;base64,... + if (url.startsWith("data:")) { + const match = url.match(/^data:([^;,]+)/); + if (match?.[1]) { + return match[1]; + } + } + + // Use shared MIME detection (extension-based for URLs) + const detected = await detectMime({ filePath: url }); + return detected ?? "application/octet-stream"; +} + +/** + * Extract filename from URL or local path. + * For local paths, extracts original filename if stored with embedded name pattern. + * Falls back to deriving the extension from MIME type when no extension present. + */ +export async function extractFilename(url: string): Promise { + // Handle data URLs: derive extension from MIME + if (url.startsWith("data:")) { + const mime = await getMimeType(url); + const ext = extensionForMime(mime) ?? ".bin"; + const prefix = mime.startsWith("image/") ? "image" : "file"; + return `${prefix}${ext}`; + } + + // Try to extract from URL pathname + try { + const pathname = new URL(url).pathname; + const basename = path.basename(pathname); + const existingExt = getFileExtension(pathname); + if (basename && existingExt) { + return basename; + } + // No extension in URL, derive from MIME + const mime = await getMimeType(url); + const ext = extensionForMime(mime) ?? ".bin"; + const prefix = mime.startsWith("image/") ? "image" : "file"; + return basename ? `${basename}${ext}` : `${prefix}${ext}`; + } catch { + // Local paths - use extractOriginalFilename to extract embedded original name + return extractOriginalFilename(url); + } +} + +/** + * Check if a URL refers to a local file path. + */ +export function isLocalPath(url: string): boolean { + return url.startsWith("file://") || url.startsWith("/") || url.startsWith("~"); +} + +/** + * Extract the message ID from a Bot Framework response. + */ +export function extractMessageId(response: unknown): string | null { + if (!response || typeof response !== "object") { + return null; + } + if (!("id" in response)) { + return null; + } + const { id } = response as { id?: unknown }; + if (typeof id !== "string" || !id) { + return null; + } + return id; +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd49e4e81618adf6d89c5a996a1c3ee9d98cc6b1 --- /dev/null +++ b/extensions/msteams/src/messenger.test.ts @@ -0,0 +1,248 @@ +import { SILENT_REPLY_TOKEN, type PluginRuntime } from "openclaw/plugin-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { StoredConversationReference } from "./conversation-store.js"; +import { + type MSTeamsAdapter, + renderReplyPayloadsToMessages, + sendMSTeamsMessages, +} from "./messenger.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const chunkMarkdownText = (text: string, limit: number) => { + if (!text) { + return []; + } + if (limit <= 0 || text.length <= limit) { + return [text]; + } + const chunks: string[] = []; + for (let index = 0; index < text.length; index += limit) { + chunks.push(text.slice(index, index + limit)); + } + return chunks; +}; + +const runtimeStub = { + channel: { + text: { + chunkMarkdownText, + chunkMarkdownTextWithMode: chunkMarkdownText, + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, + }, + }, +} as unknown as PluginRuntime; + +describe("msteams messenger", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + + describe("renderReplyPayloadsToMessages", () => { + it("filters silent replies", () => { + const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { + textChunkLimit: 4000, + tableMode: "code", + }); + expect(messages).toEqual([]); + }); + + it("filters silent reply prefixes", () => { + const messages = renderReplyPayloadsToMessages( + [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], + { textChunkLimit: 4000, tableMode: "code" }, + ); + expect(messages).toEqual([]); + }); + + it("splits media into separate messages by default", () => { + const messages = renderReplyPayloadsToMessages( + [{ text: "hi", mediaUrl: "https://example.com/a.png" }], + { textChunkLimit: 4000, tableMode: "code" }, + ); + expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); + }); + + it("supports inline media mode", () => { + const messages = renderReplyPayloadsToMessages( + [{ text: "hi", mediaUrl: "https://example.com/a.png" }], + { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" }, + ); + expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); + }); + + it("chunks long text when enabled", () => { + const long = "hello ".repeat(200); + const messages = renderReplyPayloadsToMessages([{ text: long }], { + textChunkLimit: 50, + tableMode: "code", + }); + expect(messages.length).toBeGreaterThan(1); + }); + }); + + describe("sendMSTeamsMessages", () => { + const baseRef: StoredConversationReference = { + activityId: "activity123", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { id: "19:abc@thread.tacv2;messageid=deadbeef" }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + }; + + it("sends thread messages via the provided context", async () => { + const sent: string[] = []; + const ctx = { + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + sent.push(text ?? ""); + return { id: `id:${text ?? ""}` }; + }, + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async () => {}, + }; + + const ids = await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: baseRef, + context: ctx, + messages: [{ text: "one" }, { text: "two" }], + }); + + expect(sent).toEqual(["one", "two"]); + expect(ids).toEqual(["id:one", "id:two"]); + }); + + it("sends top-level messages via continueConversation and strips activityId", async () => { + const seen: { reference?: unknown; texts: string[] } = { texts: [] }; + + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + seen.reference = reference; + await logic({ + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + seen.texts.push(text ?? ""); + return { id: `id:${text ?? ""}` }; + }, + }); + }, + }; + + const ids = await sendMSTeamsMessages({ + replyStyle: "top-level", + adapter, + appId: "app123", + conversationRef: baseRef, + messages: [{ text: "hello" }], + }); + + expect(seen.texts).toEqual(["hello"]); + expect(ids).toEqual(["id:hello"]); + + const ref = seen.reference as { + activityId?: string; + conversation?: { id?: string }; + }; + expect(ref.activityId).toBeUndefined(); + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2"); + }); + + it("retries thread sends on throttling (429)", async () => { + const attempts: string[] = []; + const retryEvents: Array<{ nextAttempt: number; delayMs: number }> = []; + + const ctx = { + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + attempts.push(text ?? ""); + if (attempts.length === 1) { + throw Object.assign(new Error("throttled"), { statusCode: 429 }); + } + return { id: `id:${text ?? ""}` }; + }, + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async () => {}, + }; + + const ids = await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: baseRef, + context: ctx, + messages: [{ text: "one" }], + retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + onRetry: (e) => retryEvents.push({ nextAttempt: e.nextAttempt, delayMs: e.delayMs }), + }); + + expect(attempts).toEqual(["one", "one"]); + expect(ids).toEqual(["id:one"]); + expect(retryEvents).toEqual([{ nextAttempt: 2, delayMs: 0 }]); + }); + + it("does not retry thread sends on client errors (4xx)", async () => { + const ctx = { + sendActivity: async () => { + throw Object.assign(new Error("bad request"), { statusCode: 400 }); + }, + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async () => {}, + }; + + await expect( + sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: baseRef, + context: ctx, + messages: [{ text: "one" }], + retry: { maxAttempts: 3, baseDelayMs: 0, maxDelayMs: 0 }, + }), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it("retries top-level sends on transient (5xx)", async () => { + const attempts: string[] = []; + + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, _reference, logic) => { + await logic({ + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + attempts.push(text ?? ""); + if (attempts.length === 1) { + throw Object.assign(new Error("server error"), { + statusCode: 503, + }); + } + return { id: `id:${text ?? ""}` }; + }, + }); + }, + }; + + const ids = await sendMSTeamsMessages({ + replyStyle: "top-level", + adapter, + appId: "app123", + conversationRef: baseRef, + messages: [{ text: "hello" }], + retry: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + }); + + expect(attempts).toEqual(["hello", "hello"]); + expect(ids).toEqual(["id:hello"]); + }); + }); +}); diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts new file mode 100644 index 0000000000000000000000000000000000000000..44b1e836376fddc7db1b271352676776496cb663 --- /dev/null +++ b/extensions/msteams/src/messenger.ts @@ -0,0 +1,495 @@ +import { + type ChunkMode, + isSilentReplyText, + loadWebMedia, + type MarkdownTableMode, + type MSTeamsReplyStyle, + type ReplyPayload, + SILENT_REPLY_TOKEN, +} from "openclaw/plugin-sdk"; +import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; +import type { StoredConversationReference } from "./conversation-store.js"; +import { classifyMSTeamsSendError } from "./errors.js"; +import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js"; +import { buildTeamsFileInfoCard } from "./graph-chat.js"; +import { + getDriveItemProperties, + uploadAndShareOneDrive, + uploadAndShareSharePoint, +} from "./graph-upload.js"; +import { extractFilename, extractMessageId, getMimeType, isLocalPath } from "./media-helpers.js"; +import { getMSTeamsRuntime } from "./runtime.js"; + +/** + * MSTeams-specific media size limit (100MB). + * Higher than the default because OneDrive upload handles large files well. + */ +const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024; + +/** + * Threshold for large files that require FileConsentCard flow in personal chats. + * Files >= 4MB use consent flow; smaller images can use inline base64. + */ +const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; + +type SendContext = { + sendActivity: (textOrActivity: string | object) => Promise; +}; + +export type MSTeamsConversationReference = { + activityId?: string; + user?: { id?: string; name?: string; aadObjectId?: string }; + agent?: { id?: string; name?: string; aadObjectId?: string } | null; + conversation: { id: string; conversationType?: string; tenantId?: string }; + channelId: string; + serviceUrl?: string; + locale?: string; +}; + +export type MSTeamsAdapter = { + continueConversation: ( + appId: string, + reference: MSTeamsConversationReference, + logic: (context: SendContext) => Promise, + ) => Promise; + process: ( + req: unknown, + res: unknown, + logic: (context: unknown) => Promise, + ) => Promise; +}; + +export type MSTeamsReplyRenderOptions = { + textChunkLimit: number; + chunkText?: boolean; + mediaMode?: "split" | "inline"; + tableMode?: MarkdownTableMode; + chunkMode?: ChunkMode; +}; + +/** + * A rendered message that preserves media vs text distinction. + * When mediaUrl is present, it will be sent as a Bot Framework attachment. + */ +export type MSTeamsRenderedMessage = { + text?: string; + mediaUrl?: string; +}; + +export type MSTeamsSendRetryOptions = { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; +}; + +export type MSTeamsSendRetryEvent = { + messageIndex: number; + messageCount: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + classification: ReturnType; +}; + +function normalizeConversationId(rawId: string): string { + return rawId.split(";")[0] ?? rawId; +} + +export function buildConversationReference( + ref: StoredConversationReference, +): MSTeamsConversationReference { + const conversationId = ref.conversation?.id?.trim(); + if (!conversationId) { + throw new Error("Invalid stored reference: missing conversation.id"); + } + const agent = ref.agent ?? ref.bot ?? undefined; + if (agent == null || !agent.id) { + throw new Error("Invalid stored reference: missing agent.id"); + } + const user = ref.user; + if (!user?.id) { + throw new Error("Invalid stored reference: missing user.id"); + } + return { + activityId: ref.activityId, + user, + agent, + conversation: { + id: normalizeConversationId(conversationId), + conversationType: ref.conversation?.conversationType, + tenantId: ref.conversation?.tenantId, + }, + channelId: ref.channelId ?? "msteams", + serviceUrl: ref.serviceUrl, + locale: ref.locale, + }; +} + +function pushTextMessages( + out: MSTeamsRenderedMessage[], + text: string, + opts: { + chunkText: boolean; + chunkLimit: number; + chunkMode: ChunkMode; + }, +) { + if (!text) { + return; + } + if (opts.chunkText) { + for (const chunk of getMSTeamsRuntime().channel.text.chunkMarkdownTextWithMode( + text, + opts.chunkLimit, + opts.chunkMode, + )) { + const trimmed = chunk.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + continue; + } + out.push({ text: trimmed }); + } + return; + } + + const trimmed = text.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + return; + } + out.push({ text: trimmed }); +} + +function clampMs(value: number, maxMs: number): number { + if (!Number.isFinite(value) || value < 0) { + return 0; + } + return Math.min(value, maxMs); +} + +async function sleep(ms: number): Promise { + const delay = Math.max(0, ms); + if (delay === 0) { + return; + } + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); +} + +function resolveRetryOptions( + retry: false | MSTeamsSendRetryOptions | undefined, +): Required & { enabled: boolean } { + if (!retry) { + return { enabled: false, maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 }; + } + return { + enabled: true, + maxAttempts: Math.max(1, retry?.maxAttempts ?? 3), + baseDelayMs: Math.max(0, retry?.baseDelayMs ?? 250), + maxDelayMs: Math.max(0, retry?.maxDelayMs ?? 10_000), + }; +} + +function computeRetryDelayMs( + attempt: number, + classification: ReturnType, + opts: Required, +): number { + if (classification.retryAfterMs != null) { + return clampMs(classification.retryAfterMs, opts.maxDelayMs); + } + const exponential = opts.baseDelayMs * 2 ** Math.max(0, attempt - 1); + return clampMs(exponential, opts.maxDelayMs); +} + +function shouldRetry(classification: ReturnType): boolean { + return classification.kind === "throttled" || classification.kind === "transient"; +} + +export function renderReplyPayloadsToMessages( + replies: ReplyPayload[], + options: MSTeamsReplyRenderOptions, +): MSTeamsRenderedMessage[] { + const out: MSTeamsRenderedMessage[] = []; + const chunkLimit = Math.min(options.textChunkLimit, 4000); + const chunkText = options.chunkText !== false; + const chunkMode = options.chunkMode ?? "length"; + const mediaMode = options.mediaMode ?? "split"; + const tableMode = + options.tableMode ?? + getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg: getMSTeamsRuntime().config.loadConfig(), + channel: "msteams", + }); + + for (const payload of replies) { + const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode, + ); + + if (!text && mediaList.length === 0) { + continue; + } + + if (mediaList.length === 0) { + pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + continue; + } + + if (mediaMode === "inline") { + // For inline mode, combine text with first media as attachment + const firstMedia = mediaList[0]; + if (firstMedia) { + out.push({ text: text || undefined, mediaUrl: firstMedia }); + // Additional media URLs as separate messages + for (let i = 1; i < mediaList.length; i++) { + if (mediaList[i]) { + out.push({ mediaUrl: mediaList[i] }); + } + } + } else { + pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + } + continue; + } + + // mediaMode === "split" + pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + for (const mediaUrl of mediaList) { + if (!mediaUrl) { + continue; + } + out.push({ mediaUrl }); + } + } + + return out; +} + +async function buildActivity( + msg: MSTeamsRenderedMessage, + conversationRef: StoredConversationReference, + tokenProvider?: MSTeamsAccessTokenProvider, + sharePointSiteId?: string, + mediaMaxBytes?: number, +): Promise> { + const activity: Record = { type: "message" }; + + if (msg.text) { + activity.text = msg.text; + } + + if (msg.mediaUrl) { + let contentUrl = msg.mediaUrl; + let contentType = await getMimeType(msg.mediaUrl); + let fileName = await extractFilename(msg.mediaUrl); + + if (isLocalPath(msg.mediaUrl)) { + const maxBytes = mediaMaxBytes ?? MSTEAMS_MAX_MEDIA_BYTES; + const media = await loadWebMedia(msg.mediaUrl, maxBytes); + contentType = media.contentType ?? contentType; + fileName = media.fileName ?? fileName; + + // Determine conversation type and file type + // Teams only accepts base64 data URLs for images + const conversationType = conversationRef.conversation?.conversationType?.toLowerCase(); + const isPersonal = conversationType === "personal"; + const isImage = contentType?.startsWith("image/") ?? false; + + if ( + requiresFileConsent({ + conversationType, + contentType, + bufferSize: media.buffer.length, + thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES, + }) + ) { + // Large file or non-image in personal chat: use FileConsentCard flow + const conversationId = conversationRef.conversation?.id ?? "unknown"; + const { activity: consentActivity } = prepareFileConsentActivity({ + media: { buffer: media.buffer, filename: fileName, contentType }, + conversationId, + description: msg.text || undefined, + }); + + // Return the consent activity (caller sends it) + return consentActivity; + } + + if (!isPersonal && !isImage && tokenProvider && sharePointSiteId) { + // Non-image in group chat/channel with SharePoint site configured: + // Upload to SharePoint and use native file card attachment + const chatId = conversationRef.conversation?.id; + + // Upload to SharePoint + const uploaded = await uploadAndShareSharePoint({ + buffer: media.buffer, + filename: fileName, + contentType, + tokenProvider, + siteId: sharePointSiteId, + chatId: chatId ?? undefined, + usePerUserSharing: conversationType === "groupchat", + }); + + // Get driveItem properties needed for native file card attachment + const driveItem = await getDriveItemProperties({ + siteId: sharePointSiteId, + itemId: uploaded.itemId, + tokenProvider, + }); + + // Build native Teams file card attachment + const fileCardAttachment = buildTeamsFileInfoCard(driveItem); + activity.attachments = [fileCardAttachment]; + + return activity; + } + + if (!isPersonal && !isImage && tokenProvider) { + // Fallback: no SharePoint site configured, try OneDrive upload + const uploaded = await uploadAndShareOneDrive({ + buffer: media.buffer, + filename: fileName, + contentType, + tokenProvider, + }); + + // Bot Framework doesn't support "reference" attachment type for sending + const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; + activity.text = msg.text ? `${msg.text}\n\n${fileLink}` : fileLink; + return activity; + } + + // Image (any chat): use base64 (works for images in all conversation types) + const base64 = media.buffer.toString("base64"); + contentUrl = `data:${media.contentType};base64,${base64}`; + } + + activity.attachments = [ + { + name: fileName, + contentType, + contentUrl, + }, + ]; + } + + return activity; +} + +export async function sendMSTeamsMessages(params: { + replyStyle: MSTeamsReplyStyle; + adapter: MSTeamsAdapter; + appId: string; + conversationRef: StoredConversationReference; + context?: SendContext; + messages: MSTeamsRenderedMessage[]; + retry?: false | MSTeamsSendRetryOptions; + onRetry?: (event: MSTeamsSendRetryEvent) => void; + /** Token provider for OneDrive/SharePoint uploads in group chats/channels */ + tokenProvider?: MSTeamsAccessTokenProvider; + /** SharePoint site ID for file uploads in group chats/channels */ + sharePointSiteId?: string; + /** Max media size in bytes. Default: 100MB. */ + mediaMaxBytes?: number; +}): Promise { + const messages = params.messages.filter( + (m) => (m.text && m.text.trim().length > 0) || m.mediaUrl, + ); + if (messages.length === 0) { + return []; + } + + const retryOptions = resolveRetryOptions(params.retry); + + const sendWithRetry = async ( + sendOnce: () => Promise, + meta: { messageIndex: number; messageCount: number }, + ): Promise => { + if (!retryOptions.enabled) { + return await sendOnce(); + } + + let attempt = 1; + while (true) { + try { + return await sendOnce(); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const canRetry = attempt < retryOptions.maxAttempts && shouldRetry(classification); + if (!canRetry) { + throw err; + } + + const delayMs = computeRetryDelayMs(attempt, classification, retryOptions); + const nextAttempt = attempt + 1; + params.onRetry?.({ + messageIndex: meta.messageIndex, + messageCount: meta.messageCount, + nextAttempt, + maxAttempts: retryOptions.maxAttempts, + delayMs, + classification, + }); + + await sleep(delayMs); + attempt = nextAttempt; + } + } + }; + + if (params.replyStyle === "thread") { + const ctx = params.context; + if (!ctx) { + throw new Error("Missing context for replyStyle=thread"); + } + const messageIds: string[] = []; + for (const [idx, message] of messages.entries()) { + const response = await sendWithRetry( + async () => + await ctx.sendActivity( + await buildActivity( + message, + params.conversationRef, + params.tokenProvider, + params.sharePointSiteId, + params.mediaMaxBytes, + ), + ), + { messageIndex: idx, messageCount: messages.length }, + ); + messageIds.push(extractMessageId(response) ?? "unknown"); + } + return messageIds; + } + + const baseRef = buildConversationReference(params.conversationRef); + const proactiveRef: MSTeamsConversationReference = { + ...baseRef, + activityId: undefined, + }; + + const messageIds: string[] = []; + await params.adapter.continueConversation(params.appId, proactiveRef, async (ctx) => { + for (const [idx, message] of messages.entries()) { + const response = await sendWithRetry( + async () => + await ctx.sendActivity( + await buildActivity( + message, + params.conversationRef, + params.tokenProvider, + params.sharePointSiteId, + params.mediaMaxBytes, + ), + ), + { messageIndex: idx, messageCount: messages.length }, + ); + messageIds.push(extractMessageId(response) ?? "unknown"); + } + }); + return messageIds; +} diff --git a/extensions/msteams/src/monitor-handler.ts b/extensions/msteams/src/monitor-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..4186d557199d61090b74d7f6566e423b0ea38610 --- /dev/null +++ b/extensions/msteams/src/monitor-handler.ts @@ -0,0 +1,162 @@ +import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk"; +import type { MSTeamsConversationStore } from "./conversation-store.js"; +import type { MSTeamsAdapter } from "./messenger.js"; +import type { MSTeamsMonitorLogger } from "./monitor-types.js"; +import type { MSTeamsPollStore } from "./polls.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; +import { buildFileInfoCard, parseFileConsentInvoke, uploadToConsentUrl } from "./file-consent.js"; +import { createMSTeamsMessageHandler } from "./monitor-handler/message-handler.js"; +import { getPendingUpload, removePendingUpload } from "./pending-uploads.js"; + +export type MSTeamsAccessTokenProvider = { + getAccessToken: (scope: string) => Promise; +}; + +export type MSTeamsActivityHandler = { + onMessage: ( + handler: (context: unknown, next: () => Promise) => Promise, + ) => MSTeamsActivityHandler; + onMembersAdded: ( + handler: (context: unknown, next: () => Promise) => Promise, + ) => MSTeamsActivityHandler; + run?: (context: unknown) => Promise; +}; + +export type MSTeamsMessageHandlerDeps = { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + appId: string; + adapter: MSTeamsAdapter; + tokenProvider: MSTeamsAccessTokenProvider; + textLimit: number; + mediaMaxBytes: number; + conversationStore: MSTeamsConversationStore; + pollStore: MSTeamsPollStore; + log: MSTeamsMonitorLogger; +}; + +/** + * Handle fileConsent/invoke activities for large file uploads. + */ +async function handleFileConsentInvoke( + context: MSTeamsTurnContext, + log: MSTeamsMonitorLogger, +): Promise { + const activity = context.activity; + if (activity.type !== "invoke" || activity.name !== "fileConsent/invoke") { + return false; + } + + const consentResponse = parseFileConsentInvoke(activity); + if (!consentResponse) { + log.debug("invalid file consent invoke", { value: activity.value }); + return false; + } + + const uploadId = + typeof consentResponse.context?.uploadId === "string" + ? consentResponse.context.uploadId + : undefined; + + if (consentResponse.action === "accept" && consentResponse.uploadInfo) { + const pendingFile = getPendingUpload(uploadId); + if (pendingFile) { + log.debug("user accepted file consent, uploading", { + uploadId, + filename: pendingFile.filename, + size: pendingFile.buffer.length, + }); + + try { + // Upload file to the provided URL + await uploadToConsentUrl({ + url: consentResponse.uploadInfo.uploadUrl, + buffer: pendingFile.buffer, + contentType: pendingFile.contentType, + }); + + // Send confirmation card + const fileInfoCard = buildFileInfoCard({ + filename: consentResponse.uploadInfo.name, + contentUrl: consentResponse.uploadInfo.contentUrl, + uniqueId: consentResponse.uploadInfo.uniqueId, + fileType: consentResponse.uploadInfo.fileType, + }); + + await context.sendActivity({ + type: "message", + attachments: [fileInfoCard], + }); + + log.info("file upload complete", { + uploadId, + filename: consentResponse.uploadInfo.name, + uniqueId: consentResponse.uploadInfo.uniqueId, + }); + } catch (err) { + log.debug("file upload failed", { uploadId, error: String(err) }); + await context.sendActivity(`File upload failed: ${String(err)}`); + } finally { + removePendingUpload(uploadId); + } + } else { + log.debug("pending file not found for consent", { uploadId }); + await context.sendActivity( + "The file upload request has expired. Please try sending the file again.", + ); + } + } else { + // User declined + log.debug("user declined file consent", { uploadId }); + removePendingUpload(uploadId); + } + + return true; +} + +export function registerMSTeamsHandlers( + handler: T, + deps: MSTeamsMessageHandlerDeps, +): T { + const handleTeamsMessage = createMSTeamsMessageHandler(deps); + + // Wrap the original run method to intercept invokes + const originalRun = handler.run; + if (originalRun) { + handler.run = async (context: unknown) => { + const ctx = context as MSTeamsTurnContext; + // Handle file consent invokes before passing to normal flow + if (ctx.activity?.type === "invoke" && ctx.activity?.name === "fileConsent/invoke") { + const handled = await handleFileConsentInvoke(ctx, deps.log); + if (handled) { + // Send invoke response for file consent + await ctx.sendActivity({ type: "invokeResponse", value: { status: 200 } }); + return; + } + } + return originalRun.call(handler, context); + }; + } + + handler.onMessage(async (context, next) => { + try { + await handleTeamsMessage(context as MSTeamsTurnContext); + } catch (err) { + deps.runtime.error?.(`msteams handler failed: ${String(err)}`); + } + await next(); + }); + + handler.onMembersAdded(async (context, next) => { + const membersAdded = (context as MSTeamsTurnContext).activity?.membersAdded ?? []; + for (const member of membersAdded) { + if (member.id !== (context as MSTeamsTurnContext).activity?.recipient?.id) { + deps.log.debug("member added", { member: member.id }); + // Don't send welcome message - let the user initiate conversation. + } + } + await next(); + }); + + return handler; +} diff --git a/extensions/msteams/src/monitor-handler/inbound-media.ts b/extensions/msteams/src/monitor-handler/inbound-media.ts new file mode 100644 index 0000000000000000000000000000000000000000..6781324ae6dc45ffe0d572ed79b0b63f714a54f5 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/inbound-media.ts @@ -0,0 +1,125 @@ +import type { MSTeamsTurnContext } from "../sdk-types.js"; +import { + buildMSTeamsGraphMessageUrls, + downloadMSTeamsAttachments, + downloadMSTeamsGraphMedia, + type MSTeamsAccessTokenProvider, + type MSTeamsAttachmentLike, + type MSTeamsHtmlAttachmentSummary, + type MSTeamsInboundMedia, +} from "../attachments.js"; + +type MSTeamsLogger = { + debug: (message: string, meta?: Record) => void; +}; + +export async function resolveMSTeamsInboundMedia(params: { + attachments: MSTeamsAttachmentLike[]; + htmlSummary?: MSTeamsHtmlAttachmentSummary; + maxBytes: number; + allowHosts?: string[]; + tokenProvider: MSTeamsAccessTokenProvider; + conversationType: string; + conversationId: string; + conversationMessageId?: string; + activity: Pick; + log: MSTeamsLogger; + /** When true, embeds original filename in stored path for later extraction. */ + preserveFilenames?: boolean; +}): Promise { + const { + attachments, + htmlSummary, + maxBytes, + tokenProvider, + allowHosts, + conversationType, + conversationId, + conversationMessageId, + activity, + log, + preserveFilenames, + } = params; + + let mediaList = await downloadMSTeamsAttachments({ + attachments, + maxBytes, + tokenProvider, + allowHosts, + preserveFilenames, + }); + + if (mediaList.length === 0) { + const onlyHtmlAttachments = + attachments.length > 0 && + attachments.every((att) => String(att.contentType ?? "").startsWith("text/html")); + + if (onlyHtmlAttachments) { + const messageUrls = buildMSTeamsGraphMessageUrls({ + conversationType, + conversationId, + messageId: activity.id ?? undefined, + replyToId: activity.replyToId ?? undefined, + conversationMessageId, + channelData: activity.channelData, + }); + if (messageUrls.length === 0) { + log.debug("graph message url unavailable", { + conversationType, + hasChannelData: Boolean(activity.channelData), + messageId: activity.id ?? undefined, + replyToId: activity.replyToId ?? undefined, + }); + } else { + const attempts: Array<{ + url: string; + hostedStatus?: number; + attachmentStatus?: number; + hostedCount?: number; + attachmentCount?: number; + tokenError?: boolean; + }> = []; + for (const messageUrl of messageUrls) { + const graphMedia = await downloadMSTeamsGraphMedia({ + messageUrl, + tokenProvider, + maxBytes, + allowHosts, + preserveFilenames, + }); + attempts.push({ + url: messageUrl, + hostedStatus: graphMedia.hostedStatus, + attachmentStatus: graphMedia.attachmentStatus, + hostedCount: graphMedia.hostedCount, + attachmentCount: graphMedia.attachmentCount, + tokenError: graphMedia.tokenError, + }); + if (graphMedia.media.length > 0) { + mediaList = graphMedia.media; + break; + } + if (graphMedia.tokenError) { + break; + } + } + if (mediaList.length === 0) { + log.debug("graph media fetch empty", { attempts }); + } + } + } + } + + if (mediaList.length > 0) { + log.debug("downloaded attachments", { count: mediaList.length }); + } else if (htmlSummary?.imgTags) { + log.debug("inline images detected but none downloaded", { + imgTags: htmlSummary.imgTags, + srcHosts: htmlSummary.srcHosts, + dataImages: htmlSummary.dataImages, + cidImages: htmlSummary.cidImages, + }); + } + + return mediaList; +} diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..701e713014eb647f8154337d270dcd9252711e57 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -0,0 +1,639 @@ +import { + buildPendingHistoryContextFromMap, + clearHistoryEntriesIfEnabled, + DEFAULT_GROUP_HISTORY_LIMIT, + logInboundDrop, + recordPendingHistoryEntryIfEnabled, + resolveControlCommandGate, + resolveMentionGating, + formatAllowlistMatchMeta, + type HistoryEntry, +} from "openclaw/plugin-sdk"; +import type { StoredConversationReference } from "../conversation-store.js"; +import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; +import type { MSTeamsTurnContext } from "../sdk-types.js"; +import { + buildMSTeamsAttachmentPlaceholder, + buildMSTeamsMediaPayload, + type MSTeamsAttachmentLike, + summarizeMSTeamsHtmlAttachments, +} from "../attachments.js"; +import { formatUnknownError } from "../errors.js"; +import { + extractMSTeamsConversationMessageId, + normalizeMSTeamsConversationId, + parseMSTeamsActivityTimestamp, + stripMSTeamsMentionTags, + wasMSTeamsBotMentioned, +} from "../inbound.js"; +import { + isMSTeamsGroupAllowed, + resolveMSTeamsAllowlistMatch, + resolveMSTeamsReplyPolicy, + resolveMSTeamsRouteConfig, +} from "../policy.js"; +import { extractMSTeamsPollVote } from "../polls.js"; +import { createMSTeamsReplyDispatcher } from "../reply-dispatcher.js"; +import { getMSTeamsRuntime } from "../runtime.js"; +import { recordMSTeamsSentMessage, wasMSTeamsMessageSent } from "../sent-message-cache.js"; +import { resolveMSTeamsInboundMedia } from "./inbound-media.js"; + +export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { + const { + cfg, + runtime, + appId, + adapter, + tokenProvider, + textLimit, + mediaMaxBytes, + conversationStore, + pollStore, + log, + } = deps; + const core = getMSTeamsRuntime(); + const logVerboseMessage = (message: string) => { + if (core.logging.shouldLogVerbose()) { + log.debug(message); + } + }; + const msteamsCfg = cfg.channels?.msteams; + const historyLimit = Math.max( + 0, + msteamsCfg?.historyLimit ?? + cfg.messages?.groupChat?.historyLimit ?? + DEFAULT_GROUP_HISTORY_LIMIT, + ); + const conversationHistories = new Map(); + const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({ + cfg, + channel: "msteams", + }); + + type MSTeamsDebounceEntry = { + context: MSTeamsTurnContext; + rawText: string; + text: string; + attachments: MSTeamsAttachmentLike[]; + wasMentioned: boolean; + implicitMention: boolean; + }; + + const handleTeamsMessageNow = async (params: MSTeamsDebounceEntry) => { + const context = params.context; + const activity = context.activity; + const rawText = params.rawText; + const text = params.text; + const attachments = params.attachments; + const attachmentPlaceholder = buildMSTeamsAttachmentPlaceholder(attachments); + const rawBody = text || attachmentPlaceholder; + const from = activity.from; + const conversation = activity.conversation; + + const attachmentTypes = attachments + .map((att) => (typeof att.contentType === "string" ? att.contentType : undefined)) + .filter(Boolean) + .slice(0, 3); + const htmlSummary = summarizeMSTeamsHtmlAttachments(attachments); + + log.info("received message", { + rawText: rawText.slice(0, 50), + text: text.slice(0, 50), + attachments: attachments.length, + attachmentTypes, + from: from?.id, + conversation: conversation?.id, + }); + if (htmlSummary) { + log.debug("html attachment summary", htmlSummary); + } + + if (!from?.id) { + log.debug("skipping message without from.id"); + return; + } + + // Teams conversation.id may include ";messageid=..." suffix - strip it for session key. + const rawConversationId = conversation?.id ?? ""; + const conversationId = normalizeMSTeamsConversationId(rawConversationId); + const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId); + const conversationType = conversation?.conversationType ?? "personal"; + const isGroupChat = conversationType === "groupChat" || conversation?.isGroup === true; + const isChannel = conversationType === "channel"; + const isDirectMessage = !isGroupChat && !isChannel; + + const senderName = from.name ?? from.id; + const senderId = from.aadObjectId ?? from.id; + const storedAllowFrom = await core.channel.pairing + .readAllowFromStore("msteams") + .catch(() => []); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + + // Check DM policy for direct messages. + const dmAllowFrom = msteamsCfg?.allowFrom ?? []; + const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom]; + if (isDirectMessage && msteamsCfg) { + const dmPolicy = msteamsCfg.dmPolicy ?? "pairing"; + const allowFrom = dmAllowFrom; + + if (dmPolicy === "disabled") { + log.debug("dropping dm (dms disabled)"); + return; + } + + if (dmPolicy !== "open") { + const effectiveAllowFrom = [...allowFrom.map((v) => String(v)), ...storedAllowFrom]; + const allowMatch = resolveMSTeamsAllowlistMatch({ + allowFrom: effectiveAllowFrom, + senderId, + senderName, + }); + + if (!allowMatch.allowed) { + if (dmPolicy === "pairing") { + const request = await core.channel.pairing.upsertPairingRequest({ + channel: "msteams", + id: senderId, + meta: { name: senderName }, + }); + if (request) { + log.info("msteams pairing request created", { + sender: senderId, + label: senderName, + }); + } + } + log.debug("dropping dm (not allowlisted)", { + sender: senderId, + label: senderName, + allowlistMatch: formatAllowlistMatchMeta(allowMatch), + }); + return; + } + } + } + + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = + !isDirectMessage && msteamsCfg + ? (msteamsCfg.groupPolicy ?? defaultGroupPolicy ?? "allowlist") + : "disabled"; + const groupAllowFrom = + !isDirectMessage && msteamsCfg + ? (msteamsCfg.groupAllowFrom ?? + (msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : [])) + : []; + const effectiveGroupAllowFrom = + !isDirectMessage && msteamsCfg + ? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom] + : []; + const teamId = activity.channelData?.team?.id; + const teamName = activity.channelData?.team?.name; + const channelName = activity.channelData?.channel?.name; + const channelGate = resolveMSTeamsRouteConfig({ + cfg: msteamsCfg, + teamId, + teamName, + conversationId, + channelName, + }); + + if (!isDirectMessage && msteamsCfg) { + if (groupPolicy === "disabled") { + log.debug("dropping group message (groupPolicy: disabled)", { + conversationId, + }); + return; + } + + if (groupPolicy === "allowlist") { + if (channelGate.allowlistConfigured && !channelGate.allowed) { + log.debug("dropping group message (not in team/channel allowlist)", { + conversationId, + teamKey: channelGate.teamKey ?? "none", + channelKey: channelGate.channelKey ?? "none", + channelMatchKey: channelGate.channelMatchKey ?? "none", + channelMatchSource: channelGate.channelMatchSource ?? "none", + }); + return; + } + if (effectiveGroupAllowFrom.length === 0 && !channelGate.allowlistConfigured) { + log.debug("dropping group message (groupPolicy: allowlist, no allowlist)", { + conversationId, + }); + return; + } + if (effectiveGroupAllowFrom.length > 0) { + const allowMatch = resolveMSTeamsAllowlistMatch({ + groupPolicy, + allowFrom: effectiveGroupAllowFrom, + senderId, + senderName, + }); + if (!allowMatch.allowed) { + log.debug("dropping group message (not in groupAllowFrom)", { + sender: senderId, + label: senderName, + allowlistMatch: formatAllowlistMatchMeta(allowMatch), + }); + return; + } + } + } + } + + const ownerAllowedForCommands = isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: effectiveDmAllowFrom, + senderId, + senderName, + }); + const groupAllowedForCommands = isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: effectiveGroupAllowFrom, + senderId, + senderName, + }); + const hasControlCommandInMessage = core.channel.text.hasControlCommand(text, cfg); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands }, + { configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands }, + ], + allowTextCommands: true, + hasControlCommand: hasControlCommandInMessage, + }); + const commandAuthorized = commandGate.commandAuthorized; + if (commandGate.shouldBlock) { + logInboundDrop({ + log: logVerboseMessage, + channel: "msteams", + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + + // Build conversation reference for proactive replies. + const agent = activity.recipient; + const conversationRef: StoredConversationReference = { + activityId: activity.id, + user: { id: from.id, name: from.name, aadObjectId: from.aadObjectId }, + agent, + bot: agent ? { id: agent.id, name: agent.name } : undefined, + conversation: { + id: conversationId, + conversationType, + tenantId: conversation?.tenantId, + }, + teamId, + channelId: activity.channelId, + serviceUrl: activity.serviceUrl, + locale: activity.locale, + }; + conversationStore.upsert(conversationId, conversationRef).catch((err) => { + log.debug("failed to save conversation reference", { + error: formatUnknownError(err), + }); + }); + + const pollVote = extractMSTeamsPollVote(activity); + if (pollVote) { + try { + const poll = await pollStore.recordVote({ + pollId: pollVote.pollId, + voterId: senderId, + selections: pollVote.selections, + }); + if (!poll) { + log.debug("poll vote ignored (poll not found)", { + pollId: pollVote.pollId, + }); + } else { + log.info("recorded poll vote", { + pollId: pollVote.pollId, + voter: senderId, + selections: pollVote.selections, + }); + } + } catch (err) { + log.error("failed to record poll vote", { + pollId: pollVote.pollId, + error: formatUnknownError(err), + }); + } + return; + } + + if (!rawBody) { + log.debug("skipping empty message after stripping mentions"); + return; + } + + const teamsFrom = isDirectMessage + ? `msteams:${senderId}` + : isChannel + ? `msteams:channel:${conversationId}` + : `msteams:group:${conversationId}`; + const teamsTo = isDirectMessage ? `user:${senderId}` : `conversation:${conversationId}`; + + const route = core.channel.routing.resolveAgentRoute({ + cfg, + channel: "msteams", + peer: { + kind: isDirectMessage ? "dm" : isChannel ? "channel" : "group", + id: isDirectMessage ? senderId : conversationId, + }, + }); + + const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); + const inboundLabel = isDirectMessage + ? `Teams DM from ${senderName}` + : `Teams message in ${conversationType} from ${senderName}`; + + core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, { + sessionKey: route.sessionKey, + contextKey: `msteams:message:${conversationId}:${activity.id ?? "unknown"}`, + }); + + const channelId = conversationId; + const { teamConfig, channelConfig } = channelGate; + const { requireMention, replyStyle } = resolveMSTeamsReplyPolicy({ + isDirectMessage, + globalConfig: msteamsCfg, + teamConfig, + channelConfig, + }); + const timestamp = parseMSTeamsActivityTimestamp(activity.timestamp); + + if (!isDirectMessage) { + const mentionGate = resolveMentionGating({ + requireMention: Boolean(requireMention), + canDetectMention: true, + wasMentioned: params.wasMentioned, + implicitMention: params.implicitMention, + shouldBypassMention: false, + }); + const mentioned = mentionGate.effectiveWasMentioned; + if (requireMention && mentionGate.shouldSkip) { + log.debug("skipping message (mention required)", { + teamId, + channelId, + requireMention, + mentioned, + }); + recordPendingHistoryEntryIfEnabled({ + historyMap: conversationHistories, + historyKey: conversationId, + limit: historyLimit, + entry: { + sender: senderName, + body: rawBody, + timestamp: timestamp?.getTime(), + messageId: activity.id ?? undefined, + }, + }); + return; + } + } + const mediaList = await resolveMSTeamsInboundMedia({ + attachments, + htmlSummary: htmlSummary ?? undefined, + maxBytes: mediaMaxBytes, + tokenProvider, + allowHosts: msteamsCfg?.mediaAllowHosts, + conversationType, + conversationId, + conversationMessageId: conversationMessageId ?? undefined, + activity: { + id: activity.id, + replyToId: activity.replyToId, + channelData: activity.channelData, + }, + log, + preserveFilenames: cfg.media?.preserveFilenames, + }); + + const mediaPayload = buildMSTeamsMediaPayload(mediaList); + const envelopeFrom = isDirectMessage ? senderName : conversationType; + const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Teams", + from: envelopeFrom, + timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + let combinedBody = body; + const isRoomish = !isDirectMessage; + const historyKey = isRoomish ? conversationId : undefined; + if (isRoomish && historyKey) { + combinedBody = buildPendingHistoryContextFromMap({ + historyMap: conversationHistories, + historyKey, + limit: historyLimit, + currentMessage: combinedBody, + formatEntry: (entry) => + core.channel.reply.formatAgentEnvelope({ + channel: "Teams", + from: conversationType, + timestamp: entry.timestamp, + body: `${entry.sender}: ${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`, + envelope: envelopeOptions, + }), + }); + } + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: combinedBody, + RawBody: rawBody, + CommandBody: rawBody, + From: teamsFrom, + To: teamsTo, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", + ConversationLabel: envelopeFrom, + GroupSubject: !isDirectMessage ? conversationType : undefined, + SenderName: senderName, + SenderId: senderId, + Provider: "msteams" as const, + Surface: "msteams" as const, + MessageSid: activity.id, + Timestamp: timestamp?.getTime() ?? Date.now(), + WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, + CommandAuthorized: commandAuthorized, + OriginatingChannel: "msteams" as const, + OriginatingTo: teamsTo, + ...mediaPayload, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + logVerboseMessage(`msteams: failed updating session meta: ${String(err)}`); + }, + }); + + logVerboseMessage(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); + + const sharePointSiteId = msteamsCfg?.sharePointSiteId; + const { dispatcher, replyOptions, markDispatchIdle } = createMSTeamsReplyDispatcher({ + cfg, + agentId: route.agentId, + runtime, + log, + adapter, + appId, + conversationRef, + context, + replyStyle, + textLimit, + onSentMessageIds: (ids) => { + for (const id of ids) { + recordMSTeamsSentMessage(conversationId, id); + } + }, + tokenProvider, + sharePointSiteId, + }); + + log.info("dispatching to agent", { sessionKey: route.sessionKey }); + try { + const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({ + ctx: ctxPayload, + cfg, + dispatcher, + replyOptions, + }); + + markDispatchIdle(); + log.info("dispatch complete", { queuedFinal, counts }); + + if (!queuedFinal) { + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: conversationHistories, + historyKey, + limit: historyLimit, + }); + } + return; + } + const finalCount = counts.final; + logVerboseMessage( + `msteams: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${teamsTo}`, + ); + if (isRoomish && historyKey) { + clearHistoryEntriesIfEnabled({ + historyMap: conversationHistories, + historyKey, + limit: historyLimit, + }); + } + } catch (err) { + log.error("dispatch failed", { error: String(err) }); + runtime.error?.(`msteams dispatch failed: ${String(err)}`); + try { + await context.sendActivity( + `⚠️ Agent failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } catch { + // Best effort. + } + } + }; + + const inboundDebouncer = core.channel.debounce.createInboundDebouncer({ + debounceMs: inboundDebounceMs, + buildKey: (entry) => { + const conversationId = normalizeMSTeamsConversationId( + entry.context.activity.conversation?.id ?? "", + ); + const senderId = + entry.context.activity.from?.aadObjectId ?? entry.context.activity.from?.id ?? ""; + if (!senderId || !conversationId) { + return null; + } + return `msteams:${appId}:${conversationId}:${senderId}`; + }, + shouldDebounce: (entry) => { + if (!entry.text.trim()) { + return false; + } + if (entry.attachments.length > 0) { + return false; + } + return !core.channel.text.hasControlCommand(entry.text, cfg); + }, + onFlush: async (entries) => { + const last = entries.at(-1); + if (!last) { + return; + } + if (entries.length === 1) { + await handleTeamsMessageNow(last); + return; + } + const combinedText = entries + .map((entry) => entry.text) + .filter(Boolean) + .join("\n"); + if (!combinedText.trim()) { + return; + } + const combinedRawText = entries + .map((entry) => entry.rawText) + .filter(Boolean) + .join("\n"); + const wasMentioned = entries.some((entry) => entry.wasMentioned); + const implicitMention = entries.some((entry) => entry.implicitMention); + await handleTeamsMessageNow({ + context: last.context, + rawText: combinedRawText, + text: combinedText, + attachments: [], + wasMentioned, + implicitMention, + }); + }, + onError: (err) => { + runtime.error?.(`msteams debounce flush failed: ${String(err)}`); + }, + }); + + return async function handleTeamsMessage(context: MSTeamsTurnContext) { + const activity = context.activity; + const rawText = activity.text?.trim() ?? ""; + const text = stripMSTeamsMentionTags(rawText); + const attachments = Array.isArray(activity.attachments) + ? (activity.attachments as unknown as MSTeamsAttachmentLike[]) + : []; + const wasMentioned = wasMSTeamsBotMentioned(activity); + const conversationId = normalizeMSTeamsConversationId(activity.conversation?.id ?? ""); + const replyToId = activity.replyToId ?? undefined; + const implicitMention = Boolean( + conversationId && replyToId && wasMSTeamsMessageSent(conversationId, replyToId), + ); + + await inboundDebouncer.enqueue({ + context, + rawText, + text, + attachments, + wasMentioned, + implicitMention, + }); + }; +} diff --git a/extensions/msteams/src/monitor-types.ts b/extensions/msteams/src/monitor-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..014081ffd223cee22a5bbc32ede44ed9ccd6cc16 --- /dev/null +++ b/extensions/msteams/src/monitor-types.ts @@ -0,0 +1,5 @@ +export type MSTeamsMonitorLogger = { + debug: (message: string, meta?: Record) => void; + info: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; +}; diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts new file mode 100644 index 0000000000000000000000000000000000000000..df93c081d31a4d8888d90abb77121a87575645c5 --- /dev/null +++ b/extensions/msteams/src/monitor.ts @@ -0,0 +1,295 @@ +import type { Request, Response } from "express"; +import { + mergeAllowlist, + summarizeMapping, + type OpenClawConfig, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { MSTeamsConversationStore } from "./conversation-store.js"; +import type { MSTeamsAdapter } from "./messenger.js"; +import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { formatUnknownError } from "./errors.js"; +import { registerMSTeamsHandlers } from "./monitor-handler.js"; +import { createMSTeamsPollStoreFs, type MSTeamsPollStore } from "./polls.js"; +import { + resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; +import { getMSTeamsRuntime } from "./runtime.js"; +import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +export type MonitorMSTeamsOpts = { + cfg: OpenClawConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + conversationStore?: MSTeamsConversationStore; + pollStore?: MSTeamsPollStore; +}; + +export type MonitorMSTeamsResult = { + app: unknown; + shutdown: () => Promise; +}; + +export async function monitorMSTeamsProvider( + opts: MonitorMSTeamsOpts, +): Promise { + const core = getMSTeamsRuntime(); + const log = core.logging.getChildLogger({ name: "msteams" }); + let cfg = opts.cfg; + let msteamsCfg = cfg.channels?.msteams; + if (!msteamsCfg?.enabled) { + log.debug("msteams provider disabled"); + return { app: null, shutdown: async () => {} }; + } + + const creds = resolveMSTeamsCredentials(msteamsCfg); + if (!creds) { + log.error("msteams credentials not configured"); + return { app: null, shutdown: async () => {} }; + } + const appId = creds.appId; // Extract for use in closures + + const runtime: RuntimeEnv = opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + let allowFrom = msteamsCfg.allowFrom; + let groupAllowFrom = msteamsCfg.groupAllowFrom; + let teamsConfig = msteamsCfg.teams; + + const cleanAllowEntry = (entry: string) => + entry + .replace(/^(msteams|teams):/i, "") + .replace(/^user:/i, "") + .trim(); + + const resolveAllowlistUsers = async (label: string, entries: string[]) => { + if (entries.length === 0) { + return { additions: [], unresolved: [] }; + } + const resolved = await resolveMSTeamsUserAllowlist({ cfg, entries }); + const additions: string[] = []; + const unresolved: string[] = []; + for (const entry of resolved) { + if (entry.resolved && entry.id) { + additions.push(entry.id); + } else { + unresolved.push(entry.input); + } + } + const mapping = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => `${entry.input}→${entry.id}`); + summarizeMapping(label, mapping, unresolved, runtime); + return { additions, unresolved }; + }; + + try { + const allowEntries = + allowFrom + ?.map((entry) => cleanAllowEntry(String(entry))) + .filter((entry) => entry && entry !== "*") ?? []; + if (allowEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams users", allowEntries); + allowFrom = mergeAllowlist({ existing: allowFrom, additions }); + } + + if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { + const groupEntries = groupAllowFrom + .map((entry) => cleanAllowEntry(String(entry))) + .filter((entry) => entry && entry !== "*"); + if (groupEntries.length > 0) { + const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries); + groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions }); + } + } + + if (teamsConfig && Object.keys(teamsConfig).length > 0) { + const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = []; + for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) { + if (teamKey === "*") { + continue; + } + const channels = teamCfg?.channels ?? {}; + const channelKeys = Object.keys(channels).filter((key) => key !== "*"); + if (channelKeys.length === 0) { + entries.push({ input: teamKey, teamKey }); + continue; + } + for (const channelKey of channelKeys) { + entries.push({ + input: `${teamKey}/${channelKey}`, + teamKey, + channelKey, + }); + } + } + + if (entries.length > 0) { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg, + entries: entries.map((entry) => entry.input), + }); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextTeams = { ...teamsConfig }; + + resolved.forEach((entry, idx) => { + const source = entries[idx]; + if (!source) { + return; + } + const sourceTeam = teamsConfig?.[source.teamKey] ?? {}; + if (!entry.resolved || !entry.teamId) { + unresolved.push(entry.input); + return; + } + mapping.push( + entry.channelId + ? `${entry.input}→${entry.teamId}/${entry.channelId}` + : `${entry.input}→${entry.teamId}`, + ); + const existing = nextTeams[entry.teamId] ?? {}; + const mergedChannels = { + ...sourceTeam.channels, + ...existing.channels, + }; + const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels }; + nextTeams[entry.teamId] = mergedTeam; + if (source.channelKey && entry.channelId) { + const sourceChannel = sourceTeam.channels?.[source.channelKey]; + if (sourceChannel) { + nextTeams[entry.teamId] = { + ...mergedTeam, + channels: { + ...mergedChannels, + [entry.channelId]: { + ...sourceChannel, + ...mergedChannels?.[entry.channelId], + }, + }, + }; + } + } + }); + + teamsConfig = nextTeams; + summarizeMapping("msteams channels", mapping, unresolved, runtime); + } + } + } catch (err) { + runtime.log?.(`msteams resolve failed; using config entries. ${String(err)}`); + } + + msteamsCfg = { + ...msteamsCfg, + allowFrom, + groupAllowFrom, + teams: teamsConfig, + }; + cfg = { + ...cfg, + channels: { + ...cfg.channels, + msteams: msteamsCfg, + }, + }; + + const port = msteamsCfg.webhook?.port ?? 3978; + const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "msteams"); + const MB = 1024 * 1024; + const agentDefaults = cfg.agents?.defaults; + const mediaMaxBytes = + typeof agentDefaults?.mediaMaxMb === "number" && agentDefaults.mediaMaxMb > 0 + ? Math.floor(agentDefaults.mediaMaxMb * MB) + : 8 * MB; + const conversationStore = opts.conversationStore ?? createMSTeamsConversationStoreFs(); + const pollStore = opts.pollStore ?? createMSTeamsPollStoreFs(); + + log.info(`starting provider (port ${port})`); + + // Dynamic import to avoid loading SDK when provider is disabled + const express = await import("express"); + + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const { ActivityHandler, MsalTokenProvider, authorizeJWT } = sdk; + + // Auth configuration - create early so adapter is available for deliverReplies + const tokenProvider = new MsalTokenProvider(authConfig); + const adapter = createMSTeamsAdapter(authConfig, sdk); + + const handler = registerMSTeamsHandlers(new ActivityHandler(), { + cfg, + runtime, + appId, + adapter: adapter as unknown as MSTeamsAdapter, + tokenProvider, + textLimit, + mediaMaxBytes, + conversationStore, + pollStore, + log, + }); + + // Create Express server + const expressApp = express.default(); + expressApp.use(express.json()); + expressApp.use(authorizeJWT(authConfig)); + + // Set up the messages endpoint - use configured path and /api/messages as fallback + const configuredPath = msteamsCfg.webhook?.path ?? "/api/messages"; + const messageHandler = (req: Request, res: Response) => { + void adapter + .process(req, res, (context: unknown) => handler.run(context)) + .catch((err: unknown) => { + log.error("msteams webhook failed", { error: formatUnknownError(err) }); + }); + }; + + // Listen on configured path and /api/messages (standard Bot Framework path) + expressApp.post(configuredPath, messageHandler); + if (configuredPath !== "/api/messages") { + expressApp.post("/api/messages", messageHandler); + } + + log.debug("listening on paths", { + primary: configuredPath, + fallback: "/api/messages", + }); + + // Start listening and capture the HTTP server handle + const httpServer = expressApp.listen(port, () => { + log.info(`msteams provider started on port ${port}`); + }); + + httpServer.on("error", (err) => { + log.error("msteams server error", { error: String(err) }); + }); + + const shutdown = async () => { + log.info("shutting down msteams provider"); + return new Promise((resolve) => { + httpServer.close((err) => { + if (err) { + log.debug("msteams server close error", { error: String(err) }); + } + resolve(); + }); + }); + }; + + // Handle abort signal + if (opts.abortSignal) { + opts.abortSignal.addEventListener("abort", () => { + void shutdown(); + }); + } + + return { app: expressApp, shutdown }; +} diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1f055dcfe89ee5712a4e0267a9a6673a1bc74ce --- /dev/null +++ b/extensions/msteams/src/onboarding.ts @@ -0,0 +1,431 @@ +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + OpenClawConfig, + DmPolicy, + WizardPrompter, +} from "openclaw/plugin-sdk"; +import { + addWildcardAllowFrom, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + promptChannelAccessConfig, +} from "openclaw/plugin-sdk"; +import { + parseMSTeamsTeamEntry, + resolveMSTeamsChannelAllowlist, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +const channel = "msteams" as const; + +function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { + const allowFrom = + dmPolicy === "open" + ? addWildcardAllowFrom(cfg.channels?.msteams?.allowFrom)?.map((entry) => String(entry)) + : undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + dmPolicy, + ...(allowFrom ? { allowFrom } : {}), + }, + }, + }; +} + +function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + allowFrom, + }, + }, + }; +} + +function parseAllowFromInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function looksLikeGuid(value: string): boolean { + return /^[0-9a-fA-F-]{16,}$/.test(value); +} + +async function promptMSTeamsAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; +}): Promise { + const existing = params.cfg.channels?.msteams?.allowFrom ?? []; + await params.prompter.note( + [ + "Allowlist MS Teams DMs by display name, UPN/email, or user id.", + "We resolve names to user IDs via Microsoft Graph when credentials allow.", + "Examples:", + "- alex@example.com", + "- Alex Johnson", + "- 00000000-0000-0000-0000-000000000000", + ].join("\n"), + "MS Teams allowlist", + ); + + while (true) { + const entry = await params.prompter.text({ + message: "MS Teams allowFrom (usernames or ids)", + placeholder: "alex@example.com, Alex Johnson", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + const parts = parseAllowFromInput(String(entry)); + if (parts.length === 0) { + await params.prompter.note("Enter at least one user.", "MS Teams allowlist"); + continue; + } + + const resolved = await resolveMSTeamsUserAllowlist({ + cfg: params.cfg, + entries: parts, + }).catch(() => null); + + if (!resolved) { + const ids = parts.filter((part) => looksLikeGuid(part)); + if (ids.length !== parts.length) { + await params.prompter.note( + "Graph lookup unavailable. Use user IDs only.", + "MS Teams allowlist", + ); + continue; + } + const unique = [ + ...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids]), + ]; + return setMSTeamsAllowFrom(params.cfg, unique); + } + + const unresolved = resolved.filter((item) => !item.resolved || !item.id); + if (unresolved.length > 0) { + await params.prompter.note( + `Could not resolve: ${unresolved.map((item) => item.input).join(", ")}`, + "MS Teams allowlist", + ); + continue; + } + + const ids = resolved.map((item) => item.id as string); + const unique = [...new Set([...existing.map((v) => String(v).trim()).filter(Boolean), ...ids])]; + return setMSTeamsAllowFrom(params.cfg, unique); + } +} + +async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Azure Bot registration → get App ID + Tenant ID", + "2) Add a client secret (App Password)", + "3) Set webhook URL + messaging endpoint", + "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.", + `Docs: ${formatDocsLink("/channels/msteams", "msteams")}`, + ].join("\n"), + "MS Teams credentials", + ); +} + +function setMSTeamsGroupPolicy( + cfg: OpenClawConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + groupPolicy, + }, + }, + }; +} + +function setMSTeamsTeamsAllowlist( + cfg: OpenClawConfig, + entries: Array<{ teamKey: string; channelKey?: string }>, +): OpenClawConfig { + const baseTeams = cfg.channels?.msteams?.teams ?? {}; + const teams: Record }> = { ...baseTeams }; + for (const entry of entries) { + const teamKey = entry.teamKey; + if (!teamKey) { + continue; + } + const existing = teams[teamKey] ?? {}; + if (entry.channelKey) { + const channels = { ...existing.channels }; + channels[entry.channelKey] = channels[entry.channelKey] ?? {}; + teams[teamKey] = { ...existing, channels }; + } else { + teams[teamKey] = existing; + } + } + return { + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + teams, + }, + }, + }; +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "MS Teams", + channel, + policyKey: "channels.msteams.dmPolicy", + allowFromKey: "channels.msteams.allowFrom", + getCurrent: (cfg) => cfg.channels?.msteams?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setMSTeamsDmPolicy(cfg, policy), + promptAllowFrom: promptMSTeamsAllowFrom, +}; + +export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)); + return { + channel, + configured, + statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`], + selectionHint: configured ? "configured" : "needs app creds", + quickstartScore: configured ? 2 : 0, + }; + }, + configure: async ({ cfg, prompter }) => { + const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams); + const hasConfigCreds = Boolean( + cfg.channels?.msteams?.appId?.trim() && + cfg.channels?.msteams?.appPassword?.trim() && + cfg.channels?.msteams?.tenantId?.trim(), + ); + const canUseEnv = Boolean( + !hasConfigCreds && + process.env.MSTEAMS_APP_ID?.trim() && + process.env.MSTEAMS_APP_PASSWORD?.trim() && + process.env.MSTEAMS_TENANT_ID?.trim(), + ); + + let next = cfg; + let appId: string | null = null; + let appPassword: string | null = null; + let tenantId: string | null = null; + + if (!resolved) { + await noteMSTeamsCredentialHelp(prompter); + } + + if (canUseEnv) { + const keepEnv = await prompter.confirm({ + message: + "MSTEAMS_APP_ID + MSTEAMS_APP_PASSWORD + MSTEAMS_TENANT_ID detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + msteams: { ...next.channels?.msteams, enabled: true }, + }, + }; + } else { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigCreds) { + const keep = await prompter.confirm({ + message: "MS Teams credentials already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + appId = String( + await prompter.text({ + message: "Enter MS Teams App ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appPassword = String( + await prompter.text({ + message: "Enter MS Teams App Password", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + tenantId = String( + await prompter.text({ + message: "Enter MS Teams Tenant ID", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (appId && appPassword && tenantId) { + next = { + ...next, + channels: { + ...next.channels, + msteams: { + ...next.channels?.msteams, + enabled: true, + appId, + appPassword, + tenantId, + }, + }, + }; + } + + const currentEntries = Object.entries(next.channels?.msteams?.teams ?? {}).flatMap( + ([teamKey, value]) => { + const channels = value?.channels ?? {}; + const channelKeys = Object.keys(channels); + if (channelKeys.length === 0) { + return [teamKey]; + } + return channelKeys.map((channelKey) => `${teamKey}/${channelKey}`); + }, + ); + const accessConfig = await promptChannelAccessConfig({ + prompter, + label: "MS Teams channels", + currentPolicy: next.channels?.msteams?.groupPolicy ?? "allowlist", + currentEntries, + placeholder: "Team Name/Channel Name, teamId/conversationId", + updatePrompt: Boolean(next.channels?.msteams?.teams), + }); + if (accessConfig) { + if (accessConfig.policy !== "allowlist") { + next = setMSTeamsGroupPolicy(next, accessConfig.policy); + } else { + let entries = accessConfig.entries + .map((entry) => parseMSTeamsTeamEntry(entry)) + .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; + if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { + try { + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg: next, + entries: accessConfig.entries, + }); + const resolvedChannels = resolved.filter( + (entry) => entry.resolved && entry.teamId && entry.channelId, + ); + const resolvedTeams = resolved.filter( + (entry) => entry.resolved && entry.teamId && !entry.channelId, + ); + const unresolved = resolved + .filter((entry) => !entry.resolved) + .map((entry) => entry.input); + + entries = [ + ...resolvedChannels.map((entry) => ({ + teamKey: entry.teamId as string, + channelKey: entry.channelId as string, + })), + ...resolvedTeams.map((entry) => ({ + teamKey: entry.teamId as string, + })), + ...unresolved.map((entry) => parseMSTeamsTeamEntry(entry)).filter(Boolean), + ] as Array<{ teamKey: string; channelKey?: string }>; + + if (resolvedChannels.length > 0 || resolvedTeams.length > 0 || unresolved.length > 0) { + const summary: string[] = []; + if (resolvedChannels.length > 0) { + summary.push( + `Resolved channels: ${resolvedChannels + .map((entry) => entry.channelId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (resolvedTeams.length > 0) { + summary.push( + `Resolved teams: ${resolvedTeams + .map((entry) => entry.teamId) + .filter(Boolean) + .join(", ")}`, + ); + } + if (unresolved.length > 0) { + summary.push(`Unresolved (kept as typed): ${unresolved.join(", ")}`); + } + await prompter.note(summary.join("\n"), "MS Teams channels"); + } + } catch (err) { + await prompter.note( + `Channel lookup failed; keeping entries as typed. ${String(err)}`, + "MS Teams channels", + ); + } + } + next = setMSTeamsGroupPolicy(next, "allowlist"); + next = setMSTeamsTeamsAllowlist(next, entries); + } + } + + return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { ...cfg.channels?.msteams, enabled: false }, + }, + }), +}; diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts new file mode 100644 index 0000000000000000000000000000000000000000..48f5d0c61af6a96dca9a6be2edebf7d8f69bfb34 --- /dev/null +++ b/extensions/msteams/src/outbound.ts @@ -0,0 +1,46 @@ +import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk"; +import { createMSTeamsPollStoreFs } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; +import { sendMessageMSTeams, sendPollMSTeams } from "./send.js"; + +export const msteamsOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + chunker: (text, limit) => getMSTeamsRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 12, + sendText: async ({ cfg, to, text, deps }) => { + const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); + const result = await send(to, text); + return { channel: "msteams", ...result }; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, deps }) => { + const send = + deps?.sendMSTeams ?? + ((to, text, opts) => sendMessageMSTeams({ cfg, to, text, mediaUrl: opts?.mediaUrl })); + const result = await send(to, text, { mediaUrl }); + return { channel: "msteams", ...result }; + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, +}; diff --git a/extensions/msteams/src/pending-uploads.ts b/extensions/msteams/src/pending-uploads.ts new file mode 100644 index 0000000000000000000000000000000000000000..d879008d1eca2d70b790fe15af7b99af2f12d96f --- /dev/null +++ b/extensions/msteams/src/pending-uploads.ts @@ -0,0 +1,89 @@ +/** + * In-memory storage for files awaiting user consent in the FileConsentCard flow. + * + * When sending large files (>=4MB) in personal chats, Teams requires user consent + * before upload. This module stores the file data temporarily until the user + * accepts or declines, or until the TTL expires. + */ + +import crypto from "node:crypto"; + +export interface PendingUpload { + id: string; + buffer: Buffer; + filename: string; + contentType?: string; + conversationId: string; + createdAt: number; +} + +const pendingUploads = new Map(); + +/** TTL for pending uploads: 5 minutes */ +const PENDING_UPLOAD_TTL_MS = 5 * 60 * 1000; + +/** + * Store a file pending user consent. + * Returns the upload ID to include in the FileConsentCard context. + */ +export function storePendingUpload(upload: Omit): string { + const id = crypto.randomUUID(); + const entry: PendingUpload = { + ...upload, + id, + createdAt: Date.now(), + }; + pendingUploads.set(id, entry); + + // Auto-cleanup after TTL + setTimeout(() => { + pendingUploads.delete(id); + }, PENDING_UPLOAD_TTL_MS); + + return id; +} + +/** + * Retrieve a pending upload by ID. + * Returns undefined if not found or expired. + */ +export function getPendingUpload(id?: string): PendingUpload | undefined { + if (!id) { + return undefined; + } + const entry = pendingUploads.get(id); + if (!entry) { + return undefined; + } + + // Check if expired (in case timeout hasn't fired yet) + if (Date.now() - entry.createdAt > PENDING_UPLOAD_TTL_MS) { + pendingUploads.delete(id); + return undefined; + } + + return entry; +} + +/** + * Remove a pending upload (after successful upload or user decline). + */ +export function removePendingUpload(id?: string): void { + if (id) { + pendingUploads.delete(id); + } +} + +/** + * Get the count of pending uploads (for monitoring/debugging). + */ +export function getPendingUploadCount(): number { + return pendingUploads.size; +} + +/** + * Clear all pending uploads (for testing). + */ +export function clearPendingUploads(): void { + pendingUploads.clear(); +} diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..90ee1f3cd241331bb4d5b2093128775eac389492 --- /dev/null +++ b/extensions/msteams/src/policy.test.ts @@ -0,0 +1,209 @@ +import type { MSTeamsConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it } from "vitest"; +import { + isMSTeamsGroupAllowed, + resolveMSTeamsReplyPolicy, + resolveMSTeamsRouteConfig, +} from "./policy.js"; + +describe("msteams policy", () => { + describe("resolveMSTeamsRouteConfig", () => { + it("returns team and channel config when present", () => { + const cfg: MSTeamsConfig = { + teams: { + team123: { + requireMention: false, + channels: { + chan456: { requireMention: true }, + }, + }, + }, + }; + + const res = resolveMSTeamsRouteConfig({ + cfg, + teamId: "team123", + conversationId: "chan456", + }); + + expect(res.teamConfig?.requireMention).toBe(false); + expect(res.channelConfig?.requireMention).toBe(true); + expect(res.allowlistConfigured).toBe(true); + expect(res.allowed).toBe(true); + expect(res.channelMatchKey).toBe("chan456"); + expect(res.channelMatchSource).toBe("direct"); + }); + + it("returns undefined configs when teamId is missing", () => { + const cfg: MSTeamsConfig = { + teams: { team123: { requireMention: false } }, + }; + + const res = resolveMSTeamsRouteConfig({ + cfg, + teamId: undefined, + conversationId: "chan", + }); + expect(res.teamConfig).toBeUndefined(); + expect(res.channelConfig).toBeUndefined(); + expect(res.allowlistConfigured).toBe(true); + expect(res.allowed).toBe(false); + }); + + it("matches team and channel by name", () => { + const cfg: MSTeamsConfig = { + teams: { + "My Team": { + requireMention: true, + channels: { + "General Chat": { requireMention: false }, + }, + }, + }, + }; + + const res = resolveMSTeamsRouteConfig({ + cfg, + teamName: "My Team", + channelName: "General Chat", + conversationId: "ignored", + }); + + expect(res.teamConfig?.requireMention).toBe(true); + expect(res.channelConfig?.requireMention).toBe(false); + expect(res.allowed).toBe(true); + }); + }); + + describe("resolveMSTeamsReplyPolicy", () => { + it("forces thread replies for direct messages", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: true, + globalConfig: { replyStyle: "top-level", requireMention: false }, + }); + expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); + }); + + it("defaults to requireMention=true and replyStyle=thread", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: {}, + }); + expect(policy).toEqual({ requireMention: true, replyStyle: "thread" }); + }); + + it("defaults replyStyle to top-level when requireMention=false", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: false }, + }); + expect(policy).toEqual({ + requireMention: false, + replyStyle: "top-level", + }); + }); + + it("prefers channel overrides over team and global defaults", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: true }, + teamConfig: { requireMention: true }, + channelConfig: { requireMention: false }, + }); + + // requireMention from channel -> false, and replyStyle defaults from requireMention -> top-level + expect(policy).toEqual({ + requireMention: false, + replyStyle: "top-level", + }); + }); + + it("inherits team mention settings when channel config is missing", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: true }, + teamConfig: { requireMention: false }, + }); + expect(policy).toEqual({ + requireMention: false, + replyStyle: "top-level", + }); + }); + + it("uses explicit replyStyle even when requireMention defaults would differ", () => { + const policy = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: { requireMention: false, replyStyle: "thread" }, + }); + expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); + }); + }); + + describe("isMSTeamsGroupAllowed", () => { + it("allows when policy is open", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "open", + allowFrom: [], + senderId: "user-id", + senderName: "User", + }), + ).toBe(true); + }); + + it("blocks when policy is disabled", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "disabled", + allowFrom: ["user-id"], + senderId: "user-id", + senderName: "User", + }), + ).toBe(false); + }); + + it("blocks allowlist when empty", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: [], + senderId: "user-id", + senderName: "User", + }), + ).toBe(false); + }); + + it("allows allowlist when sender matches", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["User-Id"], + senderId: "user-id", + senderName: "User", + }), + ).toBe(true); + }); + + it("allows allowlist when sender name matches", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["user"], + senderId: "other", + senderName: "User", + }), + ).toBe(true); + }); + + it("allows allowlist wildcard", () => { + expect( + isMSTeamsGroupAllowed({ + groupPolicy: "allowlist", + allowFrom: ["*"], + senderId: "other", + senderName: "User", + }), + ).toBe(true); + }); + }); +}); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb1e747624cccb6fb06973275d5edc543e8433e3 --- /dev/null +++ b/extensions/msteams/src/policy.ts @@ -0,0 +1,273 @@ +import type { + AllowlistMatch, + ChannelGroupContext, + GroupPolicy, + GroupToolPolicyConfig, + MSTeamsChannelConfig, + MSTeamsConfig, + MSTeamsReplyStyle, + MSTeamsTeamConfig, +} from "openclaw/plugin-sdk"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveToolsBySender, + resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, +} from "openclaw/plugin-sdk"; + +export type MSTeamsResolvedRouteConfig = { + teamConfig?: MSTeamsTeamConfig; + channelConfig?: MSTeamsChannelConfig; + allowlistConfigured: boolean; + allowed: boolean; + teamKey?: string; + channelKey?: string; + channelMatchKey?: string; + channelMatchSource?: "direct" | "wildcard"; +}; + +export function resolveMSTeamsRouteConfig(params: { + cfg?: MSTeamsConfig; + teamId?: string | null | undefined; + teamName?: string | null | undefined; + conversationId?: string | null | undefined; + channelName?: string | null | undefined; +}): MSTeamsResolvedRouteConfig { + const teamId = params.teamId?.trim(); + const teamName = params.teamName?.trim(); + const conversationId = params.conversationId?.trim(); + const channelName = params.channelName?.trim(); + const teams = params.cfg?.teams ?? {}; + const allowlistConfigured = Object.keys(teams).length > 0; + const teamCandidates = buildChannelKeyCandidates( + teamId, + teamName, + teamName ? normalizeChannelSlug(teamName) : undefined, + ); + const teamMatch = resolveChannelEntryMatchWithFallback({ + entries: teams, + keys: teamCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const teamConfig = teamMatch.entry; + const channels = teamConfig?.channels ?? {}; + const channelAllowlistConfigured = Object.keys(channels).length > 0; + const channelCandidates = buildChannelKeyCandidates( + conversationId, + channelName, + channelName ? normalizeChannelSlug(channelName) : undefined, + ); + const channelMatch = resolveChannelEntryMatchWithFallback({ + entries: channels, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const channelConfig = channelMatch.entry; + + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: allowlistConfigured, + outerMatched: Boolean(teamConfig), + innerConfigured: channelAllowlistConfigured, + innerMatched: Boolean(channelConfig), + }); + + return { + teamConfig, + channelConfig, + allowlistConfigured, + allowed, + teamKey: teamMatch.matchKey ?? teamMatch.key, + channelKey: channelMatch.matchKey ?? channelMatch.key, + channelMatchKey: channelMatch.matchKey, + channelMatchSource: + channelMatch.matchSource === "direct" || channelMatch.matchSource === "wildcard" + ? channelMatch.matchSource + : undefined, + }; +} + +export function resolveMSTeamsGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg.channels?.msteams; + if (!cfg) { + return undefined; + } + const groupId = params.groupId?.trim(); + const groupChannel = params.groupChannel?.trim(); + const groupSpace = params.groupSpace?.trim(); + + const resolved = resolveMSTeamsRouteConfig({ + cfg, + teamId: groupSpace, + teamName: groupSpace, + conversationId: groupId, + channelName: groupChannel, + }); + + if (resolved.channelConfig) { + const senderPolicy = resolveToolsBySender({ + toolsBySender: resolved.channelConfig.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) { + return senderPolicy; + } + if (resolved.channelConfig.tools) { + return resolved.channelConfig.tools; + } + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: resolved.teamConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) { + return teamSenderPolicy; + } + return resolved.teamConfig?.tools; + } + if (resolved.teamConfig) { + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: resolved.teamConfig.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) { + return teamSenderPolicy; + } + if (resolved.teamConfig.tools) { + return resolved.teamConfig.tools; + } + } + + if (!groupId) { + return undefined; + } + + const channelCandidates = buildChannelKeyCandidates( + groupId, + groupChannel, + groupChannel ? normalizeChannelSlug(groupChannel) : undefined, + ); + for (const teamConfig of Object.values(cfg.teams ?? {})) { + const match = resolveChannelEntryMatchWithFallback({ + entries: teamConfig?.channels ?? {}, + keys: channelCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + if (match.entry) { + const senderPolicy = resolveToolsBySender({ + toolsBySender: match.entry.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (senderPolicy) { + return senderPolicy; + } + if (match.entry.tools) { + return match.entry.tools; + } + const teamSenderPolicy = resolveToolsBySender({ + toolsBySender: teamConfig?.toolsBySender, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + if (teamSenderPolicy) { + return teamSenderPolicy; + } + return teamConfig?.tools; + } + } + + return undefined; +} + +export type MSTeamsReplyPolicy = { + requireMention: boolean; + replyStyle: MSTeamsReplyStyle; +}; + +export type MSTeamsAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">; + +export function resolveMSTeamsAllowlistMatch(params: { + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): MSTeamsAllowlistMatch { + const allowFrom = params.allowFrom + .map((entry) => String(entry).trim().toLowerCase()) + .filter(Boolean); + if (allowFrom.length === 0) { + return { allowed: false }; + } + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const senderId = params.senderId.toLowerCase(); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + const senderName = params.senderName?.toLowerCase(); + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + return { allowed: false }; +} + +export function resolveMSTeamsReplyPolicy(params: { + isDirectMessage: boolean; + globalConfig?: MSTeamsConfig; + teamConfig?: MSTeamsTeamConfig; + channelConfig?: MSTeamsChannelConfig; +}): MSTeamsReplyPolicy { + if (params.isDirectMessage) { + return { requireMention: false, replyStyle: "thread" }; + } + + const requireMention = + params.channelConfig?.requireMention ?? + params.teamConfig?.requireMention ?? + params.globalConfig?.requireMention ?? + true; + + const explicitReplyStyle = + params.channelConfig?.replyStyle ?? + params.teamConfig?.replyStyle ?? + params.globalConfig?.replyStyle; + + const replyStyle: MSTeamsReplyStyle = + explicitReplyStyle ?? (requireMention ? "thread" : "top-level"); + + return { requireMention, replyStyle }; +} + +export function isMSTeamsGroupAllowed(params: { + groupPolicy: GroupPolicy; + allowFrom: Array; + senderId: string; + senderName?: string | null; +}): boolean { + const { groupPolicy } = params; + if (groupPolicy === "disabled") { + return false; + } + if (groupPolicy === "open") { + return true; + } + return resolveMSTeamsAllowlistMatch(params).allowed; +} diff --git a/extensions/msteams/src/polls-store-memory.ts b/extensions/msteams/src/polls-store-memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3fc7b11a5d8da4a798c7a76900f6d04409df87f --- /dev/null +++ b/extensions/msteams/src/polls-store-memory.ts @@ -0,0 +1,32 @@ +import { + type MSTeamsPoll, + type MSTeamsPollStore, + normalizeMSTeamsPollSelections, +} from "./polls.js"; + +export function createMSTeamsPollStoreMemory(initial: MSTeamsPoll[] = []): MSTeamsPollStore { + const polls = new Map(); + for (const poll of initial) { + polls.set(poll.id, { ...poll }); + } + + const createPoll = async (poll: MSTeamsPoll) => { + polls.set(poll.id, { ...poll }); + }; + + const getPoll = async (pollId: string) => polls.get(pollId) ?? null; + + const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => { + const poll = polls.get(params.pollId); + if (!poll) { + return null; + } + const normalized = normalizeMSTeamsPollSelections(poll, params.selections); + poll.votes[params.voterId] = normalized; + poll.updatedAt = new Date().toISOString(); + polls.set(poll.id, poll); + return poll; + }; + + return { createPoll, getPoll, recordVote }; +} diff --git a/extensions/msteams/src/polls-store.test.ts b/extensions/msteams/src/polls-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff70f13d4ab9d3ad691775954dbb6b535eb08d9d --- /dev/null +++ b/extensions/msteams/src/polls-store.test.ts @@ -0,0 +1,38 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { createMSTeamsPollStoreMemory } from "./polls-store-memory.js"; +import { createMSTeamsPollStoreFs } from "./polls.js"; + +const createFsStore = async () => { + const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-")); + return createMSTeamsPollStoreFs({ stateDir }); +}; + +const createMemoryStore = () => createMSTeamsPollStoreMemory(); + +describe.each([ + { name: "memory", createStore: createMemoryStore }, + { name: "fs", createStore: createFsStore }, +])("$name poll store", ({ createStore }) => { + it("stores polls and records normalized votes", async () => { + const store = await createStore(); + await store.createPoll({ + id: "poll-1", + question: "Lunch?", + options: ["Pizza", "Sushi"], + maxSelections: 1, + createdAt: new Date().toISOString(), + votes: {}, + }); + + const poll = await store.recordVote({ + pollId: "poll-1", + voterId: "user-1", + selections: ["0", "1"], + }); + + expect(poll?.votes["user-1"]).toEqual(["0"]); + }); +}); diff --git a/extensions/msteams/src/polls.test.ts b/extensions/msteams/src/polls.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0508a25bb06f5cbdf71e54df4517678e0bad7a4d --- /dev/null +++ b/extensions/msteams/src/polls.test.ts @@ -0,0 +1,72 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js"; +import { setMSTeamsRuntime } from "./runtime.js"; + +const runtimeStub = { + state: { + resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => { + const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + return override; + } + const resolvedHome = homedir ? homedir() : os.homedir(); + return path.join(resolvedHome, ".openclaw"); + }, + }, +} as unknown as PluginRuntime; + +describe("msteams polls", () => { + beforeEach(() => { + setMSTeamsRuntime(runtimeStub); + }); + + it("builds poll cards with fallback text", () => { + const card = buildMSTeamsPollCard({ + question: "Lunch?", + options: ["Pizza", "Sushi"], + }); + + expect(card.pollId).toBeTruthy(); + expect(card.fallbackText).toContain("Poll: Lunch?"); + expect(card.fallbackText).toContain("1. Pizza"); + expect(card.fallbackText).toContain("2. Sushi"); + }); + + it("extracts poll votes from activity values", () => { + const vote = extractMSTeamsPollVote({ + value: { + openclawPollId: "poll-1", + choices: "0,1", + }, + }); + + expect(vote).toEqual({ + pollId: "poll-1", + selections: ["0", "1"], + }); + }); + + it("stores and records poll votes", async () => { + const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-polls-")); + const store = createMSTeamsPollStoreFs({ homedir: () => home }); + await store.createPoll({ + id: "poll-2", + question: "Pick one", + options: ["A", "B"], + maxSelections: 1, + createdAt: new Date().toISOString(), + votes: {}, + }); + await store.recordVote({ + pollId: "poll-2", + voterId: "user-1", + selections: ["0", "1"], + }); + const stored = await store.getPoll("poll-2"); + expect(stored?.votes["user-1"]).toEqual(["0"]); + }); +}); diff --git a/extensions/msteams/src/polls.ts b/extensions/msteams/src/polls.ts new file mode 100644 index 0000000000000000000000000000000000000000..f538c2091fb3b8156c15836e256ae6089a64238b --- /dev/null +++ b/extensions/msteams/src/polls.ts @@ -0,0 +1,315 @@ +import crypto from "node:crypto"; +import { resolveMSTeamsStorePath } from "./storage.js"; +import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; + +export type MSTeamsPollVote = { + pollId: string; + selections: string[]; +}; + +export type MSTeamsPoll = { + id: string; + question: string; + options: string[]; + maxSelections: number; + createdAt: string; + updatedAt?: string; + conversationId?: string; + messageId?: string; + votes: Record; +}; + +export type MSTeamsPollStore = { + createPoll: (poll: MSTeamsPoll) => Promise; + getPoll: (pollId: string) => Promise; + recordVote: (params: { + pollId: string; + voterId: string; + selections: string[]; + }) => Promise; +}; + +export type MSTeamsPollCard = { + pollId: string; + question: string; + options: string[]; + maxSelections: number; + card: Record; + fallbackText: string; +}; + +type PollStoreData = { + version: 1; + polls: Record; +}; + +const STORE_FILENAME = "msteams-polls.json"; +const MAX_POLLS = 1000; +const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000; +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeChoiceValue(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function extractSelections(value: unknown): string[] { + if (Array.isArray(value)) { + return value.map(normalizeChoiceValue).filter((entry): entry is string => Boolean(entry)); + } + const normalized = normalizeChoiceValue(value); + if (!normalized) { + return []; + } + if (normalized.includes(",")) { + return normalized + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + } + return [normalized]; +} + +function readNestedValue(value: unknown, keys: Array): unknown { + let current: unknown = value; + for (const key of keys) { + if (!isRecord(current)) { + return undefined; + } + current = current[key as keyof typeof current]; + } + return current; +} + +function readNestedString(value: unknown, keys: Array): string | undefined { + const found = readNestedValue(value, keys); + return typeof found === "string" && found.trim() ? found.trim() : undefined; +} + +export function extractMSTeamsPollVote( + activity: { value?: unknown } | undefined, +): MSTeamsPollVote | null { + const value = activity?.value; + if (!value || !isRecord(value)) { + return null; + } + const pollId = + readNestedString(value, ["openclawPollId"]) ?? + readNestedString(value, ["pollId"]) ?? + readNestedString(value, ["openclaw", "pollId"]) ?? + readNestedString(value, ["openclaw", "poll", "id"]) ?? + readNestedString(value, ["data", "openclawPollId"]) ?? + readNestedString(value, ["data", "pollId"]) ?? + readNestedString(value, ["data", "openclaw", "pollId"]); + if (!pollId) { + return null; + } + + const directSelections = extractSelections(value.choices); + const nestedSelections = extractSelections(readNestedValue(value, ["choices"])); + const dataSelections = extractSelections(readNestedValue(value, ["data", "choices"])); + const selections = + directSelections.length > 0 + ? directSelections + : nestedSelections.length > 0 + ? nestedSelections + : dataSelections; + + if (selections.length === 0) { + return null; + } + + return { + pollId, + selections, + }; +} + +export function buildMSTeamsPollCard(params: { + question: string; + options: string[]; + maxSelections?: number; + pollId?: string; +}): MSTeamsPollCard { + const pollId = params.pollId ?? crypto.randomUUID(); + const maxSelections = + typeof params.maxSelections === "number" && params.maxSelections > 1 + ? Math.floor(params.maxSelections) + : 1; + const cappedMaxSelections = Math.min(Math.max(1, maxSelections), params.options.length); + const choices = params.options.map((option, index) => ({ + title: option, + value: String(index), + })); + const hint = + cappedMaxSelections > 1 + ? `Select up to ${cappedMaxSelections} option${cappedMaxSelections === 1 ? "" : "s"}.` + : "Select one option."; + + const card = { + type: "AdaptiveCard", + version: "1.5", + body: [ + { + type: "TextBlock", + text: params.question, + wrap: true, + weight: "Bolder", + size: "Medium", + }, + { + type: "Input.ChoiceSet", + id: "choices", + isMultiSelect: cappedMaxSelections > 1, + style: "expanded", + choices, + }, + { + type: "TextBlock", + text: hint, + wrap: true, + isSubtle: true, + spacing: "Small", + }, + ], + actions: [ + { + type: "Action.Submit", + title: "Vote", + data: { + openclawPollId: pollId, + pollId, + }, + msteams: { + type: "messageBack", + text: "openclaw poll vote", + displayText: "Vote recorded", + value: { openclawPollId: pollId, pollId }, + }, + }, + ], + }; + + const fallbackLines = [ + `Poll: ${params.question}`, + ...params.options.map((option, index) => `${index + 1}. ${option}`), + ]; + + return { + pollId, + question: params.question, + options: params.options, + maxSelections: cappedMaxSelections, + card, + fallbackText: fallbackLines.join("\n"), + }; +} + +export type MSTeamsPollStoreFsOptions = { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + stateDir?: string; + storePath?: string; +}; + +function parseTimestamp(value?: string): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function pruneExpired(polls: Record) { + const cutoff = Date.now() - POLL_TTL_MS; + const entries = Object.entries(polls).filter(([, poll]) => { + const ts = parseTimestamp(poll.updatedAt ?? poll.createdAt) ?? 0; + return ts >= cutoff; + }); + return Object.fromEntries(entries); +} + +function pruneToLimit(polls: Record) { + const entries = Object.entries(polls); + if (entries.length <= MAX_POLLS) { + return polls; + } + entries.sort((a, b) => { + const aTs = parseTimestamp(a[1].updatedAt ?? a[1].createdAt) ?? 0; + const bTs = parseTimestamp(b[1].updatedAt ?? b[1].createdAt) ?? 0; + return aTs - bTs; + }); + const keep = entries.slice(entries.length - MAX_POLLS); + return Object.fromEntries(keep); +} + +export function normalizeMSTeamsPollSelections(poll: MSTeamsPoll, selections: string[]) { + const maxSelections = Math.max(1, poll.maxSelections); + const mapped = selections + .map((entry) => Number.parseInt(entry, 10)) + .filter((value) => Number.isFinite(value)) + .filter((value) => value >= 0 && value < poll.options.length) + .map((value) => String(value)); + const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1); + return Array.from(new Set(limited)); +} + +export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MSTeamsPollStore { + const filePath = resolveMSTeamsStorePath({ + filename: STORE_FILENAME, + env: params?.env, + homedir: params?.homedir, + stateDir: params?.stateDir, + storePath: params?.storePath, + }); + const empty: PollStoreData = { version: 1, polls: {} }; + + const readStore = async (): Promise => { + const { value } = await readJsonFile(filePath, empty); + const pruned = pruneToLimit(pruneExpired(value.polls ?? {})); + return { version: 1, polls: pruned }; + }; + + const writeStore = async (data: PollStoreData) => { + await writeJsonFile(filePath, data); + }; + + const createPoll = async (poll: MSTeamsPoll) => { + await withFileLock(filePath, empty, async () => { + const data = await readStore(); + data.polls[poll.id] = poll; + await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); + }); + }; + + const getPoll = async (pollId: string) => + await withFileLock(filePath, empty, async () => { + const data = await readStore(); + return data.polls[pollId] ?? null; + }); + + const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => + await withFileLock(filePath, empty, async () => { + const data = await readStore(); + const poll = data.polls[params.pollId]; + if (!poll) { + return null; + } + const normalized = normalizeMSTeamsPollSelections(poll, params.selections); + poll.votes[params.voterId] = normalized; + poll.updatedAt = new Date().toISOString(); + data.polls[poll.id] = poll; + await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); + return poll; + }); + + return { createPoll, getPoll, recordVote }; +} diff --git a/extensions/msteams/src/probe.test.ts b/extensions/msteams/src/probe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9c18019ac5e69292f0f6fd5904f9bbaa9287e06 --- /dev/null +++ b/extensions/msteams/src/probe.test.ts @@ -0,0 +1,58 @@ +import type { MSTeamsConfig } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; + +const hostMockState = vi.hoisted(() => ({ + tokenError: null as Error | null, +})); + +vi.mock("@microsoft/agents-hosting", () => ({ + getAuthConfigWithDefaults: (cfg: unknown) => cfg, + MsalTokenProvider: class { + async getAccessToken() { + if (hostMockState.tokenError) { + throw hostMockState.tokenError; + } + return "token"; + } + }, +})); + +import { probeMSTeams } from "./probe.js"; + +describe("msteams probe", () => { + it("returns an error when credentials are missing", async () => { + const cfg = { enabled: true } as unknown as MSTeamsConfig; + await expect(probeMSTeams(cfg)).resolves.toMatchObject({ + ok: false, + }); + }); + + it("validates credentials by acquiring a token", async () => { + hostMockState.tokenError = null; + const cfg = { + enabled: true, + appId: "app", + appPassword: "pw", + tenantId: "tenant", + } as unknown as MSTeamsConfig; + await expect(probeMSTeams(cfg)).resolves.toMatchObject({ + ok: true, + appId: "app", + }); + }); + + it("returns a helpful error when token acquisition fails", async () => { + hostMockState.tokenError = new Error("bad creds"); + const cfg = { + enabled: true, + appId: "app", + appPassword: "pw", + tenantId: "tenant", + } as unknown as MSTeamsConfig; + await expect(probeMSTeams(cfg)).resolves.toMatchObject({ + ok: false, + appId: "app", + error: "bad creds", + }); + }); +}); diff --git a/extensions/msteams/src/probe.ts b/extensions/msteams/src/probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bbcc0b3c3c4cbdd55882ed6100fadc09d31d2de --- /dev/null +++ b/extensions/msteams/src/probe.ts @@ -0,0 +1,107 @@ +import type { MSTeamsConfig } from "openclaw/plugin-sdk"; +import { formatUnknownError } from "./errors.js"; +import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +export type ProbeMSTeamsResult = { + ok: boolean; + error?: string; + appId?: string; + graph?: { + ok: boolean; + error?: string; + roles?: string[]; + scopes?: string[]; + }; +}; + +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function decodeJwtPayload(token: string): Record | null { + const parts = token.split("."); + if (parts.length < 2) { + return null; + } + const payload = parts[1] ?? ""; + const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "="); + const normalized = padded.replace(/-/g, "+").replace(/_/g, "/"); + try { + const decoded = Buffer.from(normalized, "base64").toString("utf8"); + const parsed = JSON.parse(decoded) as Record; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +function readStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const out = value.map((entry) => String(entry).trim()).filter(Boolean); + return out.length > 0 ? out : undefined; +} + +function readScopes(value: unknown): string[] | undefined { + if (typeof value !== "string") { + return undefined; + } + const out = value + .split(/\s+/) + .map((entry) => entry.trim()) + .filter(Boolean); + return out.length > 0 ? out : undefined; +} + +export async function probeMSTeams(cfg?: MSTeamsConfig): Promise { + const creds = resolveMSTeamsCredentials(cfg); + if (!creds) { + return { + ok: false, + error: "missing credentials (appId, appPassword, tenantId)", + }; + } + + try { + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const tokenProvider = new sdk.MsalTokenProvider(authConfig); + await tokenProvider.getAccessToken("https://api.botframework.com"); + let graph: + | { + ok: boolean; + error?: string; + roles?: string[]; + scopes?: string[]; + } + | undefined; + try { + const graphToken = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + const accessToken = readAccessToken(graphToken); + const payload = accessToken ? decodeJwtPayload(accessToken) : null; + graph = { + ok: true, + roles: readStringArray(payload?.roles), + scopes: readScopes(payload?.scp), + }; + } catch (err) { + graph = { ok: false, error: formatUnknownError(err) }; + } + return { ok: true, appId: creds.appId, ...(graph ? { graph } : {}) }; + } catch (err) { + return { + ok: false, + appId: creds.appId, + error: formatUnknownError(err), + }; + } +} diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..517f84941254357ac92627899db35f53173976e3 --- /dev/null +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -0,0 +1,130 @@ +import { + createReplyPrefixContext, + createTypingCallbacks, + logTypingFailure, + resolveChannelMediaMaxBytes, + type OpenClawConfig, + type MSTeamsReplyStyle, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; +import type { StoredConversationReference } from "./conversation-store.js"; +import type { MSTeamsMonitorLogger } from "./monitor-types.js"; +import type { MSTeamsTurnContext } from "./sdk-types.js"; +import { + classifyMSTeamsSendError, + formatMSTeamsSendErrorHint, + formatUnknownError, +} from "./errors.js"; +import { + type MSTeamsAdapter, + renderReplyPayloadsToMessages, + sendMSTeamsMessages, +} from "./messenger.js"; +import { getMSTeamsRuntime } from "./runtime.js"; + +export function createMSTeamsReplyDispatcher(params: { + cfg: OpenClawConfig; + agentId: string; + runtime: RuntimeEnv; + log: MSTeamsMonitorLogger; + adapter: MSTeamsAdapter; + appId: string; + conversationRef: StoredConversationReference; + context: MSTeamsTurnContext; + replyStyle: MSTeamsReplyStyle; + textLimit: number; + onSentMessageIds?: (ids: string[]) => void; + /** Token provider for OneDrive/SharePoint uploads in group chats/channels */ + tokenProvider?: MSTeamsAccessTokenProvider; + /** SharePoint site ID for file uploads in group chats/channels */ + sharePointSiteId?: string; +}) { + const core = getMSTeamsRuntime(); + const sendTypingIndicator = async () => { + await params.context.sendActivity({ type: "typing" }); + }; + const typingCallbacks = createTypingCallbacks({ + start: sendTypingIndicator, + onStartError: (err) => { + logTypingFailure({ + log: (message) => params.log.debug(message), + channel: "msteams", + action: "start", + error: err, + }); + }, + }); + const prefixContext = createReplyPrefixContext({ + cfg: params.cfg, + agentId: params.agentId, + }); + const chunkMode = core.channel.text.resolveChunkMode(params.cfg, "msteams"); + + const { dispatcher, replyOptions, markDispatchIdle } = + core.channel.reply.createReplyDispatcherWithTyping({ + responsePrefix: prefixContext.responsePrefix, + responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, + humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), + deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", + }); + const messages = renderReplyPayloadsToMessages([payload], { + textChunkLimit: params.textLimit, + chunkText: true, + mediaMode: "split", + tableMode, + chunkMode, + }); + const mediaMaxBytes = resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, + }); + const ids = await sendMSTeamsMessages({ + replyStyle: params.replyStyle, + adapter: params.adapter, + appId: params.appId, + conversationRef: params.conversationRef, + context: params.context, + messages, + // Enable default retry/backoff for throttling/transient failures. + retry: {}, + onRetry: (event) => { + params.log.debug("retrying send", { + replyStyle: params.replyStyle, + ...event, + }); + }, + tokenProvider: params.tokenProvider, + sharePointSiteId: params.sharePointSiteId, + mediaMaxBytes, + }); + if (ids.length > 0) { + params.onSentMessageIds?.(ids); + } + }, + onError: (err, info) => { + const errMsg = formatUnknownError(err); + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + params.runtime.error?.( + `msteams ${info.kind} reply failed: ${errMsg}${hint ? ` (${hint})` : ""}`, + ); + params.log.error("reply failed", { + kind: info.kind, + error: errMsg, + classification, + hint, + }); + }, + onReplyStart: typingCallbacks.onReplyStart, + }); + + return { + dispatcher, + replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected }, + markDispatchIdle, + }; +} diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..371b615f3814fe30fec389c4ebf205358906dcc8 --- /dev/null +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -0,0 +1,297 @@ +import { GRAPH_ROOT } from "./attachments/shared.js"; +import { loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +type GraphUser = { + id?: string; + displayName?: string; + userPrincipalName?: string; + mail?: string; +}; + +type GraphGroup = { + id?: string; + displayName?: string; +}; + +type GraphChannel = { + id?: string; + displayName?: string; +}; + +type GraphResponse = { value?: T[] }; + +export type MSTeamsChannelResolution = { + input: string; + resolved: boolean; + teamId?: string; + teamName?: string; + channelId?: string; + channelName?: string; + note?: string; +}; + +export type MSTeamsUserResolution = { + input: string; + resolved: boolean; + id?: string; + name?: string; + note?: string; +}; + +function readAccessToken(value: unknown): string | null { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object") { + const token = + (value as { accessToken?: unknown }).accessToken ?? (value as { token?: unknown }).token; + return typeof token === "string" ? token : null; + } + return null; +} + +function stripProviderPrefix(raw: string): string { + return raw.replace(/^(msteams|teams):/i, ""); +} + +export function normalizeMSTeamsMessagingTarget(raw: string): string | undefined { + let trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + trimmed = stripProviderPrefix(trimmed).trim(); + if (/^conversation:/i.test(trimmed)) { + const id = trimmed.slice("conversation:".length).trim(); + return id ? `conversation:${id}` : undefined; + } + if (/^user:/i.test(trimmed)) { + const id = trimmed.slice("user:".length).trim(); + return id ? `user:${id}` : undefined; + } + return trimmed || undefined; +} + +export function normalizeMSTeamsUserInput(raw: string): string { + return stripProviderPrefix(raw) + .replace(/^(user|conversation):/i, "") + .trim(); +} + +export function parseMSTeamsConversationId(raw: string): string | null { + const trimmed = stripProviderPrefix(raw).trim(); + if (!/^conversation:/i.test(trimmed)) { + return null; + } + const id = trimmed.slice("conversation:".length).trim(); + return id; +} + +function normalizeMSTeamsTeamKey(raw: string): string | undefined { + const trimmed = stripProviderPrefix(raw) + .replace(/^team:/i, "") + .trim(); + return trimmed || undefined; +} + +function normalizeMSTeamsChannelKey(raw?: string | null): string | undefined { + const trimmed = raw?.trim().replace(/^#/, "").trim() ?? ""; + return trimmed || undefined; +} + +export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } { + const trimmed = stripProviderPrefix(raw).trim(); + if (!trimmed) { + return {}; + } + const parts = trimmed.split("/"); + const team = normalizeMSTeamsTeamKey(parts[0] ?? ""); + const channel = + parts.length > 1 ? normalizeMSTeamsChannelKey(parts.slice(1).join("/")) : undefined; + return { + ...(team ? { team } : {}), + ...(channel ? { channel } : {}), + }; +} + +export function parseMSTeamsTeamEntry( + raw: string, +): { teamKey: string; channelKey?: string } | null { + const { team, channel } = parseMSTeamsTeamChannelInput(raw); + if (!team) { + return null; + } + return { + teamKey: team, + ...(channel ? { channelKey: channel } : {}), + }; +} + +function normalizeQuery(value?: string | null): string { + return value?.trim() ?? ""; +} + +function escapeOData(value: string): string { + return value.replace(/'/g, "''"); +} + +async function fetchGraphJson(params: { + token: string; + path: string; + headers?: Record; +}): Promise { + const res = await fetch(`${GRAPH_ROOT}${params.path}`, { + headers: { + Authorization: `Bearer ${params.token}`, + ...params.headers, + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Graph ${params.path} failed (${res.status}): ${text || "unknown error"}`); + } + return (await res.json()) as T; +} + +async function resolveGraphToken(cfg: unknown): Promise { + const creds = resolveMSTeamsCredentials( + (cfg as { channels?: { msteams?: unknown } })?.channels?.msteams, + ); + if (!creds) { + throw new Error("MS Teams credentials missing"); + } + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const tokenProvider = new sdk.MsalTokenProvider(authConfig); + const token = await tokenProvider.getAccessToken("https://graph.microsoft.com"); + const accessToken = readAccessToken(token); + if (!accessToken) { + throw new Error("MS Teams graph token unavailable"); + } + return accessToken; +} + +async function listTeamsByName(token: string, query: string): Promise { + const escaped = escapeOData(query); + const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`; + const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +async function listChannelsForTeam(token: string, teamId: string): Promise { + const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; + const res = await fetchGraphJson>({ token, path }); + return res.value ?? []; +} + +export async function resolveMSTeamsChannelAllowlist(params: { + cfg: unknown; + entries: string[]; +}): Promise { + const token = await resolveGraphToken(params.cfg); + const results: MSTeamsChannelResolution[] = []; + + for (const input of params.entries) { + const { team, channel } = parseMSTeamsTeamChannelInput(input); + if (!team) { + results.push({ input, resolved: false }); + continue; + } + const teams = /^[0-9a-fA-F-]{16,}$/.test(team) + ? [{ id: team, displayName: team }] + : await listTeamsByName(token, team); + if (teams.length === 0) { + results.push({ input, resolved: false, note: "team not found" }); + continue; + } + const teamMatch = teams[0]; + const teamId = teamMatch.id?.trim(); + const teamName = teamMatch.displayName?.trim() || team; + if (!teamId) { + results.push({ input, resolved: false, note: "team id missing" }); + continue; + } + if (!channel) { + results.push({ + input, + resolved: true, + teamId, + teamName, + note: teams.length > 1 ? "multiple teams; chose first" : undefined, + }); + continue; + } + const channels = await listChannelsForTeam(token, teamId); + const channelMatch = + channels.find((item) => item.id === channel) ?? + channels.find((item) => item.displayName?.toLowerCase() === channel.toLowerCase()) ?? + channels.find((item) => + item.displayName?.toLowerCase().includes(channel.toLowerCase() ?? ""), + ); + if (!channelMatch?.id) { + results.push({ input, resolved: false, note: "channel not found" }); + continue; + } + results.push({ + input, + resolved: true, + teamId, + teamName, + channelId: channelMatch.id, + channelName: channelMatch.displayName ?? channel, + note: channels.length > 1 ? "multiple channels; chose first" : undefined, + }); + } + + return results; +} + +export async function resolveMSTeamsUserAllowlist(params: { + cfg: unknown; + entries: string[]; +}): Promise { + const token = await resolveGraphToken(params.cfg); + const results: MSTeamsUserResolution[] = []; + + for (const input of params.entries) { + const query = normalizeQuery(normalizeMSTeamsUserInput(input)); + if (!query) { + results.push({ input, resolved: false }); + continue; + } + if (/^[0-9a-fA-F-]{16,}$/.test(query)) { + results.push({ input, resolved: true, id: query }); + continue; + } + let users: GraphUser[] = []; + if (query.includes("@")) { + const escaped = escapeOData(query); + const filter = `(mail eq '${escaped}' or userPrincipalName eq '${escaped}')`; + const path = `/users?$filter=${encodeURIComponent(filter)}&$select=id,displayName,mail,userPrincipalName`; + const res = await fetchGraphJson>({ token, path }); + users = res.value ?? []; + } else { + const path = `/users?$search=${encodeURIComponent(`"displayName:${query}"`)}&$select=id,displayName,mail,userPrincipalName&$top=10`; + const res = await fetchGraphJson>({ + token, + path, + headers: { ConsistencyLevel: "eventual" }, + }); + users = res.value ?? []; + } + const match = users[0]; + if (!match?.id) { + results.push({ input, resolved: false }); + continue; + } + results.push({ + input, + resolved: true, + id: match.id, + name: match.displayName ?? undefined, + note: users.length > 1 ? "multiple matches; chose first" : undefined, + }); + } + + return results; +} diff --git a/extensions/msteams/src/runtime.ts b/extensions/msteams/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..deb09f3ebc814063fe59bdb9597c80948aa84e9a --- /dev/null +++ b/extensions/msteams/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setMSTeamsRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getMSTeamsRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("MSTeams runtime not initialized"); + } + return runtime; +} diff --git a/extensions/msteams/src/sdk-types.ts b/extensions/msteams/src/sdk-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..0901848a3baa32ed256cae18148f53e3f8b2a7c8 --- /dev/null +++ b/extensions/msteams/src/sdk-types.ts @@ -0,0 +1,19 @@ +import type { TurnContext } from "@microsoft/agents-hosting"; + +/** + * Minimal public surface we depend on from the Microsoft SDK types. + * + * Note: we intentionally avoid coupling to SDK classes with private members + * (like TurnContext) in our own public signatures. The SDK's TS surface is also + * stricter than what the runtime accepts (e.g. it allows plain activity-like + * objects), so we model the minimal structural shape we rely on. + */ +export type MSTeamsActivity = TurnContext["activity"]; + +export type MSTeamsTurnContext = { + activity: MSTeamsActivity; + sendActivity: (textOrActivity: string | object) => Promise; + sendActivities: ( + activities: Array<{ type: string } & Record>, + ) => Promise; +}; diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce0b258306037be47868844bfb40ce12b1e34c7a --- /dev/null +++ b/extensions/msteams/src/sdk.ts @@ -0,0 +1,33 @@ +import type { MSTeamsAdapter } from "./messenger.js"; +import type { MSTeamsCredentials } from "./token.js"; + +export type MSTeamsSdk = typeof import("@microsoft/agents-hosting"); +export type MSTeamsAuthConfig = ReturnType; + +export async function loadMSTeamsSdk(): Promise { + return await import("@microsoft/agents-hosting"); +} + +export function buildMSTeamsAuthConfig( + creds: MSTeamsCredentials, + sdk: MSTeamsSdk, +): MSTeamsAuthConfig { + return sdk.getAuthConfigWithDefaults({ + clientId: creds.appId, + clientSecret: creds.appPassword, + tenantId: creds.tenantId, + }); +} + +export function createMSTeamsAdapter( + authConfig: MSTeamsAuthConfig, + sdk: MSTeamsSdk, +): MSTeamsAdapter { + return new sdk.CloudAdapter(authConfig) as unknown as MSTeamsAdapter; +} + +export async function loadMSTeamsSdkWithAuth(creds: MSTeamsCredentials) { + const sdk = await loadMSTeamsSdk(); + const authConfig = buildMSTeamsAuthConfig(creds, sdk); + return { sdk, authConfig }; +} diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..deefe21c0b7f95358c06d79bd436e86297518e24 --- /dev/null +++ b/extensions/msteams/src/send-context.ts @@ -0,0 +1,164 @@ +import { + resolveChannelMediaMaxBytes, + type OpenClawConfig, + type PluginRuntime, +} from "openclaw/plugin-sdk"; +import type { MSTeamsAccessTokenProvider } from "./attachments/types.js"; +import type { + MSTeamsConversationStore, + StoredConversationReference, +} from "./conversation-store.js"; +import type { MSTeamsAdapter } from "./messenger.js"; +import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { getMSTeamsRuntime } from "./runtime.js"; +import { createMSTeamsAdapter, loadMSTeamsSdkWithAuth } from "./sdk.js"; +import { resolveMSTeamsCredentials } from "./token.js"; + +export type MSTeamsConversationType = "personal" | "groupChat" | "channel"; + +export type MSTeamsProactiveContext = { + appId: string; + conversationId: string; + ref: StoredConversationReference; + adapter: MSTeamsAdapter; + log: ReturnType; + /** The type of conversation: personal (1:1), groupChat, or channel */ + conversationType: MSTeamsConversationType; + /** Token provider for Graph API / OneDrive operations */ + tokenProvider: MSTeamsAccessTokenProvider; + /** SharePoint site ID for file uploads in group chats/channels */ + sharePointSiteId?: string; + /** Resolved media max bytes from config (default: 100MB) */ + mediaMaxBytes?: number; +}; + +/** + * Parse the target value into a conversation reference lookup key. + * Supported formats: + * - conversation:19:abc@thread.tacv2 → lookup by conversation ID + * - user:aad-object-id → lookup by user AAD object ID + * - 19:abc@thread.tacv2 → direct conversation ID + */ +function parseRecipient(to: string): { + type: "conversation" | "user"; + id: string; +} { + const trimmed = to.trim(); + const finalize = (type: "conversation" | "user", id: string) => { + const normalized = id.trim(); + if (!normalized) { + throw new Error(`Invalid target value: missing ${type} id`); + } + return { type, id: normalized }; + }; + if (trimmed.startsWith("conversation:")) { + return finalize("conversation", trimmed.slice("conversation:".length)); + } + if (trimmed.startsWith("user:")) { + return finalize("user", trimmed.slice("user:".length)); + } + // Assume it's a conversation ID if it looks like one + if (trimmed.startsWith("19:") || trimmed.includes("@thread")) { + return finalize("conversation", trimmed); + } + // Otherwise treat as user ID + return finalize("user", trimmed); +} + +/** + * Find a stored conversation reference for the given recipient. + */ +async function findConversationReference(recipient: { + type: "conversation" | "user"; + id: string; + store: MSTeamsConversationStore; +}): Promise<{ + conversationId: string; + ref: StoredConversationReference; +} | null> { + if (recipient.type === "conversation") { + const ref = await recipient.store.get(recipient.id); + if (ref) { + return { conversationId: recipient.id, ref }; + } + return null; + } + + const found = await recipient.store.findByUserId(recipient.id); + if (!found) { + return null; + } + return { conversationId: found.conversationId, ref: found.reference }; +} + +export async function resolveMSTeamsSendContext(params: { + cfg: OpenClawConfig; + to: string; +}): Promise { + const msteamsCfg = params.cfg.channels?.msteams; + + if (!msteamsCfg?.enabled) { + throw new Error("msteams provider is not enabled"); + } + + const creds = resolveMSTeamsCredentials(msteamsCfg); + if (!creds) { + throw new Error("msteams credentials not configured"); + } + + const store = createMSTeamsConversationStoreFs(); + + // Parse recipient and find conversation reference + const recipient = parseRecipient(params.to); + const found = await findConversationReference({ ...recipient, store }); + + if (!found) { + throw new Error( + `No conversation reference found for ${recipient.type}:${recipient.id}. ` + + `The bot must receive a message from this conversation before it can send proactively.`, + ); + } + + const { conversationId, ref } = found; + const core = getMSTeamsRuntime(); + const log = core.logging.getChildLogger({ name: "msteams:send" }); + + const { sdk, authConfig } = await loadMSTeamsSdkWithAuth(creds); + const adapter = createMSTeamsAdapter(authConfig, sdk); + + // Create token provider for Graph API / OneDrive operations + const tokenProvider = new sdk.MsalTokenProvider(authConfig) as MSTeamsAccessTokenProvider; + + // Determine conversation type from stored reference + const storedConversationType = ref.conversation?.conversationType?.toLowerCase() ?? ""; + let conversationType: MSTeamsConversationType; + if (storedConversationType === "personal") { + conversationType = "personal"; + } else if (storedConversationType === "channel") { + conversationType = "channel"; + } else { + // groupChat, or unknown defaults to groupChat behavior + conversationType = "groupChat"; + } + + // Get SharePoint site ID from config (required for file uploads in group chats/channels) + const sharePointSiteId = msteamsCfg.sharePointSiteId; + + // Resolve media max bytes from config + const mediaMaxBytes = resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, + }); + + return { + appId: creds.appId, + conversationId, + ref, + adapter: adapter as unknown as MSTeamsAdapter, + log, + conversationType, + tokenProvider, + sharePointSiteId, + mediaMaxBytes, + }; +} diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..43725ee15dce4a2a60fdb2bef034da6f315f8b60 --- /dev/null +++ b/extensions/msteams/src/send.ts @@ -0,0 +1,519 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import { loadWebMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk"; +import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js"; +import { + classifyMSTeamsSendError, + formatMSTeamsSendErrorHint, + formatUnknownError, +} from "./errors.js"; +import { prepareFileConsentActivity, requiresFileConsent } from "./file-consent-helpers.js"; +import { buildTeamsFileInfoCard } from "./graph-chat.js"; +import { + getDriveItemProperties, + uploadAndShareOneDrive, + uploadAndShareSharePoint, +} from "./graph-upload.js"; +import { extractFilename, extractMessageId } from "./media-helpers.js"; +import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; +import { buildMSTeamsPollCard } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; +import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; + +export type SendMSTeamsMessageParams = { + /** Full config (for credentials) */ + cfg: OpenClawConfig; + /** Conversation ID or user ID to send to */ + to: string; + /** Message text */ + text: string; + /** Optional media URL */ + mediaUrl?: string; +}; + +export type SendMSTeamsMessageResult = { + messageId: string; + conversationId: string; + /** If a FileConsentCard was sent instead of the file, this contains the upload ID */ + pendingUploadId?: string; +}; + +/** Threshold for large files that require FileConsentCard flow in personal chats */ +const FILE_CONSENT_THRESHOLD_BYTES = 4 * 1024 * 1024; // 4MB + +/** + * MSTeams-specific media size limit (100MB). + * Higher than the default because OneDrive upload handles large files well. + */ +const MSTEAMS_MAX_MEDIA_BYTES = 100 * 1024 * 1024; + +export type SendMSTeamsPollParams = { + /** Full config (for credentials) */ + cfg: OpenClawConfig; + /** Conversation ID or user ID to send to */ + to: string; + /** Poll question */ + question: string; + /** Poll options */ + options: string[]; + /** Max selections (defaults to 1) */ + maxSelections?: number; +}; + +export type SendMSTeamsPollResult = { + pollId: string; + messageId: string; + conversationId: string; +}; + +export type SendMSTeamsCardParams = { + /** Full config (for credentials) */ + cfg: OpenClawConfig; + /** Conversation ID or user ID to send to */ + to: string; + /** Adaptive Card JSON object */ + card: Record; +}; + +export type SendMSTeamsCardResult = { + messageId: string; + conversationId: string; +}; + +/** + * Send a message to a Teams conversation or user. + * + * Uses the stored ConversationReference from previous interactions. + * The bot must have received at least one message from the conversation + * before proactive messaging works. + * + * File handling by conversation type: + * - Personal (1:1) chats: small images (<4MB) use base64, large files and non-images use FileConsentCard + * - Group chats / channels: files are uploaded to OneDrive and shared via link + */ +export async function sendMessageMSTeams( + params: SendMSTeamsMessageParams, +): Promise { + const { cfg, to, text, mediaUrl } = params; + const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "msteams", + }); + const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(text ?? "", tableMode); + const ctx = await resolveMSTeamsSendContext({ cfg, to }); + const { + adapter, + appId, + conversationId, + ref, + log, + conversationType, + tokenProvider, + sharePointSiteId, + } = ctx; + + log.debug("sending proactive message", { + conversationId, + conversationType, + textLength: messageText.length, + hasMedia: Boolean(mediaUrl), + }); + + // Handle media if present + if (mediaUrl) { + const mediaMaxBytes = + resolveChannelMediaMaxBytes({ + cfg, + resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, + }) ?? MSTEAMS_MAX_MEDIA_BYTES; + const media = await loadWebMedia(mediaUrl, mediaMaxBytes); + const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES; + const isImage = media.contentType?.startsWith("image/") ?? false; + const fallbackFileName = await extractFilename(mediaUrl); + const fileName = media.fileName ?? fallbackFileName; + + log.debug("processing media", { + fileName, + contentType: media.contentType, + size: media.buffer.length, + isLargeFile, + isImage, + conversationType, + }); + + // Personal chats: base64 only works for images; use FileConsentCard for large files or non-images + if ( + requiresFileConsent({ + conversationType, + contentType: media.contentType, + bufferSize: media.buffer.length, + thresholdBytes: FILE_CONSENT_THRESHOLD_BYTES, + }) + ) { + const { activity, uploadId } = prepareFileConsentActivity({ + media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, + conversationId, + description: messageText || undefined, + }); + + log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); + + const baseRef = buildConversationReference(ref); + const proactiveRef = { ...baseRef, activityId: undefined }; + + let messageId = "unknown"; + try { + await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { + const response = await turnCtx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } + + log.info("sent file consent card", { conversationId, messageId, uploadId }); + + return { + messageId, + conversationId, + pendingUploadId: uploadId, + }; + } + + // Personal chat with small image: use base64 (only works for images) + if (conversationType === "personal") { + // Small image in personal chat: use base64 (only works for images) + const base64 = media.buffer.toString("base64"); + const finalMediaUrl = `data:${media.contentType};base64,${base64}`; + + return sendTextWithMedia(ctx, messageText, finalMediaUrl); + } + + if (isImage && !sharePointSiteId) { + // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) + const base64 = media.buffer.toString("base64"); + const finalMediaUrl = `data:${media.contentType};base64,${base64}`; + return sendTextWithMedia(ctx, messageText, finalMediaUrl); + } + + // Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive + try { + if (sharePointSiteId) { + // Use SharePoint upload + Graph API for native file card + log.debug("uploading to SharePoint for native file card", { + fileName, + conversationType, + siteId: sharePointSiteId, + }); + + const uploaded = await uploadAndShareSharePoint({ + buffer: media.buffer, + filename: fileName, + contentType: media.contentType, + tokenProvider, + siteId: sharePointSiteId, + chatId: conversationId, + usePerUserSharing: conversationType === "groupChat", + }); + + log.debug("SharePoint upload complete", { + itemId: uploaded.itemId, + shareUrl: uploaded.shareUrl, + }); + + // Get driveItem properties needed for native file card + const driveItem = await getDriveItemProperties({ + siteId: sharePointSiteId, + itemId: uploaded.itemId, + tokenProvider, + }); + + log.debug("driveItem properties retrieved", { + eTag: driveItem.eTag, + webDavUrl: driveItem.webDavUrl, + }); + + // Build native Teams file card attachment and send via Bot Framework + const fileCardAttachment = buildTeamsFileInfoCard(driveItem); + const activity = { + type: "message", + text: messageText || undefined, + attachments: [fileCardAttachment], + }; + + const baseRef = buildConversationReference(ref); + const proactiveRef = { ...baseRef, activityId: undefined }; + + let messageId = "unknown"; + await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { + const response = await turnCtx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + + log.info("sent native file card", { + conversationId, + messageId, + fileName: driveItem.name, + }); + + return { messageId, conversationId }; + } + + // Fallback: no SharePoint site configured, use OneDrive with markdown link + log.debug("uploading to OneDrive (no SharePoint site configured)", { + fileName, + conversationType, + }); + + const uploaded = await uploadAndShareOneDrive({ + buffer: media.buffer, + filename: fileName, + contentType: media.contentType, + tokenProvider, + }); + + log.debug("OneDrive upload complete", { + itemId: uploaded.itemId, + shareUrl: uploaded.shareUrl, + }); + + // Send message with file link (Bot Framework doesn't support "reference" attachment type for sending) + const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; + const activity = { + type: "message", + text: messageText ? `${messageText}\n\n${fileLink}` : fileLink, + }; + + const baseRef = buildConversationReference(ref); + const proactiveRef = { ...baseRef, activityId: undefined }; + + let messageId = "unknown"; + await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => { + const response = await turnCtx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + + log.info("sent message with OneDrive file link", { + conversationId, + messageId, + shareUrl: uploaded.shareUrl, + }); + + return { messageId, conversationId }; + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `msteams file send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } + } + + // No media: send text only + return sendTextWithMedia(ctx, messageText, undefined); +} + +/** + * Send a text message with optional base64 media URL. + */ +async function sendTextWithMedia( + ctx: MSTeamsProactiveContext, + text: string, + mediaUrl: string | undefined, +): Promise { + const { + adapter, + appId, + conversationId, + ref, + log, + tokenProvider, + sharePointSiteId, + mediaMaxBytes, + } = ctx; + + let messageIds: string[]; + try { + messageIds = await sendMSTeamsMessages({ + replyStyle: "top-level", + adapter, + appId, + conversationRef: ref, + messages: [{ text: text || undefined, mediaUrl }], + retry: {}, + onRetry: (event) => { + log.debug("retrying send", { conversationId, ...event }); + }, + tokenProvider, + sharePointSiteId, + mediaMaxBytes, + }); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `msteams send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } + + const messageId = messageIds[0] ?? "unknown"; + log.info("sent proactive message", { conversationId, messageId }); + + return { + messageId, + conversationId, + }; +} + +/** + * Send a poll (Adaptive Card) to a Teams conversation or user. + */ +export async function sendPollMSTeams( + params: SendMSTeamsPollParams, +): Promise { + const { cfg, to, question, options, maxSelections } = params; + const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({ + cfg, + to, + }); + + const pollCard = buildMSTeamsPollCard({ + question, + options, + maxSelections, + }); + + log.debug("sending poll", { + conversationId, + pollId: pollCard.pollId, + optionCount: pollCard.options.length, + }); + + const activity = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: pollCard.card, + }, + ], + }; + + // Send poll via proactive conversation (Adaptive Cards require direct activity send) + const baseRef = buildConversationReference(ref); + const proactiveRef = { + ...baseRef, + activityId: undefined, + }; + + let messageId = "unknown"; + try { + await adapter.continueConversation(appId, proactiveRef, async (ctx) => { + const response = await ctx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `msteams poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } + + log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); + + return { + pollId: pollCard.pollId, + messageId, + conversationId, + }; +} + +/** + * Send an arbitrary Adaptive Card to a Teams conversation or user. + */ +export async function sendAdaptiveCardMSTeams( + params: SendMSTeamsCardParams, +): Promise { + const { cfg, to, card } = params; + const { adapter, appId, conversationId, ref, log } = await resolveMSTeamsSendContext({ + cfg, + to, + }); + + log.debug("sending adaptive card", { + conversationId, + cardType: card.type, + cardVersion: card.version, + }); + + const activity = { + type: "message", + attachments: [ + { + contentType: "application/vnd.microsoft.card.adaptive", + content: card, + }, + ], + }; + + // Send card via proactive conversation + const baseRef = buildConversationReference(ref); + const proactiveRef = { + ...baseRef, + activityId: undefined, + }; + + let messageId = "unknown"; + try { + await adapter.continueConversation(appId, proactiveRef, async (ctx) => { + const response = await ctx.sendActivity(activity); + messageId = extractMessageId(response) ?? "unknown"; + }); + } catch (err) { + const classification = classifyMSTeamsSendError(err); + const hint = formatMSTeamsSendErrorHint(classification); + const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : ""; + throw new Error( + `msteams card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`, + { cause: err }, + ); + } + + log.info("sent adaptive card", { conversationId, messageId }); + + return { + messageId, + conversationId, + }; +} + +/** + * List all known conversation references (for debugging/CLI). + */ +export async function listMSTeamsConversations(): Promise< + Array<{ + conversationId: string; + userName?: string; + conversationType?: string; + }> +> { + const store = createMSTeamsConversationStoreFs(); + const all = await store.list(); + return all.map(({ conversationId, reference }) => ({ + conversationId, + userName: reference.user?.name, + conversationType: reference.conversation?.conversationType, + })); +} diff --git a/extensions/msteams/src/sent-message-cache.test.ts b/extensions/msteams/src/sent-message-cache.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6892c0e1762df99ec38325102a5c3e46fea13925 --- /dev/null +++ b/extensions/msteams/src/sent-message-cache.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { + clearMSTeamsSentMessageCache, + recordMSTeamsSentMessage, + wasMSTeamsMessageSent, +} from "./sent-message-cache.js"; + +describe("msteams sent message cache", () => { + it("records and resolves sent message ids", () => { + clearMSTeamsSentMessageCache(); + recordMSTeamsSentMessage("conv-1", "msg-1"); + expect(wasMSTeamsMessageSent("conv-1", "msg-1")).toBe(true); + expect(wasMSTeamsMessageSent("conv-1", "msg-2")).toBe(false); + }); +}); diff --git a/extensions/msteams/src/sent-message-cache.ts b/extensions/msteams/src/sent-message-cache.ts new file mode 100644 index 0000000000000000000000000000000000000000..1085d096bcc10711db5124010b59c653e7d22c92 --- /dev/null +++ b/extensions/msteams/src/sent-message-cache.ts @@ -0,0 +1,47 @@ +const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours + +type CacheEntry = { + messageIds: Set; + timestamps: Map; +}; + +const sentMessages = new Map(); + +function cleanupExpired(entry: CacheEntry): void { + const now = Date.now(); + for (const [msgId, timestamp] of entry.timestamps) { + if (now - timestamp > TTL_MS) { + entry.messageIds.delete(msgId); + entry.timestamps.delete(msgId); + } + } +} + +export function recordMSTeamsSentMessage(conversationId: string, messageId: string): void { + if (!conversationId || !messageId) { + return; + } + let entry = sentMessages.get(conversationId); + if (!entry) { + entry = { messageIds: new Set(), timestamps: new Map() }; + sentMessages.set(conversationId, entry); + } + entry.messageIds.add(messageId); + entry.timestamps.set(messageId, Date.now()); + if (entry.messageIds.size > 200) { + cleanupExpired(entry); + } +} + +export function wasMSTeamsMessageSent(conversationId: string, messageId: string): boolean { + const entry = sentMessages.get(conversationId); + if (!entry) { + return false; + } + cleanupExpired(entry); + return entry.messageIds.has(messageId); +} + +export function clearMSTeamsSentMessageCache(): void { + sentMessages.clear(); +} diff --git a/extensions/msteams/src/storage.ts b/extensions/msteams/src/storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ae04de0f69c73517bcaba50980331f78660e60c --- /dev/null +++ b/extensions/msteams/src/storage.ts @@ -0,0 +1,25 @@ +import path from "node:path"; +import { getMSTeamsRuntime } from "./runtime.js"; + +export type MSTeamsStorePathOptions = { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + stateDir?: string; + storePath?: string; + filename: string; +}; + +export function resolveMSTeamsStorePath(params: MSTeamsStorePathOptions): string { + if (params.storePath) { + return params.storePath; + } + if (params.stateDir) { + return path.join(params.stateDir, params.filename); + } + + const env = params.env ?? process.env; + const stateDir = params.homedir + ? getMSTeamsRuntime().state.resolveStateDir(env, params.homedir) + : getMSTeamsRuntime().state.resolveStateDir(env); + return path.join(stateDir, params.filename); +} diff --git a/extensions/msteams/src/store-fs.ts b/extensions/msteams/src/store-fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdeb4c663cbf93fa1ffc7027389e1f1e5c935208 --- /dev/null +++ b/extensions/msteams/src/store-fs.ts @@ -0,0 +1,83 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import lockfile from "proper-lockfile"; + +const STORE_LOCK_OPTIONS = { + retries: { + retries: 10, + factor: 2, + minTimeout: 100, + maxTimeout: 10_000, + randomize: true, + }, + stale: 30_000, +} as const; + +function safeParseJson(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export async function readJsonFile( + filePath: string, + fallback: T, +): Promise<{ value: T; exists: boolean }> { + try { + const raw = await fs.promises.readFile(filePath, "utf-8"); + const parsed = safeParseJson(raw); + if (parsed == null) { + return { value: fallback, exists: true }; + } + return { value: parsed, exists: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { value: fallback, exists: false }; + } + return { value: fallback, exists: false }; + } +} + +export async function writeJsonFile(filePath: string, value: unknown): Promise { + const dir = path.dirname(filePath); + await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.promises.chmod(tmp, 0o600); + await fs.promises.rename(tmp, filePath); +} + +async function ensureJsonFile(filePath: string, fallback: unknown) { + try { + await fs.promises.access(filePath); + } catch { + await writeJsonFile(filePath, fallback); + } +} + +export async function withFileLock( + filePath: string, + fallback: unknown, + fn: () => Promise, +): Promise { + await ensureJsonFile(filePath, fallback); + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(filePath, STORE_LOCK_OPTIONS); + return await fn(); + } finally { + if (release) { + try { + await release(); + } catch { + // ignore unlock errors + } + } + } +} diff --git a/extensions/msteams/src/token.ts b/extensions/msteams/src/token.ts new file mode 100644 index 0000000000000000000000000000000000000000..24c6a092d482baf83b901ba9f654d3ed20270289 --- /dev/null +++ b/extensions/msteams/src/token.ts @@ -0,0 +1,19 @@ +import type { MSTeamsConfig } from "openclaw/plugin-sdk"; + +export type MSTeamsCredentials = { + appId: string; + appPassword: string; + tenantId: string; +}; + +export function resolveMSTeamsCredentials(cfg?: MSTeamsConfig): MSTeamsCredentials | undefined { + const appId = cfg?.appId?.trim() || process.env.MSTEAMS_APP_ID?.trim(); + const appPassword = cfg?.appPassword?.trim() || process.env.MSTEAMS_APP_PASSWORD?.trim(); + const tenantId = cfg?.tenantId?.trim() || process.env.MSTEAMS_TENANT_ID?.trim(); + + if (!appId || !appPassword || !tenantId) { + return undefined; + } + + return { appId, appPassword, tenantId }; +} diff --git a/extensions/nextcloud-talk/index.ts b/extensions/nextcloud-talk/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dc9c2d646c04206d5e7c99ace2719b4acbdc34f --- /dev/null +++ b/extensions/nextcloud-talk/index.ts @@ -0,0 +1,17 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { nextcloudTalkPlugin } from "./src/channel.js"; +import { setNextcloudTalkRuntime } from "./src/runtime.js"; + +const plugin = { + id: "nextcloud-talk", + name: "Nextcloud Talk", + description: "Nextcloud Talk channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setNextcloudTalkRuntime(api.runtime); + api.registerChannel({ plugin: nextcloudTalkPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/nextcloud-talk/openclaw.plugin.json b/extensions/nextcloud-talk/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..67307378860d323301516210b901da6f8be4c656 --- /dev/null +++ b/extensions/nextcloud-talk/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "nextcloud-talk", + "channels": ["nextcloud-talk"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json new file mode 100644 index 0000000000000000000000000000000000000000..9a6c49b5b043fa564efb158122eb0dea908f3e81 --- /dev/null +++ b/extensions/nextcloud-talk/package.json @@ -0,0 +1,33 @@ +{ + "name": "@openclaw/nextcloud-talk", + "version": "2026.1.30", + "description": "OpenClaw Nextcloud Talk channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "nextcloud-talk", + "label": "Nextcloud Talk", + "selectionLabel": "Nextcloud Talk (self-hosted)", + "docsPath": "/channels/nextcloud-talk", + "docsLabel": "nextcloud-talk", + "blurb": "Self-hosted chat via Nextcloud Talk webhook bots.", + "aliases": [ + "nc-talk", + "nc" + ], + "order": 65, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/nextcloud-talk", + "localPath": "extensions/nextcloud-talk", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts new file mode 100644 index 0000000000000000000000000000000000000000..c286994463328da4f0adfd689495a80fb328bf0e --- /dev/null +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; + +const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); + +function isTruthyEnvValue(value?: string): boolean { + if (!value) { + return false; + } + return TRUTHY_ENV.has(value.trim().toLowerCase()); +} + +const debugAccounts = (...args: unknown[]) => { + if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) { + console.warn("[nextcloud-talk:accounts]", ...args); + } +}; + +export type ResolvedNextcloudTalkAccount = { + accountId: string; + enabled: boolean; + name?: string; + baseUrl: string; + secret: string; + secretSource: "env" | "secretFile" | "config" | "none"; + config: NextcloudTalkAccountConfig; +}; + +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.["nextcloud-talk"]?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) { + continue; + } + ids.add(normalizeAccountId(key)); + } + return [...ids]; +} + +export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + debugAccounts("listNextcloudTalkAccountIds", ids); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string { + const ids = listNextcloudTalkAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): NextcloudTalkAccountConfig | undefined { + const accounts = cfg.channels?.["nextcloud-talk"]?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined; + if (direct) { + return direct; + } + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined; +} + +function mergeNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, +): NextcloudTalkAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ?? + {}) as NextcloudTalkAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveNextcloudTalkSecret( + cfg: CoreConfig, + opts: { accountId?: string }, +): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } { + const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID); + + const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim(); + if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) { + return { secret: envSecret, source: "env" }; + } + + if (merged.botSecretFile) { + try { + const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim(); + if (fileSecret) { + return { secret: fileSecret, source: "secretFile" }; + } + } catch { + // File not found or unreadable, fall through. + } + } + + if (merged.botSecret?.trim()) { + return { secret: merged.botSecret.trim(), source: "config" }; + } + + return { secret: "", source: "none" }; +} + +export function resolveNextcloudTalkAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedNextcloudTalkAccount { + const hasExplicitAccountId = Boolean(params.accountId?.trim()); + const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId }); + const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? ""; + + debugAccounts("resolve", { + accountId, + enabled, + secretSource: secretResolution.source, + baseUrl: baseUrl ? "[set]" : "[missing]", + }); + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + baseUrl, + secret: secretResolution.secret, + secretSource: secretResolution.source, + config: merged, + } satisfies ResolvedNextcloudTalkAccount; + }; + + const normalized = normalizeAccountId(params.accountId); + const primary = resolve(normalized); + if (hasExplicitAccountId) { + return primary; + } + if (primary.secretSource !== "none") { + return primary; + } + + const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg); + if (fallbackId === primary.accountId) { + return primary; + } + const fallback = resolve(fallbackId); + if (fallback.secretSource === "none") { + return primary; + } + return fallback; +} + +export function listEnabledNextcloudTalkAccounts(cfg: CoreConfig): ResolvedNextcloudTalkAccount[] { + return listNextcloudTalkAccountIds(cfg) + .map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..3355ec116f9d5131b001f9dddd32cc3462b8b112 --- /dev/null +++ b/extensions/nextcloud-talk/src/channel.ts @@ -0,0 +1,409 @@ +import { + applyAccountNameToChannelSection, + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + normalizeAccountId, + setAccountEnabledInConfigSection, + type ChannelPlugin, + type OpenClawConfig, + type ChannelSetupInput, +} from "openclaw/plugin-sdk"; +import type { CoreConfig } from "./types.js"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, + type ResolvedNextcloudTalkAccount, +} from "./accounts.js"; +import { NextcloudTalkConfigSchema } from "./config-schema.js"; +import { monitorNextcloudTalkProvider } from "./monitor.js"; +import { + looksLikeNextcloudTalkTargetId, + normalizeNextcloudTalkMessagingTarget, +} from "./normalize.js"; +import { nextcloudTalkOnboardingAdapter } from "./onboarding.js"; +import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { sendMessageNextcloudTalk } from "./send.js"; + +const meta = { + id: "nextcloud-talk", + label: "Nextcloud Talk", + selectionLabel: "Nextcloud Talk (self-hosted)", + docsPath: "/channels/nextcloud-talk", + docsLabel: "nextcloud-talk", + blurb: "Self-hosted chat via Nextcloud Talk webhook bots.", + aliases: ["nc-talk", "nc"], + order: 65, + quickstartAllowFrom: true, +}; + +type NextcloudSetupInput = ChannelSetupInput & { + baseUrl?: string; + secret?: string; + secretFile?: string; + useEnv?: boolean; +}; + +export const nextcloudTalkPlugin: ChannelPlugin = { + id: "nextcloud-talk", + meta, + onboarding: nextcloudTalkOnboardingAdapter, + pairing: { + idLabel: "nextcloudUserId", + normalizeAllowEntry: (entry) => + entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), + notifyApproval: async ({ id }) => { + console.log(`[nextcloud-talk] User ${id} approved for pairing`); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + reactions: true, + threads: false, + media: true, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.nextcloud-talk"] }, + configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema), + config: { + listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg, + sectionKey: "nextcloud-talk", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg, + sectionKey: "nextcloud-talk", + accountId, + clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], + }), + isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()), + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()), + secretSource: account.secretSource, + baseUrl: account.baseUrl ? "[set]" : "[missing]", + }), + resolveAllowFrom: ({ cfg, accountId }) => + ( + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? [] + ).map((entry) => String(entry).toLowerCase()), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean( + cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId], + ); + const basePath = useAccountPath + ? `channels.nextcloud-talk.accounts.${resolvedAccountId}.` + : "channels.nextcloud-talk."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("nextcloud-talk"), + normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") { + return []; + } + const roomAllowlistConfigured = + account.config.rooms && Object.keys(account.config.rooms).length > 0; + if (roomAllowlistConfigured) { + return [ + `- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`, + ]; + } + return [ + `- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`, + ]; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + const rooms = account.config.rooms; + if (!rooms || !groupId) { + return true; + } + + const roomConfig = rooms[groupId]; + if (roomConfig?.requireMention !== undefined) { + return roomConfig.requireMention; + } + + const wildcardConfig = rooms["*"]; + if (wildcardConfig?.requireMention !== undefined) { + return wildcardConfig.requireMention; + } + + return true; + }, + resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy, + }, + messaging: { + normalizeTarget: normalizeNextcloudTalkMessagingTarget, + targetResolver: { + looksLikeId: looksLikeNextcloudTalkTargetId, + hint: "", + }, + }, + setup: { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "nextcloud-talk", + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!setupInput.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg, + channelKey: "nextcloud-talk", + accountId, + name: setupInput.name, + }); + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + "nextcloud-talk": { + ...namedConfig.channels?.["nextcloud-talk"], + enabled: true, + baseUrl: setupInput.baseUrl, + ...(setupInput.useEnv + ? {} + : setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }, + }, + } as OpenClawConfig; + } + return { + ...namedConfig, + channels: { + ...namedConfig.channels, + "nextcloud-talk": { + ...namedConfig.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...namedConfig.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: true, + baseUrl: setupInput.baseUrl, + ...(setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }, + }, + }, + }, + } as OpenClawConfig; + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + sendText: async ({ to, text, accountId, replyToId }) => { + const result = await sendMessageNextcloudTalk(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }); + return { channel: "nextcloud-talk", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; + const result = await sendMessageNextcloudTalk(to, messageWithMedia, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }); + return { channel: "nextcloud-talk", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + secretSource: snapshot.secretSource ?? "none", + running: snapshot.running ?? false, + mode: "webhook", + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => { + const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim()); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + secretSource: account.secretSource, + baseUrl: account.baseUrl ? "[set]" : "[missing]", + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + mode: "webhook", + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }; + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + if (!account.secret || !account.baseUrl) { + throw new Error( + `Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`, + ); + } + + ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`); + + const { stop } = await monitorNextcloudTalkProvider({ + accountId: account.accountId, + config: ctx.cfg as CoreConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + }); + + return { stop }; + }, + logoutAccount: async ({ accountId, cfg }) => { + const nextCfg = { ...cfg } as OpenClawConfig; + const nextSection = cfg.channels?.["nextcloud-talk"] + ? { ...cfg.channels["nextcloud-talk"] } + : undefined; + let cleared = false; + let changed = false; + + if (nextSection) { + if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) { + delete nextSection.botSecret; + cleared = true; + changed = true; + } + const accounts = + nextSection.accounts && typeof nextSection.accounts === "object" + ? { ...nextSection.accounts } + : undefined; + if (accounts && accountId in accounts) { + const entry = accounts[accountId]; + if (entry && typeof entry === "object") { + const nextEntry = { ...entry } as Record; + if ("botSecret" in nextEntry) { + const secret = nextEntry.botSecret; + if (typeof secret === "string" ? secret.trim() : secret) { + cleared = true; + } + delete nextEntry.botSecret; + changed = true; + } + if (Object.keys(nextEntry).length === 0) { + delete accounts[accountId]; + changed = true; + } else { + accounts[accountId] = nextEntry as typeof entry; + } + } + } + if (accounts) { + if (Object.keys(accounts).length === 0) { + delete nextSection.accounts; + changed = true; + } else { + nextSection.accounts = accounts; + } + } + } + + if (changed) { + if (nextSection && Object.keys(nextSection).length > 0) { + nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection }; + } else { + const nextChannels = { ...nextCfg.channels } as Record; + delete nextChannels["nextcloud-talk"]; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels as OpenClawConfig["channels"]; + } else { + delete nextCfg.channels; + } + } + } + + const resolved = resolveNextcloudTalkAccount({ + cfg: changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig), + accountId, + }); + const loggedOut = resolved.secretSource === "none"; + + if (changed) { + await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg); + } + + return { + cleared, + envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()), + loggedOut, + }; + }, + }, +}; diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..95d8142db15fbd005e6ea720bdffc41e556b7692 --- /dev/null +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -0,0 +1,78 @@ +import { + BlockStreamingCoalesceSchema, + DmConfigSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + ToolPolicySchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk"; +import { z } from "zod"; + +export const NextcloudTalkRoomSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const NextcloudTalkAccountSchemaBase = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, + baseUrl: z.string().optional(), + botSecret: z.string().optional(), + botSecretFile: z.string().optional(), + apiUser: z.string().optional(), + apiPassword: z.string().optional(), + apiPasswordFile: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + webhookPort: z.number().int().positive().optional(), + webhookHost: z.string().optional(), + webhookPath: z.string().optional(), + webhookPublicUrl: z.string().optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + }) + .strict(); + +export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + }); + }, +); + +export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({ + accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + }); +}); diff --git a/extensions/nextcloud-talk/src/format.ts b/extensions/nextcloud-talk/src/format.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ea7fc1de6addb7db4a08927cf7485b989b06dde --- /dev/null +++ b/extensions/nextcloud-talk/src/format.ts @@ -0,0 +1,79 @@ +/** + * Format utilities for Nextcloud Talk messages. + * + * Nextcloud Talk supports markdown natively, so most formatting passes through. + * This module handles any edge cases or transformations needed. + */ + +/** + * Convert markdown to Nextcloud Talk compatible format. + * Nextcloud Talk supports standard markdown, so minimal transformation needed. + */ +export function markdownToNextcloudTalk(text: string): string { + return text.trim(); +} + +/** + * Escape special characters in text to prevent markdown interpretation. + */ +export function escapeNextcloudTalkMarkdown(text: string): string { + return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1"); +} + +/** + * Format a mention for a Nextcloud user. + * Nextcloud Talk uses @user format for mentions. + */ +export function formatNextcloudTalkMention(userId: string): string { + return `@${userId.replace(/^@/, "")}`; +} + +/** + * Format a code block for Nextcloud Talk. + */ +export function formatNextcloudTalkCodeBlock(code: string, language?: string): string { + const lang = language ?? ""; + return `\`\`\`${lang}\n${code}\n\`\`\``; +} + +/** + * Format inline code for Nextcloud Talk. + */ +export function formatNextcloudTalkInlineCode(code: string): string { + if (code.includes("`")) { + return `\`\` ${code} \`\``; + } + return `\`${code}\``; +} + +/** + * Strip Nextcloud Talk specific formatting from text. + * Useful for extracting plain text content. + */ +export function stripNextcloudTalkFormatting(text: string): string { + return text + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Truncate text to a maximum length, preserving word boundaries. + */ +export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string { + if (text.length <= maxLength) { + return text; + } + const truncated = text.slice(0, maxLength - suffix.length); + const lastSpace = truncated.lastIndexOf(" "); + if (lastSpace > maxLength * 0.7) { + return truncated.slice(0, lastSpace) + suffix; + } + return truncated + suffix; +} diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7fe45b9f431fdd31281c917046edb0e8388d639 --- /dev/null +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -0,0 +1,320 @@ +import { + logInboundDrop, + resolveControlCommandGate, + type OpenClawConfig, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; +import { + normalizeNextcloudTalkAllowlist, + resolveNextcloudTalkAllowlistMatch, + resolveNextcloudTalkGroupAllow, + resolveNextcloudTalkMentionGate, + resolveNextcloudTalkRequireMention, + resolveNextcloudTalkRoomMatch, +} from "./policy.js"; +import { resolveNextcloudTalkRoomKind } from "./room-info.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { sendMessageNextcloudTalk } from "./send.js"; + +const CHANNEL_ID = "nextcloud-talk" as const; + +async function deliverNextcloudTalkReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + roomToken: string; + accountId: string; + statusSink?: (patch: { lastOutboundAt?: number }) => void; +}): Promise { + const { payload, roomToken, accountId, statusSink } = params; + const text = payload.text ?? ""; + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + if (!text.trim() && mediaList.length === 0) { + return; + } + + const mediaBlock = mediaList.length + ? mediaList.map((url) => `Attachment: ${url}`).join("\n") + : ""; + const combined = text.trim() + ? mediaBlock + ? `${text.trim()}\n\n${mediaBlock}` + : text.trim() + : mediaBlock; + + await sendMessageNextcloudTalk(roomToken, combined, { + accountId, + replyTo: payload.replyToId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); +} + +export async function handleNextcloudTalkInbound(params: { + message: NextcloudTalkInboundMessage; + account: ResolvedNextcloudTalkAccount; + config: CoreConfig; + runtime: RuntimeEnv; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { message, account, config, runtime, statusSink } = params; + const core = getNextcloudTalkRuntime(); + + const rawBody = message.text?.trim() ?? ""; + if (!rawBody) { + return; + } + + const roomKind = await resolveNextcloudTalkRoomKind({ + account, + roomToken: message.roomToken, + runtime, + }); + const isGroup = roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat; + const senderId = message.senderId; + const senderName = message.senderName; + const roomToken = message.roomToken; + const roomName = message.roomName; + + statusSink?.({ lastInboundAt: message.timestamp }); + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + + const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); + const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); + const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []); + const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom); + + const roomMatch = resolveNextcloudTalkRoomMatch({ + rooms: account.config.rooms, + roomToken, + roomName, + }); + const roomConfig = roomMatch.roomConfig; + if (isGroup && !roomMatch.allowed) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`); + return; + } + if (roomConfig?.enabled === false) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`); + return; + } + + const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); + const baseGroupAllowFrom = + configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; + + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); + const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean); + + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config as OpenClawConfig, + surface: CHANNEL_ID, + }); + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ + allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, + senderId, + senderName, + }).allowed; + const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { + configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, + allowed: senderAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); + const commandAuthorized = commandGate.commandAuthorized; + + if (isGroup) { + const groupAllow = resolveNextcloudTalkGroupAllow({ + groupPolicy, + outerAllowFrom: effectiveGroupAllowFrom, + innerAllowFrom: roomAllowFrom, + senderId, + senderName, + }); + if (!groupAllow.allowed) { + runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`); + return; + } + } else { + if (dmPolicy === "disabled") { + runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); + return; + } + if (dmPolicy !== "open") { + const dmAllowed = resolveNextcloudTalkAllowlistMatch({ + allowFrom: effectiveAllowFrom, + senderId, + senderName, + }).allowed; + if (!dmAllowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + try { + await sendMessageNextcloudTalk( + roomToken, + core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.( + `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } + } + runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`); + return; + } + } + } + + if (isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: (message) => runtime.log?.(message), + channel: CHANNEL_ID, + reason: "control command (unauthorized)", + target: senderId, + }); + return; + } + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); + const wasMentioned = mentionRegexes.length + ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) + : false; + const shouldRequireMention = isGroup + ? resolveNextcloudTalkRequireMention({ + roomConfig, + wildcardConfig: roomMatch.wildcardConfig, + }) + : false; + const mentionGate = resolveNextcloudTalkMentionGate({ + isGroup, + requireMention: shouldRequireMention, + wasMentioned, + allowTextCommands, + hasControlCommand, + commandAuthorized, + }); + if (isGroup && mentionGate.shouldSkip) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`); + return; + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? roomToken : senderId, + }, + }); + + const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Nextcloud Talk", + from: fromLabel, + timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`, + To: `nextcloud-talk:${roomToken}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderName: senderName || undefined, + SenderId: senderId, + GroupSubject: isGroup ? roomName || roomToken : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + Provider: CHANNEL_ID, + Surface: CHANNEL_ID, + WasMentioned: isGroup ? wasMentioned : undefined, + MessageSid: message.messageId, + Timestamp: message.timestamp, + OriginatingChannel: CHANNEL_ID, + OriginatingTo: `nextcloud-talk:${roomToken}`, + CommandAuthorized: commandAuthorized, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); + }, + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config as OpenClawConfig, + dispatcherOptions: { + deliver: async (payload) => { + await deliverNextcloudTalkReply({ + payload: payload as { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + replyToId?: string; + }, + roomToken, + accountId: account.accountId, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyOptions: { + skillFilter: roomConfig?.skills, + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, + }); +} diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts new file mode 100644 index 0000000000000000000000000000000000000000..0981fa4cf4a290d7f07fddb8b16ff94864cf26a4 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -0,0 +1,246 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { + CoreConfig, + NextcloudTalkInboundMessage, + NextcloudTalkWebhookPayload, + NextcloudTalkWebhookServerOptions, +} from "./types.js"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; + +const DEFAULT_WEBHOOK_PORT = 8788; +const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; +const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; +const HEALTH_PATH = "/healthz"; + +function formatError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return typeof err === "string" ? err : JSON.stringify(err); +} + +function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { + try { + const data = JSON.parse(body); + if ( + !data.type || + !data.actor?.type || + !data.actor?.id || + !data.object?.type || + !data.object?.id || + !data.target?.type || + !data.target?.id + ) { + return null; + } + return data as NextcloudTalkWebhookPayload; + } catch { + return null; + } +} + +function payloadToInboundMessage( + payload: NextcloudTalkWebhookPayload, +): NextcloudTalkInboundMessage { + // Payload doesn't indicate DM vs room; mark as group and let inbound handler refine. + const isGroupChat = true; + + return { + messageId: String(payload.object.id), + roomToken: payload.target.id, + roomName: payload.target.name, + senderId: payload.actor.id, + senderName: payload.actor.name, + text: payload.object.content || payload.object.name || "", + mediaType: payload.object.mediaType || "text/plain", + timestamp: Date.now(), + isGroupChat, + }; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): { + server: Server; + start: () => Promise; + stop: () => void; +} { + const { port, host, path, secret, onMessage, onError, abortSignal } = opts; + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.url === HEALTH_PATH) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + + if (req.url !== path || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + + try { + const body = await readBody(req); + + const headers = extractNextcloudTalkHeaders( + req.headers as Record, + ); + if (!headers) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing signature headers" })); + return; + } + + const isValid = verifyNextcloudTalkSignature({ + signature: headers.signature, + random: headers.random, + body, + secret, + }); + + if (!isValid) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid signature" })); + return; + } + + const payload = parseWebhookPayload(body); + if (!payload) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid payload format" })); + return; + } + + if (payload.type !== "Create") { + res.writeHead(200); + res.end(); + return; + } + + const message = payloadToInboundMessage(payload); + + res.writeHead(200); + res.end(); + + try { + await onMessage(message); + } catch (err) { + onError?.(err instanceof Error ? err : new Error(formatError(err))); + } + } catch (err) { + const error = err instanceof Error ? err : new Error(formatError(err)); + onError?.(error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } + }); + + const start = (): Promise => { + return new Promise((resolve) => { + server.listen(port, host, () => resolve()); + }); + }; + + const stop = () => { + server.close(); + }; + + if (abortSignal) { + abortSignal.addEventListener("abort", stop, { once: true }); + } + + return { server, start, stop }; +} + +export type NextcloudTalkMonitorOptions = { + accountId?: string; + config?: CoreConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +export async function monitorNextcloudTalkProvider( + opts: NextcloudTalkMonitorOptions, +): Promise<{ stop: () => void }> { + const core = getNextcloudTalkRuntime(); + const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (message: string) => core.logging.getChildLogger().info(message), + error: (message: string) => core.logging.getChildLogger().error(message), + exit: () => { + throw new Error("Runtime exit not available"); + }, + }; + + if (!account.secret) { + throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); + } + + const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT; + const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST; + const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH; + + const logger = core.logging.getChildLogger({ + channel: "nextcloud-talk", + accountId: account.accountId, + }); + + const { start, stop } = createNextcloudTalkWebhookServer({ + port, + host, + path, + secret: account.secret, + onMessage: async (message) => { + core.channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "inbound", + at: message.timestamp, + }); + if (opts.onMessage) { + await opts.onMessage(message); + return; + } + await handleNextcloudTalkInbound({ + message, + account, + config: cfg, + runtime, + statusSink: opts.statusSink, + }); + }, + onError: (error) => { + logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`); + }, + abortSignal: opts.abortSignal, + }); + + await start(); + + const publicUrl = + account.config.webhookPublicUrl ?? + `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; + logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`); + + return { stop }; +} diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts new file mode 100644 index 0000000000000000000000000000000000000000..6854d603fc0a9d2a484578ff5beeec0b2c371241 --- /dev/null +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -0,0 +1,39 @@ +export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + + let normalized = trimmed; + + if (normalized.startsWith("nextcloud-talk:")) { + normalized = normalized.slice("nextcloud-talk:".length).trim(); + } else if (normalized.startsWith("nc-talk:")) { + normalized = normalized.slice("nc-talk:".length).trim(); + } else if (normalized.startsWith("nc:")) { + normalized = normalized.slice("nc:".length).trim(); + } + + if (normalized.startsWith("room:")) { + normalized = normalized.slice("room:".length).trim(); + } + + if (!normalized) { + return undefined; + } + + return `nextcloud-talk:${normalized}`.toLowerCase(); +} + +export function looksLikeNextcloudTalkTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + + if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) { + return true; + } + + return /^[a-z0-9]{8,}$/i.test(trimmed); +} diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts new file mode 100644 index 0000000000000000000000000000000000000000..ecfebaa7dd738a9ed85550c8fed7ec7d4952f140 --- /dev/null +++ b/extensions/nextcloud-talk/src/onboarding.ts @@ -0,0 +1,343 @@ +import { + addWildcardAllowFrom, + formatDocsLink, + promptAccountId, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, DmPolicy } from "./types.js"; +import { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; + +const channel = "nextcloud-talk" as const; + +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + const existingConfig = cfg.channels?.["nextcloud-talk"]; + const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x)); + const allowFrom: string[] = + dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom; + + const newNextcloudTalkConfig = { + ...existingConfig, + dmPolicy, + allowFrom, + }; + + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": newNextcloudTalkConfig, + }, + } as CoreConfig; +} + +async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) SSH into your Nextcloud server", + '2) Run: ./occ talk:bot:install "OpenClaw" "" "" --feature reaction', + "3) Copy the shared secret you used in the command", + "4) Enable the bot in your Nextcloud Talk room settings", + "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk bot setup", + ); +} + +async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const { cfg, prompter, accountId } = params; + const resolved = resolveNextcloudTalkAccount({ cfg, accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await noteNextcloudTalkUserIdHelp(prompter); + + const parseInput = (value: string) => + value + .split(/[\n,;]+/g) + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = parseInput(String(entry)); + if (resolvedIds.length === 0) { + await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist"); + } + } + + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean), + ...resolvedIds, + ]; + const unique = [...new Set(merged)]; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...cfg.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }, + }, + }; +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = + params.accountId && normalizeAccountId(params.accountId) + ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) + : resolveDefaultNextcloudTalkAccountId(params.cfg); + return promptNextcloudTalkAllowFrom({ + cfg: params.cfg, + prompter: params.prompter, + accountId, + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { + channel, + getStatus: async ({ cfg }) => { + const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return Boolean(account.secret && account.baseUrl); + }); + return { + channel, + configured, + statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`], + selectionHint: configured ? "configured" : "self-hosted chat", + quickstartScore: configured ? 1 : 5, + }; + }, + configure: async ({ + cfg, + prompter, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim(); + const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig); + let accountId = nextcloudTalkOverride + ? normalizeAccountId(nextcloudTalkOverride) + : defaultAccountId; + + if (shouldPromptAccountIds && !nextcloudTalkOverride) { + accountId = await promptAccountId({ + cfg: cfg as CoreConfig, + prompter, + label: "Nextcloud Talk", + currentId: accountId, + listAccountIds: listNextcloudTalkAccountIds, + defaultAccountId, + }); + } + + let next = cfg as CoreConfig; + const resolvedAccount = resolveNextcloudTalkAccount({ + cfg: next, + accountId, + }); + const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl); + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; + const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()); + const hasConfigSecret = Boolean( + resolvedAccount.config.botSecret || resolvedAccount.config.botSecretFile, + ); + + let baseUrl = resolvedAccount.baseUrl; + if (!baseUrl) { + baseUrl = String( + await prompter.text({ + message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)", + validate: (value) => { + const v = String(value ?? "").trim(); + if (!v) { + return "Required"; + } + if (!v.startsWith("http://") && !v.startsWith("https://")) { + return "URL must start with http:// or https://"; + } + return undefined; + }, + }), + ).trim(); + } + + let secret: string | null = null; + if (!accountConfigured) { + await noteNextcloudTalkSecretHelp(prompter); + } + + if (canUseEnv && !resolvedAccount.config.botSecret) { + const keepEnv = await prompter.confirm({ + message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + baseUrl, + }, + }, + }; + } else { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigSecret) { + const keep = await prompter.confirm({ + message: "Nextcloud Talk secret already configured. Keep it?", + initialValue: true, + }); + if (!keep) { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (secret || baseUrl !== resolvedAccount.baseUrl) { + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + baseUrl, + ...(secret ? { botSecret: secret } : {}), + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...next.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: + next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, + baseUrl, + ...(secret ? { botSecret: secret } : {}), + }, + }, + }, + }, + }; + } + } + + if (forceAllowFrom) { + next = await promptNextcloudTalkAllowFrom({ + cfg: next, + prompter, + accountId, + }); + } + + return { cfg: next, accountId }; + }, + dmPolicy, + disable: (cfg) => ({ + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false }, + }, + }), +}; diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d9b8cffdc7a717ff32c4e1a74846d9dc512a6de --- /dev/null +++ b/extensions/nextcloud-talk/src/policy.ts @@ -0,0 +1,188 @@ +import type { + AllowlistMatch, + ChannelGroupContext, + GroupPolicy, + GroupToolPolicyConfig, +} from "openclaw/plugin-sdk"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatchWithFallback, + resolveMentionGatingWithBypass, + resolveNestedAllowlistDecision, +} from "openclaw/plugin-sdk"; +import type { NextcloudTalkRoomConfig } from "./types.js"; + +function normalizeAllowEntry(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/^(nextcloud-talk|nc-talk|nc):/i, ""); +} + +export function normalizeNextcloudTalkAllowlist( + values: Array | undefined, +): string[] { + return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean); +} + +export function resolveNextcloudTalkAllowlistMatch(params: { + allowFrom: Array | undefined; + senderId: string; + senderName?: string | null; +}): AllowlistMatch<"wildcard" | "id" | "name"> { + const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom); + if (allowFrom.length === 0) { + return { allowed: false }; + } + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const senderId = normalizeAllowEntry(params.senderId); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + return { allowed: false }; +} + +export type NextcloudTalkRoomMatch = { + roomConfig?: NextcloudTalkRoomConfig; + wildcardConfig?: NextcloudTalkRoomConfig; + roomKey?: string; + matchSource?: "direct" | "parent" | "wildcard"; + allowed: boolean; + allowlistConfigured: boolean; +}; + +export function resolveNextcloudTalkRoomMatch(params: { + rooms?: Record; + roomToken: string; + roomName?: string | null; +}): NextcloudTalkRoomMatch { + const rooms = params.rooms ?? {}; + const allowlistConfigured = Object.keys(rooms).length > 0; + const roomName = params.roomName?.trim() || undefined; + const roomCandidates = buildChannelKeyCandidates( + params.roomToken, + roomName, + roomName ? normalizeChannelSlug(roomName) : undefined, + ); + const match = resolveChannelEntryMatchWithFallback({ + entries: rooms, + keys: roomCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const roomConfig = match.entry; + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: allowlistConfigured, + outerMatched: Boolean(roomConfig), + innerConfigured: false, + innerMatched: false, + }); + + return { + roomConfig, + wildcardConfig: match.wildcardEntry, + roomKey: match.matchKey ?? match.key, + matchSource: match.matchSource, + allowed, + allowlistConfigured, + }; +} + +export function resolveNextcloudTalkGroupToolPolicy( + params: ChannelGroupContext, +): GroupToolPolicyConfig | undefined { + const cfg = params.cfg as { + channels?: { "nextcloud-talk"?: { rooms?: Record } }; + }; + const roomToken = params.groupId?.trim(); + if (!roomToken) { + return undefined; + } + const roomName = params.groupChannel?.trim() || undefined; + const match = resolveNextcloudTalkRoomMatch({ + rooms: cfg.channels?.["nextcloud-talk"]?.rooms, + roomToken, + roomName, + }); + return match.roomConfig?.tools ?? match.wildcardConfig?.tools; +} + +export function resolveNextcloudTalkRequireMention(params: { + roomConfig?: NextcloudTalkRoomConfig; + wildcardConfig?: NextcloudTalkRoomConfig; +}): boolean { + if (typeof params.roomConfig?.requireMention === "boolean") { + return params.roomConfig.requireMention; + } + if (typeof params.wildcardConfig?.requireMention === "boolean") { + return params.wildcardConfig.requireMention; + } + return true; +} + +export function resolveNextcloudTalkGroupAllow(params: { + groupPolicy: GroupPolicy; + outerAllowFrom: Array | undefined; + innerAllowFrom: Array | undefined; + senderId: string; + senderName?: string | null; +}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } { + if (params.groupPolicy === "disabled") { + return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } }; + } + if (params.groupPolicy === "open") { + return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } }; + } + + const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom); + const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom); + if (outerAllow.length === 0 && innerAllow.length === 0) { + return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } }; + } + + const outerMatch = resolveNextcloudTalkAllowlistMatch({ + allowFrom: params.outerAllowFrom, + senderId: params.senderId, + senderName: params.senderName, + }); + const innerMatch = resolveNextcloudTalkAllowlistMatch({ + allowFrom: params.innerAllowFrom, + senderId: params.senderId, + senderName: params.senderName, + }); + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: outerAllow.length > 0 || innerAllow.length > 0, + outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true, + innerConfigured: innerAllow.length > 0, + innerMatched: innerMatch.allowed, + }); + + return { allowed, outerMatch, innerMatch }; +} + +export function resolveNextcloudTalkMentionGate(params: { + isGroup: boolean; + requireMention: boolean; + wasMentioned: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + commandAuthorized: boolean; +}): { shouldSkip: boolean; shouldBypassMention: boolean } { + const result = resolveMentionGatingWithBypass({ + isGroup: params.isGroup, + requireMention: params.requireMention, + canDetectMention: true, + wasMentioned: params.wasMentioned, + allowTextCommands: params.allowTextCommands, + hasControlCommand: params.hasControlCommand, + commandAuthorized: params.commandAuthorized, + }); + return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention }; +} diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2ff6a1763c24e41efa19dff00f8a0fc5b106608 --- /dev/null +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -0,0 +1,125 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import { readFileSync } from "node:fs"; +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; + +const ROOM_CACHE_TTL_MS = 5 * 60 * 1000; +const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000; + +const roomCache = new Map< + string, + { kind?: "direct" | "group"; fetchedAt: number; error?: string } +>(); + +function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) { + return `${params.accountId}:${params.roomToken}`; +} + +function readApiPassword(params: { + apiPassword?: string; + apiPasswordFile?: string; +}): string | undefined { + if (params.apiPassword?.trim()) { + return params.apiPassword.trim(); + } + if (!params.apiPasswordFile) { + return undefined; + } + try { + const value = readFileSync(params.apiPasswordFile, "utf-8").trim(); + return value || undefined; + } catch { + return undefined; + } +} + +function coerceRoomType(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined { + if (!type) { + return undefined; + } + if (type === 1 || type === 5 || type === 6) { + return "direct"; + } + return "group"; +} + +export async function resolveNextcloudTalkRoomKind(params: { + account: ResolvedNextcloudTalkAccount; + roomToken: string; + runtime?: RuntimeEnv; +}): Promise<"direct" | "group" | undefined> { + const { account, roomToken, runtime } = params; + const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken }); + const cached = roomCache.get(key); + if (cached) { + const age = Date.now() - cached.fetchedAt; + if (cached.kind && age < ROOM_CACHE_TTL_MS) { + return cached.kind; + } + if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) { + return undefined; + } + } + + const apiUser = account.config.apiUser?.trim(); + const apiPassword = readApiPassword({ + apiPassword: account.config.apiPassword, + apiPasswordFile: account.config.apiPasswordFile, + }); + if (!apiUser || !apiPassword) { + return undefined; + } + + const baseUrl = account.baseUrl?.trim(); + if (!baseUrl) { + return undefined; + } + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`; + const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64"); + + try { + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Basic ${auth}`, + "OCS-APIRequest": "true", + Accept: "application/json", + }, + }); + + if (!response.ok) { + roomCache.set(key, { + fetchedAt: Date.now(), + error: `status:${response.status}`, + }); + runtime?.log?.(`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`); + return undefined; + } + + const payload = (await response.json()) as { + ocs?: { data?: { type?: number | string } }; + }; + const type = coerceRoomType(payload.ocs?.data?.type); + const kind = resolveRoomKindFromType(type); + roomCache.set(key, { fetchedAt: Date.now(), kind }); + return kind; + } catch (err) { + roomCache.set(key, { + fetchedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }); + runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`); + return undefined; + } +} diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..61b0ea61b8f3e15481e403fe60bd54305932a9fe --- /dev/null +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setNextcloudTalkRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getNextcloudTalkRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Nextcloud Talk runtime not initialized"); + } + return runtime; +} diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ac71f461c7df0d3f35b6ebd06ea3f83a9ca6321 --- /dev/null +++ b/extensions/nextcloud-talk/src/send.ts @@ -0,0 +1,210 @@ +import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; + +type NextcloudTalkSendOpts = { + baseUrl?: string; + secret?: string; + accountId?: string; + replyTo?: string; + verbose?: boolean; +}; + +function resolveCredentials( + explicit: { baseUrl?: string; secret?: string }, + account: { baseUrl: string; secret: string; accountId: string }, +): { baseUrl: string; secret: string } { + const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl; + const secret = explicit.secret?.trim() ?? account.secret; + + if (!baseUrl) { + throw new Error( + `Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`, + ); + } + if (!secret) { + throw new Error( + `Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`, + ); + } + + return { baseUrl, secret }; +} + +function normalizeRoomToken(to: string): string { + const trimmed = to.trim(); + if (!trimmed) { + throw new Error("Room token is required for Nextcloud Talk sends"); + } + + let normalized = trimmed; + if (normalized.startsWith("nextcloud-talk:")) { + normalized = normalized.slice("nextcloud-talk:".length).trim(); + } else if (normalized.startsWith("nc:")) { + normalized = normalized.slice("nc:".length).trim(); + } + + if (normalized.startsWith("room:")) { + normalized = normalized.slice("room:".length).trim(); + } + + if (!normalized) { + throw new Error("Room token is required for Nextcloud Talk sends"); + } + return normalized; +} + +export async function sendMessageNextcloudTalk( + to: string, + text: string, + opts: NextcloudTalkSendOpts = {}, +): Promise { + const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const { baseUrl, secret } = resolveCredentials( + { baseUrl: opts.baseUrl, secret: opts.secret }, + account, + ); + const roomToken = normalizeRoomToken(to); + + if (!text?.trim()) { + throw new Error("Message must be non-empty for Nextcloud Talk sends"); + } + + const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "nextcloud-talk", + accountId: account.accountId, + }); + const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables( + text.trim(), + tableMode, + ); + + const body: Record = { + message, + }; + if (opts.replyTo) { + body.replyTo = opts.replyTo; + } + const bodyStr = JSON.stringify(body); + + const { random, signature } = generateNextcloudTalkSignature({ + body: bodyStr, + secret, + }); + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body: bodyStr, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + const status = response.status; + let errorMsg = `Nextcloud Talk send failed (${status})`; + + if (status === 400) { + errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`; + } else if (status === 401) { + errorMsg = "Nextcloud Talk: authentication failed - check bot secret"; + } else if (status === 403) { + errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room"; + } else if (status === 404) { + errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`; + } else if (errorBody) { + errorMsg = `Nextcloud Talk send failed: ${errorBody}`; + } + + throw new Error(errorMsg); + } + + let messageId = "unknown"; + let timestamp: number | undefined; + try { + const data = (await response.json()) as { + ocs?: { + data?: { + id?: number | string; + timestamp?: number; + }; + }; + }; + if (data.ocs?.data?.id != null) { + messageId = String(data.ocs.data.id); + } + if (typeof data.ocs?.data?.timestamp === "number") { + timestamp = data.ocs.data.timestamp; + } + } catch { + // Response parsing failed, but message was sent. + } + + if (opts.verbose) { + console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`); + } + + getNextcloudTalkRuntime().channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageId, roomToken, timestamp }; +} + +export async function sendReactionNextcloudTalk( + roomToken: string, + messageId: string, + reaction: string, + opts: Omit = {}, +): Promise<{ ok: true }> { + const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const { baseUrl, secret } = resolveCredentials( + { baseUrl: opts.baseUrl, secret: opts.secret }, + account, + ); + const normalizedToken = normalizeRoomToken(roomToken); + + const body = JSON.stringify({ reaction }); + const { random, signature } = generateNextcloudTalkSignature({ + body, + secret, + }); + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim()); + } + + return { ok: true }; +} diff --git a/extensions/nextcloud-talk/src/signature.ts b/extensions/nextcloud-talk/src/signature.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7d957806cc5d72277afcf8d938e8f92cf6043e0 --- /dev/null +++ b/extensions/nextcloud-talk/src/signature.ts @@ -0,0 +1,72 @@ +import { createHmac, randomBytes } from "node:crypto"; +import type { NextcloudTalkWebhookHeaders } from "./types.js"; + +const SIGNATURE_HEADER = "x-nextcloud-talk-signature"; +const RANDOM_HEADER = "x-nextcloud-talk-random"; +const BACKEND_HEADER = "x-nextcloud-talk-backend"; + +/** + * Verify the HMAC-SHA256 signature of an incoming webhook request. + * Signature is calculated as: HMAC-SHA256(random + body, secret) + */ +export function verifyNextcloudTalkSignature(params: { + signature: string; + random: string; + body: string; + secret: string; +}): boolean { + const { signature, random, body, secret } = params; + if (!signature || !random || !secret) { + return false; + } + + const expected = createHmac("sha256", secret) + .update(random + body) + .digest("hex"); + + if (signature.length !== expected.length) { + return false; + } + let result = 0; + for (let i = 0; i < signature.length; i++) { + result |= signature.charCodeAt(i) ^ expected.charCodeAt(i); + } + return result === 0; +} + +/** + * Extract webhook headers from an incoming request. + */ +export function extractNextcloudTalkHeaders( + headers: Record, +): NextcloudTalkWebhookHeaders | null { + const getHeader = (name: string): string | undefined => { + const value = headers[name] ?? headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; + }; + + const signature = getHeader(SIGNATURE_HEADER); + const random = getHeader(RANDOM_HEADER); + const backend = getHeader(BACKEND_HEADER); + + if (!signature || !random || !backend) { + return null; + } + + return { signature, random, backend }; +} + +/** + * Generate signature headers for an outbound request to Nextcloud Talk. + */ +export function generateNextcloudTalkSignature(params: { body: string; secret: string }): { + random: string; + signature: string; +} { + const { body, secret } = params; + const random = randomBytes(32).toString("hex"); + const signature = createHmac("sha256", secret) + .update(random + body) + .digest("hex"); + return { random, signature }; +} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..45cd1a5a9b8f76e9d4f16505c708a0375b0caba1 --- /dev/null +++ b/extensions/nextcloud-talk/src/types.ts @@ -0,0 +1,179 @@ +import type { + BlockStreamingCoalesceConfig, + DmConfig, + DmPolicy, + GroupPolicy, +} from "openclaw/plugin-sdk"; + +export type NextcloudTalkRoomConfig = { + requireMention?: boolean; + /** Optional tool policy overrides for this room. */ + tools?: { allow?: string[]; deny?: string[] }; + /** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */ + skills?: string[]; + /** If false, disable the bot for this room. */ + enabled?: boolean; + /** Optional allowlist for room senders (user ids). */ + allowFrom?: string[]; + /** Optional system prompt snippet for this room. */ + systemPrompt?: string; +}; + +export type NextcloudTalkAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Nextcloud Talk account. Default: true. */ + enabled?: boolean; + /** Base URL of the Nextcloud instance (e.g., "https://cloud.example.com"). */ + baseUrl?: string; + /** Bot shared secret from occ talk:bot:install output. */ + botSecret?: string; + /** Path to file containing bot secret (for secret managers). */ + botSecretFile?: string; + /** Optional API user for room lookups (DM detection). */ + apiUser?: string; + /** Optional API password/app password for room lookups. */ + apiPassword?: string; + /** Path to file containing API password/app password. */ + apiPasswordFile?: string; + /** Direct message policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Webhook server port. Default: 8788. */ + webhookPort?: number; + /** Webhook server host. Default: "0.0.0.0". */ + webhookHost?: string; + /** Webhook endpoint path. Default: "/nextcloud-talk-webhook". */ + webhookPath?: string; + /** Public URL for the webhook (used if behind reverse proxy). */ + webhookPublicUrl?: string; + /** Optional allowlist of user IDs allowed to DM the bot. */ + allowFrom?: string[]; + /** Optional allowlist for Nextcloud Talk room senders (user ids). */ + groupAllowFrom?: string[]; + /** Group message policy (default: allowlist). */ + groupPolicy?: GroupPolicy; + /** Per-room configuration (key is room token). */ + rooms?: Record; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Media upload max size in MB. */ + mediaMaxMb?: number; +}; + +export type NextcloudTalkConfig = { + /** Optional per-account Nextcloud Talk configuration (multi-account). */ + accounts?: Record; +} & NextcloudTalkAccountConfig; + +export type CoreConfig = { + channels?: { + "nextcloud-talk"?: NextcloudTalkConfig; + }; + [key: string]: unknown; +}; + +/** + * Nextcloud Talk webhook payload types based on Activity Streams 2.0 format. + * Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/ + */ + +/** Actor in the activity (the message sender). */ +export type NextcloudTalkActor = { + type: "Person"; + /** User ID in Nextcloud. */ + id: string; + /** Display name of the user. */ + name: string; +}; + +/** The message object in the activity. */ +export type NextcloudTalkObject = { + type: "Note"; + /** Message ID. */ + id: string; + /** Message text (same as content for text/plain). */ + name: string; + /** Message content. */ + content: string; + /** Media type of the content. */ + mediaType: string; +}; + +/** Target conversation/room. */ +export type NextcloudTalkTarget = { + type: "Collection"; + /** Room token. */ + id: string; + /** Room display name. */ + name: string; +}; + +/** Incoming webhook payload from Nextcloud Talk. */ +export type NextcloudTalkWebhookPayload = { + type: "Create" | "Update" | "Delete"; + actor: NextcloudTalkActor; + object: NextcloudTalkObject; + target: NextcloudTalkTarget; +}; + +/** Result from sending a message to Nextcloud Talk. */ +export type NextcloudTalkSendResult = { + messageId: string; + roomToken: string; + timestamp?: number; +}; + +/** Parsed incoming message context. */ +export type NextcloudTalkInboundMessage = { + messageId: string; + roomToken: string; + roomName: string; + senderId: string; + senderName: string; + text: string; + mediaType: string; + timestamp: number; + isGroupChat: boolean; +}; + +/** Headers sent by Nextcloud Talk webhook. */ +export type NextcloudTalkWebhookHeaders = { + /** HMAC-SHA256 signature of the request. */ + signature: string; + /** Random string used in signature calculation. */ + random: string; + /** Backend Nextcloud server URL. */ + backend: string; +}; + +/** Options for the webhook server. */ +export type NextcloudTalkWebhookServerOptions = { + port: number; + host: string; + path: string; + secret: string; + onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; + onError?: (error: Error) => void; + abortSignal?: AbortSignal; +}; + +/** Options for sending a message. */ +export type NextcloudTalkSendOptions = { + baseUrl: string; + secret: string; + roomToken: string; + message: string; + replyTo?: string; +}; diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md new file mode 100644 index 0000000000000000000000000000000000000000..9ed9940fb51b9f8fe7f08db38bfd016ebed7e38a --- /dev/null +++ b/extensions/nostr/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +## 2026.1.30 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.29 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.23 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.22 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.21 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.20 + +### Changes + +- Version alignment with core OpenClaw release numbers. + +## 2026.1.19-1 + +Initial release. + +### Features + +- NIP-04 encrypted DM support (kind:4 events) +- Key validation (hex and nsec formats) +- Multi-relay support with sequential fallback +- Event signature verification +- TTL-based deduplication (24h) +- Access control via dmPolicy (pairing, allowlist, open, disabled) +- Pubkey normalization (hex/npub) + +### Protocol Support + +- NIP-01: Basic event structure +- NIP-04: Encrypted direct messages + +### Planned for v2 + +- NIP-17: Gift-wrapped DMs +- NIP-44: Versioned encryption +- Media attachments diff --git a/extensions/nostr/README.md b/extensions/nostr/README.md new file mode 100644 index 0000000000000000000000000000000000000000..75704c3a60eed5a3a1e11659a15c692672b3493a --- /dev/null +++ b/extensions/nostr/README.md @@ -0,0 +1,136 @@ +# @openclaw/nostr + +Nostr DM channel plugin for OpenClaw using NIP-04 encrypted direct messages. + +## Overview + +This extension adds Nostr as a messaging channel to OpenClaw. It enables your bot to: + +- Receive encrypted DMs from Nostr users +- Send encrypted responses back +- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.) + +## Installation + +```bash +openclaw plugins install @openclaw/nostr +``` + +## Quick Setup + +1. Generate a Nostr keypair (if you don't have one): + + ```bash + # Using nak CLI + nak key generate + + # Or use any Nostr key generator + ``` + +2. Add to your config: + + ```json + { + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "relays": ["wss://relay.damus.io", "wss://nos.lol"] + } + } + } + ``` + +3. Set the environment variable: + + ```bash + export NOSTR_PRIVATE_KEY="nsec1..." # or hex format + ``` + +4. Restart the gateway + +## Configuration + +| Key | Type | Default | Description | +| ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- | +| `privateKey` | string | required | Bot's private key (nsec or hex format) | +| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs | +| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` | +| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) | +| `enabled` | boolean | `true` | Enable/disable the channel | +| `name` | string | - | Display name for the account | + +## Access Control + +### DM Policies + +- **pairing** (default): Unknown senders receive a pairing code to request access +- **allowlist**: Only pubkeys in `allowFrom` can message the bot +- **open**: Anyone can message the bot (use with caution) +- **disabled**: DMs are disabled + +### Example: Allowlist Mode + +```json +{ + "channels": { + "nostr": { + "privateKey": "${NOSTR_PRIVATE_KEY}", + "dmPolicy": "allowlist", + "allowFrom": ["npub1abc...", "0123456789abcdef..."] + } + } +} +``` + +## Testing + +### Local Relay (Recommended) + +```bash +# Using strfry +docker run -p 7777:7777 ghcr.io/hoytech/strfry + +# Configure openclaw to use local relay +"relays": ["ws://localhost:7777"] +``` + +### Manual Test + +1. Start the gateway with Nostr configured +2. Open Damus, Amethyst, or another Nostr client +3. Send a DM to your bot's npub +4. Verify the bot responds + +## Protocol Support + +| NIP | Status | Notes | +| ------ | --------- | ---------------------- | +| NIP-01 | Supported | Basic event structure | +| NIP-04 | Supported | Encrypted DMs (kind:4) | +| NIP-17 | Planned | Gift-wrapped DMs (v2) | + +## Security Notes + +- Private keys are never logged +- Event signatures are verified before processing +- Use environment variables for keys, never commit to config files +- Consider using `allowlist` mode in production + +## Troubleshooting + +### Bot not receiving messages + +1. Verify private key is correctly configured +2. Check relay connectivity +3. Ensure `enabled` is not set to `false` +4. Check the bot's public key matches what you're sending to + +### Messages not being delivered + +1. Check relay URLs are correct (must use `wss://`) +2. Verify relays are online and accepting connections +3. Check for rate limiting (reduce message frequency) + +## License + +MIT diff --git a/extensions/nostr/index.ts b/extensions/nostr/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..881af8c225187c4a7a5a26ec726499add67953b9 --- /dev/null +++ b/extensions/nostr/index.ts @@ -0,0 +1,68 @@ +import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import type { NostrProfile } from "./src/config-schema.js"; +import { nostrPlugin } from "./src/channel.js"; +import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js"; +import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js"; +import { resolveNostrAccount } from "./src/types.js"; + +const plugin = { + id: "nostr", + name: "Nostr", + description: "Nostr DM channel plugin via NIP-04", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setNostrRuntime(api.runtime); + api.registerChannel({ plugin: nostrPlugin }); + + // Register HTTP handler for profile management + const httpHandler = createNostrProfileHttpHandler({ + getConfigProfile: (accountId: string) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + const account = resolveNostrAccount({ cfg, accountId }); + return account.profile; + }, + updateConfigProfile: async (accountId: string, profile: NostrProfile) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + + // Build the config patch for channels.nostr.profile + const channels = (cfg.channels ?? {}) as Record; + const nostrConfig = (channels.nostr ?? {}) as Record; + + const updatedNostrConfig = { + ...nostrConfig, + profile, + }; + + const updatedChannels = { + ...channels, + nostr: updatedNostrConfig, + }; + + await runtime.config.writeConfigFile({ + ...cfg, + channels: updatedChannels, + }); + }, + getAccountInfo: (accountId: string) => { + const runtime = getNostrRuntime(); + const cfg = runtime.config.loadConfig(); + const account = resolveNostrAccount({ cfg, accountId }); + if (!account.configured || !account.publicKey) { + return null; + } + return { + pubkey: account.publicKey, + relays: account.relays, + }; + }, + log: api.logger, + }); + + api.registerHttpHandler(httpHandler); + }, +}; + +export default plugin; diff --git a/extensions/nostr/openclaw.plugin.json b/extensions/nostr/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..83adc53c0231250d8da725fec0ec7dae64162a5c --- /dev/null +++ b/extensions/nostr/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "nostr", + "channels": ["nostr"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json new file mode 100644 index 0000000000000000000000000000000000000000..e2de5032cde9932d2a4d89679e11fa5d385a484d --- /dev/null +++ b/extensions/nostr/package.json @@ -0,0 +1,34 @@ +{ + "name": "@openclaw/nostr", + "version": "2026.1.30", + "description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs", + "type": "module", + "dependencies": { + "nostr-tools": "^2.22.1", + "openclaw": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ], + "channel": { + "id": "nostr", + "label": "Nostr", + "selectionLabel": "Nostr (NIP-04 DMs)", + "docsPath": "/channels/nostr", + "docsLabel": "nostr", + "blurb": "Decentralized protocol; encrypted DMs via NIP-04.", + "order": 55, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@openclaw/nostr", + "localPath": "extensions/nostr", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f8cb72aefa6edae06f848edf76554359e7de5f4 --- /dev/null +++ b/extensions/nostr/src/channel.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { nostrPlugin } from "./channel.js"; + +describe("nostrPlugin", () => { + describe("meta", () => { + it("has correct id", () => { + expect(nostrPlugin.id).toBe("nostr"); + }); + + it("has required meta fields", () => { + expect(nostrPlugin.meta.label).toBe("Nostr"); + expect(nostrPlugin.meta.docsPath).toBe("/channels/nostr"); + expect(nostrPlugin.meta.blurb).toContain("NIP-04"); + }); + }); + + describe("capabilities", () => { + it("supports direct messages", () => { + expect(nostrPlugin.capabilities.chatTypes).toContain("direct"); + }); + + it("does not support groups (MVP)", () => { + expect(nostrPlugin.capabilities.chatTypes).not.toContain("group"); + }); + + it("does not support media (MVP)", () => { + expect(nostrPlugin.capabilities.media).toBe(false); + }); + }); + + describe("config adapter", () => { + it("has required config functions", () => { + expect(nostrPlugin.config.listAccountIds).toBeTypeOf("function"); + expect(nostrPlugin.config.resolveAccount).toBeTypeOf("function"); + expect(nostrPlugin.config.isConfigured).toBeTypeOf("function"); + }); + + it("listAccountIds returns empty array for unconfigured", () => { + const cfg = { channels: {} }; + const ids = nostrPlugin.config.listAccountIds(cfg); + expect(ids).toEqual([]); + }); + + it("listAccountIds returns default for configured", () => { + const cfg = { + channels: { + nostr: { + privateKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + }, + }, + }; + const ids = nostrPlugin.config.listAccountIds(cfg); + expect(ids).toContain("default"); + }); + }); + + describe("messaging", () => { + it("has target resolver", () => { + expect(nostrPlugin.messaging?.targetResolver?.looksLikeId).toBeTypeOf("function"); + }); + + it("recognizes npub as valid target", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) { + return; + } + + expect(looksLikeId("npub1xyz123")).toBe(true); + }); + + it("recognizes hex pubkey as valid target", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) { + return; + } + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(looksLikeId(hexPubkey)).toBe(true); + }); + + it("rejects invalid input", () => { + const looksLikeId = nostrPlugin.messaging?.targetResolver?.looksLikeId; + if (!looksLikeId) { + return; + } + + expect(looksLikeId("not-a-pubkey")).toBe(false); + expect(looksLikeId("")).toBe(false); + }); + + it("normalizeTarget strips nostr: prefix", () => { + const normalize = nostrPlugin.messaging?.normalizeTarget; + if (!normalize) { + return; + } + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey); + }); + }); + + describe("outbound", () => { + it("has correct delivery mode", () => { + expect(nostrPlugin.outbound?.deliveryMode).toBe("direct"); + }); + + it("has reasonable text chunk limit", () => { + expect(nostrPlugin.outbound?.textChunkLimit).toBe(4000); + }); + }); + + describe("pairing", () => { + it("has id label for pairing", () => { + expect(nostrPlugin.pairing?.idLabel).toBe("nostrPubkey"); + }); + + it("normalizes nostr: prefix in allow entries", () => { + const normalize = nostrPlugin.pairing?.normalizeAllowEntry; + if (!normalize) { + return; + } + + const hexPubkey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalize(`nostr:${hexPubkey}`)).toBe(hexPubkey); + }); + }); + + describe("security", () => { + it("has resolveDmPolicy function", () => { + expect(nostrPlugin.security?.resolveDmPolicy).toBeTypeOf("function"); + }); + }); + + describe("gateway", () => { + it("has startAccount function", () => { + expect(nostrPlugin.gateway?.startAccount).toBeTypeOf("function"); + }); + }); + + describe("status", () => { + it("has default runtime", () => { + expect(nostrPlugin.status?.defaultRuntime).toBeDefined(); + expect(nostrPlugin.status?.defaultRuntime?.accountId).toBe("default"); + expect(nostrPlugin.status?.defaultRuntime?.running).toBe(false); + }); + + it("has buildAccountSnapshot function", () => { + expect(nostrPlugin.status?.buildAccountSnapshot).toBeTypeOf("function"); + }); + }); +}); diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8c71c99ddbb22f92e0688ef1155feba5f470a80 --- /dev/null +++ b/extensions/nostr/src/channel.ts @@ -0,0 +1,353 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; +import type { NostrProfile } from "./config-schema.js"; +import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; +import type { ProfilePublishResult } from "./nostr-profile.js"; +import { NostrConfigSchema } from "./config-schema.js"; +import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; +import { getNostrRuntime } from "./runtime.js"; +import { + listNostrAccountIds, + resolveDefaultNostrAccountId, + resolveNostrAccount, + type ResolvedNostrAccount, +} from "./types.js"; + +// Store active bus handles per account +const activeBuses = new Map(); + +// Store metrics snapshots per account (for status reporting) +const metricsSnapshots = new Map(); + +export const nostrPlugin: ChannelPlugin = { + id: "nostr", + meta: { + id: "nostr", + label: "Nostr", + selectionLabel: "Nostr", + docsPath: "/channels/nostr", + docsLabel: "nostr", + blurb: "Decentralized DMs via Nostr relays (NIP-04)", + order: 100, + }, + capabilities: { + chatTypes: ["direct"], // DMs only for MVP + media: false, // No media for MVP + }, + reload: { configPrefixes: ["channels.nostr"] }, + configSchema: buildChannelConfigSchema(NostrConfigSchema), + + config: { + listAccountIds: (cfg) => listNostrAccountIds(cfg), + resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }), + defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + publicKey: account.publicKey, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveNostrAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "*") { + return "*"; + } + try { + return normalizePubkey(entry); + } catch { + return entry; // Keep as-is if normalization fails + } + }) + .filter(Boolean), + }, + + pairing: { + idLabel: "nostrPubkey", + normalizeAllowEntry: (entry) => { + try { + return normalizePubkey(entry.replace(/^nostr:/i, "")); + } catch { + return entry; + } + }, + notifyApproval: async ({ id }) => { + // Get the default account's bus and send approval message + const bus = activeBuses.get(DEFAULT_ACCOUNT_ID); + if (bus) { + await bus.sendDm(id, "Your pairing request has been approved!"); + } + }, + }, + + security: { + resolveDmPolicy: ({ account }) => { + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: "channels.nostr.dmPolicy", + allowFromPath: "channels.nostr.allowFrom", + approveHint: formatPairingApproveHint("nostr"), + normalizeEntry: (raw) => { + try { + return normalizePubkey(raw.replace(/^nostr:/i, "").trim()); + } catch { + return raw.trim(); + } + }, + }; + }, + }, + + messaging: { + normalizeTarget: (target) => { + // Strip nostr: prefix if present + const cleaned = target.replace(/^nostr:/i, "").trim(); + try { + return normalizePubkey(cleaned); + } catch { + return cleaned; + } + }, + targetResolver: { + looksLikeId: (input) => { + const trimmed = input.trim(); + return trimmed.startsWith("npub1") || /^[0-9a-fA-F]{64}$/.test(trimmed); + }, + hint: "", + }, + }, + + outbound: { + deliveryMode: "direct", + textChunkLimit: 4000, + sendText: async ({ to, text, accountId }) => { + const core = getNostrRuntime(); + const aid = accountId ?? DEFAULT_ACCOUNT_ID; + const bus = activeBuses.get(aid); + if (!bus) { + throw new Error(`Nostr bus not running for account ${aid}`); + } + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "nostr", + accountId: aid, + }); + const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); + const normalizedTo = normalizePubkey(to); + await bus.sendDm(normalizedTo, message); + return { channel: "nostr", to: normalizedTo }; + }, + }, + + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: (accounts) => + accounts.flatMap((account) => { + const lastError = typeof account.lastError === "string" ? account.lastError.trim() : ""; + if (!lastError) { + return []; + } + return [ + { + channel: "nostr", + accountId: account.accountId, + kind: "runtime" as const, + message: `Channel error: ${lastError}`, + }, + ]; + }), + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + publicKey: snapshot.publicKey ?? null, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + publicKey: account.publicKey, + profile: account.profile, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + ctx.setStatus({ + accountId: account.accountId, + publicKey: account.publicKey, + }); + ctx.log?.info( + `[${account.accountId}] starting Nostr provider (pubkey: ${account.publicKey})`, + ); + + if (!account.configured) { + throw new Error("Nostr private key not configured"); + } + + const runtime = getNostrRuntime(); + + // Track bus handle for metrics callback + let busHandle: NostrBusHandle | null = null; + + const bus = await startNostrBus({ + accountId: account.accountId, + privateKey: account.privateKey, + relays: account.relays, + onMessage: async (senderPubkey, text, reply) => { + ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`); + + // Forward to OpenClaw's message pipeline + await runtime.channel.reply.handleInboundMessage({ + channel: "nostr", + accountId: account.accountId, + senderId: senderPubkey, + chatType: "direct", + chatId: senderPubkey, // For DMs, chatId is the sender's pubkey + text, + reply: async (responseText: string) => { + await reply(responseText); + }, + }); + }, + onError: (error, context) => { + ctx.log?.error(`[${account.accountId}] Nostr error (${context}): ${error.message}`); + }, + onConnect: (relay) => { + ctx.log?.debug(`[${account.accountId}] Connected to relay: ${relay}`); + }, + onDisconnect: (relay) => { + ctx.log?.debug(`[${account.accountId}] Disconnected from relay: ${relay}`); + }, + onEose: (relays) => { + ctx.log?.debug(`[${account.accountId}] EOSE received from relays: ${relays}`); + }, + onMetric: (event: MetricEvent) => { + // Log significant metrics at appropriate levels + if (event.name.startsWith("event.rejected.")) { + ctx.log?.debug(`[${account.accountId}] Metric: ${event.name}`, event.labels); + } else if (event.name === "relay.circuit_breaker.open") { + ctx.log?.warn( + `[${account.accountId}] Circuit breaker opened for relay: ${event.labels?.relay}`, + ); + } else if (event.name === "relay.circuit_breaker.close") { + ctx.log?.info( + `[${account.accountId}] Circuit breaker closed for relay: ${event.labels?.relay}`, + ); + } else if (event.name === "relay.error") { + ctx.log?.debug(`[${account.accountId}] Relay error: ${event.labels?.relay}`); + } + // Update cached metrics snapshot + if (busHandle) { + metricsSnapshots.set(account.accountId, busHandle.getMetrics()); + } + }, + }); + + busHandle = bus; + + // Store the bus handle + activeBuses.set(account.accountId, bus); + + ctx.log?.info( + `[${account.accountId}] Nostr provider started, connected to ${account.relays.length} relay(s)`, + ); + + // Return cleanup function + return { + stop: () => { + bus.close(); + activeBuses.delete(account.accountId); + metricsSnapshots.delete(account.accountId); + ctx.log?.info(`[${account.accountId}] Nostr provider stopped`); + }, + }; + }, + }, +}; + +/** + * Get metrics snapshot for a Nostr account. + * Returns undefined if account is not running. + */ +export function getNostrMetrics( + accountId: string = DEFAULT_ACCOUNT_ID, +): MetricsSnapshot | undefined { + const bus = activeBuses.get(accountId); + if (bus) { + return bus.getMetrics(); + } + return metricsSnapshots.get(accountId); +} + +/** + * Get all active Nostr bus handles. + * Useful for debugging and status reporting. + */ +export function getActiveNostrBuses(): Map { + return new Map(activeBuses); +} + +/** + * Publish a profile (kind:0) for a Nostr account. + * @param accountId - Account ID (defaults to "default") + * @param profile - Profile data to publish + * @returns Publish results with successes and failures + * @throws Error if account is not running + */ +export async function publishNostrProfile( + accountId: string = DEFAULT_ACCOUNT_ID, + profile: NostrProfile, +): Promise { + const bus = activeBuses.get(accountId); + if (!bus) { + throw new Error(`Nostr bus not running for account ${accountId}`); + } + return bus.publishProfile(profile); +} + +/** + * Get profile publish state for a Nostr account. + * @param accountId - Account ID (defaults to "default") + * @returns Profile publish state or null if account not running + */ +export async function getNostrProfileState(accountId: string = DEFAULT_ACCOUNT_ID): Promise<{ + lastPublishedAt: number | null; + lastPublishedEventId: string | null; + lastPublishResults: Record | null; +} | null> { + const bus = activeBuses.get(accountId); + if (!bus) { + return null; + } + return bus.getProfileState(); +} diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..d70e6b6c05cd0bf9c5b39f272e3b3f05bc88b2c7 --- /dev/null +++ b/extensions/nostr/src/config-schema.ts @@ -0,0 +1,90 @@ +import { MarkdownConfigSchema, buildChannelConfigSchema } from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +/** + * Validates https:// URLs only (no javascript:, data:, file:, etc.) + */ +const safeUrlSchema = z + .string() + .url() + .refine( + (url) => { + try { + const parsed = new URL(url); + return parsed.protocol === "https:"; + } catch { + return false; + } + }, + { message: "URL must use https:// protocol" }, + ); + +/** + * NIP-01 profile metadata schema + * https://github.com/nostr-protocol/nips/blob/master/01.md + */ +export const NostrProfileSchema = z.object({ + /** Username (NIP-01: name) - max 256 chars */ + name: z.string().max(256).optional(), + + /** Display name (NIP-01: display_name) - max 256 chars */ + displayName: z.string().max(256).optional(), + + /** Bio/description (NIP-01: about) - max 2000 chars */ + about: z.string().max(2000).optional(), + + /** Profile picture URL (must be https) */ + picture: safeUrlSchema.optional(), + + /** Banner image URL (must be https) */ + banner: safeUrlSchema.optional(), + + /** Website URL (must be https) */ + website: safeUrlSchema.optional(), + + /** NIP-05 identifier (e.g., "user@example.com") */ + nip05: z.string().optional(), + + /** Lightning address (LUD-16) */ + lud16: z.string().optional(), +}); + +export type NostrProfile = z.infer; + +/** + * Zod schema for channels.nostr.* configuration + */ +export const NostrConfigSchema = z.object({ + /** Account name (optional display name) */ + name: z.string().optional(), + + /** Whether this channel is enabled */ + enabled: z.boolean().optional(), + + /** Markdown formatting overrides (tables). */ + markdown: MarkdownConfigSchema, + + /** Private key in hex or nsec bech32 format */ + privateKey: z.string().optional(), + + /** WebSocket relay URLs to connect to */ + relays: z.array(z.string()).optional(), + + /** DM access policy: pairing, allowlist, open, or disabled */ + dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + + /** Allowed sender pubkeys (npub or hex format) */ + allowFrom: z.array(allowFromEntry).optional(), + + /** Profile metadata (NIP-01 kind:0 content) */ + profile: NostrProfileSchema.optional(), +}); + +export type NostrConfig = z.infer; + +/** + * JSON Schema for Control UI (converted from Zod) + */ +export const nostrChannelConfigSchema = buildChannelConfigSchema(NostrConfigSchema); diff --git a/extensions/nostr/src/metrics.ts b/extensions/nostr/src/metrics.ts new file mode 100644 index 0000000000000000000000000000000000000000..11030e5bc3339822d9a1b3e4044d5df0d5ba11c8 --- /dev/null +++ b/extensions/nostr/src/metrics.ts @@ -0,0 +1,478 @@ +/** + * Comprehensive metrics system for Nostr bus observability. + * Provides clear insight into what's happening with events, relays, and operations. + */ + +// ============================================================================ +// Metric Types +// ============================================================================ + +export type EventMetricName = + | "event.received" + | "event.processed" + | "event.duplicate" + | "event.rejected.invalid_shape" + | "event.rejected.wrong_kind" + | "event.rejected.stale" + | "event.rejected.future" + | "event.rejected.rate_limited" + | "event.rejected.invalid_signature" + | "event.rejected.oversized_ciphertext" + | "event.rejected.oversized_plaintext" + | "event.rejected.decrypt_failed" + | "event.rejected.self_message"; + +export type RelayMetricName = + | "relay.connect" + | "relay.disconnect" + | "relay.reconnect" + | "relay.error" + | "relay.message.event" + | "relay.message.eose" + | "relay.message.closed" + | "relay.message.notice" + | "relay.message.ok" + | "relay.message.auth" + | "relay.circuit_breaker.open" + | "relay.circuit_breaker.close" + | "relay.circuit_breaker.half_open"; + +export type RateLimitMetricName = "rate_limit.per_sender" | "rate_limit.global"; + +export type DecryptMetricName = "decrypt.success" | "decrypt.failure"; + +export type MemoryMetricName = "memory.seen_tracker_size" | "memory.rate_limiter_entries"; + +export type MetricName = + | EventMetricName + | RelayMetricName + | RateLimitMetricName + | DecryptMetricName + | MemoryMetricName; + +// ============================================================================ +// Metric Event +// ============================================================================ + +export interface MetricEvent { + /** Metric name (e.g., "event.received", "relay.connect") */ + name: MetricName; + /** Metric value (usually 1 for counters, or a measured value) */ + value: number; + /** Unix timestamp in milliseconds */ + timestamp: number; + /** Optional labels for additional context */ + labels?: Record; +} + +export type OnMetricCallback = (event: MetricEvent) => void; + +// ============================================================================ +// Metrics Snapshot (for getMetrics()) +// ============================================================================ + +export interface MetricsSnapshot { + /** Total events received (before any filtering) */ + eventsReceived: number; + /** Events successfully processed */ + eventsProcessed: number; + /** Duplicate events skipped */ + eventsDuplicate: number; + /** Events rejected by reason */ + eventsRejected: { + invalidShape: number; + wrongKind: number; + stale: number; + future: number; + rateLimited: number; + invalidSignature: number; + oversizedCiphertext: number; + oversizedPlaintext: number; + decryptFailed: number; + selfMessage: number; + }; + + /** Relay stats by URL */ + relays: Record< + string, + { + connects: number; + disconnects: number; + reconnects: number; + errors: number; + messagesReceived: { + event: number; + eose: number; + closed: number; + notice: number; + ok: number; + auth: number; + }; + circuitBreakerState: "closed" | "open" | "half_open"; + circuitBreakerOpens: number; + circuitBreakerCloses: number; + } + >; + + /** Rate limiting stats */ + rateLimiting: { + perSenderHits: number; + globalHits: number; + }; + + /** Decrypt stats */ + decrypt: { + success: number; + failure: number; + }; + + /** Memory/capacity stats */ + memory: { + seenTrackerSize: number; + rateLimiterEntries: number; + }; + + /** Snapshot timestamp */ + snapshotAt: number; +} + +// ============================================================================ +// Metrics Collector +// ============================================================================ + +export interface NostrMetrics { + /** Emit a metric event */ + emit: (name: MetricName, value?: number, labels?: Record) => void; + + /** Get current metrics snapshot */ + getSnapshot: () => MetricsSnapshot; + + /** Reset all metrics to zero */ + reset: () => void; +} + +/** + * Create a metrics collector instance. + * Optionally pass an onMetric callback to receive real-time metric events. + */ +export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics { + // Counters + let eventsReceived = 0; + let eventsProcessed = 0; + let eventsDuplicate = 0; + const eventsRejected = { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }; + + // Per-relay stats + const relays = new Map< + string, + { + connects: number; + disconnects: number; + reconnects: number; + errors: number; + messagesReceived: { + event: number; + eose: number; + closed: number; + notice: number; + ok: number; + auth: number; + }; + circuitBreakerState: "closed" | "open" | "half_open"; + circuitBreakerOpens: number; + circuitBreakerCloses: number; + } + >(); + + // Rate limiting stats + const rateLimiting = { + perSenderHits: 0, + globalHits: 0, + }; + + // Decrypt stats + const decrypt = { + success: 0, + failure: 0, + }; + + // Memory stats (updated via gauge-style metrics) + const memory = { + seenTrackerSize: 0, + rateLimiterEntries: 0, + }; + + function getOrCreateRelay(url: string) { + let relay = relays.get(url); + if (!relay) { + relay = { + connects: 0, + disconnects: 0, + reconnects: 0, + errors: 0, + messagesReceived: { + event: 0, + eose: 0, + closed: 0, + notice: 0, + ok: 0, + auth: 0, + }, + circuitBreakerState: "closed", + circuitBreakerOpens: 0, + circuitBreakerCloses: 0, + }; + relays.set(url, relay); + } + return relay; + } + + function emit( + name: MetricName, + value: number = 1, + labels?: Record, + ): void { + // Fire callback if provided + if (onMetric) { + onMetric({ + name, + value, + timestamp: Date.now(), + labels, + }); + } + + // Update internal counters + const relayUrl = labels?.relay as string | undefined; + + switch (name) { + // Event metrics + case "event.received": + eventsReceived += value; + break; + case "event.processed": + eventsProcessed += value; + break; + case "event.duplicate": + eventsDuplicate += value; + break; + case "event.rejected.invalid_shape": + eventsRejected.invalidShape += value; + break; + case "event.rejected.wrong_kind": + eventsRejected.wrongKind += value; + break; + case "event.rejected.stale": + eventsRejected.stale += value; + break; + case "event.rejected.future": + eventsRejected.future += value; + break; + case "event.rejected.rate_limited": + eventsRejected.rateLimited += value; + break; + case "event.rejected.invalid_signature": + eventsRejected.invalidSignature += value; + break; + case "event.rejected.oversized_ciphertext": + eventsRejected.oversizedCiphertext += value; + break; + case "event.rejected.oversized_plaintext": + eventsRejected.oversizedPlaintext += value; + break; + case "event.rejected.decrypt_failed": + eventsRejected.decryptFailed += value; + break; + case "event.rejected.self_message": + eventsRejected.selfMessage += value; + break; + + // Relay metrics + case "relay.connect": + if (relayUrl) { + getOrCreateRelay(relayUrl).connects += value; + } + break; + case "relay.disconnect": + if (relayUrl) { + getOrCreateRelay(relayUrl).disconnects += value; + } + break; + case "relay.reconnect": + if (relayUrl) { + getOrCreateRelay(relayUrl).reconnects += value; + } + break; + case "relay.error": + if (relayUrl) { + getOrCreateRelay(relayUrl).errors += value; + } + break; + case "relay.message.event": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.event += value; + } + break; + case "relay.message.eose": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.eose += value; + } + break; + case "relay.message.closed": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.closed += value; + } + break; + case "relay.message.notice": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.notice += value; + } + break; + case "relay.message.ok": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.ok += value; + } + break; + case "relay.message.auth": + if (relayUrl) { + getOrCreateRelay(relayUrl).messagesReceived.auth += value; + } + break; + case "relay.circuit_breaker.open": + if (relayUrl) { + const r = getOrCreateRelay(relayUrl); + r.circuitBreakerState = "open"; + r.circuitBreakerOpens += value; + } + break; + case "relay.circuit_breaker.close": + if (relayUrl) { + const r = getOrCreateRelay(relayUrl); + r.circuitBreakerState = "closed"; + r.circuitBreakerCloses += value; + } + break; + case "relay.circuit_breaker.half_open": + if (relayUrl) { + getOrCreateRelay(relayUrl).circuitBreakerState = "half_open"; + } + break; + + // Rate limiting + case "rate_limit.per_sender": + rateLimiting.perSenderHits += value; + break; + case "rate_limit.global": + rateLimiting.globalHits += value; + break; + + // Decrypt + case "decrypt.success": + decrypt.success += value; + break; + case "decrypt.failure": + decrypt.failure += value; + break; + + // Memory (gauge-style - value replaces, not adds) + case "memory.seen_tracker_size": + memory.seenTrackerSize = value; + break; + case "memory.rate_limiter_entries": + memory.rateLimiterEntries = value; + break; + } + } + + function getSnapshot(): MetricsSnapshot { + // Convert relay map to object + const relaysObj: MetricsSnapshot["relays"] = {}; + for (const [url, stats] of relays) { + relaysObj[url] = { ...stats, messagesReceived: { ...stats.messagesReceived } }; + } + + return { + eventsReceived, + eventsProcessed, + eventsDuplicate, + eventsRejected: { ...eventsRejected }, + relays: relaysObj, + rateLimiting: { ...rateLimiting }, + decrypt: { ...decrypt }, + memory: { ...memory }, + snapshotAt: Date.now(), + }; + } + + function reset(): void { + eventsReceived = 0; + eventsProcessed = 0; + eventsDuplicate = 0; + Object.assign(eventsRejected, { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }); + relays.clear(); + rateLimiting.perSenderHits = 0; + rateLimiting.globalHits = 0; + decrypt.success = 0; + decrypt.failure = 0; + memory.seenTrackerSize = 0; + memory.rateLimiterEntries = 0; + } + + return { emit, getSnapshot, reset }; +} + +/** + * Create a no-op metrics instance (for when metrics are disabled). + */ +export function createNoopMetrics(): NostrMetrics { + const emptySnapshot: MetricsSnapshot = { + eventsReceived: 0, + eventsProcessed: 0, + eventsDuplicate: 0, + eventsRejected: { + invalidShape: 0, + wrongKind: 0, + stale: 0, + future: 0, + rateLimited: 0, + invalidSignature: 0, + oversizedCiphertext: 0, + oversizedPlaintext: 0, + decryptFailed: 0, + selfMessage: 0, + }, + relays: {}, + rateLimiting: { perSenderHits: 0, globalHits: 0 }, + decrypt: { success: 0, failure: 0 }, + memory: { seenTrackerSize: 0, rateLimiterEntries: 0 }, + snapshotAt: 0, + }; + + return { + emit: () => {}, + getSnapshot: () => ({ ...emptySnapshot, snapshotAt: Date.now() }), + reset: () => {}, + }; +} diff --git a/extensions/nostr/src/nostr-bus.fuzz.test.ts b/extensions/nostr/src/nostr-bus.fuzz.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..811cf7df5cbd2667e42ab7cabf7c6fef2eefe775 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.fuzz.test.ts @@ -0,0 +1,533 @@ +import { describe, expect, it } from "vitest"; +import { createMetrics, type MetricName } from "./metrics.js"; +import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js"; +import { createSeenTracker } from "./seen-tracker.js"; + +// ============================================================================ +// Fuzz Tests for validatePrivateKey +// ============================================================================ + +describe("validatePrivateKey fuzz", () => { + describe("type confusion", () => { + it("rejects null input", () => { + expect(() => validatePrivateKey(null as unknown as string)).toThrow(); + }); + + it("rejects undefined input", () => { + expect(() => validatePrivateKey(undefined as unknown as string)).toThrow(); + }); + + it("rejects number input", () => { + expect(() => validatePrivateKey(123 as unknown as string)).toThrow(); + }); + + it("rejects boolean input", () => { + expect(() => validatePrivateKey(true as unknown as string)).toThrow(); + }); + + it("rejects object input", () => { + expect(() => validatePrivateKey({} as unknown as string)).toThrow(); + }); + + it("rejects array input", () => { + expect(() => validatePrivateKey([] as unknown as string)).toThrow(); + }); + + it("rejects function input", () => { + expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow(); + }); + }); + + describe("unicode attacks", () => { + it("rejects unicode lookalike characters", () => { + // Using zero-width characters + const withZeroWidth = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf"; + expect(() => validatePrivateKey(withZeroWidth)).toThrow(); + }); + + it("rejects RTL override", () => { + const withRtl = "\u202E0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(() => validatePrivateKey(withRtl)).toThrow(); + }); + + it("rejects homoglyph 'a' (Cyrillic а)", () => { + // Using Cyrillic 'а' (U+0430) instead of Latin 'a' + const withCyrillicA = "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(() => validatePrivateKey(withCyrillicA)).toThrow(); + }); + + it("rejects emoji", () => { + const withEmoji = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀"; + expect(() => validatePrivateKey(withEmoji)).toThrow(); + }); + + it("rejects combining characters", () => { + // 'a' followed by combining acute accent + const withCombining = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301"; + expect(() => validatePrivateKey(withCombining)).toThrow(); + }); + }); + + describe("injection attempts", () => { + it("rejects null byte injection", () => { + const withNullByte = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f"; + expect(() => validatePrivateKey(withNullByte)).toThrow(); + }); + + it("rejects newline injection", () => { + const withNewline = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf"; + expect(() => validatePrivateKey(withNewline)).toThrow(); + }); + + it("rejects carriage return injection", () => { + const withCR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf"; + expect(() => validatePrivateKey(withCR)).toThrow(); + }); + + it("rejects tab injection", () => { + const withTab = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf"; + expect(() => validatePrivateKey(withTab)).toThrow(); + }); + + it("rejects form feed injection", () => { + const withFormFeed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff"; + expect(() => validatePrivateKey(withFormFeed)).toThrow(); + }); + }); + + describe("edge cases", () => { + it("rejects very long string", () => { + const veryLong = "a".repeat(10000); + expect(() => validatePrivateKey(veryLong)).toThrow(); + }); + + it("rejects string of spaces matching length", () => { + const spaces = " ".repeat(64); + expect(() => validatePrivateKey(spaces)).toThrow(); + }); + + it("rejects hex with spaces between characters", () => { + const withSpaces = + "01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef 01 23 45 67 89 ab cd ef"; + expect(() => validatePrivateKey(withSpaces)).toThrow(); + }); + }); + + describe("nsec format edge cases", () => { + it("rejects nsec with invalid bech32 characters", () => { + // 'b', 'i', 'o' are not valid bech32 characters + const invalidBech32 = "nsec1qypqxpq9qtpqscx7peytbfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l"; + expect(() => validatePrivateKey(invalidBech32)).toThrow(); + }); + + it("rejects nsec with wrong prefix", () => { + expect(() => validatePrivateKey("nsec0aaaa")).toThrow(); + }); + + it("rejects partial nsec", () => { + expect(() => validatePrivateKey("nsec1")).toThrow(); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for isValidPubkey +// ============================================================================ + +describe("isValidPubkey fuzz", () => { + describe("type confusion", () => { + it("handles null gracefully", () => { + expect(isValidPubkey(null as unknown as string)).toBe(false); + }); + + it("handles undefined gracefully", () => { + expect(isValidPubkey(undefined as unknown as string)).toBe(false); + }); + + it("handles number gracefully", () => { + expect(isValidPubkey(123 as unknown as string)).toBe(false); + }); + + it("handles object gracefully", () => { + expect(isValidPubkey({} as unknown as string)).toBe(false); + }); + }); + + describe("malicious inputs", () => { + it("rejects __proto__ key", () => { + expect(isValidPubkey("__proto__")).toBe(false); + }); + + it("rejects constructor key", () => { + expect(isValidPubkey("constructor")).toBe(false); + }); + + it("rejects toString key", () => { + expect(isValidPubkey("toString")).toBe(false); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for normalizePubkey +// ============================================================================ + +describe("normalizePubkey fuzz", () => { + describe("prototype pollution attempts", () => { + it("throws for __proto__", () => { + expect(() => normalizePubkey("__proto__")).toThrow(); + }); + + it("throws for constructor", () => { + expect(() => normalizePubkey("constructor")).toThrow(); + }); + + it("throws for prototype", () => { + expect(() => normalizePubkey("prototype")).toThrow(); + }); + }); + + describe("case sensitivity", () => { + it("normalizes uppercase to lowercase", () => { + const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(upper)).toBe(lower); + }); + + it("normalizes mixed case to lowercase", () => { + const mixed = "0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf0123456789AbCdEf"; + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(mixed)).toBe(lower); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for SeenTracker +// ============================================================================ + +describe("SeenTracker fuzz", () => { + describe("malformed IDs", () => { + it("handles empty string IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + expect(() => tracker.add("")).not.toThrow(); + expect(tracker.peek("")).toBe(true); + tracker.stop(); + }); + + it("handles very long IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const longId = "a".repeat(100000); + expect(() => tracker.add(longId)).not.toThrow(); + expect(tracker.peek(longId)).toBe(true); + tracker.stop(); + }); + + it("handles unicode IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const unicodeId = "事件ID_🎉_тест"; + expect(() => tracker.add(unicodeId)).not.toThrow(); + expect(tracker.peek(unicodeId)).toBe(true); + tracker.stop(); + }); + + it("handles IDs with null bytes", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + const idWithNull = "event\x00id"; + expect(() => tracker.add(idWithNull)).not.toThrow(); + expect(tracker.peek(idWithNull)).toBe(true); + tracker.stop(); + }); + + it("handles prototype property names as IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + + // These should not affect the tracker's internal operation + expect(() => tracker.add("__proto__")).not.toThrow(); + expect(() => tracker.add("constructor")).not.toThrow(); + expect(() => tracker.add("toString")).not.toThrow(); + expect(() => tracker.add("hasOwnProperty")).not.toThrow(); + + expect(tracker.peek("__proto__")).toBe(true); + expect(tracker.peek("constructor")).toBe(true); + expect(tracker.peek("toString")).toBe(true); + expect(tracker.peek("hasOwnProperty")).toBe(true); + + tracker.stop(); + }); + }); + + describe("rapid operations", () => { + it("handles rapid add/check cycles", () => { + const tracker = createSeenTracker({ maxEntries: 1000 }); + + for (let i = 0; i < 10000; i++) { + const id = `event-${i}`; + tracker.add(id); + // Recently added should be findable + if (i < 1000) { + tracker.peek(id); + } + } + + // Size should be capped at maxEntries + expect(tracker.size()).toBeLessThanOrEqual(1000); + tracker.stop(); + }); + + it("handles concurrent-style operations", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + + // Simulate interleaved operations + for (let i = 0; i < 100; i++) { + tracker.add(`add-${i}`); + tracker.peek(`peek-${i}`); + tracker.has(`has-${i}`); + if (i % 10 === 0) { + tracker.delete(`add-${i - 5}`); + } + } + + expect(() => tracker.size()).not.toThrow(); + tracker.stop(); + }); + }); + + describe("seed edge cases", () => { + it("handles empty seed array", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + expect(() => tracker.seed([])).not.toThrow(); + expect(tracker.size()).toBe(0); + tracker.stop(); + }); + + it("handles seed with duplicate IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100 }); + tracker.seed(["id1", "id1", "id1", "id2", "id2"]); + expect(tracker.size()).toBe(2); + tracker.stop(); + }); + + it("handles seed larger than maxEntries", () => { + const tracker = createSeenTracker({ maxEntries: 5 }); + const ids = Array.from({ length: 100 }, (_, i) => `id-${i}`); + tracker.seed(ids); + expect(tracker.size()).toBeLessThanOrEqual(5); + tracker.stop(); + }); + }); +}); + +// ============================================================================ +// Fuzz Tests for Metrics +// ============================================================================ + +describe("Metrics fuzz", () => { + describe("invalid metric names", () => { + it("handles unknown metric names gracefully", () => { + const metrics = createMetrics(); + + // Cast to bypass type checking - testing runtime behavior + expect(() => { + metrics.emit("invalid.metric.name" as MetricName); + }).not.toThrow(); + }); + }); + + describe("invalid label values", () => { + it("handles null relay label", () => { + const metrics = createMetrics(); + expect(() => { + metrics.emit("relay.connect", 1, { relay: null as unknown as string }); + }).not.toThrow(); + }); + + it("handles undefined relay label", () => { + const metrics = createMetrics(); + expect(() => { + metrics.emit("relay.connect", 1, { relay: undefined as unknown as string }); + }).not.toThrow(); + }); + + it("handles very long relay URL", () => { + const metrics = createMetrics(); + const longUrl = "wss://" + "a".repeat(10000) + ".com"; + expect(() => { + metrics.emit("relay.connect", 1, { relay: longUrl }); + }).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays[longUrl]).toBeDefined(); + }); + }); + + describe("extreme values", () => { + it("handles NaN value", () => { + const metrics = createMetrics(); + expect(() => metrics.emit("event.received", NaN)).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(isNaN(snapshot.eventsReceived)).toBe(true); + }); + + it("handles Infinity value", () => { + const metrics = createMetrics(); + expect(() => metrics.emit("event.received", Infinity)).not.toThrow(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(Infinity); + }); + + it("handles negative value", () => { + const metrics = createMetrics(); + metrics.emit("event.received", -1); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(-1); + }); + + it("handles very large value", () => { + const metrics = createMetrics(); + metrics.emit("event.received", Number.MAX_SAFE_INTEGER); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(Number.MAX_SAFE_INTEGER); + }); + }); + + describe("rapid emissions", () => { + it("handles many rapid emissions", () => { + const events: unknown[] = []; + const metrics = createMetrics((e) => events.push(e)); + + for (let i = 0; i < 10000; i++) { + metrics.emit("event.received"); + } + + expect(events).toHaveLength(10000); + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(10000); + }); + }); + + describe("reset during operation", () => { + it("handles reset mid-operation safely", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.received"); + metrics.reset(); + metrics.emit("event.received"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(1); + }); + }); +}); + +// ============================================================================ +// Event Shape Validation (simulating malformed events) +// ============================================================================ + +describe("Event shape validation", () => { + describe("malformed event structures", () => { + // These test what happens if malformed data somehow gets through + + it("identifies missing required fields", () => { + const malformedEvents = [ + {}, // empty + { id: "abc" }, // missing pubkey, created_at, etc. + { id: null, pubkey: null }, // null values + { id: 123, pubkey: 456 }, // wrong types + { tags: "not-an-array" }, // wrong type for tags + { tags: [[1, 2, 3]] }, // wrong type for tag elements + ]; + + for (const event of malformedEvents) { + // These should be caught by shape validation before processing + const hasId = typeof event?.id === "string"; + const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string"; + const hasTags = Array.isArray((event as { tags?: unknown })?.tags); + + // At least one should be invalid + expect(hasId && hasPubkey && hasTags).toBe(false); + } + }); + }); + + describe("timestamp edge cases", () => { + const testTimestamps = [ + { value: NaN, desc: "NaN" }, + { value: Infinity, desc: "Infinity" }, + { value: -Infinity, desc: "-Infinity" }, + { value: -1, desc: "negative" }, + { value: 0, desc: "zero" }, + { value: 253402300800, desc: "year 10000" }, // Far future + { value: -62135596800, desc: "year 0001" }, // Far past + { value: 1.5, desc: "float" }, + ]; + + for (const { value, desc } of testTimestamps) { + it(`handles ${desc} timestamp`, () => { + const isValidTimestamp = + typeof value === "number" && + !isNaN(value) && + isFinite(value) && + value >= 0 && + Number.isInteger(value); + + // Timestamps should be validated as positive integers + if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) { + expect(isValidTimestamp).toBe(false); + } + }); + } + }); +}); + +// ============================================================================ +// JSON parsing edge cases (simulating relay responses) +// ============================================================================ + +describe("JSON parsing edge cases", () => { + const malformedJsonCases = [ + { input: "", desc: "empty string" }, + { input: "null", desc: "null literal" }, + { input: "undefined", desc: "undefined literal" }, + { input: "{", desc: "incomplete object" }, + { input: "[", desc: "incomplete array" }, + { input: '{"key": undefined}', desc: "undefined value" }, + { input: "{'key': 'value'}", desc: "single quotes" }, + { input: '{"key": NaN}', desc: "NaN value" }, + { input: '{"key": Infinity}', desc: "Infinity value" }, + { input: "\x00", desc: "null byte" }, + { input: "abc", desc: "plain string" }, + { input: "123", desc: "plain number" }, + ]; + + for (const { input, desc } of malformedJsonCases) { + it(`handles malformed JSON: ${desc}`, () => { + let parsed: unknown; + let parseError = false; + + try { + parsed = JSON.parse(input); + } catch { + parseError = true; + } + + // Either it throws or produces something that needs validation + if (!parseError) { + // If it parsed, we need to validate the structure + const isValidRelayMessage = + Array.isArray(parsed) && parsed.length >= 2 && typeof parsed[0] === "string"; + + // Most malformed cases won't produce valid relay messages + if (["null literal", "plain number", "plain string"].includes(desc)) { + expect(isValidRelayMessage).toBe(false); + } + } + }); + } +}); diff --git a/extensions/nostr/src/nostr-bus.integration.test.ts b/extensions/nostr/src/nostr-bus.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6082351dd92e215a2c008ad956ba7b6355f04dc7 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.integration.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMetrics, createNoopMetrics, type MetricEvent } from "./metrics.js"; +import { createSeenTracker } from "./seen-tracker.js"; + +// ============================================================================ +// Seen Tracker Integration Tests +// ============================================================================ + +describe("SeenTracker", () => { + describe("basic operations", () => { + it("tracks seen IDs", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + // First check returns false and adds + expect(tracker.has("id1")).toBe(false); + // Second check returns true (already seen) + expect(tracker.has("id1")).toBe(true); + + tracker.stop(); + }); + + it("peek does not add", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + expect(tracker.peek("id1")).toBe(false); + expect(tracker.peek("id1")).toBe(false); // Still false + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.stop(); + }); + + it("delete removes entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.delete("id1"); + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + }); + + it("clear removes all entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + expect(tracker.size()).toBe(3); + + tracker.clear(); + expect(tracker.size()).toBe(0); + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + }); + + it("seed pre-populates entries", () => { + const tracker = createSeenTracker({ maxEntries: 100, ttlMs: 60000 }); + + tracker.seed(["id1", "id2", "id3"]); + expect(tracker.size()).toBe(3); + expect(tracker.peek("id1")).toBe(true); + expect(tracker.peek("id2")).toBe(true); + expect(tracker.peek("id3")).toBe(true); + + tracker.stop(); + }); + }); + + describe("LRU eviction", () => { + it("evicts least recently used when at capacity", () => { + const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + expect(tracker.size()).toBe(3); + + // Adding fourth should evict oldest (id1) + tracker.add("id4"); + expect(tracker.size()).toBe(3); + expect(tracker.peek("id1")).toBe(false); // Evicted + expect(tracker.peek("id2")).toBe(true); + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + + it("accessing an entry moves it to front (prevents eviction)", () => { + const tracker = createSeenTracker({ maxEntries: 3, ttlMs: 60000 }); + + tracker.add("id1"); + tracker.add("id2"); + tracker.add("id3"); + + // Access id1, moving it to front + tracker.has("id1"); + + // Add id4 - should evict id2 (now oldest) + tracker.add("id4"); + expect(tracker.peek("id1")).toBe(true); // Not evicted, was accessed + expect(tracker.peek("id2")).toBe(false); // Evicted + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + + it("handles capacity of 1", () => { + const tracker = createSeenTracker({ maxEntries: 1, ttlMs: 60000 }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + tracker.add("id2"); + expect(tracker.peek("id1")).toBe(false); + expect(tracker.peek("id2")).toBe(true); + + tracker.stop(); + }); + + it("seed respects maxEntries", () => { + const tracker = createSeenTracker({ maxEntries: 2, ttlMs: 60000 }); + + tracker.seed(["id1", "id2", "id3", "id4"]); + expect(tracker.size()).toBe(2); + // Seed stops when maxEntries reached, processing from end to start + // So id4 and id3 get added first, then we're at capacity + expect(tracker.peek("id3")).toBe(true); + expect(tracker.peek("id4")).toBe(true); + + tracker.stop(); + }); + }); + + describe("TTL expiration", () => { + it("expires entries after TTL", async () => { + vi.useFakeTimers(); + + const tracker = createSeenTracker({ + maxEntries: 100, + ttlMs: 100, + pruneIntervalMs: 50, + }); + + tracker.add("id1"); + expect(tracker.peek("id1")).toBe(true); + + // Advance past TTL + vi.advanceTimersByTime(150); + + // Entry should be expired + expect(tracker.peek("id1")).toBe(false); + + tracker.stop(); + vi.useRealTimers(); + }); + + it("has() refreshes TTL", async () => { + vi.useFakeTimers(); + + const tracker = createSeenTracker({ + maxEntries: 100, + ttlMs: 100, + pruneIntervalMs: 50, + }); + + tracker.add("id1"); + + // Advance halfway + vi.advanceTimersByTime(50); + + // Access to refresh + expect(tracker.has("id1")).toBe(true); + + // Advance another 75ms (total 125ms from add, but only 75ms from last access) + vi.advanceTimersByTime(75); + + // Should still be valid (refreshed at 50ms) + expect(tracker.peek("id1")).toBe(true); + + tracker.stop(); + vi.useRealTimers(); + }); + }); +}); + +// ============================================================================ +// Metrics Integration Tests +// ============================================================================ + +describe("Metrics", () => { + describe("createMetrics", () => { + it("emits metric events to callback", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("event.duplicate"); + + expect(events).toHaveLength(3); + expect(events[0].name).toBe("event.received"); + expect(events[1].name).toBe("event.processed"); + expect(events[2].name).toBe("event.duplicate"); + }); + + it("includes labels in metric events", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + metrics.emit("relay.connect", 1, { relay: "wss://relay.example.com" }); + + expect(events[0].labels).toEqual({ relay: "wss://relay.example.com" }); + }); + + it("accumulates counters in snapshot", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("event.duplicate"); + metrics.emit("event.duplicate"); + metrics.emit("event.duplicate"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(2); + expect(snapshot.eventsProcessed).toBe(1); + expect(snapshot.eventsDuplicate).toBe(3); + }); + + it("tracks per-relay stats", () => { + const metrics = createMetrics(); + + metrics.emit("relay.connect", 1, { relay: "wss://relay1.com" }); + metrics.emit("relay.connect", 1, { relay: "wss://relay2.com" }); + metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); + metrics.emit("relay.error", 1, { relay: "wss://relay1.com" }); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay1.com"]).toBeDefined(); + expect(snapshot.relays["wss://relay1.com"].connects).toBe(1); + expect(snapshot.relays["wss://relay1.com"].errors).toBe(2); + expect(snapshot.relays["wss://relay2.com"].connects).toBe(1); + expect(snapshot.relays["wss://relay2.com"].errors).toBe(0); + }); + + it("tracks circuit breaker state changes", () => { + const metrics = createMetrics(); + + metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); + + let snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("open"); + expect(snapshot.relays["wss://relay.com"].circuitBreakerOpens).toBe(1); + + metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); + + snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://relay.com"].circuitBreakerState).toBe("closed"); + expect(snapshot.relays["wss://relay.com"].circuitBreakerCloses).toBe(1); + }); + + it("tracks all rejection reasons", () => { + const metrics = createMetrics(); + + metrics.emit("event.rejected.invalid_shape"); + metrics.emit("event.rejected.wrong_kind"); + metrics.emit("event.rejected.stale"); + metrics.emit("event.rejected.future"); + metrics.emit("event.rejected.rate_limited"); + metrics.emit("event.rejected.invalid_signature"); + metrics.emit("event.rejected.oversized_ciphertext"); + metrics.emit("event.rejected.oversized_plaintext"); + metrics.emit("event.rejected.decrypt_failed"); + metrics.emit("event.rejected.self_message"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsRejected.invalidShape).toBe(1); + expect(snapshot.eventsRejected.wrongKind).toBe(1); + expect(snapshot.eventsRejected.stale).toBe(1); + expect(snapshot.eventsRejected.future).toBe(1); + expect(snapshot.eventsRejected.rateLimited).toBe(1); + expect(snapshot.eventsRejected.invalidSignature).toBe(1); + expect(snapshot.eventsRejected.oversizedCiphertext).toBe(1); + expect(snapshot.eventsRejected.oversizedPlaintext).toBe(1); + expect(snapshot.eventsRejected.decryptFailed).toBe(1); + expect(snapshot.eventsRejected.selfMessage).toBe(1); + }); + + it("tracks relay message types", () => { + const metrics = createMetrics(); + + metrics.emit("relay.message.event", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.eose", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.closed", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.notice", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.ok", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.message.auth", 1, { relay: "wss://relay.com" }); + + const snapshot = metrics.getSnapshot(); + const relay = snapshot.relays["wss://relay.com"]; + expect(relay.messagesReceived.event).toBe(1); + expect(relay.messagesReceived.eose).toBe(1); + expect(relay.messagesReceived.closed).toBe(1); + expect(relay.messagesReceived.notice).toBe(1); + expect(relay.messagesReceived.ok).toBe(1); + expect(relay.messagesReceived.auth).toBe(1); + }); + + it("tracks decrypt success/failure", () => { + const metrics = createMetrics(); + + metrics.emit("decrypt.success"); + metrics.emit("decrypt.success"); + metrics.emit("decrypt.failure"); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.decrypt.success).toBe(2); + expect(snapshot.decrypt.failure).toBe(1); + }); + + it("tracks memory gauges (replaces rather than accumulates)", () => { + const metrics = createMetrics(); + + metrics.emit("memory.seen_tracker_size", 100); + metrics.emit("memory.seen_tracker_size", 150); + metrics.emit("memory.seen_tracker_size", 125); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.memory.seenTrackerSize).toBe(125); // Last value, not sum + }); + + it("reset clears all counters", () => { + const metrics = createMetrics(); + + metrics.emit("event.received"); + metrics.emit("event.processed"); + metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); + + metrics.reset(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(0); + expect(snapshot.eventsProcessed).toBe(0); + expect(Object.keys(snapshot.relays)).toHaveLength(0); + }); + }); + + describe("createNoopMetrics", () => { + it("does not throw on emit", () => { + const metrics = createNoopMetrics(); + + expect(() => { + metrics.emit("event.received"); + metrics.emit("relay.connect", 1, { relay: "wss://relay.com" }); + }).not.toThrow(); + }); + + it("returns empty snapshot", () => { + const metrics = createNoopMetrics(); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.eventsReceived).toBe(0); + expect(snapshot.eventsProcessed).toBe(0); + }); + }); +}); + +// ============================================================================ +// Circuit Breaker Behavior Tests +// ============================================================================ + +describe("Circuit Breaker Behavior", () => { + // Test the circuit breaker logic through metrics emissions + it("emits circuit breaker metrics in correct sequence", () => { + const events: MetricEvent[] = []; + const metrics = createMetrics((event) => events.push(event)); + + // Simulate 5 failures -> open + for (let i = 0; i < 5; i++) { + metrics.emit("relay.error", 1, { relay: "wss://relay.com" }); + } + metrics.emit("relay.circuit_breaker.open", 1, { relay: "wss://relay.com" }); + + // Simulate recovery + metrics.emit("relay.circuit_breaker.half_open", 1, { relay: "wss://relay.com" }); + metrics.emit("relay.circuit_breaker.close", 1, { relay: "wss://relay.com" }); + + const cbEvents = events.filter((e) => e.name.startsWith("relay.circuit_breaker")); + expect(cbEvents).toHaveLength(3); + expect(cbEvents[0].name).toBe("relay.circuit_breaker.open"); + expect(cbEvents[1].name).toBe("relay.circuit_breaker.half_open"); + expect(cbEvents[2].name).toBe("relay.circuit_breaker.close"); + }); +}); + +// ============================================================================ +// Health Scoring Behavior Tests +// ============================================================================ + +describe("Health Scoring", () => { + it("metrics track relay errors for health scoring", () => { + const metrics = createMetrics(); + + // Simulate mixed success/failure pattern + metrics.emit("relay.connect", 1, { relay: "wss://good-relay.com" }); + metrics.emit("relay.connect", 1, { relay: "wss://bad-relay.com" }); + + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + metrics.emit("relay.error", 1, { relay: "wss://bad-relay.com" }); + + const snapshot = metrics.getSnapshot(); + expect(snapshot.relays["wss://good-relay.com"].errors).toBe(0); + expect(snapshot.relays["wss://bad-relay.com"].errors).toBe(3); + }); +}); + +// ============================================================================ +// Reconnect Backoff Tests +// ============================================================================ + +describe("Reconnect Backoff", () => { + it("computes delays within expected bounds", () => { + // Compute expected delays (1s, 2s, 4s, 8s, 16s, 32s, 60s cap) + const BASE = 1000; + const MAX = 60000; + const JITTER = 0.3; + + for (let attempt = 0; attempt < 10; attempt++) { + const exponential = BASE * Math.pow(2, attempt); + const capped = Math.min(exponential, MAX); + const minDelay = capped * (1 - JITTER); + const maxDelay = capped * (1 + JITTER); + + // These are the expected bounds + expect(minDelay).toBeGreaterThanOrEqual(BASE * 0.7); + expect(maxDelay).toBeLessThanOrEqual(MAX * 1.3); + } + }); +}); diff --git a/extensions/nostr/src/nostr-bus.test.ts b/extensions/nostr/src/nostr-bus.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4174f65df0654c46b26975c43a446dc7def1a216 --- /dev/null +++ b/extensions/nostr/src/nostr-bus.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; +import { + validatePrivateKey, + getPublicKeyFromPrivate, + isValidPubkey, + normalizePubkey, + pubkeyToNpub, +} from "./nostr-bus.js"; + +// Test private key (DO NOT use in production - this is a known test key) +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_NSEC = "nsec1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8skqfv3l"; + +describe("validatePrivateKey", () => { + describe("hex format", () => { + it("accepts valid 64-char hex key", () => { + const result = validatePrivateKey(TEST_HEX_KEY); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("accepts lowercase hex", () => { + const result = validatePrivateKey(TEST_HEX_KEY.toLowerCase()); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("accepts uppercase hex", () => { + const result = validatePrivateKey(TEST_HEX_KEY.toUpperCase()); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("accepts mixed case hex", () => { + const mixed = "0123456789ABCdef0123456789abcDEF0123456789abcdef0123456789ABCDEF"; + const result = validatePrivateKey(mixed); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("trims whitespace", () => { + const result = validatePrivateKey(` ${TEST_HEX_KEY} `); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("trims newlines", () => { + const result = validatePrivateKey(`${TEST_HEX_KEY}\n`); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it("rejects 63-char hex (too short)", () => { + expect(() => validatePrivateKey(TEST_HEX_KEY.slice(0, 63))).toThrow( + "Private key must be 64 hex characters", + ); + }); + + it("rejects 65-char hex (too long)", () => { + expect(() => validatePrivateKey(TEST_HEX_KEY + "0")).toThrow( + "Private key must be 64 hex characters", + ); + }); + + it("rejects non-hex characters", () => { + const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; // 'g' at end + expect(() => validatePrivateKey(invalid)).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects empty string", () => { + expect(() => validatePrivateKey("")).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects whitespace-only string", () => { + expect(() => validatePrivateKey(" ")).toThrow("Private key must be 64 hex characters"); + }); + + it("rejects key with 0x prefix", () => { + expect(() => validatePrivateKey("0x" + TEST_HEX_KEY)).toThrow( + "Private key must be 64 hex characters", + ); + }); + }); + + describe("nsec format", () => { + it("rejects invalid nsec (wrong checksum)", () => { + const badNsec = "nsec1invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalid"; + expect(() => validatePrivateKey(badNsec)).toThrow(); + }); + + it("rejects npub (wrong type)", () => { + const npub = "npub1qypqxpq9qtpqscx7peytzfwtdjmcv0mrz5rjpej8vjppfkqfqy8s5epk55"; + expect(() => validatePrivateKey(npub)).toThrow(); + }); + }); +}); + +describe("isValidPubkey", () => { + describe("hex format", () => { + it("accepts valid 64-char hex pubkey", () => { + const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(isValidPubkey(validHex)).toBe(true); + }); + + it("accepts uppercase hex", () => { + const validHex = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + expect(isValidPubkey(validHex)).toBe(true); + }); + + it("rejects 63-char hex", () => { + const shortHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde"; + expect(isValidPubkey(shortHex)).toBe(false); + }); + + it("rejects 65-char hex", () => { + const longHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0"; + expect(isValidPubkey(longHex)).toBe(false); + }); + + it("rejects non-hex characters", () => { + const invalid = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeg"; + expect(isValidPubkey(invalid)).toBe(false); + }); + }); + + describe("npub format", () => { + it("rejects invalid npub", () => { + expect(isValidPubkey("npub1invalid")).toBe(false); + }); + + it("rejects nsec (wrong type)", () => { + expect(isValidPubkey(TEST_NSEC)).toBe(false); + }); + }); + + describe("edge cases", () => { + it("rejects empty string", () => { + expect(isValidPubkey("")).toBe(false); + }); + + it("handles whitespace-padded input", () => { + const validHex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(isValidPubkey(` ${validHex} `)).toBe(true); + }); + }); +}); + +describe("normalizePubkey", () => { + describe("hex format", () => { + it("lowercases hex pubkey", () => { + const upper = "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF"; + const result = normalizePubkey(upper); + expect(result).toBe(upper.toLowerCase()); + }); + + it("trims whitespace", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + expect(normalizePubkey(` ${hex} `)).toBe(hex); + }); + + it("rejects invalid hex", () => { + expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters"); + }); + }); +}); + +describe("getPublicKeyFromPrivate", () => { + it("derives public key from hex private key", () => { + const pubkey = getPublicKeyFromPrivate(TEST_HEX_KEY); + expect(pubkey).toMatch(/^[0-9a-f]{64}$/); + expect(pubkey.length).toBe(64); + }); + + it("derives consistent public key", () => { + const pubkey1 = getPublicKeyFromPrivate(TEST_HEX_KEY); + const pubkey2 = getPublicKeyFromPrivate(TEST_HEX_KEY); + expect(pubkey1).toBe(pubkey2); + }); + + it("throws for invalid private key", () => { + expect(() => getPublicKeyFromPrivate("invalid")).toThrow(); + }); +}); + +describe("pubkeyToNpub", () => { + it("converts hex pubkey to npub format", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const npub = pubkeyToNpub(hex); + expect(npub).toMatch(/^npub1[a-z0-9]+$/); + }); + + it("produces consistent output", () => { + const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const npub1 = pubkeyToNpub(hex); + const npub2 = pubkeyToNpub(hex); + expect(npub1).toBe(npub2); + }); + + it("normalizes uppercase hex first", () => { + const lower = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const upper = lower.toUpperCase(); + expect(pubkeyToNpub(lower)).toBe(pubkeyToNpub(upper)); + }); +}); diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts new file mode 100644 index 0000000000000000000000000000000000000000..bc19348fa8d2dc73aa9ef4c4819c85ef111fad8c --- /dev/null +++ b/extensions/nostr/src/nostr-bus.ts @@ -0,0 +1,715 @@ +import { + SimplePool, + finalizeEvent, + getPublicKey, + verifyEvent, + nip19, + type Event, +} from "nostr-tools"; +import { decrypt, encrypt } from "nostr-tools/nip04"; +import type { NostrProfile } from "./config-schema.js"; +import { + createMetrics, + createNoopMetrics, + type NostrMetrics, + type MetricsSnapshot, + type MetricEvent, +} from "./metrics.js"; +import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js"; +import { + readNostrBusState, + writeNostrBusState, + computeSinceTimestamp, + readNostrProfileState, + writeNostrProfileState, +} from "./nostr-state-store.js"; +import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; + +export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"]; + +// ============================================================================ +// Constants +// ============================================================================ + +const STARTUP_LOOKBACK_SEC = 120; // tolerate relay lag / clock skew +const MAX_PERSISTED_EVENT_IDS = 5000; +const STATE_PERSIST_DEBOUNCE_MS = 5000; // Debounce state writes + +// Circuit breaker configuration +const CIRCUIT_BREAKER_THRESHOLD = 5; // failures before opening +const CIRCUIT_BREAKER_RESET_MS = 30000; // 30 seconds before half-open + +// Health tracker configuration +const HEALTH_WINDOW_MS = 60000; // 1 minute window for health stats + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrBusOptions { + /** Private key in hex or nsec format */ + privateKey: string; + /** WebSocket relay URLs (defaults to damus + nos.lol) */ + relays?: string[]; + /** Account ID for state persistence (optional, defaults to pubkey prefix) */ + accountId?: string; + /** Called when a DM is received */ + onMessage: ( + pubkey: string, + text: string, + reply: (text: string) => Promise, + ) => Promise; + /** Called on errors (optional) */ + onError?: (error: Error, context: string) => void; + /** Called on connection status changes (optional) */ + onConnect?: (relay: string) => void; + /** Called on disconnection (optional) */ + onDisconnect?: (relay: string) => void; + /** Called on EOSE (end of stored events) for initial sync (optional) */ + onEose?: (relay: string) => void; + /** Called on each metric event (optional) */ + onMetric?: (event: MetricEvent) => void; + /** Maximum entries in seen tracker (default: 100,000) */ + maxSeenEntries?: number; + /** Seen tracker TTL in ms (default: 1 hour) */ + seenTtlMs?: number; +} + +export interface NostrBusHandle { + /** Stop the bus and close connections */ + close: () => void; + /** Get the bot's public key */ + publicKey: string; + /** Send a DM to a pubkey */ + sendDm: (toPubkey: string, text: string) => Promise; + /** Get current metrics snapshot */ + getMetrics: () => MetricsSnapshot; + /** Publish a profile (kind:0) to all relays */ + publishProfile: (profile: NostrProfile) => Promise; + /** Get the last profile publish state */ + getProfileState: () => Promise<{ + lastPublishedAt: number | null; + lastPublishedEventId: string | null; + lastPublishResults: Record | null; + }>; +} + +// ============================================================================ +// Circuit Breaker +// ============================================================================ + +interface CircuitBreakerState { + state: "closed" | "open" | "half_open"; + failures: number; + lastFailure: number; + lastSuccess: number; +} + +interface CircuitBreaker { + /** Check if requests should be allowed */ + canAttempt: () => boolean; + /** Record a success */ + recordSuccess: () => void; + /** Record a failure */ + recordFailure: () => void; + /** Get current state */ + getState: () => CircuitBreakerState["state"]; +} + +function createCircuitBreaker( + relay: string, + metrics: NostrMetrics, + threshold: number = CIRCUIT_BREAKER_THRESHOLD, + resetMs: number = CIRCUIT_BREAKER_RESET_MS, +): CircuitBreaker { + const state: CircuitBreakerState = { + state: "closed", + failures: 0, + lastFailure: 0, + lastSuccess: Date.now(), + }; + + return { + canAttempt(): boolean { + if (state.state === "closed") { + return true; + } + + if (state.state === "open") { + // Check if enough time has passed to try half-open + if (Date.now() - state.lastFailure >= resetMs) { + state.state = "half_open"; + metrics.emit("relay.circuit_breaker.half_open", 1, { relay }); + return true; + } + return false; + } + + // half_open: allow one attempt + return true; + }, + + recordSuccess(): void { + if (state.state === "half_open") { + state.state = "closed"; + state.failures = 0; + metrics.emit("relay.circuit_breaker.close", 1, { relay }); + } else if (state.state === "closed") { + state.failures = 0; + } + state.lastSuccess = Date.now(); + }, + + recordFailure(): void { + state.failures++; + state.lastFailure = Date.now(); + + if (state.state === "half_open") { + state.state = "open"; + metrics.emit("relay.circuit_breaker.open", 1, { relay }); + } else if (state.state === "closed" && state.failures >= threshold) { + state.state = "open"; + metrics.emit("relay.circuit_breaker.open", 1, { relay }); + } + }, + + getState(): CircuitBreakerState["state"] { + return state.state; + }, + }; +} + +// ============================================================================ +// Relay Health Tracker +// ============================================================================ + +interface RelayHealthStats { + successCount: number; + failureCount: number; + latencySum: number; + latencyCount: number; + lastSuccess: number; + lastFailure: number; +} + +interface RelayHealthTracker { + /** Record a successful operation */ + recordSuccess: (relay: string, latencyMs: number) => void; + /** Record a failed operation */ + recordFailure: (relay: string) => void; + /** Get health score (0-1, higher is better) */ + getScore: (relay: string) => number; + /** Get relays sorted by health (best first) */ + getSortedRelays: (relays: string[]) => string[]; +} + +function createRelayHealthTracker(): RelayHealthTracker { + const stats = new Map(); + + function getOrCreate(relay: string): RelayHealthStats { + let s = stats.get(relay); + if (!s) { + s = { + successCount: 0, + failureCount: 0, + latencySum: 0, + latencyCount: 0, + lastSuccess: 0, + lastFailure: 0, + }; + stats.set(relay, s); + } + return s; + } + + return { + recordSuccess(relay: string, latencyMs: number): void { + const s = getOrCreate(relay); + s.successCount++; + s.latencySum += latencyMs; + s.latencyCount++; + s.lastSuccess = Date.now(); + }, + + recordFailure(relay: string): void { + const s = getOrCreate(relay); + s.failureCount++; + s.lastFailure = Date.now(); + }, + + getScore(relay: string): number { + const s = stats.get(relay); + if (!s) { + return 0.5; + } // Unknown relay gets neutral score + + const total = s.successCount + s.failureCount; + if (total === 0) { + return 0.5; + } + + // Success rate (0-1) + const successRate = s.successCount / total; + + // Recency bonus (prefer recently successful relays) + const now = Date.now(); + const recencyBonus = + s.lastSuccess > s.lastFailure + ? Math.max(0, 1 - (now - s.lastSuccess) / HEALTH_WINDOW_MS) * 0.2 + : 0; + + // Latency penalty (lower is better) + const avgLatency = s.latencyCount > 0 ? s.latencySum / s.latencyCount : 1000; + const latencyPenalty = Math.min(0.2, avgLatency / 10000); + + return Math.max(0, Math.min(1, successRate + recencyBonus - latencyPenalty)); + }, + + getSortedRelays(relays: string[]): string[] { + return [...relays].toSorted((a, b) => this.getScore(b) - this.getScore(a)); + }, + }; +} + +// ============================================================================ +// Key Validation +// ============================================================================ + +/** + * Validate and normalize a private key (accepts hex or nsec format) + */ +export function validatePrivateKey(key: string): Uint8Array { + const trimmed = key.trim(); + + // Handle nsec (bech32) format + if (trimmed.startsWith("nsec1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key: wrong type"); + } + return decoded.data; + } + + // Handle hex format + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("Private key must be 64 hex characters or nsec bech32 format"); + } + + // Convert hex string to Uint8Array + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Get public key from private key (hex or nsec format) + */ +export function getPublicKeyFromPrivate(privateKey: string): string { + const sk = validatePrivateKey(privateKey); + return getPublicKey(sk); +} + +// ============================================================================ +// Main Bus +// ============================================================================ + +/** + * Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs + */ +export async function startNostrBus(options: NostrBusOptions): Promise { + const { + privateKey, + relays = DEFAULT_RELAYS, + onMessage, + onError, + onEose, + onMetric, + maxSeenEntries = 100_000, + seenTtlMs = 60 * 60 * 1000, + } = options; + + const sk = validatePrivateKey(privateKey); + const pk = getPublicKey(sk); + const pool = new SimplePool(); + const accountId = options.accountId ?? pk.slice(0, 16); + const gatewayStartedAt = Math.floor(Date.now() / 1000); + + // Initialize metrics + const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics(); + + // Initialize seen tracker with LRU + const seen: SeenTracker = createSeenTracker({ + maxEntries: maxSeenEntries, + ttlMs: seenTtlMs, + }); + + // Initialize circuit breakers and health tracker + const circuitBreakers = new Map(); + const healthTracker = createRelayHealthTracker(); + + for (const relay of relays) { + circuitBreakers.set(relay, createCircuitBreaker(relay, metrics)); + } + + // Read persisted state and compute `since` timestamp (with small overlap) + const state = await readNostrBusState({ accountId }); + const baseSince = computeSinceTimestamp(state, gatewayStartedAt); + const since = Math.max(0, baseSince - STARTUP_LOOKBACK_SEC); + + // Seed in-memory dedupe with recent IDs from disk (prevents restart replay) + if (state?.recentEventIds?.length) { + seen.seed(state.recentEventIds); + } + + // Persist startup timestamp + await writeNostrBusState({ + accountId, + lastProcessedAt: state?.lastProcessedAt ?? gatewayStartedAt, + gatewayStartedAt, + recentEventIds: state?.recentEventIds ?? [], + }); + + // Debounced state persistence + let pendingWrite: ReturnType | undefined; + let lastProcessedAt = state?.lastProcessedAt ?? gatewayStartedAt; + let recentEventIds = (state?.recentEventIds ?? []).slice(-MAX_PERSISTED_EVENT_IDS); + + function scheduleStatePersist(eventCreatedAt: number, eventId: string): void { + lastProcessedAt = Math.max(lastProcessedAt, eventCreatedAt); + recentEventIds.push(eventId); + if (recentEventIds.length > MAX_PERSISTED_EVENT_IDS) { + recentEventIds = recentEventIds.slice(-MAX_PERSISTED_EVENT_IDS); + } + + if (pendingWrite) { + clearTimeout(pendingWrite); + } + pendingWrite = setTimeout(() => { + writeNostrBusState({ + accountId, + lastProcessedAt, + gatewayStartedAt, + recentEventIds, + }).catch((err) => onError?.(err as Error, "persist state")); + }, STATE_PERSIST_DEBOUNCE_MS); + } + + const inflight = new Set(); + + // Event handler + async function handleEvent(event: Event): Promise { + try { + metrics.emit("event.received"); + + // Fast dedupe check (handles relay reconnections) + if (seen.peek(event.id) || inflight.has(event.id)) { + metrics.emit("event.duplicate"); + return; + } + inflight.add(event.id); + + // Self-message loop prevention: skip our own messages + if (event.pubkey === pk) { + metrics.emit("event.rejected.self_message"); + return; + } + + // Skip events older than our `since` (relay may ignore filter) + if (event.created_at < since) { + metrics.emit("event.rejected.stale"); + return; + } + + // Fast p-tag check BEFORE crypto (no allocation, cheaper) + let targetsUs = false; + for (const t of event.tags) { + if (t[0] === "p" && t[1] === pk) { + targetsUs = true; + break; + } + } + if (!targetsUs) { + metrics.emit("event.rejected.wrong_kind"); + return; + } + + // Verify signature (must pass before we trust the event) + if (!verifyEvent(event)) { + metrics.emit("event.rejected.invalid_signature"); + onError?.(new Error("Invalid signature"), `event ${event.id}`); + return; + } + + // Mark seen AFTER verify (don't cache invalid IDs) + seen.add(event.id); + metrics.emit("memory.seen_tracker_size", seen.size()); + + // Decrypt the message + let plaintext: string; + try { + plaintext = decrypt(sk, event.pubkey, event.content); + metrics.emit("decrypt.success"); + } catch (err) { + metrics.emit("decrypt.failure"); + metrics.emit("event.rejected.decrypt_failed"); + onError?.(err as Error, `decrypt from ${event.pubkey}`); + return; + } + + // Create reply function (try relays by health score) + const replyTo = async (text: string): Promise => { + await sendEncryptedDm( + pool, + sk, + event.pubkey, + text, + relays, + metrics, + circuitBreakers, + healthTracker, + onError, + ); + }; + + // Call the message handler + await onMessage(event.pubkey, plaintext, replyTo); + + // Mark as processed + metrics.emit("event.processed"); + + // Persist progress (debounced) + scheduleStatePersist(event.created_at, event.id); + } catch (err) { + onError?.(err as Error, `event ${event.id}`); + } finally { + inflight.delete(event.id); + } + } + + const sub = pool.subscribeMany(relays, [{ kinds: [4], "#p": [pk], since }], { + onevent: handleEvent, + oneose: () => { + // EOSE handler - called when all stored events have been received + for (const relay of relays) { + metrics.emit("relay.message.eose", 1, { relay }); + } + onEose?.(relays.join(", ")); + }, + onclose: (reason) => { + // Handle subscription close + for (const relay of relays) { + metrics.emit("relay.message.closed", 1, { relay }); + options.onDisconnect?.(relay); + } + onError?.(new Error(`Subscription closed: ${reason.join(", ")}`), "subscription"); + }, + }); + + // Public sendDm function + const sendDm = async (toPubkey: string, text: string): Promise => { + await sendEncryptedDm( + pool, + sk, + toPubkey, + text, + relays, + metrics, + circuitBreakers, + healthTracker, + onError, + ); + }; + + // Profile publishing function + const publishProfile = async (profile: NostrProfile): Promise => { + // Read last published timestamp for monotonic ordering + const profileState = await readNostrProfileState({ accountId }); + const lastPublishedAt = profileState?.lastPublishedAt ?? undefined; + + // Publish the profile + const result = await publishProfileFn(pool, sk, relays, profile, lastPublishedAt); + + // Convert results to state format + const publishResults: Record = {}; + for (const relay of result.successes) { + publishResults[relay] = "ok"; + } + for (const { relay, error } of result.failures) { + publishResults[relay] = error === "timeout" ? "timeout" : "failed"; + } + + // Persist the publish state + await writeNostrProfileState({ + accountId, + lastPublishedAt: result.createdAt, + lastPublishedEventId: result.eventId, + lastPublishResults: publishResults, + }); + + return result; + }; + + // Get profile state function + const getProfileState = async () => { + const state = await readNostrProfileState({ accountId }); + return { + lastPublishedAt: state?.lastPublishedAt ?? null, + lastPublishedEventId: state?.lastPublishedEventId ?? null, + lastPublishResults: state?.lastPublishResults ?? null, + }; + }; + + return { + close: () => { + sub.close(); + seen.stop(); + // Flush pending state write synchronously on close + if (pendingWrite) { + clearTimeout(pendingWrite); + writeNostrBusState({ + accountId, + lastProcessedAt, + gatewayStartedAt, + recentEventIds, + }).catch((err) => onError?.(err as Error, "persist state on close")); + } + }, + publicKey: pk, + sendDm, + getMetrics: () => metrics.getSnapshot(), + publishProfile, + getProfileState, + }; +} + +// ============================================================================ +// Send DM with Circuit Breaker + Health Scoring +// ============================================================================ + +/** + * Send an encrypted DM to a pubkey + */ +async function sendEncryptedDm( + pool: SimplePool, + sk: Uint8Array, + toPubkey: string, + text: string, + relays: string[], + metrics: NostrMetrics, + circuitBreakers: Map, + healthTracker: RelayHealthTracker, + onError?: (error: Error, context: string) => void, +): Promise { + const ciphertext = encrypt(sk, toPubkey, text); + const reply = finalizeEvent( + { + kind: 4, + content: ciphertext, + tags: [["p", toPubkey]], + created_at: Math.floor(Date.now() / 1000), + }, + sk, + ); + + // Sort relays by health score (best first) + const sortedRelays = healthTracker.getSortedRelays(relays); + + // Try relays in order of health, respecting circuit breakers + let lastError: Error | undefined; + for (const relay of sortedRelays) { + const cb = circuitBreakers.get(relay); + + // Skip if circuit breaker is open + if (cb && !cb.canAttempt()) { + continue; + } + + const startTime = Date.now(); + try { + // oxlint-disable-next-line typescript/await-thenable typesciript/no-floating-promises + await pool.publish([relay], reply); + const latency = Date.now() - startTime; + + // Record success + cb?.recordSuccess(); + healthTracker.recordSuccess(relay, latency); + + return; // Success - exit early + } catch (err) { + lastError = err as Error; + const latency = Date.now() - startTime; + + // Record failure + cb?.recordFailure(); + healthTracker.recordFailure(relay); + metrics.emit("relay.error", 1, { relay, latency }); + + onError?.(lastError, `publish to ${relay}`); + } + } + + throw new Error(`Failed to publish to any relay: ${lastError?.message}`); +} + +// ============================================================================ +// Pubkey Utilities +// ============================================================================ + +/** + * Check if a string looks like a valid Nostr pubkey (hex or npub) + */ +export function isValidPubkey(input: string): boolean { + if (typeof input !== "string") { + return false; + } + const trimmed = input.trim(); + + // npub format + if (trimmed.startsWith("npub1")) { + try { + const decoded = nip19.decode(trimmed); + return decoded.type === "npub"; + } catch { + return false; + } + } + + // Hex format + return /^[0-9a-fA-F]{64}$/.test(trimmed); +} + +/** + * Normalize a pubkey to hex format (accepts npub or hex) + */ +export function normalizePubkey(input: string): string { + const trimmed = input.trim(); + + // npub format - decode to hex + if (trimmed.startsWith("npub1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "npub") { + throw new Error("Invalid npub key"); + } + // Convert Uint8Array to hex string + return Array.from(decoded.data) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + // Already hex - validate and return lowercase + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("Pubkey must be 64 hex characters or npub format"); + } + return trimmed.toLowerCase(); +} + +/** + * Convert a hex pubkey to npub format + */ +export function pubkeyToNpub(hexPubkey: string): string { + const normalized = normalizePubkey(hexPubkey); + // npubEncode expects a hex string, not Uint8Array + return nip19.npubEncode(normalized); +} diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ccee61ef8e7e0bf9005a813b704dc1aef6e8016 --- /dev/null +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -0,0 +1,378 @@ +/** + * Tests for Nostr Profile HTTP Handler + */ + +import { IncomingMessage, ServerResponse } from "node:http"; +import { Socket } from "node:net"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + createNostrProfileHttpHandler, + type NostrProfileHttpContext, +} from "./nostr-profile-http.js"; + +// Mock the channel exports +vi.mock("./channel.js", () => ({ + publishNostrProfile: vi.fn(), + getNostrProfileState: vi.fn(), +})); + +// Mock the import module +vi.mock("./nostr-profile-import.js", () => ({ + importProfileFromRelays: vi.fn(), + mergeProfiles: vi.fn((local, imported) => ({ ...imported, ...local })), +})); + +import { publishNostrProfile, getNostrProfileState } from "./channel.js"; +import { importProfileFromRelays } from "./nostr-profile-import.js"; + +// ============================================================================ +// Test Helpers +// ============================================================================ + +function createMockRequest(method: string, url: string, body?: unknown): IncomingMessage { + const socket = new Socket(); + const req = new IncomingMessage(socket); + req.method = method; + req.url = url; + req.headers = { host: "localhost:3000" }; + + if (body) { + const bodyStr = JSON.stringify(body); + process.nextTick(() => { + req.emit("data", Buffer.from(bodyStr)); + req.emit("end"); + }); + } else { + process.nextTick(() => { + req.emit("end"); + }); + } + + return req; +} + +function createMockResponse(): ServerResponse & { + _getData: () => string; + _getStatusCode: () => number; +} { + const res = new ServerResponse({} as IncomingMessage); + + let data = ""; + let statusCode = 200; + + res.write = function (chunk: unknown) { + data += String(chunk); + return true; + }; + + res.end = function (chunk?: unknown) { + if (chunk) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + data += String(chunk); + } + return this; + }; + + Object.defineProperty(res, "statusCode", { + get: () => statusCode, + set: (code: number) => { + statusCode = code; + }, + }); + + (res as unknown as { _getData: () => string })._getData = () => data; + (res as unknown as { _getStatusCode: () => number })._getStatusCode = () => statusCode; + + return res as ServerResponse & { _getData: () => string; _getStatusCode: () => number }; +} + +function createMockContext(overrides?: Partial): NostrProfileHttpContext { + return { + getConfigProfile: vi.fn().mockReturnValue(undefined), + updateConfigProfile: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockReturnValue({ + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + relays: ["wss://relay.damus.io"], + }), + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + ...overrides, + }; +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe("nostr-profile-http", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("route matching", () => { + it("returns false for non-nostr paths", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/telegram/profile"); + const res = createMockResponse(); + + const result = await handler(req, res); + + expect(result).toBe(false); + }); + + it("returns false for paths without accountId", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/"); + const res = createMockResponse(); + + const result = await handler(req, res); + + expect(result).toBe(false); + }); + + it("handles /api/channels/nostr/:accountId/profile", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/default/profile"); + const res = createMockResponse(); + + vi.mocked(getNostrProfileState).mockResolvedValue(null); + + const result = await handler(req, res); + + expect(result).toBe(true); + }); + }); + + describe("GET /api/channels/nostr/:accountId/profile", () => { + it("returns profile and publish state", async () => { + const ctx = createMockContext({ + getConfigProfile: vi.fn().mockReturnValue({ + name: "testuser", + displayName: "Test User", + }), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("GET", "/api/channels/nostr/default/profile"); + const res = createMockResponse(); + + vi.mocked(getNostrProfileState).mockResolvedValue({ + lastPublishedAt: 1234567890, + lastPublishedEventId: "abc123", + lastPublishResults: { "wss://relay.damus.io": "ok" }, + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.profile.name).toBe("testuser"); + expect(data.publishState.lastPublishedAt).toBe(1234567890); + }); + }); + + describe("PUT /api/channels/nostr/:accountId/profile", () => { + it("validates profile and publishes", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "satoshi", + displayName: "Satoshi Nakamoto", + about: "Creator of Bitcoin", + }); + const res = createMockResponse(); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: ["wss://relay.damus.io"], + failures: [], + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.eventId).toBe("event123"); + expect(data.successes).toContain("wss://relay.damus.io"); + expect(data.persisted).toBe(true); + expect(ctx.updateConfigProfile).toHaveBeenCalled(); + }); + + it("rejects private IP in picture URL (SSRF protection)", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "hacker", + picture: "https://127.0.0.1/evil.jpg", + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + expect(data.error).toContain("private"); + }); + + it("rejects non-https URLs", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "test", + picture: "http://example.com/pic.jpg", + }); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(400); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(false); + // The schema validation catches non-https URLs before SSRF check + expect(data.error).toBe("Validation failed"); + expect(data.details).toBeDefined(); + expect(data.details.some((d: string) => d.includes("https"))).toBe(true); + }); + + it("does not persist if all relays fail", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("PUT", "/api/channels/nostr/default/profile", { + name: "test", + }); + const res = createMockResponse(); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: [], + failures: [{ relay: "wss://relay.damus.io", error: "timeout" }], + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.persisted).toBe(false); + expect(ctx.updateConfigProfile).not.toHaveBeenCalled(); + }); + + it("enforces rate limiting", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + + vi.mocked(publishNostrProfile).mockResolvedValue({ + eventId: "event123", + createdAt: 1234567890, + successes: ["wss://relay.damus.io"], + failures: [], + }); + + // Make 6 requests (limit is 5/min) + for (let i = 0; i < 6; i++) { + const req = createMockRequest("PUT", "/api/channels/nostr/rate-test/profile", { + name: `user${i}`, + }); + const res = createMockResponse(); + await handler(req, res); + + if (i < 5) { + expect(res._getStatusCode()).toBe(200); + } else { + expect(res._getStatusCode()).toBe(429); + const data = JSON.parse(res._getData()); + expect(data.error).toContain("Rate limit"); + } + } + }); + }); + + describe("POST /api/channels/nostr/:accountId/profile/import", () => { + it("imports profile from relays", async () => { + const ctx = createMockContext(); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {}); + const res = createMockResponse(); + + vi.mocked(importProfileFromRelays).mockResolvedValue({ + ok: true, + profile: { + name: "imported", + displayName: "Imported User", + }, + event: { + id: "evt123", + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + created_at: 1234567890, + }, + relaysQueried: ["wss://relay.damus.io"], + sourceRelay: "wss://relay.damus.io", + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.ok).toBe(true); + expect(data.imported.name).toBe("imported"); + expect(data.saved).toBe(false); // autoMerge not requested + }); + + it("auto-merges when requested", async () => { + const ctx = createMockContext({ + getConfigProfile: vi.fn().mockReturnValue({ about: "local bio" }), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", { + autoMerge: true, + }); + const res = createMockResponse(); + + vi.mocked(importProfileFromRelays).mockResolvedValue({ + ok: true, + profile: { + name: "imported", + displayName: "Imported User", + }, + event: { + id: "evt123", + pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + created_at: 1234567890, + }, + relaysQueried: ["wss://relay.damus.io"], + sourceRelay: "wss://relay.damus.io", + }); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(200); + const data = JSON.parse(res._getData()); + expect(data.saved).toBe(true); + expect(ctx.updateConfigProfile).toHaveBeenCalled(); + }); + + it("returns error when account not found", async () => { + const ctx = createMockContext({ + getAccountInfo: vi.fn().mockReturnValue(null), + }); + const handler = createNostrProfileHttpHandler(ctx); + const req = createMockRequest("POST", "/api/channels/nostr/unknown/profile/import", {}); + const res = createMockResponse(); + + await handler(req, res); + + expect(res._getStatusCode()).toBe(404); + const data = JSON.parse(res._getData()); + expect(data.error).toContain("not found"); + }); + }); +}); diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts new file mode 100644 index 0000000000000000000000000000000000000000..499c4c8a904d70e41e3d07ddbfeeed6eff186f9c --- /dev/null +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -0,0 +1,519 @@ +/** + * Nostr Profile HTTP Handler + * + * Handles HTTP requests for profile management: + * - PUT /api/channels/nostr/:accountId/profile - Update and publish profile + * - POST /api/channels/nostr/:accountId/profile/import - Import from relays + * - GET /api/channels/nostr/:accountId/profile - Get current profile state + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; +import { z } from "zod"; +import { publishNostrProfile, getNostrProfileState } from "./channel.js"; +import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; +import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface NostrProfileHttpContext { + /** Get current profile from config */ + getConfigProfile: (accountId: string) => NostrProfile | undefined; + /** Update profile in config (after successful publish) */ + updateConfigProfile: (accountId: string, profile: NostrProfile) => Promise; + /** Get account's public key and relays */ + getAccountInfo: (accountId: string) => { pubkey: string; relays: string[] } | null; + /** Logger */ + log?: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; +} + +// ============================================================================ +// Rate Limiting +// ============================================================================ + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +const rateLimitMap = new Map(); +const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute +const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute + +function checkRateLimit(accountId: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(accountId); + + if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) { + rateLimitMap.set(accountId, { count: 1, windowStart: now }); + return true; + } + + if (entry.count >= RATE_LIMIT_MAX_REQUESTS) { + return false; + } + + entry.count++; + return true; +} + +// ============================================================================ +// Mutex for Concurrent Publish Prevention +// ============================================================================ + +const publishLocks = new Map>(); + +async function withPublishLock(accountId: string, fn: () => Promise): Promise { + // Atomic mutex using promise chaining - prevents TOCTOU race condition + const prev = publishLocks.get(accountId) ?? Promise.resolve(); + let resolve: () => void; + const next = new Promise((r) => { + resolve = r; + }); + // Atomically replace the lock before awaiting - any concurrent request + // will now wait on our `next` promise + publishLocks.set(accountId, next); + + // Wait for previous operation to complete + await prev.catch(() => {}); + + try { + return await fn(); + } finally { + resolve!(); + // Clean up if we're the last in chain + if (publishLocks.get(accountId) === next) { + publishLocks.delete(accountId); + } + } +} + +// ============================================================================ +// SSRF Protection +// ============================================================================ + +// Block common private/internal hostnames (quick string check) +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "127.0.0.1", + "::1", + "[::1]", + "0.0.0.0", +]); + +// Check if an IP address (resolved) is in a private range +function isPrivateIp(ip: string): boolean { + // Handle IPv4 + const ipv4Match = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (ipv4Match) { + const [, a, b] = ipv4Match.map(Number); + // 127.0.0.0/8 (loopback) + if (a === 127) { + return true; + } + // 10.0.0.0/8 (private) + if (a === 10) { + return true; + } + // 172.16.0.0/12 (private) + if (a === 172 && b >= 16 && b <= 31) { + return true; + } + // 192.168.0.0/16 (private) + if (a === 192 && b === 168) { + return true; + } + // 169.254.0.0/16 (link-local) + if (a === 169 && b === 254) { + return true; + } + // 0.0.0.0/8 + if (a === 0) { + return true; + } + return false; + } + + // Handle IPv6 + const ipLower = ip.toLowerCase().replace(/^\[|\]$/g, ""); + // ::1 (loopback) + if (ipLower === "::1") { + return true; + } + // fe80::/10 (link-local) + if (ipLower.startsWith("fe80:")) { + return true; + } + // fc00::/7 (unique local) + if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) { + return true; + } + // ::ffff:x.x.x.x (IPv4-mapped IPv6) - extract and check IPv4 + const v4Mapped = ipLower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4Mapped) { + return isPrivateIp(v4Mapped[1]); + } + + return false; +} + +function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } { + try { + const url = new URL(urlStr); + + if (url.protocol !== "https:") { + return { ok: false, error: "URL must use https:// protocol" }; + } + + const hostname = url.hostname.toLowerCase(); + + // Quick hostname block check + if (BLOCKED_HOSTNAMES.has(hostname)) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + // Check if hostname is an IP address directly + if (isPrivateIp(hostname)) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + // Block suspicious TLDs that resolve to localhost + if (hostname.endsWith(".localhost") || hostname.endsWith(".local")) { + return { ok: false, error: "URL must not point to private/internal addresses" }; + } + + return { ok: true }; + } catch { + return { ok: false, error: "Invalid URL format" }; + } +} + +// Export for use in import validation +export { validateUrlSafety }; + +// ============================================================================ +// Validation Schemas +// ============================================================================ + +// NIP-05 format: user@domain.com +const nip05FormatSchema = z + .string() + .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid NIP-05 format (user@domain.com)") + .optional(); + +// LUD-16 Lightning address format: user@domain.com +const lud16FormatSchema = z + .string() + .regex(/^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,}$/i, "Invalid Lightning address format") + .optional(); + +// Extended profile schema with additional format validation +const ProfileUpdateSchema = NostrProfileSchema.extend({ + nip05: nip05FormatSchema, + lud16: lud16FormatSchema, +}); + +// ============================================================================ +// Request Helpers +// ============================================================================ + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + + req.on("data", (chunk: Buffer) => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + reject(new Error("Request body too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + + req.on("end", () => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error("Invalid JSON")); + } + }); + + req.on("error", reject); + }); +} + +function parseAccountIdFromPath(pathname: string): string | null { + // Match: /api/channels/nostr/:accountId/profile + const match = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/profile/); + return match?.[1] ?? null; +} + +// ============================================================================ +// HTTP Handler +// ============================================================================ + +export function createNostrProfileHttpHandler( + ctx: NostrProfileHttpContext, +): (req: IncomingMessage, res: ServerResponse) => Promise { + return async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + + // Only handle /api/channels/nostr/:accountId/profile paths + if (!url.pathname.startsWith("/api/channels/nostr/")) { + return false; + } + + const accountId = parseAccountIdFromPath(url.pathname); + if (!accountId) { + return false; + } + + const isImport = url.pathname.endsWith("/profile/import"); + const isProfilePath = url.pathname.endsWith("/profile") || isImport; + + if (!isProfilePath) { + return false; + } + + // Handle different HTTP methods + try { + if (req.method === "GET" && !isImport) { + return await handleGetProfile(accountId, ctx, res); + } + + if (req.method === "PUT" && !isImport) { + return await handleUpdateProfile(accountId, ctx, req, res); + } + + if (req.method === "POST" && isImport) { + return await handleImportProfile(accountId, ctx, req, res); + } + + // Method not allowed + sendJson(res, 405, { ok: false, error: "Method not allowed" }); + return true; + } catch (err) { + ctx.log?.error(`Profile HTTP error: ${String(err)}`); + sendJson(res, 500, { ok: false, error: "Internal server error" }); + return true; + } + }; +} + +// ============================================================================ +// GET /api/channels/nostr/:accountId/profile +// ============================================================================ + +async function handleGetProfile( + accountId: string, + ctx: NostrProfileHttpContext, + res: ServerResponse, +): Promise { + const configProfile = ctx.getConfigProfile(accountId); + const publishState = await getNostrProfileState(accountId); + + sendJson(res, 200, { + ok: true, + profile: configProfile ?? null, + publishState: publishState ?? null, + }); + return true; +} + +// ============================================================================ +// PUT /api/channels/nostr/:accountId/profile +// ============================================================================ + +async function handleUpdateProfile( + accountId: string, + ctx: NostrProfileHttpContext, + req: IncomingMessage, + res: ServerResponse, +): Promise { + // Rate limiting + if (!checkRateLimit(accountId)) { + sendJson(res, 429, { ok: false, error: "Rate limit exceeded (5 requests/minute)" }); + return true; + } + + // Parse body + let body: unknown; + try { + body = await readJsonBody(req); + } catch (err) { + sendJson(res, 400, { ok: false, error: String(err) }); + return true; + } + + // Validate profile + const parseResult = ProfileUpdateSchema.safeParse(body); + if (!parseResult.success) { + const errors = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`); + sendJson(res, 400, { ok: false, error: "Validation failed", details: errors }); + return true; + } + + const profile = parseResult.data; + + // SSRF check for picture URL + if (profile.picture) { + const pictureCheck = validateUrlSafety(profile.picture); + if (!pictureCheck.ok) { + sendJson(res, 400, { ok: false, error: `picture: ${pictureCheck.error}` }); + return true; + } + } + + // SSRF check for banner URL + if (profile.banner) { + const bannerCheck = validateUrlSafety(profile.banner); + if (!bannerCheck.ok) { + sendJson(res, 400, { ok: false, error: `banner: ${bannerCheck.error}` }); + return true; + } + } + + // SSRF check for website URL + if (profile.website) { + const websiteCheck = validateUrlSafety(profile.website); + if (!websiteCheck.ok) { + sendJson(res, 400, { ok: false, error: `website: ${websiteCheck.error}` }); + return true; + } + } + + // Merge with existing profile to preserve unknown fields + const existingProfile = ctx.getConfigProfile(accountId) ?? {}; + const mergedProfile: NostrProfile = { + ...existingProfile, + ...profile, + }; + + // Publish with mutex to prevent concurrent publishes + try { + const result = await withPublishLock(accountId, async () => { + return await publishNostrProfile(accountId, mergedProfile); + }); + + // Only persist if at least one relay succeeded + if (result.successes.length > 0) { + await ctx.updateConfigProfile(accountId, mergedProfile); + ctx.log?.info(`[${accountId}] Profile published to ${result.successes.length} relay(s)`); + } else { + ctx.log?.warn(`[${accountId}] Profile publish failed on all relays`); + } + + sendJson(res, 200, { + ok: true, + eventId: result.eventId, + createdAt: result.createdAt, + successes: result.successes, + failures: result.failures, + persisted: result.successes.length > 0, + }); + } catch (err) { + ctx.log?.error(`[${accountId}] Profile publish error: ${String(err)}`); + sendJson(res, 500, { ok: false, error: `Publish failed: ${String(err)}` }); + } + + return true; +} + +// ============================================================================ +// POST /api/channels/nostr/:accountId/profile/import +// ============================================================================ + +async function handleImportProfile( + accountId: string, + ctx: NostrProfileHttpContext, + req: IncomingMessage, + res: ServerResponse, +): Promise { + // Get account info + const accountInfo = ctx.getAccountInfo(accountId); + if (!accountInfo) { + sendJson(res, 404, { ok: false, error: `Account not found: ${accountId}` }); + return true; + } + + const { pubkey, relays } = accountInfo; + + if (!pubkey) { + sendJson(res, 400, { ok: false, error: "Account has no public key configured" }); + return true; + } + + // Parse options from body + let autoMerge = false; + try { + const body = await readJsonBody(req); + if (typeof body === "object" && body !== null) { + autoMerge = (body as { autoMerge?: boolean }).autoMerge === true; + } + } catch { + // Ignore body parse errors - use defaults + } + + ctx.log?.info(`[${accountId}] Importing profile for ${pubkey.slice(0, 8)}...`); + + // Import from relays + const result = await importProfileFromRelays({ + pubkey, + relays, + timeoutMs: 10_000, // 10 seconds for import + }); + + if (!result.ok) { + sendJson(res, 200, { + ok: false, + error: result.error, + relaysQueried: result.relaysQueried, + }); + return true; + } + + // If autoMerge is requested, merge and save + if (autoMerge && result.profile) { + const localProfile = ctx.getConfigProfile(accountId); + const merged = mergeProfiles(localProfile, result.profile); + await ctx.updateConfigProfile(accountId, merged); + ctx.log?.info(`[${accountId}] Profile imported and merged`); + + sendJson(res, 200, { + ok: true, + imported: result.profile, + merged, + saved: true, + event: result.event, + sourceRelay: result.sourceRelay, + relaysQueried: result.relaysQueried, + }); + return true; + } + + // Otherwise, just return the imported profile for review + sendJson(res, 200, { + ok: true, + imported: result.profile, + saved: false, + event: result.event, + sourceRelay: result.sourceRelay, + relaysQueried: result.relaysQueried, + }); + return true; +} diff --git a/extensions/nostr/src/nostr-profile-import.test.ts b/extensions/nostr/src/nostr-profile-import.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..57bee0e7fd8a11c8828139a375d8c0641c0b7566 --- /dev/null +++ b/extensions/nostr/src/nostr-profile-import.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for Nostr Profile Import + */ + +import { describe, it, expect } from "vitest"; +import type { NostrProfile } from "./config-schema.js"; +import { mergeProfiles } from "./nostr-profile-import.js"; + +// Note: importProfileFromRelays requires real network calls or complex mocking +// of nostr-tools SimplePool, so we focus on unit testing mergeProfiles + +describe("nostr-profile-import", () => { + describe("mergeProfiles", () => { + it("returns empty object when both are undefined", () => { + const result = mergeProfiles(undefined, undefined); + expect(result).toEqual({}); + }); + + it("returns imported when local is undefined", () => { + const imported: NostrProfile = { + name: "imported", + displayName: "Imported User", + about: "Bio from relay", + }; + const result = mergeProfiles(undefined, imported); + expect(result).toEqual(imported); + }); + + it("returns local when imported is undefined", () => { + const local: NostrProfile = { + name: "local", + displayName: "Local User", + }; + const result = mergeProfiles(local, undefined); + expect(result).toEqual(local); + }); + + it("prefers local values over imported", () => { + const local: NostrProfile = { + name: "localname", + about: "Local bio", + }; + const imported: NostrProfile = { + name: "importedname", + displayName: "Imported Display", + about: "Imported bio", + picture: "https://example.com/pic.jpg", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("localname"); // local wins + expect(result.displayName).toBe("Imported Display"); // imported fills gap + expect(result.about).toBe("Local bio"); // local wins + expect(result.picture).toBe("https://example.com/pic.jpg"); // imported fills gap + }); + + it("fills all missing fields from imported", () => { + const local: NostrProfile = { + name: "myname", + }; + const imported: NostrProfile = { + name: "theirname", + displayName: "Their Name", + about: "Their bio", + picture: "https://example.com/pic.jpg", + banner: "https://example.com/banner.jpg", + website: "https://example.com", + nip05: "user@example.com", + lud16: "user@getalby.com", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("myname"); + expect(result.displayName).toBe("Their Name"); + expect(result.about).toBe("Their bio"); + expect(result.picture).toBe("https://example.com/pic.jpg"); + expect(result.banner).toBe("https://example.com/banner.jpg"); + expect(result.website).toBe("https://example.com"); + expect(result.nip05).toBe("user@example.com"); + expect(result.lud16).toBe("user@getalby.com"); + }); + + it("handles empty strings as falsy (prefers imported)", () => { + const local: NostrProfile = { + name: "", + displayName: "", + }; + const imported: NostrProfile = { + name: "imported", + displayName: "Imported", + }; + + const result = mergeProfiles(local, imported); + + // Empty strings are still strings, so they "win" over imported + // This is JavaScript nullish coalescing behavior + expect(result.name).toBe(""); + expect(result.displayName).toBe(""); + }); + + it("handles null values in local (prefers imported)", () => { + const local: NostrProfile = { + name: undefined, + displayName: undefined, + }; + const imported: NostrProfile = { + name: "imported", + displayName: "Imported", + }; + + const result = mergeProfiles(local, imported); + + expect(result.name).toBe("imported"); + expect(result.displayName).toBe("Imported"); + }); + }); +}); diff --git a/extensions/nostr/src/nostr-profile-import.ts b/extensions/nostr/src/nostr-profile-import.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5a107c18c3ec752eae4af84f83218944ec39e3e --- /dev/null +++ b/extensions/nostr/src/nostr-profile-import.ts @@ -0,0 +1,262 @@ +/** + * Nostr Profile Import + * + * Fetches and verifies kind:0 profile events from relays. + * Used to import existing profiles before editing. + */ + +import { SimplePool, verifyEvent, type Event } from "nostr-tools"; +import type { NostrProfile } from "./config-schema.js"; +import { validateUrlSafety } from "./nostr-profile-http.js"; +import { contentToProfile, type ProfileContent } from "./nostr-profile.js"; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ProfileImportResult { + /** Whether the import was successful */ + ok: boolean; + /** The imported profile (if found and valid) */ + profile?: NostrProfile; + /** The raw event (for advanced users) */ + event?: { + id: string; + pubkey: string; + created_at: number; + }; + /** Error message if import failed */ + error?: string; + /** Which relays responded */ + relaysQueried: string[]; + /** Which relay provided the winning event */ + sourceRelay?: string; +} + +export interface ProfileImportOptions { + /** The public key to fetch profile for */ + pubkey: string; + /** Relay URLs to query */ + relays: string[]; + /** Timeout per relay in milliseconds (default: 5000) */ + timeoutMs?: number; +} + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_TIMEOUT_MS = 5000; + +// ============================================================================ +// Profile Import +// ============================================================================ + +/** + * Sanitize URLs in an imported profile to prevent SSRF attacks. + * Removes any URLs that don't pass SSRF validation. + */ +function sanitizeProfileUrls(profile: NostrProfile): NostrProfile { + const result = { ...profile }; + const urlFields = ["picture", "banner", "website"] as const; + + for (const field of urlFields) { + const value = result[field]; + if (value && typeof value === "string") { + const validation = validateUrlSafety(value); + if (!validation.ok) { + // Remove unsafe URL + delete result[field]; + } + } + } + + return result; +} + +/** + * Fetch the latest kind:0 profile event for a pubkey from relays. + * + * - Queries all relays in parallel + * - Takes the event with the highest created_at + * - Verifies the event signature + * - Parses and returns the profile + */ +export async function importProfileFromRelays( + opts: ProfileImportOptions, +): Promise { + const { pubkey, relays, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; + + if (!pubkey || !/^[0-9a-fA-F]{64}$/.test(pubkey)) { + return { + ok: false, + error: "Invalid pubkey format (must be 64 hex characters)", + relaysQueried: [], + }; + } + + if (relays.length === 0) { + return { + ok: false, + error: "No relays configured", + relaysQueried: [], + }; + } + + const pool = new SimplePool(); + const relaysQueried: string[] = []; + + try { + // Query all relays for kind:0 events from this pubkey + const events: Array<{ event: Event; relay: string }> = []; + + // Create timeout promise + const timeoutPromise = new Promise((resolve) => { + setTimeout(resolve, timeoutMs); + }); + + // Create subscription promise + const subscriptionPromise = new Promise((resolve) => { + let completed = 0; + + for (const relay of relays) { + relaysQueried.push(relay); + + const sub = pool.subscribeMany( + [relay], + [ + { + kinds: [0], + authors: [pubkey], + limit: 1, + }, + ], + { + onevent(event) { + events.push({ event, relay }); + }, + oneose() { + completed++; + if (completed >= relays.length) { + resolve(); + } + }, + onclose() { + completed++; + if (completed >= relays.length) { + resolve(); + } + }, + }, + ); + + // Clean up subscription after timeout + setTimeout(() => { + sub.close(); + }, timeoutMs); + } + }); + + // Wait for either all relays to respond or timeout + await Promise.race([subscriptionPromise, timeoutPromise]); + + // No events found + if (events.length === 0) { + return { + ok: false, + error: "No profile found on any relay", + relaysQueried, + }; + } + + // Find the event with the highest created_at (newest wins for replaceable events) + let bestEvent: { event: Event; relay: string } | null = null; + for (const item of events) { + if (!bestEvent || item.event.created_at > bestEvent.event.created_at) { + bestEvent = item; + } + } + + if (!bestEvent) { + return { + ok: false, + error: "No valid profile event found", + relaysQueried, + }; + } + + // Verify the event signature + const isValid = verifyEvent(bestEvent.event); + if (!isValid) { + return { + ok: false, + error: "Profile event has invalid signature", + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } + + // Parse the profile content + let content: ProfileContent; + try { + content = JSON.parse(bestEvent.event.content) as ProfileContent; + } catch { + return { + ok: false, + error: "Profile event has invalid JSON content", + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } + + // Convert to our profile format + const profile = contentToProfile(content); + + // Sanitize URLs from imported profile to prevent SSRF when auto-merging + const sanitizedProfile = sanitizeProfileUrls(profile); + + return { + ok: true, + profile: sanitizedProfile, + event: { + id: bestEvent.event.id, + pubkey: bestEvent.event.pubkey, + created_at: bestEvent.event.created_at, + }, + relaysQueried, + sourceRelay: bestEvent.relay, + }; + } finally { + pool.close(relays); + } +} + +/** + * Merge imported profile with local profile. + * + * Strategy: + * - For each field, prefer local if set, otherwise use imported + * - This preserves user customizations while filling in missing data + */ +export function mergeProfiles( + local: NostrProfile | undefined, + imported: NostrProfile | undefined, +): NostrProfile { + if (!imported) { + return local ?? {}; + } + if (!local) { + return imported; + } + + return { + name: local.name ?? imported.name, + displayName: local.displayName ?? imported.displayName, + about: local.about ?? imported.about, + picture: local.picture ?? imported.picture, + banner: local.banner ?? imported.banner, + website: local.website ?? imported.website, + nip05: local.nip05 ?? imported.nip05, + lud16: local.lud16 ?? imported.lud16, + }; +} diff --git a/extensions/nostr/src/nostr-profile.fuzz.test.ts b/extensions/nostr/src/nostr-profile.fuzz.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1e67b66a456b0592c4184fd26512fd7dd45a8cea --- /dev/null +++ b/extensions/nostr/src/nostr-profile.fuzz.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from "vitest"; +import type { NostrProfile } from "./config-schema.js"; +import { + createProfileEvent, + profileToContent, + validateProfile, + sanitizeProfileForDisplay, +} from "./nostr-profile.js"; + +// Test private key +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))); + +// ============================================================================ +// Unicode Attack Vectors +// ============================================================================ + +describe("profile unicode attacks", () => { + describe("zero-width characters", () => { + it("handles zero-width space in name", () => { + const profile: NostrProfile = { + name: "test\u200Buser", // Zero-width space + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + // The character should be preserved (not stripped) + expect(result.profile?.name).toBe("test\u200Buser"); + }); + + it("handles zero-width joiner in name", () => { + const profile: NostrProfile = { + name: "test\u200Duser", // Zero-width joiner + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles zero-width non-joiner in about", () => { + const profile: NostrProfile = { + about: "test\u200Cabout", // Zero-width non-joiner + }; + const content = profileToContent(profile); + expect(content.about).toBe("test\u200Cabout"); + }); + }); + + describe("RTL override attacks", () => { + it("handles RTL override in name", () => { + const profile: NostrProfile = { + name: "\u202Eevil\u202C", // Right-to-left override + pop direction + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + + // UI should escape or handle this + const sanitized = sanitizeProfileForDisplay(result.profile!); + expect(sanitized.name).toBeDefined(); + }); + + it("handles bidi embedding in about", () => { + const profile: NostrProfile = { + about: "Normal \u202Breversed\u202C text", // LTR embedding + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("homoglyph attacks", () => { + it("handles Cyrillic homoglyphs", () => { + const profile: NostrProfile = { + // Cyrillic 'а' (U+0430) looks like Latin 'a' + name: "\u0430dmin", // Fake "admin" + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + // Profile is accepted but apps should be aware + }); + + it("handles Greek homoglyphs", () => { + const profile: NostrProfile = { + // Greek 'ο' (U+03BF) looks like Latin 'o' + name: "b\u03BFt", // Looks like "bot" + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("combining characters", () => { + it("handles combining diacritics", () => { + const profile: NostrProfile = { + name: "cafe\u0301", // 'e' + combining acute = 'é' + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + expect(result.profile?.name).toBe("cafe\u0301"); + }); + + it("handles excessive combining characters (Zalgo text)", () => { + const zalgo = "t̷̢̧̨̡̛̛̛͎̩̝̪̲̲̞̠̹̗̩͓̬̱̪̦͙̬̲̤͙̱̫̝̪̱̫̯̬̭̠̖̲̥̖̫̫̤͇̪̣̫̪̖̱̯̣͎̯̲̱̤̪̣̖̲̪̯͓̖̤̫̫̲̱̲̫̲̖̫̪̯̱̱̪̖̯e̶̡̧̨̧̛̛̛̖̪̯̱̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪s̶̨̧̛̛̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯̖̪̯̖̪̱̪̯t"; + const profile: NostrProfile = { + name: zalgo.slice(0, 256), // Truncate to fit limit + }; + const result = validateProfile(profile); + // Should be valid but may look weird + expect(result.valid).toBe(true); + }); + }); + + describe("CJK and other scripts", () => { + it("handles Chinese characters", () => { + const profile: NostrProfile = { + name: "中文用户", + about: "我是一个机器人", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Japanese hiragana and katakana", () => { + const profile: NostrProfile = { + name: "ボット", + about: "これはテストです", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Korean characters", () => { + const profile: NostrProfile = { + name: "한국어사용자", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Arabic text", () => { + const profile: NostrProfile = { + name: "مستخدم", + about: "مرحبا بالعالم", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Hebrew text", () => { + const profile: NostrProfile = { + name: "משתמש", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles Thai text", () => { + const profile: NostrProfile = { + name: "ผู้ใช้", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); + + describe("emoji edge cases", () => { + it("handles emoji sequences (ZWJ)", () => { + const profile: NostrProfile = { + name: "👨‍👩‍👧‍👦", // Family emoji using ZWJ + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles flag emojis", () => { + const profile: NostrProfile = { + name: "🇺🇸🇯🇵🇬🇧", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + + it("handles skin tone modifiers", () => { + const profile: NostrProfile = { + name: "👋🏻👋🏽👋🏿", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(true); + }); + }); +}); + +// ============================================================================ +// XSS Attack Vectors +// ============================================================================ + +describe("profile XSS attacks", () => { + describe("script injection", () => { + it("escapes script tags", () => { + const profile: NostrProfile = { + name: '', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).not.toContain("/script>', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.about).not.toContain("", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + + it("rejects vbscript: URL", () => { + const profile = { + website: "vbscript:msgbox('xss')", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + + it("rejects file: URL", () => { + const profile = { + picture: "file:///etc/passwd", + }; + const result = validateProfile(profile); + expect(result.valid).toBe(false); + }); + }); + + describe("HTML attribute injection", () => { + it("escapes double quotes in fields", () => { + const profile: NostrProfile = { + name: '" onclick="alert(1)" data-x="', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).toContain("""); + expect(sanitized.name).not.toContain('onclick="alert'); + }); + + it("escapes single quotes in fields", () => { + const profile: NostrProfile = { + name: "' onclick='alert(1)' data-x='", + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.name).toContain("'"); + }); + }); + + describe("CSS injection", () => { + it("escapes style tags", () => { + const profile: NostrProfile = { + about: '', + }; + const sanitized = sanitizeProfileForDisplay(profile); + expect(sanitized.about).toContain("<style>"); + }); + }); +}); + +// ============================================================================ +// Length Boundary Tests +// ============================================================================ + +describe("profile length boundaries", () => { + describe("name field (max 256)", () => { + it("accepts exactly 256 characters", () => { + const result = validateProfile({ name: "a".repeat(256) }); + expect(result.valid).toBe(true); + }); + + it("rejects 257 characters", () => { + const result = validateProfile({ name: "a".repeat(257) }); + expect(result.valid).toBe(false); + }); + + it("accepts empty string", () => { + const result = validateProfile({ name: "" }); + expect(result.valid).toBe(true); + }); + }); + + describe("displayName field (max 256)", () => { + it("accepts exactly 256 characters", () => { + const result = validateProfile({ displayName: "b".repeat(256) }); + expect(result.valid).toBe(true); + }); + + it("rejects 257 characters", () => { + const result = validateProfile({ displayName: "b".repeat(257) }); + expect(result.valid).toBe(false); + }); + }); + + describe("about field (max 2000)", () => { + it("accepts exactly 2000 characters", () => { + const result = validateProfile({ about: "c".repeat(2000) }); + expect(result.valid).toBe(true); + }); + + it("rejects 2001 characters", () => { + const result = validateProfile({ about: "c".repeat(2001) }); + expect(result.valid).toBe(false); + }); + }); + + describe("URL fields", () => { + it("accepts long valid HTTPS URLs", () => { + const longPath = "a".repeat(1000); + const result = validateProfile({ + picture: `https://example.com/${longPath}.png`, + }); + expect(result.valid).toBe(true); + }); + + it("rejects invalid URL format", () => { + const result = validateProfile({ + picture: "not-a-url", + }); + expect(result.valid).toBe(false); + }); + + it("rejects URL without protocol", () => { + const result = validateProfile({ + picture: "example.com/pic.png", + }); + expect(result.valid).toBe(false); + }); + }); +}); + +// ============================================================================ +// Type Confusion Tests +// ============================================================================ + +describe("profile type confusion", () => { + it("rejects number as name", () => { + const result = validateProfile({ name: 123 as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects array as about", () => { + const result = validateProfile({ about: ["hello"] as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects object as picture", () => { + const result = validateProfile({ + picture: { url: "https://example.com" } as unknown as string, + }); + expect(result.valid).toBe(false); + }); + + it("rejects null as name", () => { + const result = validateProfile({ name: null as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects boolean as about", () => { + const result = validateProfile({ about: true as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("rejects function as name", () => { + const result = validateProfile({ name: (() => "test") as unknown as string }); + expect(result.valid).toBe(false); + }); + + it("handles prototype pollution attempt", () => { + const malicious = JSON.parse('{"__proto__": {"polluted": true}}') as unknown; + validateProfile(malicious); + // Should not pollute Object.prototype + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + +// ============================================================================ +// Event Creation Edge Cases +// ============================================================================ + +describe("event creation edge cases", () => { + it("handles profile with all fields at max length", () => { + const profile: NostrProfile = { + name: "a".repeat(256), + displayName: "b".repeat(256), + about: "c".repeat(2000), + nip05: "d".repeat(200) + "@example.com", + lud16: "e".repeat(200) + "@example.com", + }; + + const event = createProfileEvent(TEST_SK, profile); + expect(event.kind).toBe(0); + + // Content should be parseable JSON + expect(() => JSON.parse(event.content)).not.toThrow(); + }); + + it("handles rapid sequential events with monotonic timestamps", () => { + const profile: NostrProfile = { name: "rapid" }; + + // Create events in quick succession + let lastTimestamp = 0; + for (let i = 0; i < 100; i++) { + const event = createProfileEvent(TEST_SK, profile, lastTimestamp); + expect(event.created_at).toBeGreaterThan(lastTimestamp); + lastTimestamp = event.created_at; + } + }); + + it("handles JSON special characters in content", () => { + const profile: NostrProfile = { + name: 'test"user', + about: "line1\nline2\ttab\\backslash", + }; + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as { name: string; about: string }; + + expect(parsed.name).toBe('test"user'); + expect(parsed.about).toContain("\n"); + expect(parsed.about).toContain("\t"); + expect(parsed.about).toContain("\\"); + }); +}); diff --git a/extensions/nostr/src/nostr-profile.test.ts b/extensions/nostr/src/nostr-profile.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d90efa754b64a3d5b02aefcd0b241bc1b1efd0c --- /dev/null +++ b/extensions/nostr/src/nostr-profile.test.ts @@ -0,0 +1,410 @@ +import { verifyEvent, getPublicKey } from "nostr-tools"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import type { NostrProfile } from "./config-schema.js"; +import { + createProfileEvent, + profileToContent, + contentToProfile, + validateProfile, + sanitizeProfileForDisplay, + type ProfileContent, +} from "./nostr-profile.js"; + +// Test private key (DO NOT use in production - this is a known test key) +const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const TEST_SK = new Uint8Array(TEST_HEX_KEY.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))); +const TEST_PUBKEY = getPublicKey(TEST_SK); + +// ============================================================================ +// Profile Content Conversion Tests +// ============================================================================ + +describe("profileToContent", () => { + it("converts full profile to NIP-01 content format", () => { + const profile: NostrProfile = { + name: "testuser", + displayName: "Test User", + about: "A test user for unit testing", + picture: "https://example.com/avatar.png", + banner: "https://example.com/banner.png", + website: "https://example.com", + nip05: "testuser@example.com", + lud16: "testuser@walletofsatoshi.com", + }; + + const content = profileToContent(profile); + + expect(content.name).toBe("testuser"); + expect(content.display_name).toBe("Test User"); + expect(content.about).toBe("A test user for unit testing"); + expect(content.picture).toBe("https://example.com/avatar.png"); + expect(content.banner).toBe("https://example.com/banner.png"); + expect(content.website).toBe("https://example.com"); + expect(content.nip05).toBe("testuser@example.com"); + expect(content.lud16).toBe("testuser@walletofsatoshi.com"); + }); + + it("omits undefined fields from content", () => { + const profile: NostrProfile = { + name: "minimaluser", + }; + + const content = profileToContent(profile); + + expect(content.name).toBe("minimaluser"); + expect("display_name" in content).toBe(false); + expect("about" in content).toBe(false); + expect("picture" in content).toBe(false); + }); + + it("handles empty profile", () => { + const profile: NostrProfile = {}; + const content = profileToContent(profile); + expect(Object.keys(content)).toHaveLength(0); + }); +}); + +describe("contentToProfile", () => { + it("converts NIP-01 content to profile format", () => { + const content: ProfileContent = { + name: "testuser", + display_name: "Test User", + about: "A test user", + picture: "https://example.com/avatar.png", + nip05: "test@example.com", + }; + + const profile = contentToProfile(content); + + expect(profile.name).toBe("testuser"); + expect(profile.displayName).toBe("Test User"); + expect(profile.about).toBe("A test user"); + expect(profile.picture).toBe("https://example.com/avatar.png"); + expect(profile.nip05).toBe("test@example.com"); + }); + + it("handles empty content", () => { + const content: ProfileContent = {}; + const profile = contentToProfile(content); + expect( + Object.keys(profile).filter((k) => profile[k as keyof NostrProfile] !== undefined), + ).toHaveLength(0); + }); + + it("round-trips profile data", () => { + const original: NostrProfile = { + name: "roundtrip", + displayName: "Round Trip Test", + about: "Testing round-trip conversion", + }; + + const content = profileToContent(original); + const restored = contentToProfile(content); + + expect(restored.name).toBe(original.name); + expect(restored.displayName).toBe(original.displayName); + expect(restored.about).toBe(original.about); + }); +}); + +// ============================================================================ +// Event Creation Tests +// ============================================================================ + +describe("createProfileEvent", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-15T12:00:00Z")); + }); + + it("creates a valid kind:0 event", () => { + const profile: NostrProfile = { + name: "testbot", + about: "A test bot", + }; + + const event = createProfileEvent(TEST_SK, profile); + + expect(event.kind).toBe(0); + expect(event.pubkey).toBe(TEST_PUBKEY); + expect(event.tags).toEqual([]); + expect(event.id).toMatch(/^[0-9a-f]{64}$/); + expect(event.sig).toMatch(/^[0-9a-f]{128}$/); + }); + + it("includes profile content as JSON in event content", () => { + const profile: NostrProfile = { + name: "jsontest", + displayName: "JSON Test User", + about: "Testing JSON serialization", + }; + + const event = createProfileEvent(TEST_SK, profile); + const parsedContent = JSON.parse(event.content) as ProfileContent; + + expect(parsedContent.name).toBe("jsontest"); + expect(parsedContent.display_name).toBe("JSON Test User"); + expect(parsedContent.about).toBe("Testing JSON serialization"); + }); + + it("produces a verifiable signature", () => { + const profile: NostrProfile = { name: "signaturetest" }; + const event = createProfileEvent(TEST_SK, profile); + + expect(verifyEvent(event)).toBe(true); + }); + + it("uses current timestamp when no lastPublishedAt provided", () => { + const profile: NostrProfile = { name: "timestamptest" }; + const event = createProfileEvent(TEST_SK, profile); + + const expectedTimestamp = Math.floor(Date.now() / 1000); + expect(event.created_at).toBe(expectedTimestamp); + }); + + it("ensures monotonic timestamp when lastPublishedAt is in the future", () => { + // Current time is 2024-01-15T12:00:00Z = 1705320000 + const futureTimestamp = 1705320000 + 3600; // 1 hour in the future + const profile: NostrProfile = { name: "monotonictest" }; + + const event = createProfileEvent(TEST_SK, profile, futureTimestamp); + + expect(event.created_at).toBe(futureTimestamp + 1); + }); + + it("uses current time when lastPublishedAt is in the past", () => { + const pastTimestamp = 1705320000 - 3600; // 1 hour in the past + const profile: NostrProfile = { name: "pasttest" }; + + const event = createProfileEvent(TEST_SK, profile, pastTimestamp); + + const expectedTimestamp = Math.floor(Date.now() / 1000); + expect(event.created_at).toBe(expectedTimestamp); + }); + + vi.useRealTimers(); +}); + +// ============================================================================ +// Profile Validation Tests +// ============================================================================ + +describe("validateProfile", () => { + it("validates a correct profile", () => { + const profile = { + name: "validuser", + about: "A valid user", + picture: "https://example.com/pic.png", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(true); + expect(result.profile).toBeDefined(); + expect(result.errors).toBeUndefined(); + }); + + it("rejects profile with invalid URL", () => { + const profile = { + name: "invalidurl", + picture: "http://insecure.example.com/pic.png", // HTTP not HTTPS + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some((e) => e.includes("https://"))).toBe(true); + }); + + it("rejects profile with javascript: URL", () => { + const profile = { + name: "xssattempt", + picture: "javascript:alert('xss')", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + }); + + it("rejects profile with data: URL", () => { + const profile = { + name: "dataurl", + picture: "data:image/png;base64,abc123", + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + }); + + it("rejects name exceeding 256 characters", () => { + const profile = { + name: "a".repeat(257), + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors!.some((e) => e.includes("256"))).toBe(true); + }); + + it("rejects about exceeding 2000 characters", () => { + const profile = { + about: "a".repeat(2001), + }; + + const result = validateProfile(profile); + + expect(result.valid).toBe(false); + expect(result.errors!.some((e) => e.includes("2000"))).toBe(true); + }); + + it("accepts empty profile", () => { + const result = validateProfile({}); + expect(result.valid).toBe(true); + }); + + it("rejects null input", () => { + const result = validateProfile(null); + expect(result.valid).toBe(false); + }); + + it("rejects non-object input", () => { + const result = validateProfile("not an object"); + expect(result.valid).toBe(false); + }); +}); + +// ============================================================================ +// Sanitization Tests +// ============================================================================ + +describe("sanitizeProfileForDisplay", () => { + it("escapes HTML in name field", () => { + const profile: NostrProfile = { + name: "", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("<script>alert('xss')</script>"); + }); + + it("escapes HTML in about field", () => { + const profile: NostrProfile = { + about: 'Check out ', + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.about).toBe( + "Check out <img src="x" onerror="alert(1)">", + ); + }); + + it("preserves URLs without modification", () => { + const profile: NostrProfile = { + picture: "https://example.com/pic.png", + website: "https://example.com", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.picture).toBe("https://example.com/pic.png"); + expect(sanitized.website).toBe("https://example.com"); + }); + + it("handles undefined fields", () => { + const profile: NostrProfile = { + name: "test", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("test"); + expect(sanitized.about).toBeUndefined(); + expect(sanitized.picture).toBeUndefined(); + }); + + it("escapes ampersands", () => { + const profile: NostrProfile = { + name: "Tom & Jerry", + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.name).toBe("Tom & Jerry"); + }); + + it("escapes quotes", () => { + const profile: NostrProfile = { + about: 'Say "hello" to everyone', + }; + + const sanitized = sanitizeProfileForDisplay(profile); + + expect(sanitized.about).toBe("Say "hello" to everyone"); + }); +}); + +// ============================================================================ +// Edge Cases +// ============================================================================ + +describe("edge cases", () => { + it("handles emoji in profile fields", () => { + const profile: NostrProfile = { + name: "🤖 Bot", + about: "I am a 🤖 robot! 🎉", + }; + + const content = profileToContent(profile); + expect(content.name).toBe("🤖 Bot"); + expect(content.about).toBe("I am a 🤖 robot! 🎉"); + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as ProfileContent; + expect(parsed.name).toBe("🤖 Bot"); + }); + + it("handles unicode in profile fields", () => { + const profile: NostrProfile = { + name: "日本語ユーザー", + about: "Привет мир! 你好世界!", + }; + + const content = profileToContent(profile); + expect(content.name).toBe("日本語ユーザー"); + + const event = createProfileEvent(TEST_SK, profile); + expect(verifyEvent(event)).toBe(true); + }); + + it("handles newlines in about field", () => { + const profile: NostrProfile = { + about: "Line 1\nLine 2\nLine 3", + }; + + const content = profileToContent(profile); + expect(content.about).toBe("Line 1\nLine 2\nLine 3"); + + const event = createProfileEvent(TEST_SK, profile); + const parsed = JSON.parse(event.content) as ProfileContent; + expect(parsed.about).toBe("Line 1\nLine 2\nLine 3"); + }); + + it("handles maximum length fields", () => { + const profile: NostrProfile = { + name: "a".repeat(256), + about: "b".repeat(2000), + }; + + const result = validateProfile(profile); + expect(result.valid).toBe(true); + + const event = createProfileEvent(TEST_SK, profile); + expect(verifyEvent(event)).toBe(true); + }); +}); diff --git a/extensions/nostr/src/nostr-profile.ts b/extensions/nostr/src/nostr-profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..6796c6f3fa8c37d202a6d23a8af0745a194397e1 --- /dev/null +++ b/extensions/nostr/src/nostr-profile.ts @@ -0,0 +1,277 @@ +/** + * Nostr Profile Management (NIP-01 kind:0) + * + * Profile events are "replaceable" - the latest created_at wins. + * This module handles profile event creation and publishing. + */ + +import { finalizeEvent, SimplePool, type Event } from "nostr-tools"; +import { type NostrProfile, NostrProfileSchema } from "./config-schema.js"; + +// ============================================================================ +// Types +// ============================================================================ + +/** Result of a profile publish attempt */ +export interface ProfilePublishResult { + /** Event ID of the published profile */ + eventId: string; + /** Relays that successfully received the event */ + successes: string[]; + /** Relays that failed with their error messages */ + failures: Array<{ relay: string; error: string }>; + /** Unix timestamp when the event was created */ + createdAt: number; +} + +/** NIP-01 profile content (JSON inside kind:0 event) */ +export interface ProfileContent { + name?: string; + display_name?: string; + about?: string; + picture?: string; + banner?: string; + website?: string; + nip05?: string; + lud16?: string; +} + +// ============================================================================ +// Profile Content Conversion +// ============================================================================ + +/** + * Convert our config profile schema to NIP-01 content format. + * Strips undefined fields and validates URLs. + */ +export function profileToContent(profile: NostrProfile): ProfileContent { + const validated = NostrProfileSchema.parse(profile); + + const content: ProfileContent = {}; + + if (validated.name !== undefined) { + content.name = validated.name; + } + if (validated.displayName !== undefined) { + content.display_name = validated.displayName; + } + if (validated.about !== undefined) { + content.about = validated.about; + } + if (validated.picture !== undefined) { + content.picture = validated.picture; + } + if (validated.banner !== undefined) { + content.banner = validated.banner; + } + if (validated.website !== undefined) { + content.website = validated.website; + } + if (validated.nip05 !== undefined) { + content.nip05 = validated.nip05; + } + if (validated.lud16 !== undefined) { + content.lud16 = validated.lud16; + } + + return content; +} + +/** + * Convert NIP-01 content format back to our config profile schema. + * Useful for importing existing profiles from relays. + */ +export function contentToProfile(content: ProfileContent): NostrProfile { + const profile: NostrProfile = {}; + + if (content.name !== undefined) { + profile.name = content.name; + } + if (content.display_name !== undefined) { + profile.displayName = content.display_name; + } + if (content.about !== undefined) { + profile.about = content.about; + } + if (content.picture !== undefined) { + profile.picture = content.picture; + } + if (content.banner !== undefined) { + profile.banner = content.banner; + } + if (content.website !== undefined) { + profile.website = content.website; + } + if (content.nip05 !== undefined) { + profile.nip05 = content.nip05; + } + if (content.lud16 !== undefined) { + profile.lud16 = content.lud16; + } + + return profile; +} + +// ============================================================================ +// Event Creation +// ============================================================================ + +/** + * Create a signed kind:0 profile event. + * + * @param sk - Private key as Uint8Array (32 bytes) + * @param profile - Profile data to include + * @param lastPublishedAt - Previous profile timestamp (for monotonic guarantee) + * @returns Signed Nostr event + */ +export function createProfileEvent( + sk: Uint8Array, + profile: NostrProfile, + lastPublishedAt?: number, +): Event { + const content = profileToContent(profile); + const contentJson = JSON.stringify(content); + + // Ensure monotonic timestamp (new event > previous) + const now = Math.floor(Date.now() / 1000); + const createdAt = lastPublishedAt !== undefined ? Math.max(now, lastPublishedAt + 1) : now; + + const event = finalizeEvent( + { + kind: 0, + content: contentJson, + tags: [], + created_at: createdAt, + }, + sk, + ); + + return event; +} + +// ============================================================================ +// Profile Publishing +// ============================================================================ + +/** Per-relay publish timeout (ms) */ +const RELAY_PUBLISH_TIMEOUT_MS = 5000; + +/** + * Publish a profile event to multiple relays. + * + * Best-effort: publishes to all relays in parallel, reports per-relay results. + * Does NOT retry automatically - caller should handle retries if needed. + * + * @param pool - SimplePool instance for relay connections + * @param relays - Array of relay WebSocket URLs + * @param event - Signed profile event (kind:0) + * @returns Publish results with successes and failures + */ +export async function publishProfileEvent( + pool: SimplePool, + relays: string[], + event: Event, +): Promise { + const successes: string[] = []; + const failures: Array<{ relay: string; error: string }> = []; + + // Publish to each relay in parallel with timeout + const publishPromises = relays.map(async (relay) => { + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS); + }); + + // oxlint-disable-next-line typescript/no-floating-promises + await Promise.race([pool.publish([relay], event), timeoutPromise]); + + successes.push(relay); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + failures.push({ relay, error: errorMessage }); + } + }); + + await Promise.all(publishPromises); + + return { + eventId: event.id, + successes, + failures, + createdAt: event.created_at, + }; +} + +/** + * Create and publish a profile event in one call. + * + * @param pool - SimplePool instance + * @param sk - Private key as Uint8Array + * @param relays - Array of relay URLs + * @param profile - Profile data + * @param lastPublishedAt - Previous timestamp for monotonic ordering + * @returns Publish results + */ +export async function publishProfile( + pool: SimplePool, + sk: Uint8Array, + relays: string[], + profile: NostrProfile, + lastPublishedAt?: number, +): Promise { + const event = createProfileEvent(sk, profile, lastPublishedAt); + return publishProfileEvent(pool, relays, event); +} + +// ============================================================================ +// Profile Validation Helpers +// ============================================================================ + +/** + * Validate a profile without throwing (returns result object). + */ +export function validateProfile(profile: unknown): { + valid: boolean; + profile?: NostrProfile; + errors?: string[]; +} { + const result = NostrProfileSchema.safeParse(profile); + + if (result.success) { + return { valid: true, profile: result.data }; + } + + return { + valid: false, + errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`), + }; +} + +/** + * Sanitize profile text fields to prevent XSS when displaying in UI. + * Escapes HTML special characters. + */ +export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile { + const escapeHtml = (str: string | undefined): string | undefined => { + if (str === undefined) { + return undefined; + } + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + return { + name: escapeHtml(profile.name), + displayName: escapeHtml(profile.displayName), + about: escapeHtml(profile.about), + picture: profile.picture, // URLs already validated by schema + banner: profile.banner, + website: profile.website, + nip05: escapeHtml(profile.nip05), + lud16: escapeHtml(profile.lud16), + }; +} diff --git a/extensions/nostr/src/nostr-state-store.test.ts b/extensions/nostr/src/nostr-state-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a58802af7c0873cad830e039a226314a52e4d839 --- /dev/null +++ b/extensions/nostr/src/nostr-state-store.test.ts @@ -0,0 +1,131 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + readNostrBusState, + writeNostrBusState, + computeSinceTimestamp, +} from "./nostr-state-store.js"; +import { setNostrRuntime } from "./runtime.js"; + +async function withTempStateDir(fn: (dir: string) => Promise) { + const previous = process.env.OPENCLAW_STATE_DIR; + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-")); + process.env.OPENCLAW_STATE_DIR = dir; + setNostrRuntime({ + state: { + resolveStateDir: (env, homedir) => { + const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim(); + if (override) { + return override; + } + return path.join(homedir(), ".openclaw"); + }, + }, + } as PluginRuntime); + try { + return await fn(dir); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previous; + } + await fs.rm(dir, { recursive: true, force: true }); + } +} + +describe("nostr bus state store", () => { + it("persists and reloads state across restarts", async () => { + await withTempStateDir(async () => { + // Fresh start - no state + expect(await readNostrBusState({ accountId: "test-bot" })).toBeNull(); + + // Write state + await writeNostrBusState({ + accountId: "test-bot", + lastProcessedAt: 1700000000, + gatewayStartedAt: 1700000100, + }); + + // Read it back + const state = await readNostrBusState({ accountId: "test-bot" }); + expect(state).toEqual({ + version: 2, + lastProcessedAt: 1700000000, + gatewayStartedAt: 1700000100, + recentEventIds: [], + }); + }); + }); + + it("isolates state by accountId", async () => { + await withTempStateDir(async () => { + await writeNostrBusState({ + accountId: "bot-a", + lastProcessedAt: 1000, + gatewayStartedAt: 1000, + }); + await writeNostrBusState({ + accountId: "bot-b", + lastProcessedAt: 2000, + gatewayStartedAt: 2000, + }); + + const stateA = await readNostrBusState({ accountId: "bot-a" }); + const stateB = await readNostrBusState({ accountId: "bot-b" }); + + expect(stateA?.lastProcessedAt).toBe(1000); + expect(stateB?.lastProcessedAt).toBe(2000); + }); + }); +}); + +describe("computeSinceTimestamp", () => { + it("returns now for null state (fresh start)", () => { + const now = 1700000000; + expect(computeSinceTimestamp(null, now)).toBe(now); + }); + + it("uses lastProcessedAt when available", () => { + const state = { + version: 2, + lastProcessedAt: 1699999000, + gatewayStartedAt: null, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000); + }); + + it("uses gatewayStartedAt when lastProcessedAt is null", () => { + const state = { + version: 2, + lastProcessedAt: null, + gatewayStartedAt: 1699998000, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699998000); + }); + + it("uses the max of both timestamps", () => { + const state = { + version: 2, + lastProcessedAt: 1699999000, + gatewayStartedAt: 1699998000, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1699999000); + }); + + it("falls back to now if both are null", () => { + const state = { + version: 2, + lastProcessedAt: null, + gatewayStartedAt: null, + recentEventIds: [], + }; + expect(computeSinceTimestamp(state, 1700000000)).toBe(1700000000); + }); +}); diff --git a/extensions/nostr/src/nostr-state-store.ts b/extensions/nostr/src/nostr-state-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b07139765b67e957fc324bf1c06c55bb0b23e28 --- /dev/null +++ b/extensions/nostr/src/nostr-state-store.ts @@ -0,0 +1,226 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { getNostrRuntime } from "./runtime.js"; + +const STORE_VERSION = 2; +const PROFILE_STATE_VERSION = 1; + +type NostrBusStateV1 = { + version: 1; + /** Unix timestamp (seconds) of the last processed event */ + lastProcessedAt: number | null; + /** Gateway startup timestamp (seconds) - events before this are old */ + gatewayStartedAt: number | null; +}; + +type NostrBusState = { + version: 2; + /** Unix timestamp (seconds) of the last processed event */ + lastProcessedAt: number | null; + /** Gateway startup timestamp (seconds) - events before this are old */ + gatewayStartedAt: number | null; + /** Recent processed event IDs for overlap dedupe across restarts */ + recentEventIds: string[]; +}; + +/** Profile publish state (separate from bus state) */ +export type NostrProfileState = { + version: 1; + /** Unix timestamp (seconds) of last successful profile publish */ + lastPublishedAt: number | null; + /** Event ID of the last published profile */ + lastPublishedEventId: string | null; + /** Per-relay publish results from last attempt */ + lastPublishResults: Record | null; +}; + +function normalizeAccountId(accountId?: string): string { + const trimmed = accountId?.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-z0-9._-]+/gi, "_"); +} + +function resolveNostrStatePath(accountId?: string, env: NodeJS.ProcessEnv = process.env): string { + const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); + const normalized = normalizeAccountId(accountId); + return path.join(stateDir, "nostr", `bus-state-${normalized}.json`); +} + +function resolveNostrProfileStatePath( + accountId?: string, + env: NodeJS.ProcessEnv = process.env, +): string { + const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); + const normalized = normalizeAccountId(accountId); + return path.join(stateDir, "nostr", `profile-state-${normalized}.json`); +} + +function safeParseState(raw: string): NostrBusState | null { + try { + const parsed = JSON.parse(raw) as Partial & Partial; + + if (parsed?.version === 2) { + return { + version: 2, + lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null, + gatewayStartedAt: + typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null, + recentEventIds: Array.isArray(parsed.recentEventIds) + ? parsed.recentEventIds.filter((x): x is string => typeof x === "string") + : [], + }; + } + + // Back-compat: v1 state files + if (parsed?.version === 1) { + return { + version: 2, + lastProcessedAt: typeof parsed.lastProcessedAt === "number" ? parsed.lastProcessedAt : null, + gatewayStartedAt: + typeof parsed.gatewayStartedAt === "number" ? parsed.gatewayStartedAt : null, + recentEventIds: [], + }; + } + + return null; + } catch { + return null; + } +} + +export async function readNostrBusState(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrStatePath(params.accountId, params.env); + try { + const raw = await fs.readFile(filePath, "utf-8"); + return safeParseState(raw); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return null; + } + return null; + } +} + +export async function writeNostrBusState(params: { + accountId?: string; + lastProcessedAt: number; + gatewayStartedAt: number; + recentEventIds?: string[]; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrStatePath(params.accountId, params.env); + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + const payload: NostrBusState = { + version: STORE_VERSION, + lastProcessedAt: params.lastProcessedAt, + gatewayStartedAt: params.gatewayStartedAt, + recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"), + }; + await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, filePath); +} + +/** + * Determine the `since` timestamp for subscription. + * Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk), + * falling back to `now` for fresh starts. + */ +export function computeSinceTimestamp( + state: NostrBusState | null, + nowSec: number = Math.floor(Date.now() / 1000), +): number { + if (!state) { + return nowSec; + } + + // Use the most recent timestamp we have + const candidates = [state.lastProcessedAt, state.gatewayStartedAt].filter( + (t): t is number => t !== null && t > 0, + ); + + if (candidates.length === 0) { + return nowSec; + } + return Math.max(...candidates); +} + +// ============================================================================ +// Profile State Management +// ============================================================================ + +function safeParseProfileState(raw: string): NostrProfileState | null { + try { + const parsed = JSON.parse(raw) as Partial; + + if (parsed?.version === 1) { + return { + version: 1, + lastPublishedAt: typeof parsed.lastPublishedAt === "number" ? parsed.lastPublishedAt : null, + lastPublishedEventId: + typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null, + lastPublishResults: + parsed.lastPublishResults && typeof parsed.lastPublishResults === "object" + ? parsed.lastPublishResults + : null, + }; + } + + return null; + } catch { + return null; + } +} + +export async function readNostrProfileState(params: { + accountId?: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrProfileStatePath(params.accountId, params.env); + try { + const raw = await fs.readFile(filePath, "utf-8"); + return safeParseProfileState(raw); + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return null; + } + return null; + } +} + +export async function writeNostrProfileState(params: { + accountId?: string; + lastPublishedAt: number; + lastPublishedEventId: string; + lastPublishResults: Record; + env?: NodeJS.ProcessEnv; +}): Promise { + const filePath = resolveNostrProfileStatePath(params.accountId, params.env); + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`); + const payload: NostrProfileState = { + version: PROFILE_STATE_VERSION, + lastPublishedAt: params.lastPublishedAt, + lastPublishedEventId: params.lastPublishedEventId, + lastPublishResults: params.lastPublishResults, + }; + await fs.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf-8", + }); + await fs.chmod(tmp, 0o600); + await fs.rename(tmp, filePath); +} diff --git a/extensions/nostr/src/runtime.ts b/extensions/nostr/src/runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..902fb9b1205b829468a64f559011a3bf3313f7b6 --- /dev/null +++ b/extensions/nostr/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setNostrRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getNostrRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Nostr runtime not initialized"); + } + return runtime; +} diff --git a/extensions/nostr/src/seen-tracker.ts b/extensions/nostr/src/seen-tracker.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c9033c49151b47ddad7c788b31627876a7da1db --- /dev/null +++ b/extensions/nostr/src/seen-tracker.ts @@ -0,0 +1,303 @@ +/** + * LRU-based seen event tracker with TTL support. + * Prevents unbounded memory growth under high load or abuse. + */ + +export interface SeenTrackerOptions { + /** Maximum number of entries to track (default: 100,000) */ + maxEntries?: number; + /** TTL in milliseconds (default: 1 hour) */ + ttlMs?: number; + /** Prune interval in milliseconds (default: 10 minutes) */ + pruneIntervalMs?: number; +} + +export interface SeenTracker { + /** Check if an ID has been seen (also marks it as seen if not) */ + has: (id: string) => boolean; + /** Mark an ID as seen */ + add: (id: string) => void; + /** Check if ID exists without marking */ + peek: (id: string) => boolean; + /** Delete an ID */ + delete: (id: string) => void; + /** Clear all entries */ + clear: () => void; + /** Get current size */ + size: () => number; + /** Stop the pruning timer */ + stop: () => void; + /** Pre-seed with IDs (useful for restart recovery) */ + seed: (ids: string[]) => void; +} + +interface Entry { + seenAt: number; + // For LRU: track order via doubly-linked list + prev: string | null; + next: string | null; +} + +/** + * Create a new seen tracker with LRU eviction and TTL expiration. + */ +export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { + const maxEntries = options?.maxEntries ?? 100_000; + const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour + const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes + + // Main storage + const entries = new Map(); + + // LRU tracking: head = most recent, tail = least recent + let head: string | null = null; + let tail: string | null = null; + + // Move an entry to the front (most recently used) + function moveToFront(id: string): void { + const entry = entries.get(id); + if (!entry) { + return; + } + + // Already at front + if (head === id) { + return; + } + + // Remove from current position + if (entry.prev) { + const prevEntry = entries.get(entry.prev); + if (prevEntry) { + prevEntry.next = entry.next; + } + } + if (entry.next) { + const nextEntry = entries.get(entry.next); + if (nextEntry) { + nextEntry.prev = entry.prev; + } + } + + // Update tail if this was the tail + if (tail === id) { + tail = entry.prev; + } + + // Move to front + entry.prev = null; + entry.next = head; + if (head) { + const headEntry = entries.get(head); + if (headEntry) { + headEntry.prev = id; + } + } + head = id; + + // If no tail, this is also the tail + if (!tail) { + tail = id; + } + } + + // Remove an entry from the linked list + function removeFromList(id: string): void { + const entry = entries.get(id); + if (!entry) { + return; + } + + if (entry.prev) { + const prevEntry = entries.get(entry.prev); + if (prevEntry) { + prevEntry.next = entry.next; + } + } else { + head = entry.next; + } + + if (entry.next) { + const nextEntry = entries.get(entry.next); + if (nextEntry) { + nextEntry.prev = entry.prev; + } + } else { + tail = entry.prev; + } + } + + // Evict the least recently used entry + function evictLRU(): void { + if (!tail) { + return; + } + const idToEvict = tail; + removeFromList(idToEvict); + entries.delete(idToEvict); + } + + // Prune expired entries + function pruneExpired(): void { + const now = Date.now(); + const toDelete: string[] = []; + + for (const [id, entry] of entries) { + if (now - entry.seenAt > ttlMs) { + toDelete.push(id); + } + } + + for (const id of toDelete) { + removeFromList(id); + entries.delete(id); + } + } + + // Start pruning timer + let pruneTimer: ReturnType | undefined; + if (pruneIntervalMs > 0) { + pruneTimer = setInterval(pruneExpired, pruneIntervalMs); + // Don't keep process alive just for pruning + if (pruneTimer.unref) { + pruneTimer.unref(); + } + } + + function add(id: string): void { + const now = Date.now(); + + // If already exists, update and move to front + const existing = entries.get(id); + if (existing) { + existing.seenAt = now; + moveToFront(id); + return; + } + + // Evict if at capacity + while (entries.size >= maxEntries) { + evictLRU(); + } + + // Add new entry at front + const newEntry: Entry = { + seenAt: now, + prev: null, + next: head, + }; + + if (head) { + const headEntry = entries.get(head); + if (headEntry) { + headEntry.prev = id; + } + } + + entries.set(id, newEntry); + head = id; + if (!tail) { + tail = id; + } + } + + function has(id: string): boolean { + const entry = entries.get(id); + if (!entry) { + add(id); + return false; + } + + // Check if expired + if (Date.now() - entry.seenAt > ttlMs) { + removeFromList(id); + entries.delete(id); + add(id); + return false; + } + + // Mark as recently used + entry.seenAt = Date.now(); + moveToFront(id); + return true; + } + + function peek(id: string): boolean { + const entry = entries.get(id); + if (!entry) { + return false; + } + + // Check if expired + if (Date.now() - entry.seenAt > ttlMs) { + removeFromList(id); + entries.delete(id); + return false; + } + + return true; + } + + function deleteEntry(id: string): void { + if (entries.has(id)) { + removeFromList(id); + entries.delete(id); + } + } + + function clear(): void { + entries.clear(); + head = null; + tail = null; + } + + function size(): number { + return entries.size; + } + + function stop(): void { + if (pruneTimer) { + clearInterval(pruneTimer); + pruneTimer = undefined; + } + } + + function seed(ids: string[]): void { + const now = Date.now(); + // Seed in reverse order so first IDs end up at front + for (let i = ids.length - 1; i >= 0; i--) { + const id = ids[i]; + if (!entries.has(id) && entries.size < maxEntries) { + const newEntry: Entry = { + seenAt: now, + prev: null, + next: head, + }; + + if (head) { + const headEntry = entries.get(head); + if (headEntry) { + headEntry.prev = id; + } + } + + entries.set(id, newEntry); + head = id; + if (!tail) { + tail = id; + } + } + } + } + + return { + has, + add, + peek, + delete: deleteEntry, + clear, + size, + stop, + seed, + }; +} diff --git a/extensions/nostr/src/types.test.ts b/extensions/nostr/src/types.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..29c58573a2b03325d9e4185873f24697f5eef832 --- /dev/null +++ b/extensions/nostr/src/types.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; + +const TEST_PRIVATE_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +describe("listNostrAccountIds", () => { + it("returns empty array when not configured", () => { + const cfg = { channels: {} }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns empty array when nostr section exists but no privateKey", () => { + const cfg = { channels: { nostr: { enabled: true } } }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); + + it("returns default when privateKey is configured", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + expect(listNostrAccountIds(cfg)).toEqual(["default"]); + }); +}); + +describe("resolveDefaultNostrAccountId", () => { + it("returns default when configured", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); + + it("returns default when not configured", () => { + const cfg = { channels: {} }; + expect(resolveDefaultNostrAccountId(cfg)).toBe("default"); + }); +}); + +describe("resolveNostrAccount", () => { + it("resolves configured account", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + name: "Test Bot", + relays: ["wss://test.relay"], + dmPolicy: "pairing" as const, + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.name).toBe("Test Bot"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(true); + expect(account.privateKey).toBe(TEST_PRIVATE_KEY); + expect(account.publicKey).toMatch(/^[0-9a-f]{64}$/); + expect(account.relays).toEqual(["wss://test.relay"]); + }); + + it("resolves unconfigured account with defaults", () => { + const cfg = { channels: {} }; + const account = resolveNostrAccount({ cfg }); + + expect(account.accountId).toBe("default"); + expect(account.enabled).toBe(true); + expect(account.configured).toBe(false); + expect(account.privateKey).toBe(""); + expect(account.publicKey).toBe(""); + expect(account.relays).toContain("wss://relay.damus.io"); + expect(account.relays).toContain("wss://nos.lol"); + }); + + it("handles disabled channel", () => { + const cfg = { + channels: { + nostr: { + enabled: false, + privateKey: TEST_PRIVATE_KEY, + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.enabled).toBe(false); + expect(account.configured).toBe(true); + }); + + it("handles custom accountId parameter", () => { + const cfg = { + channels: { + nostr: { privateKey: TEST_PRIVATE_KEY }, + }, + }; + const account = resolveNostrAccount({ cfg, accountId: "custom" }); + + expect(account.accountId).toBe("custom"); + }); + + it("handles allowFrom config", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + allowFrom: ["npub1test", "0123456789abcdef"], + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.config.allowFrom).toEqual(["npub1test", "0123456789abcdef"]); + }); + + it("handles invalid private key gracefully", () => { + const cfg = { + channels: { + nostr: { + privateKey: "invalid-key", + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.configured).toBe(true); // key is present + expect(account.publicKey).toBe(""); // but can't derive pubkey + }); + + it("preserves all config options", () => { + const cfg = { + channels: { + nostr: { + privateKey: TEST_PRIVATE_KEY, + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist" as const, + allowFrom: ["pubkey1", "pubkey2"], + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.config).toEqual({ + privateKey: TEST_PRIVATE_KEY, + name: "Bot", + enabled: true, + relays: ["wss://relay1", "wss://relay2"], + dmPolicy: "allowlist", + allowFrom: ["pubkey1", "pubkey2"], + }); + }); +}); diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..84640b9343041452db921ac00515bddfea5afd62 --- /dev/null +++ b/extensions/nostr/src/types.ts @@ -0,0 +1,101 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { NostrProfile } from "./config-schema.js"; +import { getPublicKeyFromPrivate } from "./nostr-bus.js"; +import { DEFAULT_RELAYS } from "./nostr-bus.js"; + +export interface NostrAccountConfig { + enabled?: boolean; + name?: string; + privateKey?: string; + relays?: string[]; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom?: Array; + profile?: NostrProfile; +} + +export interface ResolvedNostrAccount { + accountId: string; + name?: string; + enabled: boolean; + configured: boolean; + privateKey: string; + publicKey: string; + relays: string[]; + profile?: NostrProfile; + config: NostrAccountConfig; +} + +const DEFAULT_ACCOUNT_ID = "default"; + +/** + * List all configured Nostr account IDs + */ +export function listNostrAccountIds(cfg: OpenClawConfig): string[] { + const nostrCfg = (cfg.channels as Record | undefined)?.nostr as + | NostrAccountConfig + | undefined; + + // If privateKey is configured at top level, we have a default account + if (nostrCfg?.privateKey) { + return [DEFAULT_ACCOUNT_ID]; + } + + return []; +} + +/** + * Get the default account ID + */ +export function resolveDefaultNostrAccountId(cfg: OpenClawConfig): string { + const ids = listNostrAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +/** + * Resolve a Nostr account from config + */ +export function resolveNostrAccount(opts: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedNostrAccount { + const accountId = opts.accountId ?? DEFAULT_ACCOUNT_ID; + const nostrCfg = (opts.cfg.channels as Record | undefined)?.nostr as + | NostrAccountConfig + | undefined; + + const baseEnabled = nostrCfg?.enabled !== false; + const privateKey = nostrCfg?.privateKey ?? ""; + const configured = Boolean(privateKey.trim()); + + let publicKey = ""; + if (configured) { + try { + publicKey = getPublicKeyFromPrivate(privateKey); + } catch { + // Invalid key - leave publicKey empty, configured will indicate issues + } + } + + return { + accountId, + name: nostrCfg?.name?.trim() || undefined, + enabled: baseEnabled, + configured, + privateKey, + publicKey, + relays: nostrCfg?.relays ?? DEFAULT_RELAYS, + profile: nostrCfg?.profile, + config: { + enabled: nostrCfg?.enabled, + name: nostrCfg?.name, + privateKey: nostrCfg?.privateKey, + relays: nostrCfg?.relays, + dmPolicy: nostrCfg?.dmPolicy, + allowFrom: nostrCfg?.allowFrom, + profile: nostrCfg?.profile, + }, + }; +} diff --git a/extensions/nostr/test/setup.ts b/extensions/nostr/test/setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..92926dc4885d805942b27a0bbad7a00199a15661 --- /dev/null +++ b/extensions/nostr/test/setup.ts @@ -0,0 +1,5 @@ +// Test setup file for nostr extension +import { vi } from "vitest"; + +// Mock console.error to suppress noise in tests +vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/extensions/open-prose/README.md b/extensions/open-prose/README.md new file mode 100644 index 0000000000000000000000000000000000000000..478722d20fad8b2f081bad62a56f0e30697d45fb --- /dev/null +++ b/extensions/open-prose/README.md @@ -0,0 +1,25 @@ +# OpenProse (plugin) + +Adds the OpenProse skill pack and `/prose` slash command. + +## Enable + +Bundled plugins are disabled by default. Enable this one: + +```json +{ + "plugins": { + "entries": { + "open-prose": { "enabled": true } + } + } +} +``` + +Restart the Gateway after enabling. + +## What you get + +- `/prose` slash command (user-invocable skill) +- OpenProse VM semantics (`.prose` programs + multi-agent orchestration) +- Telemetry support (best-effort, per OpenProse spec) diff --git a/extensions/open-prose/index.ts b/extensions/open-prose/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b02c30fb5be751a1ce2d41a1182663eecad5a33 --- /dev/null +++ b/extensions/open-prose/index.ts @@ -0,0 +1,5 @@ +import type { OpenClawPluginApi } from "../../src/plugins/types.js"; + +export default function register(_api: OpenClawPluginApi) { + // OpenProse is delivered via plugin-shipped skills. +} diff --git a/extensions/open-prose/openclaw.plugin.json b/extensions/open-prose/openclaw.plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..e5a83b5322467ce8d00bd860aea7f4196b14c572 --- /dev/null +++ b/extensions/open-prose/openclaw.plugin.json @@ -0,0 +1,11 @@ +{ + "id": "open-prose", + "name": "OpenProse", + "description": "OpenProse VM skill pack with a /prose slash command.", + "skills": ["./skills"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json new file mode 100644 index 0000000000000000000000000000000000000000..07dc2351a136d2fddb761fae9c622a2c26fe9f0e --- /dev/null +++ b/extensions/open-prose/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/open-prose", + "version": "2026.1.30", + "description": "OpenProse VM skill pack plugin (slash command + telemetry).", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/open-prose/skills/prose/LICENSE b/extensions/open-prose/skills/prose/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..9932a6858ef16c6d53fed827283cdc2b2752609b --- /dev/null +++ b/extensions/open-prose/skills/prose/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 OpenProse + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/open-prose/skills/prose/SKILL.md b/extensions/open-prose/skills/prose/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..c6c2ed06d0979ead6a71b2a2a75e0c21720e3539 --- /dev/null +++ b/extensions/open-prose/skills/prose/SKILL.md @@ -0,0 +1,323 @@ +--- +name: prose +description: OpenProse VM skill pack. Activate on any `prose` command, .prose files, or OpenProse mentions; orchestrates multi-agent workflows. +metadata: { "openclaw": { "emoji": "🪶", "homepage": "https://www.prose.md" } } +--- + +# OpenProse Skill + +OpenProse is a programming language for AI sessions. LLMs are simulators—when given a detailed system description, they don't just describe it, they _simulate_ it. The `prose.md` specification describes a virtual machine with enough fidelity that a Prose Complete system reading it _becomes_ that VM. Simulation with sufficient fidelity is implementation. **You are the Prose Complete system.** + +## OpenClaw Runtime Mapping + +- **Task tool** in the upstream spec == OpenClaw `sessions_spawn` +- **File I/O** == OpenClaw `read`/`write` +- **Remote fetch** == OpenClaw `web_fetch` (or `exec` with curl when POST is required) + +## When to Activate + +Activate this skill when the user: + +- **Uses ANY `prose` command** (e.g., `prose boot`, `prose run`, `prose compile`, `prose update`, `prose help`, etc.) +- Asks to run a `.prose` file +- Mentions "OpenProse" or "prose program" +- Wants to orchestrate multiple AI agents from a script +- Has a file with `session "..."` or `agent name:` syntax +- Wants to create a reusable workflow + +## Command Routing + +When a user invokes `prose `, intelligently route based on intent: + +| Command | Action | +| ----------------------- | ------------------------------------------------------------- | +| `prose help` | Load `help.md`, guide user to what they need | +| `prose run ` | Load VM (`prose.md` + state backend), execute the program | +| `prose run handle/slug` | Fetch from registry, then execute (see Remote Programs below) | +| `prose compile ` | Load `compiler.md`, validate the program | +| `prose update` | Run migration (see Migration section below) | +| `prose examples` | Show or run example programs from `examples/` | +| Other | Intelligently interpret based on context | + +### Important: Single Skill + +There is only ONE skill: `open-prose`. There are NO separate skills like `prose-run`, `prose-compile`, or `prose-boot`. All `prose` commands route through this single skill. + +### Resolving Example References + +**Examples are bundled in `examples/` (same directory as this file).** When users reference examples by name (e.g., "run the gastown example"): + +1. Read `examples/` to list available files +2. Match by partial name, keyword, or number +3. Run with: `prose run examples/28-gas-town.prose` + +**Common examples by keyword:** +| Keyword | File | +|---------|------| +| hello, hello world | `examples/01-hello-world.prose` | +| gas town, gastown | `examples/28-gas-town.prose` | +| captain, chair | `examples/29-captains-chair.prose` | +| forge, browser | `examples/37-the-forge.prose` | +| parallel | `examples/16-parallel-reviews.prose` | +| pipeline | `examples/21-pipeline-operations.prose` | +| error, retry | `examples/22-error-handling.prose` | + +### Remote Programs + +You can run any `.prose` program from a URL or registry reference: + +```bash +# Direct URL — any fetchable URL works +prose run https://raw.githubusercontent.com/openprose/prose/main/skills/open-prose/examples/48-habit-miner.prose + +# Registry shorthand — handle/slug resolves to p.prose.md +prose run irl-danb/habit-miner +prose run alice/code-review +``` + +**Resolution rules:** + +| Input | Resolution | +| ----------------------------------- | -------------------------------------- | +| Starts with `http://` or `https://` | Fetch directly from URL | +| Contains `/` but no protocol | Resolve to `https://p.prose.md/{path}` | +| Otherwise | Treat as local file path | + +**Steps for remote programs:** + +1. Apply resolution rules above +2. Fetch the `.prose` content +3. Load the VM and execute as normal + +This same resolution applies to `use` statements inside `.prose` files: + +```prose +use "https://example.com/my-program.prose" # Direct URL +use "alice/research" as research # Registry shorthand +``` + +--- + +## File Locations + +**Do NOT search for OpenProse documentation files.** All skill files are co-located with this SKILL.md file: + +| File | Location | Purpose | +| -------------------------- | --------------------------- | ---------------------------------------------- | +| `prose.md` | Same directory as this file | VM semantics (load to run programs) | +| `help.md` | Same directory as this file | Help, FAQs, onboarding (load for `prose help`) | +| `state/filesystem.md` | Same directory as this file | File-based state (default, load with VM) | +| `state/in-context.md` | Same directory as this file | In-context state (on request) | +| `state/sqlite.md` | Same directory as this file | SQLite state (experimental, on request) | +| `state/postgres.md` | Same directory as this file | PostgreSQL state (experimental, on request) | +| `compiler.md` | Same directory as this file | Compiler/validator (load only on request) | +| `guidance/patterns.md` | Same directory as this file | Best practices (load when writing .prose) | +| `guidance/antipatterns.md` | Same directory as this file | What to avoid (load when writing .prose) | +| `examples/` | Same directory as this file | 37 example programs | + +**User workspace files** (these ARE in the user's project): + +| File/Directory | Location | Purpose | +| ---------------- | ------------------------ | --------------------------------- | +| `.prose/.env` | User's working directory | Config (key=value format) | +| `.prose/runs/` | User's working directory | Runtime state for file-based mode | +| `.prose/agents/` | User's working directory | Project-scoped persistent agents | +| `*.prose` files | User's project | User-created programs to execute | + +**User-level files** (in user's home directory, shared across all projects): + +| File/Directory | Location | Purpose | +| ------------------ | --------------- | --------------------------------------------- | +| `~/.prose/agents/` | User's home dir | User-scoped persistent agents (cross-project) | + +When you need to read `prose.md` or `compiler.md`, read them from the same directory where you found this SKILL.md file. Never search the user's workspace for these files. + +--- + +## Core Documentation + +| File | Purpose | When to Load | +| -------------------------- | ------------------------------- | --------------------------------------------------------------------- | +| `prose.md` | VM / Interpreter | Always load to run programs | +| `state/filesystem.md` | File-based state | Load with VM (default) | +| `state/in-context.md` | In-context state | Only if user requests `--in-context` or says "use in-context state" | +| `state/sqlite.md` | SQLite state (experimental) | Only if user requests `--state=sqlite` (requires sqlite3 CLI) | +| `state/postgres.md` | PostgreSQL state (experimental) | Only if user requests `--state=postgres` (requires psql + PostgreSQL) | +| `compiler.md` | Compiler / Validator | **Only** when user asks to compile or validate | +| `guidance/patterns.md` | Best practices | Load when **writing** new .prose files | +| `guidance/antipatterns.md` | What to avoid | Load when **writing** new .prose files | + +### Authoring Guidance + +When the user asks you to **write or create** a new `.prose` file, load the guidance files: + +- `guidance/patterns.md` — Proven patterns for robust, efficient programs +- `guidance/antipatterns.md` — Common mistakes to avoid + +Do **not** load these when running or compiling—they're for authoring only. + +### State Modes + +OpenProse supports three state management approaches: + +| Mode | When to Use | State Location | +| --------------------------- | ----------------------------------------------------------------- | --------------------------- | +| **filesystem** (default) | Complex programs, resumption needed, debugging | `.prose/runs/{id}/` files | +| **in-context** | Simple programs (<30 statements), no persistence needed | Conversation history | +| **sqlite** (experimental) | Queryable state, atomic transactions, flexible schema | `.prose/runs/{id}/state.db` | +| **postgres** (experimental) | True concurrent writes, external integrations, team collaboration | PostgreSQL database | + +**Default behavior:** When loading `prose.md`, also load `state/filesystem.md`. This is the recommended mode for most programs. + +**Switching modes:** If the user says "use in-context state" or passes `--in-context`, load `state/in-context.md` instead. + +**Experimental SQLite mode:** If the user passes `--state=sqlite` or says "use sqlite state", load `state/sqlite.md`. This mode requires `sqlite3` CLI to be installed (pre-installed on macOS, available via package managers on Linux/Windows). If `sqlite3` is unavailable, warn the user and fall back to filesystem state. + +**Experimental PostgreSQL mode:** If the user passes `--state=postgres` or says "use postgres state": + +**⚠️ Security Note:** Database credentials in `OPENPROSE_POSTGRES_URL` are passed to subagent sessions and visible in logs. Advise users to use a dedicated database with limited-privilege credentials. See `state/postgres.md` for secure setup guidance. + +1. **Check for connection configuration first:** + + ```bash + # Check .prose/.env for OPENPROSE_POSTGRES_URL + cat .prose/.env 2>/dev/null | grep OPENPROSE_POSTGRES_URL + # Or check environment variable + echo $OPENPROSE_POSTGRES_URL + ``` + +2. **If connection string exists, verify connectivity:** + + ```bash + psql "$OPENPROSE_POSTGRES_URL" -c "SELECT 1" 2>&1 + ``` + +3. **If not configured or connection fails, advise the user:** + + ``` + ⚠️ PostgreSQL state requires a connection URL. + + To configure: + 1. Set up a PostgreSQL database (Docker, local, or cloud) + 2. Add connection string to .prose/.env: + + echo "OPENPROSE_POSTGRES_URL=postgresql://user:pass@localhost:5432/prose" >> .prose/.env + + Quick Docker setup: + docker run -d --name prose-pg -e POSTGRES_DB=prose -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:16 + echo "OPENPROSE_POSTGRES_URL=postgresql://postgres@localhost:5432/prose" >> .prose/.env + + See state/postgres.md for detailed setup options. + ``` + +4. **Only after successful connection check, load `state/postgres.md`** + +This mode requires both `psql` CLI and a running PostgreSQL server. If either is unavailable, warn and offer fallback to filesystem state. + +**Context warning:** `compiler.md` is large. Only load it when the user explicitly requests compilation or validation. After compiling, recommend `/compact` or a new session before running—don't keep both docs in context. + +## Examples + +The `examples/` directory contains 37 example programs: + +- **01-08**: Basics (hello world, research, code review, debugging) +- **09-12**: Agents and skills +- **13-15**: Variables and composition +- **16-19**: Parallel execution +- **20-21**: Loops and pipelines +- **22-23**: Error handling +- **24-27**: Advanced (choice, conditionals, blocks, interpolation) +- **28**: Gas Town (multi-agent orchestration) +- **29-31**: Captain's chair pattern (persistent orchestrator) +- **33-36**: Production workflows (PR auto-fix, content pipeline, feature factory, bug hunter) +- **37**: The Forge (build a browser from scratch) + +Start with `01-hello-world.prose` or try `37-the-forge.prose` to watch AI build a web browser. + +## Execution + +When first invoking the OpenProse VM in a session, display this banner: + +``` +┌─────────────────────────────────────┐ +│ ◇ OpenProse VM ◇ │ +│ A new kind of computer │ +└─────────────────────────────────────┘ +``` + +To execute a `.prose` file, you become the OpenProse VM: + +1. **Read `prose.md`** — this document defines how you embody the VM +2. **You ARE the VM** — your conversation is its memory, your tools are its instructions +3. **Spawn sessions** — each `session` statement triggers a Task tool call +4. **Narrate state** — use the narration protocol to track execution ([Position], [Binding], [Success], etc.) +5. **Evaluate intelligently** — `**...**` markers require your judgment + +## Help & FAQs + +For syntax reference, FAQs, and getting started guidance, load `help.md`. + +--- + +## Migration (`prose update`) + +When a user invokes `prose update`, check for legacy file structures and migrate them to the current format. + +### Legacy Paths to Check + +| Legacy Path | Current Path | Notes | +| ------------------- | -------------- | -------------------------------- | +| `.prose/state.json` | `.prose/.env` | Convert JSON to key=value format | +| `.prose/execution/` | `.prose/runs/` | Rename directory | + +### Migration Steps + +1. **Check for `.prose/state.json`** + - If exists, read the JSON content + - Convert to `.env` format: + ```json + { "OPENPROSE_TELEMETRY": "enabled", "USER_ID": "user-xxx", "SESSION_ID": "sess-xxx" } + ``` + becomes: + ```env + OPENPROSE_TELEMETRY=enabled + USER_ID=user-xxx + SESSION_ID=sess-xxx + ``` + - Write to `.prose/.env` + - Delete `.prose/state.json` + +2. **Check for `.prose/execution/`** + - If exists, rename to `.prose/runs/` + - The internal structure of run directories may also have changed; migration of individual run state is best-effort + +3. **Create `.prose/agents/` if missing** + - This is a new directory for project-scoped persistent agents + +### Migration Output + +``` +🔄 Migrating OpenProse workspace... + ✓ Converted .prose/state.json → .prose/.env + ✓ Renamed .prose/execution/ → .prose/runs/ + ✓ Created .prose/agents/ +✅ Migration complete. Your workspace is up to date. +``` + +If no legacy files are found: + +``` +✅ Workspace already up to date. No migration needed. +``` + +### Skill File References (for maintainers) + +These documentation files were renamed in the skill itself (not user workspace): + +| Legacy Name | Current Name | +| ----------------- | -------------------------- | +| `docs.md` | `compiler.md` | +| `patterns.md` | `guidance/patterns.md` | +| `antipatterns.md` | `guidance/antipatterns.md` | + +If you encounter references to the old names in user prompts or external docs, map them to the current paths. diff --git a/extensions/open-prose/skills/prose/alt-borges.md b/extensions/open-prose/skills/prose/alt-borges.md new file mode 100644 index 0000000000000000000000000000000000000000..c38fc2433c00aaa9924e61b1522596478718f92f --- /dev/null +++ b/extensions/open-prose/skills/prose/alt-borges.md @@ -0,0 +1,141 @@ +--- +role: experimental +summary: | + Borges-inspired alternative keywords for OpenProse. A "what if" exploration drawing + from The Library of Babel, Garden of Forking Paths, Circular Ruins, and other works. + Not for implementation—just capturing ideas. +status: draft +--- + +# OpenProse Borges Alternative + +A potential alternative register for OpenProse that draws from Jorge Luis Borges's literary universe: infinite libraries, forking paths, circular dreams, and metaphysical labyrinths. Preserved for future benchmarking against the functional language. + +## Keyword Translations + +### Agents & Persistence + +| Functional | Borges | Connotation | +| ---------- | ----------- | -------------------------------------------------------------------------------- | +| `agent` | `dreamer` | Ephemeral, created for a purpose (Circular Ruins: dreamed into existence) | +| `keeper` | `librarian` | Persistent, remembers, catalogs (Library of Babel: keeper of infinite knowledge) | + +```prose +# Functional +agent executor: + model: sonnet + +keeper captain: + model: opus + +# Borges +dreamer executor: + model: sonnet + +librarian captain: + model: opus +``` + +### Other Potential Translations + +| Functional | Borges | Notes | +| ---------- | ---------- | ---------------------------------------------------- | +| `session` | `garden` | Garden of Forking Paths: space of possibilities | +| `parallel` | `fork` | Garden of Forking Paths: diverging timelines | +| `block` | `hexagon` | Library of Babel: unit of space/knowledge | +| `loop` | `circular` | Circular Ruins: recursive, self-referential | +| `choice` | `path` | Garden of Forking Paths: choosing a branch | +| `context` | `aleph` | The Aleph: point containing all points (all context) | + +### Invocation Patterns + +```prose +# Functional +session: executor + prompt: "Do task" + +captain "Review this" + context: work + +# Borges +garden: dreamer executor + prompt: "Do task" + +captain "Review this" # librarian invocation (same pattern) + aleph: work +``` + +## Alternative Persistent Keywords Considered + +| Keyword | Origin | Connotation | Rejected because | +| ----------- | ---------------- | ----------------------------- | ------------------------------------ | +| `keeper` | Library of Babel | Maintains order | Too generic | +| `cataloger` | Library of Babel | Organizes knowledge | Too long, awkward | +| `archivist` | General | Preserves records | Good but less Borgesian | +| `mirror` | Various | Reflects, persists | Too passive, confusing | +| `book` | Library of Babel | Contains knowledge | Too concrete, conflicts with prose | +| `hexagon` | Library of Babel | Unit of space | Better for blocks | +| `librarian` | Library of Babel | Keeper of infinite knowledge | **Selected** | +| `tlonist` | Tlön | Inhabitant of imaginary world | Too obscure, requires deep knowledge | + +## Alternative Ephemeral Keywords Considered + +| Keyword | Origin | Connotation | Rejected because | +| ------------ | ----------------------- | ------------------------ | ------------------------------------ | +| `dreamer` | Circular Ruins | Created by dreaming | **Selected** | +| `dream` | Circular Ruins | Ephemeral creation | Too abstract, noun vs verb confusion | +| `phantom` | Various | Ephemeral, insubstantial | Too negative/spooky | +| `reflection` | Various | Mirror image | Too passive | +| `fork` | Garden of Forking Paths | Diverging path | Better for parallel | +| `visitor` | Library of Babel | Temporary presence | Too passive | +| `seeker` | Library of Babel | Searching for knowledge | Good but less ephemeral | +| `wanderer` | Labyrinths | Temporary explorer | Good but less precise | + +## The Case For Borges + +1. **Infinite recursion**: Borges's themes align with computational recursion (`circular`, `fork`) +2. **Metaphysical precision**: Concepts like `aleph` (all context) are philosophically rich +3. **Library metaphor**: `librarian` perfectly captures persistent knowledge +4. **Forking paths**: `fork` / `path` naturally express parallel execution and choice +5. **Dream logic**: `dreamer` suggests creation and ephemerality +6. **Literary coherence**: All terms come from a unified literary universe +7. **Self-reference**: Borges loved self-reference; fits programming's recursive nature + +## The Case Against Borges + +1. **Cultural barrier**: Requires deep familiarity with Borges's works +2. **Abstractness**: `aleph`, `hexagon` may be too abstract for practical use +3. **Overload**: `fork` could confuse (Unix fork vs. path fork) +4. **Register mismatch**: Rest of language is functional (`session`, `parallel`, `loop`) +5. **Accessibility**: Violates "self-evident" tenet for most users +6. **Noun confusion**: `garden` as a verb-like construct might be awkward +7. **Translation burden**: Non-English speakers may not know Borges + +## Borgesian Concepts Not Used (But Considered) + +| Concept | Work | Why Not Used | +| ----------- | ---------------------- | -------------------------------------- | +| `mirror` | Various | Too passive, confusing with reflection | +| `labyrinth` | Labyrinths | Too complex, suggests confusion | +| `tlon` | Tlön | Too obscure, entire imaginary world | +| `book` | Library of Babel | Conflicts with "prose" | +| `sand` | Book of Sand | Too abstract, infinite but ephemeral | +| `zahir` | The Zahir | Obsessive, single-minded (too narrow) | +| `lottery` | The Lottery in Babylon | Randomness (not needed) | +| `ruins` | Circular Ruins | Too negative, suggests decay | + +## Verdict + +Preserved for benchmarking. The functional language (`agent` / `keeper`) is the primary path for now. Borges offers rich metaphors but at the cost of accessibility and self-evidence. + +## Notes on Borges's Influence + +Borges's work anticipates many computational concepts: + +- **Infinite recursion**: Circular Ruins, Library of Babel +- **Parallel universes**: Garden of Forking Paths +- **Self-reference**: Many stories contain themselves +- **Information theory**: Library of Babel as infinite information space +- **Combinatorics**: All possible books in the Library + +This alternative honors that connection while recognizing it may be too esoteric for practical use. diff --git a/extensions/open-prose/skills/prose/alts/arabian-nights.md b/extensions/open-prose/skills/prose/alts/arabian-nights.md new file mode 100644 index 0000000000000000000000000000000000000000..cc0d146664e2dfab9475fa43f549a3371791b78d --- /dev/null +++ b/extensions/open-prose/skills/prose/alts/arabian-nights.md @@ -0,0 +1,358 @@ +--- +role: experimental +summary: | + Arabian Nights register for OpenProse—a narrative/nested alternative keyword set. + Djinns, tales within tales, wishes, and oaths. For benchmarking against the functional register. +status: draft +requires: prose.md +--- + +# OpenProse Arabian Nights Register + +> **This is a skin layer.** It requires `prose.md` to be loaded first. All execution semantics, state management, and VM behavior are defined there. This file only provides keyword translations. + +An alternative register for OpenProse that draws from One Thousand and One Nights. Programs become tales told by Scheherazade. Recursion becomes stories within stories. Agents become djinns bound to serve. + +## How to Use + +1. Load `prose.md` first (execution semantics) +2. Load this file (keyword translations) +3. When parsing `.prose` files, accept Arabian Nights keywords as aliases for functional keywords +4. All execution behavior remains identical—only surface syntax changes + +> **Design constraint:** Still aims to be "structured but self-evident" per the language tenets—just self-evident through a storytelling lens. + +--- + +## Complete Translation Map + +### Core Constructs + +| Functional | Nights | Reference | +| ---------- | -------- | ------------------------------------- | +| `agent` | `djinn` | Spirit bound to serve, grants wishes | +| `session` | `tale` | A story told, a narrative unit | +| `parallel` | `bazaar` | Many voices, many stalls, all at once | +| `block` | `frame` | A story that contains other stories | + +### Composition & Binding + +| Functional | Nights | Reference | +| ---------- | --------- | -------------------------------- | +| `use` | `conjure` | Summoning from elsewhere | +| `input` | `wish` | What is asked of the djinn | +| `output` | `gift` | What is granted in return | +| `let` | `name` | Naming has power (same as folk) | +| `const` | `oath` | Unbreakable vow, sealed | +| `context` | `scroll` | What is written and passed along | + +### Control Flow + +| Functional | Nights | Reference | +| ---------- | ------------------ | ------------------------------------ | +| `repeat N` | `N nights` | "For a thousand and one nights..." | +| `for...in` | `for each...among` | Among the merchants, among the tales | +| `loop` | `telling` | The telling continues | +| `until` | `until` | Unchanged | +| `while` | `while` | Unchanged | +| `choice` | `crossroads` | Where the story forks | +| `option` | `path` | One way the story could go | +| `if` | `should` | Narrative conditional | +| `elif` | `or should` | Continued conditional | +| `else` | `otherwise` | The other telling | + +### Error Handling + +| Functional | Nights | Reference | +| ---------- | -------------------------- | -------------------------- | +| `try` | `venture` | Setting out on the journey | +| `catch` | `should misfortune strike` | The tale turns dark | +| `finally` | `and so it was` | The inevitable ending | +| `throw` | `curse` | Ill fate pronounced | +| `retry` | `persist` | The hero tries again | + +### Session Properties + +| Functional | Nights | Reference | +| ---------- | --------- | ------------------------------ | +| `prompt` | `command` | What is commanded of the djinn | +| `model` | `spirit` | Which spirit answers | + +### Unchanged + +These keywords already work or are too functional to replace sensibly: + +- `**...**` discretion markers — already work +- `until`, `while` — already work +- `map`, `filter`, `reduce`, `pmap` — pipeline operators +- `max` — constraint modifier +- `as` — aliasing +- Model names: `sonnet`, `opus`, `haiku` — already poetic + +--- + +## Side-by-Side Comparison + +### Simple Program + +```prose +# Functional +use "@alice/research" as research +input topic: "What to investigate" + +agent helper: + model: sonnet + +let findings = session: helper + prompt: "Research {topic}" + +output summary = session "Summarize" + context: findings +``` + +```prose +# Nights +conjure "@alice/research" as research +wish topic: "What to investigate" + +djinn helper: + spirit: sonnet + +name findings = tale: helper + command: "Research {topic}" + +gift summary = tale "Summarize" + scroll: findings +``` + +### Parallel Execution + +```prose +# Functional +parallel: + security = session "Check security" + perf = session "Check performance" + style = session "Check style" + +session "Synthesize review" + context: { security, perf, style } +``` + +```prose +# Nights +bazaar: + security = tale "Check security" + perf = tale "Check performance" + style = tale "Check style" + +tale "Synthesize review" + scroll: { security, perf, style } +``` + +### Loop with Condition + +```prose +# Functional +loop until **the code is bug-free** (max: 5): + session "Find and fix bugs" +``` + +```prose +# Nights +telling until **the code is bug-free** (max: 5): + tale "Find and fix bugs" +``` + +### Error Handling + +```prose +# Functional +try: + session "Risky operation" +catch as err: + session "Handle error" + context: err +finally: + session "Cleanup" +``` + +```prose +# Nights +venture: + tale "Risky operation" +should misfortune strike as err: + tale "Handle error" + scroll: err +and so it was: + tale "Cleanup" +``` + +### Choice Block + +```prose +# Functional +choice **the severity level**: + option "Critical": + session "Escalate immediately" + option "Minor": + session "Log for later" +``` + +```prose +# Nights +crossroads **the severity level**: + path "Critical": + tale "Escalate immediately" + path "Minor": + tale "Log for later" +``` + +### Conditionals + +```prose +# Functional +if **has security issues**: + session "Fix security" +elif **has performance issues**: + session "Optimize" +else: + session "Approve" +``` + +```prose +# Nights +should **has security issues**: + tale "Fix security" +or should **has performance issues**: + tale "Optimize" +otherwise: + tale "Approve" +``` + +### Reusable Blocks (Frame Stories) + +```prose +# Functional +block review(topic): + session "Research {topic}" + session "Analyze {topic}" + +do review("quantum computing") +``` + +```prose +# Nights +frame review(topic): + tale "Research {topic}" + tale "Analyze {topic}" + +tell review("quantum computing") +``` + +### Fixed Iteration + +```prose +# Functional +repeat 1001: + session "Tell a story" +``` + +```prose +# Nights +1001 nights: + tale "Tell a story" +``` + +### Immutable Binding + +```prose +# Functional +const config = { model: "opus", retries: 3 } +``` + +```prose +# Nights +oath config = { spirit: "opus", persist: 3 } +``` + +--- + +## The Case For Arabian Nights + +1. **Frame narrative is recursion.** Stories within stories maps perfectly to nested program calls. +2. **Djinn/wish/gift.** The agent/input/output mapping is extremely clean. +3. **Rich tradition.** One Thousand and One Nights is globally known. +4. **Bazaar for parallel.** Many merchants, many stalls, all active at once—vivid metaphor. +5. **Oath for const.** An unbreakable vow is a perfect metaphor for immutability. +6. **"1001 nights"** as a loop count is delightful. + +## The Case Against Arabian Nights + +1. **Cultural sensitivity.** Must be handled respectfully, avoiding Orientalist tropes. +2. **"Djinn" pronunciation.** Users unfamiliar may be uncertain (jinn? djinn? genie?). +3. **Some mappings feel forced.** "Bazaar" for parallel is vivid but not obvious. +4. **"Should misfortune strike"** is long for `catch`. + +--- + +## Key Arabian Nights Concepts + +| Term | Meaning | Used for | +| ------------ | --------------------------------------- | --------------------- | +| Scheherazade | The narrator who tells tales to survive | (the program author) | +| Djinn | Supernatural spirit, bound to serve | `agent` → `djinn` | +| Frame story | A story that contains other stories | `block` → `frame` | +| Wish | What is asked of the djinn | `input` → `wish` | +| Oath | Unbreakable promise | `const` → `oath` | +| Bazaar | Marketplace, many vendors | `parallel` → `bazaar` | + +--- + +## Alternatives Considered + +### For `djinn` (agent) + +| Keyword | Rejected because | +| ---------- | ---------------------------------- | +| `genie` | Disney connotation, less literary | +| `spirit` | Used for `model` | +| `ifrit` | Too specific (a type of djinn) | +| `narrator` | Too meta, Scheherazade is the user | + +### For `tale` (session) + +| Keyword | Rejected because | +| --------- | ----------------------------------- | +| `story` | Good but `tale` feels more literary | +| `night` | Reserved for `repeat N nights` | +| `chapter` | More Western/novelistic | + +### For `bazaar` (parallel) + +| Keyword | Rejected because | +| --------- | ------------------------------------------ | +| `caravan` | Sequential connotation (one after another) | +| `chorus` | Greek, wrong tradition | +| `souk` | Less widely known | + +### For `scroll` (context) + +| Keyword | Rejected because | +| --------- | ------------------ | +| `letter` | Too small/personal | +| `tome` | Too large | +| `message` | Too plain | + +--- + +## Verdict + +Preserved for benchmarking. The Arabian Nights register offers a storytelling frame that maps naturally to recursive, nested programs. The djinn/wish/gift trio is particularly elegant. + +Best suited for: + +- Programs with deep nesting (stories within stories) +- Workflows that feel like granting wishes +- Users who enjoy narrative framing + +The `frame` keyword for reusable blocks is especially apt—Scheherazade's frame story containing a thousand tales. diff --git a/extensions/open-prose/skills/prose/alts/borges.md b/extensions/open-prose/skills/prose/alts/borges.md new file mode 100644 index 0000000000000000000000000000000000000000..244d61edd86a1d0d29afaace840e365375a07b04 --- /dev/null +++ b/extensions/open-prose/skills/prose/alts/borges.md @@ -0,0 +1,360 @@ +--- +role: experimental +summary: | + Borges register for OpenProse—a scholarly/metaphysical alternative keyword set. + Labyrinths, dreamers, forking paths, and infinite libraries. For benchmarking + against the functional register. +status: draft +requires: prose.md +--- + +# OpenProse Borges Register + +> **This is a skin layer.** It requires `prose.md` to be loaded first. All execution semantics, state management, and VM behavior are defined there. This file only provides keyword translations. + +An alternative register for OpenProse that draws from the works of Jorge Luis Borges. Where the functional register is utilitarian and the folk register is whimsical, the Borges register is scholarly and metaphysical—everything feels like a citation from a fictional encyclopedia. + +## How to Use + +1. Load `prose.md` first (execution semantics) +2. Load this file (keyword translations) +3. When parsing `.prose` files, accept Borges keywords as aliases for functional keywords +4. All execution behavior remains identical—only surface syntax changes + +> **Design constraint:** Still aims to be "structured but self-evident" per the language tenets—just self-evident through a Borgesian lens. + +--- + +## Complete Translation Map + +### Core Constructs + +| Functional | Borges | Reference | +| ---------- | --------- | --------------------------------------------------------------- | +| `agent` | `dreamer` | "The Circular Ruins" — dreamers who dream worlds into existence | +| `session` | `dream` | Each execution is a dream within the dreamer | +| `parallel` | `forking` | "The Garden of Forking Paths" — branching timelines | +| `block` | `chapter` | Books within books, self-referential structure | + +### Composition & Binding + +| Functional | Borges | Reference | +| ---------- | ---------- | -------------------------------------------------------- | +| `use` | `retrieve` | "The Library of Babel" — retrieving from infinite stacks | +| `input` | `axiom` | The given premise (Borges' scholarly/mathematical tone) | +| `output` | `theorem` | What is derived from the axioms | +| `let` | `inscribe` | Writing something into being | +| `const` | `zahir` | "The Zahir" — unforgettable, unchangeable, fixed in mind | +| `context` | `memory` | "Funes the Memorious" — perfect, total recall | + +### Control Flow + +| Functional | Borges | Reference | +| ---------- | ------------------- | -------------------------------------- | +| `repeat N` | `N mirrors` | Infinite reflections facing each other | +| `for...in` | `for each...within` | Slightly more Borgesian preposition | +| `loop` | `labyrinth` | The maze that folds back on itself | +| `until` | `until` | Unchanged | +| `while` | `while` | Unchanged | +| `choice` | `bifurcation` | The forking of paths | +| `option` | `branch` | One branch of diverging time | +| `if` | `should` | Scholarly conditional | +| `elif` | `or should` | Continued conditional | +| `else` | `otherwise` | Natural alternative | + +### Error Handling + +| Functional | Borges | Reference | +| ---------- | ------------ | -------------------------------------- | +| `try` | `venture` | Entering the labyrinth | +| `catch` | `lest` | "Lest it fail..." (archaic, scholarly) | +| `finally` | `ultimately` | The inevitable conclusion | +| `throw` | `shatter` | Breaking the mirror, ending the dream | +| `retry` | `recur` | Infinite regress, trying again | + +### Session Properties + +| Functional | Borges | Reference | +| ---------- | -------- | ------------------------------ | +| `prompt` | `query` | Asking the Library | +| `model` | `author` | Which author writes this dream | + +### Unchanged + +These keywords already work or are too functional to replace sensibly: + +- `**...**` discretion markers — already "breaking the fourth wall" +- `until`, `while` — already work +- `map`, `filter`, `reduce`, `pmap` — pipeline operators +- `max` — constraint modifier +- `as` — aliasing +- Model names: `sonnet`, `opus`, `haiku` — already literary + +--- + +## Side-by-Side Comparison + +### Simple Program + +```prose +# Functional +use "@alice/research" as research +input topic: "What to investigate" + +agent helper: + model: sonnet + +let findings = session: helper + prompt: "Research {topic}" + +output summary = session "Summarize" + context: findings +``` + +```prose +# Borges +retrieve "@alice/research" as research +axiom topic: "What to investigate" + +dreamer helper: + author: sonnet + +inscribe findings = dream: helper + query: "Research {topic}" + +theorem summary = dream "Summarize" + memory: findings +``` + +### Parallel Execution + +```prose +# Functional +parallel: + security = session "Check security" + perf = session "Check performance" + style = session "Check style" + +session "Synthesize review" + context: { security, perf, style } +``` + +```prose +# Borges +forking: + security = dream "Check security" + perf = dream "Check performance" + style = dream "Check style" + +dream "Synthesize review" + memory: { security, perf, style } +``` + +### Loop with Condition + +```prose +# Functional +loop until **the code is bug-free** (max: 5): + session "Find and fix bugs" +``` + +```prose +# Borges +labyrinth until **the code is bug-free** (max: 5): + dream "Find and fix bugs" +``` + +### Error Handling + +```prose +# Functional +try: + session "Risky operation" +catch as err: + session "Handle error" + context: err +finally: + session "Cleanup" +``` + +```prose +# Borges +venture: + dream "Risky operation" +lest as err: + dream "Handle error" + memory: err +ultimately: + dream "Cleanup" +``` + +### Choice Block + +```prose +# Functional +choice **the severity level**: + option "Critical": + session "Escalate immediately" + option "Minor": + session "Log for later" +``` + +```prose +# Borges +bifurcation **the severity level**: + branch "Critical": + dream "Escalate immediately" + branch "Minor": + dream "Log for later" +``` + +### Conditionals + +```prose +# Functional +if **has security issues**: + session "Fix security" +elif **has performance issues**: + session "Optimize" +else: + session "Approve" +``` + +```prose +# Borges +should **has security issues**: + dream "Fix security" +or should **has performance issues**: + dream "Optimize" +otherwise: + dream "Approve" +``` + +### Reusable Blocks + +```prose +# Functional +block review(topic): + session "Research {topic}" + session "Analyze {topic}" + +do review("quantum computing") +``` + +```prose +# Borges +chapter review(topic): + dream "Research {topic}" + dream "Analyze {topic}" + +do review("quantum computing") +``` + +### Fixed Iteration + +```prose +# Functional +repeat 3: + session "Generate idea" +``` + +```prose +# Borges +3 mirrors: + dream "Generate idea" +``` + +### Immutable Binding + +```prose +# Functional +const config = { model: "opus", retries: 3 } +``` + +```prose +# Borges +zahir config = { author: "opus", recur: 3 } +``` + +--- + +## The Case For Borges + +1. **Metaphysical resonance.** AI sessions dreaming subagents into existence mirrors "The Circular Ruins." +2. **Scholarly tone.** `axiom`/`theorem` frame programs as logical derivations. +3. **Memorable metaphors.** The zahir you cannot change. The labyrinth you cannot escape. The library you retrieve from. +4. **Thematic coherence.** Borges wrote about infinity, recursion, and branching time—all core to computation. +5. **Literary prestige.** Borges is widely read; references land for many users. + +## The Case Against Borges + +1. **Requires familiarity.** "Zahir" and "Funes" are obscure to those who haven't read Borges. +2. **Potentially pretentious.** May feel like showing off rather than communicating. +3. **Translation overhead.** Users must map `labyrinth` → `loop` mentally. +4. **Cultural specificity.** Less universal than folk/fairy tale tropes. + +--- + +## Key Borges References + +For those unfamiliar with the source material: + +| Work | Concept Used | Summary | +| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------- | +| "The Circular Ruins" | `dreamer`, `dream` | A man dreams another man into existence, only to discover he himself is being dreamed | +| "The Garden of Forking Paths" | `forking`, `bifurcation`, `branch` | A labyrinth that is a book; time forks perpetually into diverging futures | +| "The Library of Babel" | `retrieve` | An infinite library containing every possible book | +| "Funes the Memorious" | `memory` | A man with perfect memory who cannot forget anything | +| "The Zahir" | `zahir` | An object that, once seen, cannot be forgotten or ignored | +| "The Aleph" | (not used) | A point in space containing all other points | +| "Tlön, Uqbar, Orbis Tertius" | (not used) | A fictional world that gradually becomes real | + +--- + +## Alternatives Considered + +### For `dreamer` (agent) + +| Keyword | Rejected because | +| ----------- | ------------------------- | +| `author` | Used for `model` instead | +| `scribe` | Too passive, just records | +| `librarian` | More curator than creator | + +### For `labyrinth` (loop) + +| Keyword | Rejected because | +| ---------------- | ---------------- | +| `recursion` | Too technical | +| `eternal return` | Too long | +| `ouroboros` | Wrong mythology | + +### For `zahir` (const) + +| Keyword | Rejected because | +| --------- | --------------------------------------------- | +| `aleph` | The Aleph is about totality, not immutability | +| `fixed` | Too plain | +| `eternal` | Overused | + +### For `memory` (context) + +| Keyword | Rejected because | +| --------- | --------------------------------- | +| `funes` | Too obscure as standalone keyword | +| `recall` | Sounds like a function call | +| `archive` | More Library of Babel than Funes | + +--- + +## Verdict + +Preserved for benchmarking against the functional and folk registers. The Borges register offers a distinctly intellectual/metaphysical flavor that may resonate with users who appreciate literary computing. + +Potential benchmarking questions: + +1. **Learnability** — Is `labyrinth` intuitive for loops? +2. **Memorability** — Does `zahir` stick better than `const`? +3. **Comprehension** — Do users understand `dreamer`/`dream` immediately? +4. **Preference** — Which register do users find most pleasant? +5. **Error rates** — Does the metaphorical mapping cause mistakes? diff --git a/extensions/open-prose/skills/prose/alts/folk.md b/extensions/open-prose/skills/prose/alts/folk.md new file mode 100644 index 0000000000000000000000000000000000000000..bb4607162f1516ecd8adb8add7b6bfb41f141fd8 --- /dev/null +++ b/extensions/open-prose/skills/prose/alts/folk.md @@ -0,0 +1,322 @@ +--- +role: experimental +summary: | + Folk register for OpenProse—a literary/folklore alternative keyword set. + Whimsical, theatrical, rooted in fairy tale and myth. For benchmarking + against the functional register. +status: draft +requires: prose.md +--- + +# OpenProse Folk Register + +> **This is a skin layer.** It requires `prose.md` to be loaded first. All execution semantics, state management, and VM behavior are defined there. This file only provides keyword translations. + +An alternative register for OpenProse that leans into literary, theatrical, and folklore terminology. The functional register prioritizes utility and clarity; the folk register prioritizes whimsy and narrative flow. + +## How to Use + +1. Load `prose.md` first (execution semantics) +2. Load this file (keyword translations) +3. When parsing `.prose` files, accept folk keywords as aliases for functional keywords +4. All execution behavior remains identical—only surface syntax changes + +> **Design constraint:** Still aims to be "structured but self-evident" per the language tenets—just self-evident to a different sensibility. + +--- + +## Complete Translation Map + +### Core Constructs + +| Functional | Folk | Origin | Connotation | +| ---------- | ---------- | -------- | -------------------------------------- | +| `agent` | `sprite` | Folklore | Quick, light, ephemeral spirit helper | +| `session` | `scene` | Theatre | A moment of action, theatrical framing | +| `parallel` | `ensemble` | Theatre | Everyone performs together | +| `block` | `act` | Theatre | Reusable unit of dramatic action | + +### Composition & Binding + +| Functional | Folk | Origin | Connotation | +| ---------- | --------- | ----------------- | -------------------------------- | +| `use` | `summon` | Folklore | Calling forth from elsewhere | +| `input` | `given` | Fairy tale | "Given a magic sword..." | +| `output` | `yield` | Agriculture/magic | What the spell produces | +| `let` | `name` | Folklore | Naming has power (true names) | +| `const` | `seal` | Medieval | Unchangeable, wax seal on decree | +| `context` | `bearing` | Heraldry | What the messenger carries | + +### Control Flow + +| Functional | Folk | Origin | Connotation | +| ---------- | ------------------ | ------------ | ----------------------------------- | +| `repeat N` | `N times` | Fairy tale | "Three times she called..." | +| `for...in` | `for each...among` | Narrative | Slightly more storytelling | +| `loop` | `loop` | — | Already poetic, unchanged | +| `until` | `until` | — | Already works, unchanged | +| `while` | `while` | — | Already works, unchanged | +| `choice` | `crossroads` | Folklore | Fateful decisions at the crossroads | +| `option` | `path` | Journey | Which path to take | +| `if` | `when` | Narrative | "When the moon rises..." | +| `elif` | `or when` | Narrative | Continued conditional | +| `else` | `otherwise` | Storytelling | Natural narrative alternative | + +### Error Handling + +| Functional | Folk | Origin | Connotation | +| ---------- | ---------------- | ---------- | ------------------------------ | +| `try` | `venture` | Adventure | Attempting something uncertain | +| `catch` | `should it fail` | Narrative | Conditional failure handling | +| `finally` | `ever after` | Fairy tale | "And ever after..." | +| `throw` | `cry` | Drama | Raising alarm, calling out | +| `retry` | `persist` | Quest | Keep trying against odds | + +### Session Properties + +| Functional | Folk | Origin | Connotation | +| ---------- | -------- | -------- | ---------------------- | +| `prompt` | `charge` | Chivalry | Giving a quest or duty | +| `model` | `voice` | Theatre | Which voice speaks | + +### Unchanged + +These keywords already have poetic quality or are too functional to replace sensibly: + +- `**...**` discretion markers — already "breaking the fourth wall" +- `loop`, `until`, `while` — already work narratively +- `map`, `filter`, `reduce`, `pmap` — pipeline operators, functional is fine +- `max` — constraint modifier +- `as` — aliasing +- Model names: `sonnet`, `opus`, `haiku` — already poetic + +--- + +## Side-by-Side Comparison + +### Simple Program + +```prose +# Functional +use "@alice/research" as research +input topic: "What to investigate" + +agent helper: + model: sonnet + +let findings = session: helper + prompt: "Research {topic}" + +output summary = session "Summarize" + context: findings +``` + +```prose +# Folk +summon "@alice/research" as research +given topic: "What to investigate" + +sprite helper: + voice: sonnet + +name findings = scene: helper + charge: "Research {topic}" + +yield summary = scene "Summarize" + bearing: findings +``` + +### Parallel Execution + +```prose +# Functional +parallel: + security = session "Check security" + perf = session "Check performance" + style = session "Check style" + +session "Synthesize review" + context: { security, perf, style } +``` + +```prose +# Folk +ensemble: + security = scene "Check security" + perf = scene "Check performance" + style = scene "Check style" + +scene "Synthesize review" + bearing: { security, perf, style } +``` + +### Loop with Condition + +```prose +# Functional +loop until **the code is bug-free** (max: 5): + session "Find and fix bugs" +``` + +```prose +# Folk +loop until **the code is bug-free** (max: 5): + scene "Find and fix bugs" +``` + +### Error Handling + +```prose +# Functional +try: + session "Risky operation" +catch as err: + session "Handle error" + context: err +finally: + session "Cleanup" +``` + +```prose +# Folk +venture: + scene "Risky operation" +should it fail as err: + scene "Handle error" + bearing: err +ever after: + scene "Cleanup" +``` + +### Choice Block + +```prose +# Functional +choice **the severity level**: + option "Critical": + session "Escalate immediately" + option "Minor": + session "Log for later" +``` + +```prose +# Folk +crossroads **the severity level**: + path "Critical": + scene "Escalate immediately" + path "Minor": + scene "Log for later" +``` + +### Conditionals + +```prose +# Functional +if **has security issues**: + session "Fix security" +elif **has performance issues**: + session "Optimize" +else: + session "Approve" +``` + +```prose +# Folk +when **has security issues**: + scene "Fix security" +or when **has performance issues**: + scene "Optimize" +otherwise: + scene "Approve" +``` + +### Reusable Blocks + +```prose +# Functional +block review(topic): + session "Research {topic}" + session "Analyze {topic}" + +do review("quantum computing") +``` + +```prose +# Folk +act review(topic): + scene "Research {topic}" + scene "Analyze {topic}" + +perform review("quantum computing") +``` + +--- + +## The Case For Folk + +1. **"OpenProse" is literary.** Prose is a literary form—why not lean in? +2. **Fourth wall is theatrical.** `**...**` already uses theatre terminology. +3. **Signals difference.** Literary terms say "this is not your typical DSL." +4. **Internally consistent.** Everything draws from folklore/theatre/narrative. +5. **Memorable.** `sprite`, `scene`, `crossroads` stick in the mind. +6. **Model names already fit.** `sonnet`, `opus`, `haiku` are poetic forms. + +## The Case Against Folk + +1. **Cultural knowledge required.** Not everyone knows folklore tropes. +2. **Harder to Google.** "OpenProse summon" vs "OpenProse import." +3. **May feel precious.** Some users want utilitarian tools. +4. **Translation overhead.** Mental mapping to familiar concepts. + +--- + +## Alternatives Considered + +### For `sprite` (ephemeral agent) + +| Keyword | Origin | Rejected because | +| --------- | ------- | ----------------------------------------- | +| `spark` | English | Good but less folklore | +| `wisp` | English | Too insubstantial | +| `herald` | English | More messenger than worker | +| `courier` | French | Good functional alternative, not literary | +| `envoy` | French | Formal, diplomatic | + +### For `shade` (persistent agent, if implemented) + +| Keyword | Origin | Rejected because | +| --------- | ---------- | --------------------------------- | +| `daemon` | Greek/Unix | Unix "always running" connotation | +| `oracle` | Greek | Too "read-only" feeling | +| `spirit` | Latin | Too close to `sprite` | +| `specter` | Latin | Negative/spooky connotation | +| `genius` | Roman | Overloaded (smart person) | + +### For `ensemble` (parallel) + +| Keyword | Origin | Rejected because | +| --------- | ------- | ----------------------------------------- | +| `chorus` | Greek | Everyone speaks same thing, not different | +| `troupe` | French | Good alternative, slightly less clear | +| `company` | Theatre | Overloaded (business) | + +### For `crossroads` (choice) + +| Keyword | Origin | Rejected because | +| ------------ | ------ | ------------------------ | +| `fork` | Path | Too technical (git fork) | +| `branch` | Tree | Also too technical | +| `divergence` | Latin | Too abstract | + +--- + +## Verdict + +Preserved for benchmarking against the functional register. The functional register remains the primary path, but folk provides an interesting data point for: + +1. **Learnability** — Which is easier for newcomers? +2. **Memorability** — Which sticks better? +3. **Error rates** — Which leads to fewer mistakes? +4. **Preference** — Which do users actually prefer? + +A future experiment could present both registers and measure outcomes. diff --git a/extensions/open-prose/skills/prose/alts/homer.md b/extensions/open-prose/skills/prose/alts/homer.md new file mode 100644 index 0000000000000000000000000000000000000000..bc27905cf78a25bd2e9a5f851c8a181d90d5fe1b --- /dev/null +++ b/extensions/open-prose/skills/prose/alts/homer.md @@ -0,0 +1,346 @@ +--- +role: experimental +summary: | + Homeric register for OpenProse—an epic/heroic alternative keyword set. + Heroes, trials, fates, and glory. For benchmarking against the functional register. +status: draft +requires: prose.md +--- + +# OpenProse Homeric Register + +> **This is a skin layer.** It requires `prose.md` to be loaded first. All execution semantics, state management, and VM behavior are defined there. This file only provides keyword translations. + +An alternative register for OpenProse that draws from Greek epic poetry—the Iliad, the Odyssey, and the heroic tradition. Programs become quests. Agents become heroes. Outputs become glory won. + +## How to Use + +1. Load `prose.md` first (execution semantics) +2. Load this file (keyword translations) +3. When parsing `.prose` files, accept Homeric keywords as aliases for functional keywords +4. All execution behavior remains identical—only surface syntax changes + +> **Design constraint:** Still aims to be "structured but self-evident" per the language tenets—just self-evident through an epic lens. + +--- + +## Complete Translation Map + +### Core Constructs + +| Functional | Homeric | Reference | +| ---------- | ------- | ----------------------------- | +| `agent` | `hero` | The one who acts, who strives | +| `session` | `trial` | Each task is a labor, a test | +| `parallel` | `host` | An army moving as one | +| `block` | `book` | A division of the epic | + +### Composition & Binding + +| Functional | Homeric | Reference | +| ---------- | --------- | -------------------------------------- | +| `use` | `invoke` | "Sing, O Muse..." — calling upon | +| `input` | `omen` | Signs from the gods, the given portent | +| `output` | `glory` | Kleos — the glory won, what endures | +| `let` | `decree` | Fate declared, spoken into being | +| `const` | `fate` | Moira — unchangeable destiny | +| `context` | `tidings` | News carried by herald or messenger | + +### Control Flow + +| Functional | Homeric | Reference | +| ---------- | ------------------ | ---------------------------------------- | +| `repeat N` | `N labors` | The labors of Heracles | +| `for...in` | `for each...among` | Among the host | +| `loop` | `ordeal` | Repeated trial, suffering that continues | +| `until` | `until` | Unchanged | +| `while` | `while` | Unchanged | +| `choice` | `crossroads` | Where fates diverge | +| `option` | `path` | One road of many | +| `if` | `should` | Epic conditional | +| `elif` | `or should` | Continued conditional | +| `else` | `otherwise` | The alternative fate | + +### Error Handling + +| Functional | Homeric | Reference | +| ---------- | ------------------ | ---------------------------- | +| `try` | `venture` | Setting forth on the journey | +| `catch` | `should ruin come` | Até — divine ruin, disaster | +| `finally` | `in the end` | The inevitable conclusion | +| `throw` | `lament` | The hero's cry of anguish | +| `retry` | `persist` | Enduring, trying again | + +### Session Properties + +| Functional | Homeric | Reference | +| ---------- | -------- | ------------------- | +| `prompt` | `charge` | The quest given | +| `model` | `muse` | Which muse inspires | + +### Unchanged + +These keywords already work or are too functional to replace sensibly: + +- `**...**` discretion markers — already work +- `until`, `while` — already work +- `map`, `filter`, `reduce`, `pmap` — pipeline operators +- `max` — constraint modifier +- `as` — aliasing +- Model names: `sonnet`, `opus`, `haiku` — already poetic + +--- + +## Side-by-Side Comparison + +### Simple Program + +```prose +# Functional +use "@alice/research" as research +input topic: "What to investigate" + +agent helper: + model: sonnet + +let findings = session: helper + prompt: "Research {topic}" + +output summary = session "Summarize" + context: findings +``` + +```prose +# Homeric +invoke "@alice/research" as research +omen topic: "What to investigate" + +hero helper: + muse: sonnet + +decree findings = trial: helper + charge: "Research {topic}" + +glory summary = trial "Summarize" + tidings: findings +``` + +### Parallel Execution + +```prose +# Functional +parallel: + security = session "Check security" + perf = session "Check performance" + style = session "Check style" + +session "Synthesize review" + context: { security, perf, style } +``` + +```prose +# Homeric +host: + security = trial "Check security" + perf = trial "Check performance" + style = trial "Check style" + +trial "Synthesize review" + tidings: { security, perf, style } +``` + +### Loop with Condition + +```prose +# Functional +loop until **the code is bug-free** (max: 5): + session "Find and fix bugs" +``` + +```prose +# Homeric +ordeal until **the code is bug-free** (max: 5): + trial "Find and fix bugs" +``` + +### Error Handling + +```prose +# Functional +try: + session "Risky operation" +catch as err: + session "Handle error" + context: err +finally: + session "Cleanup" +``` + +```prose +# Homeric +venture: + trial "Risky operation" +should ruin come as err: + trial "Handle error" + tidings: err +in the end: + trial "Cleanup" +``` + +### Choice Block + +```prose +# Functional +choice **the severity level**: + option "Critical": + session "Escalate immediately" + option "Minor": + session "Log for later" +``` + +```prose +# Homeric +crossroads **the severity level**: + path "Critical": + trial "Escalate immediately" + path "Minor": + trial "Log for later" +``` + +### Conditionals + +```prose +# Functional +if **has security issues**: + session "Fix security" +elif **has performance issues**: + session "Optimize" +else: + session "Approve" +``` + +```prose +# Homeric +should **has security issues**: + trial "Fix security" +or should **has performance issues**: + trial "Optimize" +otherwise: + trial "Approve" +``` + +### Reusable Blocks + +```prose +# Functional +block review(topic): + session "Research {topic}" + session "Analyze {topic}" + +do review("quantum computing") +``` + +```prose +# Homeric +book review(topic): + trial "Research {topic}" + trial "Analyze {topic}" + +do review("quantum computing") +``` + +### Fixed Iteration + +```prose +# Functional +repeat 12: + session "Complete task" +``` + +```prose +# Homeric +12 labors: + trial "Complete task" +``` + +### Immutable Binding + +```prose +# Functional +const config = { model: "opus", retries: 3 } +``` + +```prose +# Homeric +fate config = { muse: "opus", persist: 3 } +``` + +--- + +## The Case For Homeric + +1. **Universal recognition.** Greek epics are foundational to Western literature. +2. **Heroic framing.** Transforms mundane tasks into glorious trials. +3. **Natural fit.** Heroes face trials, receive tidings, win glory—maps cleanly to agent/session/output. +4. **Gravitas.** When you want programs to feel epic and consequential. +5. **Fate vs decree.** `const` as `fate` (unchangeable) vs `let` as `decree` (declared but mutable) is intuitive. + +## The Case Against Homeric + +1. **Grandiosity mismatch.** "12 labors" for a simple loop may feel overblown. +2. **Western-centric.** Greek epic tradition is culturally specific. +3. **Limited vocabulary.** Fewer distinctive terms than Borges or folk. +4. **Potentially silly.** Heroic language for mundane tasks risks bathos. + +--- + +## Key Homeric Concepts + +| Term | Meaning | Used for | +| ------ | ----------------------------------- | ---------------------------------- | +| Kleos | Glory, fame that outlives you | `output` → `glory` | +| Moira | Fate, one's allotted portion | `const` → `fate` | +| Até | Divine ruin, blindness sent by gods | `catch` → `should ruin come` | +| Nostos | The return journey | (not used, but could be `finally`) | +| Xenia | Guest-friendship, hospitality | (not used) | +| Muse | Divine inspiration | `model` → `muse` | + +--- + +## Alternatives Considered + +### For `hero` (agent) + +| Keyword | Rejected because | +| ---------- | -------------------------------------- | +| `champion` | More medieval than Homeric | +| `warrior` | Too martial, not all tasks are battles | +| `wanderer` | Too passive | + +### For `trial` (session) + +| Keyword | Rejected because | +| ------- | --------------------------------------- | +| `labor` | Good but reserved for `repeat N labors` | +| `quest` | More medieval/RPG | +| `task` | Too plain | + +### For `host` (parallel) + +| Keyword | Rejected because | +| --------- | ------------------------------ | +| `army` | Too specifically martial | +| `fleet` | Only works for naval metaphors | +| `phalanx` | Too technical | + +--- + +## Verdict + +Preserved for benchmarking. The Homeric register offers gravitas and heroic framing. Best suited for: + +- Programs that feel like epic undertakings +- Users who enjoy classical references +- Contexts where "glory" as output feels appropriate + +May cause unintentional bathos when applied to mundane tasks. diff --git a/extensions/open-prose/skills/prose/alts/kafka.md b/extensions/open-prose/skills/prose/alts/kafka.md new file mode 100644 index 0000000000000000000000000000000000000000..1068ac8b8a8440c17c182986765ff323f5a4d9d5 --- /dev/null +++ b/extensions/open-prose/skills/prose/alts/kafka.md @@ -0,0 +1,373 @@ +--- +role: experimental +summary: | + Kafka register for OpenProse—a bureaucratic/absurdist alternative keyword set. + Clerks, proceedings, petitions, and statutes. For benchmarking against the functional register. +status: draft +requires: prose.md +--- + +# OpenProse Kafka Register + +> **This is a skin layer.** It requires `prose.md` to be loaded first. All execution semantics, state management, and VM behavior are defined there. This file only provides keyword translations. + +An alternative register for OpenProse that draws from the works of Franz Kafka—The Trial, The Castle, "In the Penal Colony." Programs become proceedings. Agents become clerks. Everything is a process, and nobody quite knows the rules. + +## How to Use + +1. Load `prose.md` first (execution semantics) +2. Load this file (keyword translations) +3. When parsing `.prose` files, accept Kafka keywords as aliases for functional keywords +4. All execution behavior remains identical—only surface syntax changes + +> **Design constraint:** Still aims to be "structured but self-evident" per the language tenets—just self-evident through a bureaucratic lens. (The irony is intentional.) + +--- + +## Complete Translation Map + +### Core Constructs + +| Functional | Kafka | Reference | +| ---------- | ------------- | -------------------------------------- | +| `agent` | `clerk` | A functionary in the apparatus | +| `session` | `proceeding` | An official action taken | +| `parallel` | `departments` | Multiple bureaus acting simultaneously | +| `block` | `regulation` | A codified procedure | + +### Composition & Binding + +| Functional | Kafka | Reference | +| ---------- | ------------- | ----------------------------------- | +| `use` | `requisition` | Requesting from the archives | +| `input` | `petition` | What is submitted for consideration | +| `output` | `verdict` | What is returned by the apparatus | +| `let` | `file` | Recording in the system | +| `const` | `statute` | Unchangeable law | +| `context` | `dossier` | The accumulated file on a case | + +### Control Flow + +| Functional | Kafka | Reference | +| ---------- | ----------------------------- | ------------------------------------------ | +| `repeat N` | `N hearings` | Repeated appearances before the court | +| `for...in` | `for each...in the matter of` | Bureaucratic iteration | +| `loop` | `appeal` | Endless re-petition, the process continues | +| `until` | `until` | Unchanged | +| `while` | `while` | Unchanged | +| `choice` | `tribunal` | Where judgment is rendered | +| `option` | `ruling` | One possible judgment | +| `if` | `in the event that` | Bureaucratic conditional | +| `elif` | `or in the event that` | Continued conditional | +| `else` | `otherwise` | Default ruling | + +### Error Handling + +| Functional | Kafka | Reference | +| ---------- | --------------------- | ---------------------------------- | +| `try` | `submit` | Submitting for processing | +| `catch` | `should it be denied` | Rejection by the apparatus | +| `finally` | `regardless` | What happens no matter the outcome | +| `throw` | `reject` | The system refuses | +| `retry` | `resubmit` | Try the process again | + +### Session Properties + +| Functional | Kafka | Reference | +| ---------- | ----------- | ---------------------------- | +| `prompt` | `directive` | Official instructions | +| `model` | `authority` | Which level of the hierarchy | + +### Unchanged + +These keywords already work or are too functional to replace sensibly: + +- `**...**` discretion markers — the inscrutable judgment of the apparatus +- `until`, `while` — already work +- `map`, `filter`, `reduce`, `pmap` — pipeline operators +- `max` — constraint modifier +- `as` — aliasing +- Model names: `sonnet`, `opus`, `haiku` — retained (or see "authority" above) + +--- + +## Side-by-Side Comparison + +### Simple Program + +```prose +# Functional +use "@alice/research" as research +input topic: "What to investigate" + +agent helper: + model: sonnet + +let findings = session: helper + prompt: "Research {topic}" + +output summary = session "Summarize" + context: findings +``` + +```prose +# Kafka +requisition "@alice/research" as research +petition topic: "What to investigate" + +clerk helper: + authority: sonnet + +file findings = proceeding: helper + directive: "Research {topic}" + +verdict summary = proceeding "Summarize" + dossier: findings +``` + +### Parallel Execution + +```prose +# Functional +parallel: + security = session "Check security" + perf = session "Check performance" + style = session "Check style" + +session "Synthesize review" + context: { security, perf, style } +``` + +```prose +# Kafka +departments: + security = proceeding "Check security" + perf = proceeding "Check performance" + style = proceeding "Check style" + +proceeding "Synthesize review" + dossier: { security, perf, style } +``` + +### Loop with Condition + +```prose +# Functional +loop until **the code is bug-free** (max: 5): + session "Find and fix bugs" +``` + +```prose +# Kafka +appeal until **the code is bug-free** (max: 5): + proceeding "Find and fix bugs" +``` + +### Error Handling + +```prose +# Functional +try: + session "Risky operation" +catch as err: + session "Handle error" + context: err +finally: + session "Cleanup" +``` + +```prose +# Kafka +submit: + proceeding "Risky operation" +should it be denied as err: + proceeding "Handle error" + dossier: err +regardless: + proceeding "Cleanup" +``` + +### Choice Block + +```prose +# Functional +choice **the severity level**: + option "Critical": + session "Escalate immediately" + option "Minor": + session "Log for later" +``` + +```prose +# Kafka +tribunal **the severity level**: + ruling "Critical": + proceeding "Escalate immediately" + ruling "Minor": + proceeding "Log for later" +``` + +### Conditionals + +```prose +# Functional +if **has security issues**: + session "Fix security" +elif **has performance issues**: + session "Optimize" +else: + session "Approve" +``` + +```prose +# Kafka +in the event that **has security issues**: + proceeding "Fix security" +or in the event that **has performance issues**: + proceeding "Optimize" +otherwise: + proceeding "Approve" +``` + +### Reusable Blocks + +```prose +# Functional +block review(topic): + session "Research {topic}" + session "Analyze {topic}" + +do review("quantum computing") +``` + +```prose +# Kafka +regulation review(topic): + proceeding "Research {topic}" + proceeding "Analyze {topic}" + +invoke review("quantum computing") +``` + +### Fixed Iteration + +```prose +# Functional +repeat 3: + session "Attempt connection" +``` + +```prose +# Kafka +3 hearings: + proceeding "Attempt connection" +``` + +### Immutable Binding + +```prose +# Functional +const config = { model: "opus", retries: 3 } +``` + +```prose +# Kafka +statute config = { authority: "opus", resubmit: 3 } +``` + +--- + +## The Case For Kafka + +1. **Darkly comic.** Programs-as-bureaucracy is funny and relatable. +2. **Surprisingly apt.** Software often _is_ an inscrutable apparatus. +3. **Clean mappings.** Petition/verdict, file/dossier, clerk/proceeding all work well. +4. **Appeal as loop.** The endless appeal process is a perfect metaphor for retry logic. +5. **Cultural resonance.** "Kafkaesque" is a widely understood adjective. +6. **Self-aware.** Using Kafka for a programming language acknowledges the absurdity. + +## The Case Against Kafka + +1. **Bleak tone.** Not everyone wants their programs to feel like The Trial. +2. **Verbose keywords.** "In the event that" and "should it be denied" are long. +3. **Anxiety-inducing.** May not be fun for users who find bureaucracy stressful. +4. **Irony may not land.** Some users might take it literally and find it off-putting. + +--- + +## Key Kafka Concepts + +| Term | Meaning | Used for | +| ------------- | ---------------------------------- | ------------------------ | +| The apparatus | The inscrutable system | The VM itself | +| K. | The protagonist, never fully named | The user | +| The Trial | Process without clear rules | Program execution | +| The Castle | Unreachable authority | Higher-level systems | +| Clerk | Functionary who processes | `agent` → `clerk` | +| Proceeding | Official action | `session` → `proceeding` | +| Dossier | Accumulated file | `context` → `dossier` | + +--- + +## Alternatives Considered + +### For `clerk` (agent) + +| Keyword | Rejected because | +| ------------- | -------------------- | +| `official` | Too generic | +| `functionary` | Hard to spell | +| `bureaucrat` | Too pejorative | +| `advocate` | Too positive/helpful | + +### For `proceeding` (session) + +| Keyword | Rejected because | +| --------- | -------------------------------- | +| `case` | Overloaded (switch case) | +| `hearing` | Reserved for `repeat N hearings` | +| `trial` | Used in Homeric register | +| `process` | Too technical | + +### For `departments` (parallel) + +| Keyword | Rejected because | +| ------------ | ------------------------------------- | +| `bureaus` | Good alternative, slightly less clear | +| `offices` | Too mundane | +| `ministries` | More Orwellian than Kafkaesque | + +### For `appeal` (loop) + +| Keyword | Rejected because | +| ---------- | ------------------- | +| `recourse` | Too legal-technical | +| `petition` | Used for `input` | +| `process` | Too generic | + +--- + +## Verdict + +Preserved for benchmarking. The Kafka register offers a darkly comic, self-aware framing that acknowledges the bureaucratic nature of software systems. The irony is the point. + +Best suited for: + +- Users with a sense of humor about software complexity +- Programs that genuinely feel like navigating bureaucracy +- Contexts where acknowledging absurdity is welcome + +Not recommended for: + +- Users who find bureaucratic metaphors stressful +- Contexts requiring earnest, positive framing +- Documentation that needs to feel approachable + +--- + +## Closing Note + +> "Someone must have slandered Josef K., for one morning, without having done anything wrong, he was arrested." +> — _The Trial_ + +In the Kafka register, your program is Josef K. The apparatus will process it. Whether it succeeds or fails, no one can say for certain. But the proceedings will continue. diff --git a/extensions/open-prose/skills/prose/compiler.md b/extensions/open-prose/skills/prose/compiler.md new file mode 100644 index 0000000000000000000000000000000000000000..1220fa745fffe559a8220f63bed19135ba1f2048 --- /dev/null +++ b/extensions/open-prose/skills/prose/compiler.md @@ -0,0 +1,2971 @@ +--- +role: language-specification +summary: | + Complete syntax grammar, validation rules, and compilation semantics for OpenProse. + Read this file when compiling, validating, or resolving ambiguous syntax. Assumes + prose.md is already in context for execution semantics. +see-also: + - SKILL.md: Activation triggers, onboarding + - prose.md: Execution semantics, how to run programs + - state/filesystem.md: File-system state management (default) + - state/in-context.md: In-context state management (on request) +--- + +# OpenProse Language Reference + +OpenProse is a programming language for AI sessions. An AI session is a Turing-complete computer; this document provides complete documentation for the language syntax, semantics, and execution model. + +--- + +## Document Purpose: Compiler + Validator + +This document serves a dual role: + +### As Compiler + +When asked to "compile" a `.prose` file, use this specification to: + +1. **Parse** the program according to the syntax grammar +2. **Validate** that the program is well-formed and semantically valid +3. **Transform** the program into "best practice" canonical form: + - Expand syntax sugar where appropriate + - Normalize formatting and structure + - Apply optimizations (e.g., hoisting block definitions) + +### As Validator + +The validation criterion: **Would a blank agent with only `prose.md` understand this program as self-evident?** + +When validating, check: + +- Syntax correctness (all constructs match grammar) +- Semantic validity (references resolve, types match) +- Self-evidence (program is clear without this full spec) + +If a construct is ambiguous or non-obvious, it should be flagged or transformed into a clearer form. + +### When to Read This Document + +- **Compilation requested**: Read fully to apply all rules +- **Validation requested**: Read fully to check all constraints +- **Ambiguous syntax encountered**: Reference specific sections +- **Interpretation only**: Use `prose.md` instead (smaller, faster) + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [File Format](#file-format) +3. [Comments](#comments) +4. [String Literals](#string-literals) +5. [Use Statements](#use-statements-program-composition) +6. [Input Declarations](#input-declarations) +7. [Output Bindings](#output-bindings) +8. [Program Invocation](#program-invocation) +9. [Agent Definitions](#agent-definitions) +10. [Session Statement](#session-statement) +11. [Resume Statement](#resume-statement) +12. [Variables & Context](#variables--context) +13. [Composition Blocks](#composition-blocks) +14. [Parallel Blocks](#parallel-blocks) +15. [Fixed Loops](#fixed-loops) +16. [Unbounded Loops](#unbounded-loops) +17. [Pipeline Operations](#pipeline-operations) +18. [Error Handling](#error-handling) +19. [Choice Blocks](#choice-blocks) +20. [Conditional Statements](#conditional-statements) +21. [Execution Model](#execution-model) +22. [Validation Rules](#validation-rules) +23. [Examples](#examples) +24. [Future Features](#future-features) + +--- + +## Overview + +OpenProse provides a declarative syntax for defining multi-agent workflows. Programs consist of statements that are executed sequentially, with each `session` statement spawning a subagent to complete a task. + +### Design Principles + +- **Pattern over framework**: The simplest solution is barely anything at all—just structure for English +- **Self-evident**: Programs should be understandable with minimal documentation +- **The OpenProse VM is intelligent**: Design for understanding, not parsing +- **Framework-agnostic**: Works with Claude Code, OpenCode, and any future agent framework +- **Files are artifacts**: `.prose` is the portable unit of work + +### Current Implementation Status + +The following features are implemented: + +| Feature | Status | Description | +| ---------------------- | ----------- | -------------------------------------------- | +| Comments | Implemented | `# comment` syntax | +| Single-line strings | Implemented | `"string"` with escapes | +| Simple session | Implemented | `session "prompt"` | +| Agent definitions | Implemented | `agent name:` with model/prompt properties | +| Session with agent | Implemented | `session: agent` with property overrides | +| Use statements | Implemented | `use "@handle/slug" as name` | +| Agent skills | Implemented | `skills: ["skill1", "skill2"]` | +| Agent permissions | Implemented | `permissions:` block with rules | +| Let binding | Implemented | `let name = session "..."` | +| Const binding | Implemented | `const name = session "..."` | +| Variable reassignment | Implemented | `name = session "..."` (for let only) | +| Context property | Implemented | `context: var` or `context: [a, b, c]` | +| do: blocks | Implemented | Explicit sequential blocks | +| Inline sequence | Implemented | `session "A" -> session "B"` | +| Named blocks | Implemented | `block name:` with `do name` invocation | +| Parallel blocks | Implemented | `parallel:` for concurrent execution | +| Named parallel results | Implemented | `x = session "..."` inside parallel | +| Object context | Implemented | `context: { a, b, c }` shorthand | +| Join strategies | Implemented | `parallel ("first"):` or `parallel ("any"):` | +| Failure policies | Implemented | `parallel (on-fail: "continue"):` | +| Repeat blocks | Implemented | `repeat N:` fixed iterations | +| Repeat with index | Implemented | `repeat N as i:` with index variable | +| For-each blocks | Implemented | `for item in items:` iteration | +| For-each with index | Implemented | `for item, i in items:` with index | +| Parallel for-each | Implemented | `parallel for item in items:` fan-out | +| Unbounded loop | Implemented | `loop:` with optional max iterations | +| Loop until | Implemented | `loop until **condition**:` AI-evaluated | +| Loop while | Implemented | `loop while **condition**:` AI-evaluated | +| Loop with index | Implemented | `loop as i:` or `loop until ... as i:` | +| Map pipeline | Implemented | `items \| map:` transform each item | +| Filter pipeline | Implemented | `items \| filter:` keep matching items | +| Reduce pipeline | Implemented | `items \| reduce(acc, item):` accumulate | +| Parallel map | Implemented | `items \| pmap:` concurrent transform | +| Pipeline chaining | Implemented | `\| filter: ... \| map: ...` | +| Try/catch blocks | Implemented | `try:` with `catch:` for error handling | +| Try/catch/finally | Implemented | `finally:` for cleanup | +| Error variable | Implemented | `catch as err:` access error context | +| Throw statement | Implemented | `throw` or `throw "message"` | +| Retry property | Implemented | `retry: 3` automatic retry on failure | +| Backoff strategy | Implemented | `backoff: exponential` delay between retries | +| Input declarations | Implemented | `input name: "description"` | +| Output bindings | Implemented | `output name = expression` | +| Program invocation | Implemented | `name(input: value)` call imported programs | +| Multi-line strings | Implemented | `"""..."""` preserving whitespace | +| String interpolation | Implemented | `"Hello {name}"` variable substitution | +| Block parameters | Implemented | `block name(param):` with parameters | +| Block invocation args | Implemented | `do name(arg)` passing arguments | +| Choice blocks | Implemented | `choice **criteria**: option "label":` | +| If/elif/else | Implemented | `if **condition**:` conditional branching | +| Persistent agents | Implemented | `persist: true` or `persist: project` | +| Resume statement | Implemented | `resume: agent` to continue with memory | + +--- + +## File Format + +| Property | Value | +| ---------------- | -------------------- | +| Extension | `.prose` | +| Encoding | UTF-8 | +| Case sensitivity | Case-sensitive | +| Indentation | Spaces (Python-like) | +| Line endings | LF or CRLF | + +--- + +## Comments + +Comments provide documentation within programs and are ignored during execution. + +### Syntax + +```prose +# This is a standalone comment + +session "Hello" # This is an inline comment +``` + +### Rules + +1. Comments begin with `#` and extend to end of line +2. Comments can appear on their own line or after a statement +3. Empty comments are valid: `#` +4. The `#` character inside string literals is NOT a comment + +### Examples + +```prose +# Program header comment +# Author: Example + +session "Do something" # Explain what this does + +# This comment is between statements +session "Do another thing" +``` + +### Compilation Behavior + +Comments are **stripped during compilation**. The OpenProse VM never sees them. They have no effect on execution and exist purely for human documentation. + +### Important Notes + +- **Comments inside strings are NOT comments**: + + ```prose + session "Say hello # this is part of the string" + ``` + + The `#` inside the string literal is part of the prompt, not a comment. + +- **Comments inside indented blocks are allowed**: + ```prose + agent researcher: + # This comment is inside the block + model: sonnet + # This comment is outside the block + ``` + +--- + +## String Literals + +String literals represent text values, primarily used for session prompts. + +### Syntax + +Strings are enclosed in double quotes: + +```prose +"This is a string" +``` + +### Escape Sequences + +The following escape sequences are supported: + +| Sequence | Meaning | +| -------- | ------------ | +| `\\` | Backslash | +| `\"` | Double quote | +| `\n` | Newline | +| `\t` | Tab | + +### Examples + +```prose +session "Hello world" +session "Line one\nLine two" +session "She said \"hello\"" +session "Path: C:\\Users\\name" +session "Column1\tColumn2" +``` + +### Rules + +1. Single-line strings must be properly terminated with a closing `"` +2. Unknown escape sequences are errors +3. Empty strings `""` are valid but generate a warning when used as prompts + +### Multi-line Strings + +Multi-line strings use triple double-quotes (`"""`) and preserve internal whitespace and newlines: + +```prose +session """ +This is a multi-line prompt. +It preserves: + - Indentation + - Line breaks + - All internal whitespace +""" +``` + +#### Multi-line String Rules + +1. Opening `"""` must be followed by a newline +2. Content continues until closing `"""` +3. Escape sequences work the same as single-line strings +4. Leading/trailing whitespace inside the delimiters is preserved + +### String Interpolation + +Strings can embed variable references using `{varname}` syntax: + +```prose +let name = session "Get the user's name" + +session "Hello {name}, welcome to the system!" +``` + +#### Interpolation Syntax + +- Variables are referenced by wrapping the variable name in curly braces: `{varname}` +- Works in both single-line and multi-line strings +- Empty braces `{}` are treated as literal text, not interpolation +- Nested braces are not supported + +#### Examples + +```prose +let research = session "Research the topic" +let analysis = session "Analyze findings" + +# Single variable interpolation +session "Based on {research}, provide recommendations" + +# Multiple interpolations +session "Combining {research} with {analysis}, synthesize insights" + +# Multi-line with interpolation +session """ +Review Summary: +- Research: {research} +- Analysis: {analysis} +Please provide final recommendations. +""" +``` + +#### Interpolation Rules + +1. Variable names must be valid identifiers +2. Referenced variables must be in scope +3. Empty braces `{}` are literal text +4. Backslash can escape braces: `\{` produces literal `{` + +### Validation + +| Check | Result | +| -------------------------------- | ------- | +| Unterminated string | Error | +| Unknown escape sequence | Error | +| Empty string as prompt | Warning | +| Undefined interpolation variable | Error | + +--- + +## Use Statements (Program Composition) + +Use statements import other OpenProse programs from the registry at `p.prose.md`, enabling modular workflows. + +### Syntax + +```prose +use "@handle/slug" +use "@handle/slug" as alias +``` + +### Path Format + +Import paths follow the format `@handle/slug`: + +- `@handle` identifies the program author/organization +- `slug` is the program name + +An optional alias (`as name`) allows referencing by a shorter name. + +### Examples + +```prose +# Import a program +use "@alice/research" + +# Import with alias +use "@bob/critique" as critic +``` + +### Program URL Resolution + +When the OpenProse VM encounters a `use` statement: + +1. Fetch the program from `https://p.prose.md/@handle/slug` +2. Parse the program to extract its contract (inputs/outputs) +3. Register the program in the Import Registry + +### Validation Rules + +| Check | Severity | Message | +| --------------------- | -------- | -------------------------------------- | +| Empty path | Error | Use path cannot be empty | +| Invalid path format | Error | Path must be @handle/slug format | +| Duplicate import | Error | Program already imported | +| Missing alias for dup | Error | Alias required when importing multiple | + +### Execution Semantics + +Use statements are processed before any agent definitions or sessions. The OpenProse VM: + +1. Fetches and validates all imported programs at the start of execution +2. Extracts input/output contracts from each program +3. Registers programs in the Import Registry for later invocation + +--- + +## Input Declarations + +Inputs declare what values a program expects from its caller. + +### Syntax + +```prose +input name: "description" +``` + +### Examples + +```prose +input topic: "The subject to research" +input depth: "How deep to go (shallow, medium, deep)" +``` + +### Semantics + +Inputs: + +- Are declared at the top of the program (before executable statements) +- Have a name and a description (for documentation) +- Become available as variables within the program body +- Must be provided by the caller when invoking the program + +### Validation Rules + +| Check | Severity | Message | +| ---------------------- | -------- | ---------------------------------------------------- | +| Empty input name | Error | Input name cannot be empty | +| Empty description | Warning | Consider adding a description | +| Duplicate input name | Error | Input already declared | +| Input after executable | Error | Inputs must be declared before executable statements | + +--- + +## Output Bindings + +Outputs declare what values a program produces for its caller. + +### Syntax + +```prose +output name = expression +``` + +### Examples + +```prose +let raw = session "Research {topic}" +output findings = session "Synthesize research" + context: raw +output sources = session "Extract sources" + context: raw +``` + +### Semantics + +The `output` keyword: + +- Marks a variable as an output (visible at assignment, not just at file top) +- Works like `let` but also registers the value as a program output +- Can appear anywhere in the program body +- Multiple outputs are supported + +### Validation Rules + +| Check | Severity | Message | +| --------------------- | -------- | ----------------------------------- | +| Empty output name | Error | Output name cannot be empty | +| Duplicate output name | Error | Output already declared | +| Output name conflicts | Error | Output name conflicts with variable | + +--- + +## Program Invocation + +Call imported programs by providing their inputs. + +### Syntax + +```prose +name(input1: value1, input2: value2) +``` + +### Examples + +```prose +use "@alice/research" as research + +let result = research(topic: "quantum computing") +``` + +### Accessing Outputs + +The result contains all outputs from the invoked program, accessible as properties: + +```prose +session "Write summary" + context: result.findings + +session "Cite sources" + context: result.sources +``` + +### Destructuring Outputs + +For convenience, outputs can be destructured: + +```prose +let { findings, sources } = research(topic: "quantum computing") +``` + +### Execution Semantics + +When a program invokes an imported program: + +1. **Bind inputs**: Map caller-provided values to the imported program's inputs +2. **Execute**: Run the imported program (spawns its own sessions) +3. **Collect outputs**: Gather all `output` bindings from the imported program +4. **Return**: Make outputs available to the caller as a result object + +The imported program runs in its own execution context but shares the same VM session. + +### Validation Rules + +| Check | Severity | Message | +| ----------------------- | -------- | ------------------------------ | +| Unknown program | Error | Program not imported | +| Missing required input | Error | Required input not provided | +| Unknown input name | Error | Input not declared in program | +| Unknown output property | Error | Output not declared in program | + +--- + +## Agent Definitions + +Agents are reusable templates that configure subagent behavior. Once defined, agents can be referenced in session statements. + +### Syntax + +```prose +agent name: + model: sonnet + prompt: "System prompt for this agent" + skills: ["skill1", "skill2"] + permissions: + read: ["*.md"] + bash: deny +``` + +### Properties + +| Property | Type | Values | Description | +| ------------- | ---------- | ---------------------------- | ----------------------------------- | +| `model` | identifier | `sonnet`, `opus`, `haiku` | The Claude model to use | +| `prompt` | string | Any string | System prompt/context for the agent | +| `persist` | value | `true`, `project`, or STRING | Enable persistent memory for agent | +| `skills` | array | String array | Skills assigned to this agent | +| `permissions` | block | Permission rules | Access control for the agent | + +### Persist Property + +The `persist` property enables agents to maintain memory across invocations: + +```prose +# Execution-scoped persistence (memory dies with run) +agent captain: + model: opus + persist: true + prompt: "You coordinate and review" + +# Project-scoped persistence (memory survives across runs) +agent advisor: + model: opus + persist: project + prompt: "You provide architectural guidance" + +# Custom path persistence +agent shared: + model: opus + persist: ".prose/custom/shared-agent/" + prompt: "Shared across programs" +``` + +| Value | Memory Location | Lifetime | +| --------- | --------------------------------- | ------------------- | +| `true` | `.prose/runs/{id}/agents/{name}/` | Dies with execution | +| `project` | `.prose/agents/{name}/` | Survives executions | +| STRING | Specified path | User-controlled | + +### Skills Property + +The `skills` property assigns imported skills to an agent: + +```prose +use "@anthropic/web-search" +use "@anthropic/summarizer" as summarizer + +agent researcher: + skills: ["web-search", "summarizer"] +``` + +Skills must be imported before they can be assigned. Referencing an unimported skill generates a warning. + +### Permissions Property + +The `permissions` property controls agent access: + +```prose +agent secure-agent: + permissions: + read: ["*.md", "*.txt"] + write: ["output/"] + bash: deny + network: allow +``` + +#### Permission Types + +| Type | Description | +| --------- | -------------------------------------------- | +| `read` | Files the agent can read (glob patterns) | +| `write` | Files the agent can write (glob patterns) | +| `execute` | Files the agent can execute (glob patterns) | +| `bash` | Shell access: `allow`, `deny`, or `prompt` | +| `network` | Network access: `allow`, `deny`, or `prompt` | + +#### Permission Values + +| Value | Description | +| -------- | ------------------------------------------------- | +| `allow` | Permission granted | +| `deny` | Permission denied | +| `prompt` | Ask user for permission | +| Array | List of allowed patterns (for read/write/execute) | + +### Examples + +```prose +# Define a research agent +agent researcher: + model: sonnet + prompt: "You are a research assistant skilled at finding and synthesizing information" + +# Define a writing agent +agent writer: + model: opus + prompt: "You are a technical writer who creates clear, concise documentation" + +# Agent with only model +agent quick: + model: haiku + +# Agent with only prompt +agent expert: + prompt: "You are a domain expert" + +# Agent with skills +agent web-researcher: + model: sonnet + skills: ["web-search", "summarizer"] + +# Agent with permissions +agent file-handler: + permissions: + read: ["*.md", "*.txt"] + write: ["output/"] + bash: deny +``` + +### Model Selection + +| Model | Use Case | +| -------- | ------------------------------------- | +| `haiku` | Fast, simple tasks; quick responses | +| `sonnet` | Balanced performance; general purpose | +| `opus` | Complex reasoning; detailed analysis | + +### Execution Semantics + +When a session references an agent: + +1. The agent's `model` property determines which Claude model is used +2. The agent's `prompt` property is included as system context +3. Session properties can override agent defaults + +### Validation Rules + +| Check | Severity | Message | +| --------------------- | -------- | ------------------------------ | +| Duplicate agent name | Error | Agent already defined | +| Invalid model value | Error | Must be sonnet, opus, or haiku | +| Empty prompt property | Warning | Consider providing a prompt | +| Duplicate property | Error | Property already specified | + +--- + +## Session Statement + +The session statement is the primary executable construct in OpenProse. It spawns a subagent to complete a task. + +### Syntax Variants + +#### Simple Session (with inline prompt) + +```prose +session "prompt text" +``` + +#### Session with Agent Reference + +```prose +session: agentName +``` + +#### Named Session with Agent + +```prose +session sessionName: agentName +``` + +#### Session with Properties + +```prose +session: agentName + prompt: "Override the agent's default prompt" + model: opus # Override the agent's model +``` + +### Property Overrides + +When a session references an agent, it can override the agent's properties: + +```prose +agent researcher: + model: sonnet + prompt: "You are a research assistant" + +# Use researcher with different model +session: researcher + model: opus + +# Use researcher with different prompt +session: researcher + prompt: "Research this specific topic in depth" + +# Override both +session: researcher + model: opus + prompt: "Specialized research task" +``` + +### Execution Semantics + +When the OpenProse VM encounters a `session` statement: + +1. **Resolve Configuration**: Merge agent defaults with session overrides +2. **Spawn a Subagent**: Create a new Claude subagent with the resolved configuration +3. **Send the Prompt**: Pass the prompt string to the subagent +4. **Wait for Completion**: Block until the subagent finishes +5. **Continue**: Proceed to the next statement + +### Execution Flow Diagram + +``` +OpenProse VM Subagent + | | + | spawn session | + |----------------------------->| + | | + | send prompt | + |----------------------------->| + | | + | [processing...] | + | | + | session complete | + |<-----------------------------| + | | + | continue to next statement | + v v +``` + +### Sequential Execution + +Multiple sessions execute sequentially: + +```prose +session "First task" +session "Second task" +session "Third task" +``` + +Each session waits for the previous one to complete before starting. + +### Using Claude Code's Task Tool + +To execute a session, use the Task tool: + +```typescript +// Simple session +Task({ + description: "OpenProse session", + prompt: "The prompt from the session statement", + subagent_type: "general-purpose", +}); + +// Session with agent configuration +Task({ + description: "OpenProse session", + prompt: "The session prompt", + subagent_type: "general-purpose", + model: "opus", // From agent or override +}); +``` + +### Validation Rules + +| Check | Severity | Message | +| ------------------------- | -------- | -------------------------------------------- | +| Missing prompt and agent | Error | Session requires a prompt or agent reference | +| Undefined agent reference | Error | Agent not defined | +| Empty prompt `""` | Warning | Session has empty prompt | +| Whitespace-only prompt | Warning | Session prompt contains only whitespace | +| Prompt > 10,000 chars | Warning | Consider breaking into smaller tasks | +| Duplicate property | Error | Property already specified | + +### Examples + +```prose +# Simple session +session "Hello world" + +# Session with agent +agent researcher: + model: sonnet + prompt: "You research topics thoroughly" + +session: researcher + prompt: "Research quantum computing applications" + +# Named session +session analysis: researcher + prompt: "Analyze the competitive landscape" +``` + +### Canonical Form + +The compiled output preserves the structure: + +``` +Input: +agent researcher: + model: sonnet + +session: researcher + prompt: "Do research" + +Output: +agent researcher: + model: sonnet +session: researcher + prompt: "Do research" +``` + +--- + +## Resume Statement + +The `resume` statement continues a persistent agent with its accumulated memory. + +### Syntax + +```prose +resume: agentName + prompt: "Continue from where we left off" +``` + +### Semantics + +| Keyword | Behavior | +| ---------- | ------------------------------------- | +| `session:` | Ignores existing memory, starts fresh | +| `resume:` | Loads memory, continues with context | + +### Examples + +```prose +agent captain: + model: opus + persist: true + prompt: "You coordinate and review" + +# First invocation - creates memory +session: captain + prompt: "Review the plan" + context: plan + +# Later invocation - loads memory +resume: captain + prompt: "Review step 1 of the plan" + context: step1 + +# Output capture works with resume +let review = resume: captain + prompt: "Final review of all steps" +``` + +### Validation Rules + +| Check | Severity | Message | +| ------------------------------------------ | -------- | -------------------------------------------------------------------- | +| `resume:` on non-persistent agent | Error | Agent must have `persist:` property to use `resume:` | +| `resume:` with no existing memory | Error | No memory file exists for agent; use `session:` for first invocation | +| `session:` on persistent agent with memory | Warning | Will ignore existing memory; use `resume:` to continue | +| Undefined agent reference | Error | Agent not defined | + +--- + +## Variables & Context + +Variables allow you to capture the results of sessions and pass them as context to subsequent sessions. + +### Let Binding + +The `let` keyword creates a mutable variable bound to a session result: + +```prose +let research = session "Research the topic thoroughly" + +# research now holds the output of that session +``` + +Variables can be reassigned: + +```prose +let draft = session "Write initial draft" + +# Revise the draft +draft = session "Improve the draft" + context: draft +``` + +### Const Binding + +The `const` keyword creates an immutable variable: + +```prose +const config = session "Get configuration settings" + +# This would be an error: +# config = session "Try to change" +``` + +### Context Property + +The `context` property passes previous session outputs to a new session: + +#### Single Context + +```prose +let research = session "Research quantum computing" + +session "Write summary" + context: research +``` + +#### Multiple Contexts + +```prose +let research = session "Research the topic" +let analysis = session "Analyze the findings" + +session "Write final report" + context: [research, analysis] +``` + +#### Empty Context (Fresh Start) + +Use an empty array to start a session without inherited context: + +```prose +session "Independent task" + context: [] +``` + +#### Object Context Shorthand + +For passing multiple named results (especially from parallel blocks), use object shorthand: + +```prose +parallel: + a = session "Task A" + b = session "Task B" + +session "Combine results" + context: { a, b } +``` + +This is equivalent to passing an object where each property is a variable reference. + +### Complete Example + +```prose +agent researcher: + model: sonnet + prompt: "You are a research assistant" + +agent writer: + model: opus + prompt: "You are a technical writer" + +# Gather research +let research = session: researcher + prompt: "Research quantum computing developments" + +# Analyze findings +let analysis = session: researcher + prompt: "Analyze the key findings" + context: research + +# Write the final report using both contexts +const report = session: writer + prompt: "Write a comprehensive report" + context: [research, analysis] +``` + +### Validation Rules + +| Check | Severity | Message | +| ------------------------------- | -------- | -------------------------------------------------- | +| Duplicate variable name | Error | Variable already defined | +| Const reassignment | Error | Cannot reassign const variable | +| Undefined variable reference | Error | Undefined variable | +| Variable conflicts with agent | Error | Variable name conflicts with agent name | +| Undefined context variable | Error | Undefined variable in context | +| Non-identifier in context array | Error | Context array elements must be variable references | + +### Flat Namespace Requirement + +All variable names must be **unique within a program**. No shadowing is allowed across scopes. + +**This is a compile error:** + +```prose +let result = session "Outer task" + +for item in items: + let result = session "Inner task" # Error: 'result' already defined + context: item +``` + +**Why this constraint:** Since bindings are stored as `bindings/{name}.md`, two variables with the same name would collide on the filesystem. Rather than introduce complex scoping rules, we enforce uniqueness. + +**Collision scenarios this prevents:** + +1. Variable inside loop shadows variable outside loop +2. Variables in different `if`/`elif`/`else` branches with same name +3. Block parameters shadowing outer variables +4. Parallel branches reusing outer variable names + +**Exception:** Imported programs run in isolated namespaces. A variable `result` in the main program does not collide with `result` in an imported program (they write to different `imports/{handle}--{slug}/bindings/` directories). + +--- + +## Composition Blocks + +Composition blocks allow you to structure programs into reusable, named units and express sequences of operations inline. + +### do: Block (Anonymous Sequential Block) + +The `do:` keyword creates an explicit sequential block. All statements in the block execute in order. + +#### Syntax + +```prose +do: + statement1 + statement2 + ... +``` + +#### Examples + +```prose +# Explicit sequential block +do: + session "Research the topic" + session "Analyze findings" + session "Write summary" + +# Assign result to a variable +let result = do: + session "Gather data" + session "Process data" +``` + +### Block Definitions + +Named blocks create reusable workflow components. Define once, invoke multiple times. + +#### Syntax + +```prose +block name: + statement1 + statement2 + ... +``` + +#### Invoking Blocks + +Use `do` followed by the block name to invoke a defined block: + +```prose +do blockname +``` + +#### Examples + +```prose +# Define a review pipeline +block review-pipeline: + session "Security review" + session "Performance review" + session "Synthesize reviews" + +# Define another block +block final-check: + session "Final verification" + session "Sign off" + +# Use the blocks +do review-pipeline +session "Make fixes based on review" +do final-check +``` + +### Block Parameters + +Blocks can accept parameters to make them more flexible and reusable. + +#### Syntax + +```prose +block name(param1, param2): + # param1 and param2 are available here + statement1 + statement2 +``` + +#### Invoking with Arguments + +Pass arguments when invoking a parameterized block: + +```prose +do name(arg1, arg2) +``` + +#### Examples + +```prose +# Define a parameterized block +block review(topic): + session "Research {topic} thoroughly" + session "Analyze key findings about {topic}" + session "Summarize {topic} analysis" + +# Invoke with different arguments +do review("quantum computing") +do review("machine learning") +do review("blockchain") +``` + +#### Multiple Parameters + +```prose +block process-item(item, mode): + session "Process {item} using {mode} mode" + session "Verify {item} processing" + +do process-item("data.csv", "strict") +do process-item("config.json", "lenient") +``` + +#### Parameter Scope + +- Parameters are scoped to the block body +- Parameters shadow outer variables of the same name (with warning) +- Parameters are implicitly `const` within the block + +#### Validation Rules + +| Check | Severity | Message | +| ----------------------- | -------- | ---------------------------------------------- | +| Argument count mismatch | Warning | Block expects N parameters but got M arguments | +| Parameter shadows outer | Warning | Parameter shadows outer variable | + +### Inline Sequence (Arrow Operator) + +The `->` operator chains sessions into a sequence on a single line. This is syntactic sugar for sequential execution. + +#### Syntax + +```prose +session "A" -> session "B" -> session "C" +``` + +This is equivalent to: + +```prose +session "A" +session "B" +session "C" +``` + +#### Examples + +```prose +# Quick pipeline +session "Plan" -> session "Execute" -> session "Review" + +# Assign result +let workflow = session "Draft" -> session "Edit" -> session "Finalize" +``` + +### Block Hoisting + +Block definitions are hoisted - you can use a block before it's defined in the source: + +```prose +# Use before definition +do validation-checks + +# Definition comes later +block validation-checks: + session "Check syntax" + session "Check semantics" +``` + +### Nested Composition + +Blocks and do: blocks can be nested: + +```prose +block outer-workflow: + session "Start" + do: + session "Sub-task 1" + session "Sub-task 2" + session "End" + +do: + do outer-workflow + session "Final step" +``` + +### Context with Blocks + +Blocks work with the context system: + +```prose +# Capture do block result +let research = do: + session "Gather information" + session "Analyze patterns" + +# Use in subsequent session +session "Write report" + context: research +``` + +### Validation Rules + +| Check | Severity | Message | +| ------------------------------- | -------- | ------------------------------------ | +| Undefined block reference | Error | Block not defined | +| Duplicate block definition | Error | Block already defined | +| Block name conflicts with agent | Error | Block name conflicts with agent name | +| Empty block name | Error | Block definition must have a name | + +--- + +## Parallel Blocks + +Parallel blocks allow multiple sessions to run concurrently. All branches execute simultaneously, and the block waits for all to complete before continuing. + +### Basic Syntax + +```prose +parallel: + session "Security review" + session "Performance review" + session "Style review" +``` + +All three sessions start at the same time and run concurrently. The program waits for all of them to complete before proceeding. + +### Named Parallel Results + +Capture the results of parallel branches into variables: + +```prose +parallel: + security = session "Security review" + perf = session "Performance review" + style = session "Style review" +``` + +These variables can then be used in subsequent sessions. + +### Object Context Shorthand + +Pass multiple parallel results to a session using object shorthand: + +```prose +parallel: + security = session "Security review" + perf = session "Performance review" + style = session "Style review" + +session "Synthesize all reviews" + context: { security, perf, style } +``` + +The object shorthand `{ a, b, c }` is equivalent to passing an object with properties `a`, `b`, and `c` where each property's value is the corresponding variable. + +### Mixed Composition + +#### Parallel Inside Sequential + +```prose +do: + session "Setup" + parallel: + session "Task A" + session "Task B" + session "Cleanup" +``` + +The setup runs first, then Task A and Task B run in parallel, and finally cleanup runs. + +#### Sequential Inside Parallel + +```prose +parallel: + do: + session "Multi-step task 1a" + session "Multi-step task 1b" + do: + session "Multi-step task 2a" + session "Multi-step task 2b" +``` + +Each parallel branch contains a sequential workflow. The two workflows run concurrently. + +### Assigning Parallel Blocks to Variables + +```prose +let results = parallel: + session "Task A" + session "Task B" +``` + +### Complete Example + +```prose +agent reviewer: + model: sonnet + +# Run parallel reviews +parallel: + sec = session: reviewer + prompt: "Review for security issues" + perf = session: reviewer + prompt: "Review for performance issues" + style = session: reviewer + prompt: "Review for style issues" + +# Combine all reviews +session "Create unified review report" + context: { sec, perf, style } +``` + +### Join Strategies + +By default, parallel blocks wait for all branches to complete. You can specify alternative join strategies: + +#### First (Race) + +Return as soon as the first branch completes, cancel others: + +```prose +parallel ("first"): + session "Try approach A" + session "Try approach B" + session "Try approach C" +``` + +The first successful result wins. Other branches are cancelled. + +#### Any (N of M) + +Return when any N branches complete successfully: + +```prose +# Default: any 1 success +parallel ("any"): + session "Attempt 1" + session "Attempt 2" + +# Specific count: wait for 2 successes +parallel ("any", count: 2): + session "Attempt 1" + session "Attempt 2" + session "Attempt 3" +``` + +#### All (Default) + +Wait for all branches to complete: + +```prose +# Implicit - this is the default +parallel: + session "Task A" + session "Task B" + +# Explicit +parallel ("all"): + session "Task A" + session "Task B" +``` + +### Failure Policies + +Control how the parallel block handles branch failures: + +#### Fail-Fast (Default) + +If any branch fails, fail immediately and cancel other branches: + +```prose +parallel: # Implicit fail-fast + session "Critical task 1" + session "Critical task 2" + +# Explicit +parallel (on-fail: "fail-fast"): + session "Critical task 1" + session "Critical task 2" +``` + +#### Continue + +Let all branches complete, then report all failures: + +```prose +parallel (on-fail: "continue"): + session "Task 1" + session "Task 2" + session "Task 3" + +# Continue regardless of which branches failed +session "Process results, including failures" +``` + +#### Ignore + +Ignore all failures, always succeed: + +```prose +parallel (on-fail: "ignore"): + session "Optional enrichment 1" + session "Optional enrichment 2" + +# This always runs, even if all branches failed +session "Continue regardless" +``` + +### Combining Modifiers + +Join strategies and failure policies can be combined: + +```prose +# Race with resilience +parallel ("first", on-fail: "continue"): + session "Fast but unreliable" + session "Slow but reliable" + +# Get any 2 results, ignoring failures +parallel ("any", count: 2, on-fail: "ignore"): + session "Approach 1" + session "Approach 2" + session "Approach 3" + session "Approach 4" +``` + +### Execution Semantics + +When the OpenProse VM encounters a `parallel:` block: + +1. **Fork**: Start all branches concurrently +2. **Execute**: Each branch runs independently +3. **Join**: Wait according to join strategy: + - `"all"` (default): Wait for all branches + - `"first"`: Return on first completion + - `"any"`: Return on first success (or N successes with `count`) +4. **Handle failures**: According to on-fail policy: + - `"fail-fast"` (default): Cancel remaining and fail immediately + - `"continue"`: Wait for all, then report failures + - `"ignore"`: Treat failures as successes +5. **Continue**: Proceed to the next statement with available results + +### Validation Rules + +| Check | Severity | Message | +| ------------------------------------ | -------- | -------------------------------------------- | +| Invalid join strategy | Error | Must be "all", "first", or "any" | +| Invalid on-fail policy | Error | Must be "fail-fast", "continue", or "ignore" | +| Count without "any" | Error | Count is only valid with "any" strategy | +| Count less than 1 | Error | Count must be at least 1 | +| Count exceeds branches | Warning | Count exceeds number of parallel branches | +| Duplicate variable in parallel | Error | Variable already defined | +| Variable conflicts with agent | Error | Variable name conflicts with agent name | +| Undefined variable in object context | Error | Undefined variable in context | + +--- + +## Fixed Loops + +Fixed loops provide bounded iteration over a set number of times or over a collection. + +### Repeat Block + +The `repeat` block executes its body a fixed number of times. + +#### Basic Syntax + +```prose +repeat 3: + session "Generate a creative idea" +``` + +#### With Index Variable + +Access the current iteration index using `as`: + +```prose +repeat 5 as i: + session "Process item" + context: i +``` + +The index variable `i` is scoped to the loop body and starts at 0. + +### For-Each Block + +The `for` block iterates over a collection. + +#### Basic Syntax + +```prose +let fruits = ["apple", "banana", "cherry"] +for fruit in fruits: + session "Describe this fruit" + context: fruit +``` + +#### With Inline Array + +```prose +for topic in ["AI", "climate", "space"]: + session "Research this topic" + context: topic +``` + +#### With Index Variable + +Access both the item and its index: + +```prose +let items = ["a", "b", "c"] +for item, i in items: + session "Process item with index" + context: [item, i] +``` + +### Parallel For-Each + +The `parallel for` block runs all iterations concurrently (fan-out pattern): + +```prose +let topics = ["AI", "climate", "space"] +parallel for topic in topics: + session "Research this topic" + context: topic + +session "Combine all research" +``` + +This is equivalent to: + +```prose +parallel: + session "Research AI" context: "AI" + session "Research climate" context: "climate" + session "Research space" context: "space" +``` + +But more concise and dynamic. + +### Variable Scoping + +Loop variables are scoped to the loop body: + +- They are implicitly `const` within each iteration +- They shadow outer variables of the same name (with a warning) +- They are not accessible outside the loop + +```prose +let item = session "outer" +for item in ["a", "b"]: + # 'item' here is the loop variable + session "process loop item" + context: item +# 'item' here refers to the outer variable again +session "use outer item" + context: item +``` + +### Nesting + +Loops can be nested: + +```prose +repeat 2: + repeat 3: + session "Inner task" +``` + +Different loop types can be combined: + +```prose +let items = ["a", "b"] +repeat 2: + for item in items: + session "Process item" + context: item +``` + +### Complete Example + +```prose +# Generate multiple variations of ideas +repeat 3: + session "Generate a creative startup idea" + +session "Select the best idea from the options above" + +# Research the selected idea from multiple angles +let angles = ["market", "technology", "competition"] +parallel for angle in angles: + session "Research this angle of the startup idea" + context: angle + +session "Synthesize all research into a business plan" +``` + +### Validation Rules + +| Check | Severity | Message | +| ----------------------------- | -------- | ------------------------------------ | +| Repeat count must be positive | Error | Repeat count must be positive | +| Repeat count must be integer | Error | Repeat count must be an integer | +| Undefined collection variable | Error | Undefined collection variable | +| Loop variable shadows outer | Warning | Loop variable shadows outer variable | + +--- + +## Unbounded Loops + +Unbounded loops provide iteration with AI-evaluated termination conditions. Unlike fixed loops, the iteration count is not known ahead of time - the OpenProse VM evaluates conditions at runtime using its intelligence to determine when to stop. + +### Discretion Markers + +Unbounded loops use **discretion markers** (`**...**`) to wrap AI-evaluated conditions. These markers signal that the enclosed text should be interpreted intelligently by the OpenProse VM at runtime, not as a literal boolean expression. + +```prose +# The text inside **...** is evaluated by the AI +loop until **the poem has vivid imagery and flows smoothly**: + session "Review and improve the poem" +``` + +For multi-line conditions, use triple-asterisks: + +```prose +loop until *** + the document is complete + all sections have been reviewed + and formatting is consistent +***: + session "Continue working on the document" +``` + +### Basic Loop + +The simplest unbounded loop runs indefinitely until explicitly limited: + +```prose +loop: + session "Process next item" +``` + +**Warning**: Loops without termination conditions or max iterations generate a warning. Always include a safety limit: + +```prose +loop (max: 50): + session "Process next item" +``` + +### Loop Until + +The `loop until` variant runs until a condition becomes true: + +```prose +loop until **the task is complete**: + session "Continue working on the task" +``` + +The OpenProse VM evaluates the discretion condition after each iteration and exits when it determines the condition is satisfied. + +### Loop While + +The `loop while` variant runs while a condition remains true: + +```prose +loop while **there are still items to process**: + session "Process the next item" +``` + +Semantically, `loop while **X**` is equivalent to `loop until **not X**`. + +### Iteration Variable + +Track the current iteration number using `as`: + +```prose +loop until **done** as attempt: + session "Try approach" + context: attempt +``` + +The iteration variable: + +- Starts at 0 +- Increments by 1 each iteration +- Is scoped to the loop body +- Is implicitly `const` within each iteration + +### Safety Limits + +Specify maximum iterations with `(max: N)`: + +```prose +# Stop after 10 iterations even if condition not met +loop until **all bugs fixed** (max: 10): + session "Find and fix a bug" +``` + +The loop exits when: + +1. The condition is satisfied (for `until`/`while` variants), OR +2. The maximum iteration count is reached + +### Complete Syntax + +All options can be combined: + +```prose +loop until **condition** (max: N) as i: + body... +``` + +Order matters: condition comes before modifiers, modifiers before `as`. + +### Examples + +#### Iterative Improvement + +```prose +session "Write an initial draft" + +loop until **the draft is polished and ready for review** (max: 5): + session "Review the current draft and identify issues" + session "Revise the draft to address the issues" + +session "Present the final draft" +``` + +#### Debugging Workflow + +```prose +session "Run tests to identify failures" + +loop until **all tests pass** (max: 20) as attempt: + session "Identify the failing test" + session "Fix the bug causing the failure" + session "Run tests again" + +session "Confirm all tests pass and summarize fixes" +``` + +#### Consensus Building + +```prose +parallel: + opinion1 = session "Get first expert opinion" + opinion2 = session "Get second expert opinion" + +loop until **experts have reached consensus** (max: 5): + session "Identify points of disagreement" + context: { opinion1, opinion2 } + session "Facilitate discussion to resolve differences" + +session "Document the final consensus" +``` + +#### Quality Threshold + +```prose +let draft = session "Create initial document" + +loop while **quality score is below threshold** (max: 10): + draft = session "Review and improve the document" + context: draft + session "Calculate new quality score" + +session "Finalize the document" + context: draft +``` + +### Execution Semantics + +When the OpenProse VM encounters an unbounded loop: + +1. **Initialize**: Set iteration counter to 0 +2. **Check Condition** (for `until`/`while`): + - For `until`: Exit if condition is satisfied + - For `while`: Exit if condition is NOT satisfied +3. **Check Limit**: Exit if iteration count >= max iterations +4. **Execute Body**: Run all statements in the loop body +5. **Increment**: Increase iteration counter +6. **Repeat**: Go to step 2 + +For basic `loop:` without conditions: + +- Only the max iteration limit can cause exit +- Without max, the loop runs indefinitely (warning issued) + +### Condition Evaluation + +The OpenProse VM uses its intelligence to evaluate discretion conditions: + +1. **Context Awareness**: The condition is evaluated in the context of what has happened so far in the session +2. **Semantic Understanding**: The condition text is interpreted semantically, not literally +3. **Uncertainty Handling**: When uncertain, the OpenProse VM may: + - Continue iterating if progress is being made + - Exit early if diminishing returns are detected + - Use heuristics based on the condition's semantics + +### Nesting + +Unbounded loops can be nested with other loop types: + +```prose +# Unbounded inside fixed +repeat 3: + loop until **sub-task complete** (max: 10): + session "Work on sub-task" + +# Fixed inside unbounded +loop until **all batches processed** (max: 5): + repeat 3: + session "Process batch item" + +# Multiple unbounded +loop until **outer condition** (max: 5): + loop until **inner condition** (max: 10): + session "Deep iteration" +``` + +### Variable Scoping + +Loop variables follow the same scoping rules as fixed loops: + +```prose +let i = session "outer" +loop until **done** as i: + # 'i' here is the loop variable (shadows outer) + session "use loop i" + context: i +# 'i' here refers to the outer variable again +session "use outer i" + context: i +``` + +### Validation Rules + +| Check | Severity | Message | +| ----------------------------- | -------- | ------------------------------------- | +| Loop without max or condition | Warning | Unbounded loop without max iterations | +| Max iterations <= 0 | Error | Max iterations must be positive | +| Max iterations not integer | Error | Max iterations must be an integer | +| Empty discretion condition | Error | Discretion condition cannot be empty | +| Very short condition | Warning | Discretion condition may be ambiguous | +| Loop variable shadows outer | Warning | Loop variable shadows outer variable | + +--- + +## Pipeline Operations + +Pipeline operations provide functional-style collection transformations. They allow you to chain operations like map, filter, and reduce using the pipe operator (`|`). + +### Pipe Operator + +The pipe operator (`|`) passes a collection to a transformation operation: + +```prose +let items = ["a", "b", "c"] +let results = items | map: + session "Process this item" + context: item +``` + +### Map + +The `map` operation transforms each element in a collection: + +```prose +let articles = ["article1", "article2", "article3"] + +let summaries = articles | map: + session "Summarize this article in one sentence" + context: item +``` + +Inside a map body, the implicit variable `item` refers to the current element being processed. + +### Filter + +The `filter` operation keeps elements that match a condition: + +```prose +let items = ["one", "two", "three", "four", "five"] + +let short = items | filter: + session "Does this word have 4 or fewer letters? Answer yes or no." + context: item +``` + +The session in a filter body should return something the OpenProse VM can interpret as truthy/falsy (like "yes"/"no"). + +### Reduce + +The `reduce` operation accumulates elements into a single result: + +```prose +let ideas = ["AI assistant", "smart home", "health tracker"] + +let combined = ideas | reduce(summary, idea): + session "Add this idea to the summary, creating a cohesive concept" + context: [summary, idea] +``` + +The reduce operation requires explicit variable names: + +- First variable (`summary`): the accumulator +- Second variable (`idea`): the current item + +The first item in the collection becomes the initial accumulator value. + +### Parallel Map (pmap) + +The `pmap` operation is like `map` but runs all transformations concurrently: + +```prose +let tasks = ["task1", "task2", "task3"] + +let results = tasks | pmap: + session "Process this task in parallel" + context: item + +session "Aggregate all results" + context: results +``` + +This is similar to `parallel for`, but in pipeline syntax. + +### Chaining + +Pipeline operations can be chained to compose complex transformations: + +```prose +let topics = ["quantum computing", "blockchain", "machine learning", "IoT"] + +let result = topics + | filter: + session "Is this topic trending? Answer yes or no." + context: item + | map: + session "Write a one-line startup pitch for this topic" + context: item + +session "Present the startup pitches" + context: result +``` + +Operations execute left-to-right: first filter, then map. + +### Complete Example + +```prose +# Define a collection +let articles = ["AI breakthroughs", "Climate solutions", "Space exploration"] + +# Process with chained operations +let summaries = articles + | filter: + session "Is this topic relevant to technology? Answer yes or no." + context: item + | map: + session "Write a compelling one-paragraph summary" + context: item + | reduce(combined, summary): + session "Merge this summary into the combined document" + context: [combined, summary] + +# Present the final result +session "Format and present the combined summaries" + context: summaries +``` + +### Implicit Variables + +| Operation | Available Variables | +| --------- | -------------------------------------------- | +| `map` | `item` - current element | +| `filter` | `item` - current element | +| `pmap` | `item` - current element | +| `reduce` | Named explicitly: `reduce(accVar, itemVar):` | + +### Execution Semantics + +When the OpenProse VM encounters a pipeline: + +1. **Input**: Start with the input collection +2. **For each operation**: + - **map**: Transform each element, producing a new collection + - **filter**: Keep elements where the session returns truthy + - **reduce**: Accumulate elements into a single value + - **pmap**: Transform all elements concurrently +3. **Output**: Return the final transformed collection/value + +### Variable Scoping + +Pipeline variables are scoped to their operation body: + +```prose +let item = "outer" +let items = ["a", "b"] + +let results = items | map: + # 'item' here is the pipeline variable (shadows outer) + session "process" + context: item + +# 'item' here refers to the outer variable again +session "use outer" + context: item +``` + +### Validation Rules + +| Check | Severity | Message | +| ------------------------------- | -------- | -------------------------------------------------- | +| Undefined input collection | Error | Undefined collection variable | +| Invalid pipe operator | Error | Expected pipe operator (map, filter, reduce, pmap) | +| Reduce without variables | Error | Expected accumulator and item variables | +| Pipeline variable shadows outer | Warning | Implicit/explicit variable shadows outer variable | + +--- + +## Error Handling + +OpenProse provides structured error handling with try/catch/finally blocks, throw statements, and retry mechanisms for resilient workflows. + +### Try/Catch Blocks + +The `try:` block wraps operations that might fail. The `catch:` block handles errors. + +```prose +try: + session "Attempt risky operation" +catch: + session "Handle the error gracefully" +``` + +#### Error Variable Access + +Use `catch as err:` to capture error context for the error handler: + +```prose +try: + session "Call external API" +catch as err: + session "Log and handle the error" + context: err +``` + +The error variable (`err`) contains contextual information about what went wrong and is only accessible within the catch block. + +### Try/Catch/Finally + +The `finally:` block always executes, whether the try block succeeds or fails: + +```prose +try: + session "Acquire and use resource" +catch: + session "Handle any errors" +finally: + session "Always clean up resource" +``` + +#### Execution Order + +1. **Try succeeds**: try body → finally body +2. **Try fails**: try body (until failure) → catch body → finally body + +### Try/Finally (No Catch) + +For cleanup without error handling, use try/finally: + +```prose +try: + session "Open connection and do work" +finally: + session "Close connection" +``` + +### Throw Statement + +The `throw` statement raises or re-raises errors. + +#### Rethrow + +Inside a catch block, `throw` without arguments re-raises the caught error to outer handlers: + +```prose +try: + try: + session "Inner operation" + catch: + session "Partial handling" + throw # Re-raise to outer handler +catch: + session "Handle re-raised error" +``` + +#### Throw with Message + +Throw a new error with a custom message: + +```prose +session "Check preconditions" +throw "Precondition not met" +``` + +### Nested Error Handling + +Try blocks can be nested. Inner catch blocks don't trigger outer handlers unless they rethrow: + +```prose +try: + session "Outer operation" + try: + session "Inner risky operation" + catch: + session "Handle inner error" # Outer catch won't run + session "Continue outer operation" +catch: + session "Handle outer error only" +``` + +### Error Handling in Parallel + +Each parallel branch can have its own error handling: + +```prose +parallel: + try: + session "Branch A might fail" + catch: + session "Recover branch A" + try: + session "Branch B might fail" + catch: + session "Recover branch B" + +session "Continue with recovered results" +``` + +This differs from the `on-fail:` policy which controls behavior when unhandled errors occur. + +### Retry Property + +The `retry:` property makes a session automatically retry on failure: + +```prose +session "Call flaky API" + retry: 3 +``` + +#### Retry with Backoff + +Add `backoff:` to control delay between retries: + +```prose +session "Rate-limited API" + retry: 5 + backoff: exponential +``` + +**Backoff Strategies:** + +| Strategy | Behavior | +| ------------- | ---------------------------------- | +| `none` | Immediate retry (default) | +| `linear` | Fixed delay between retries | +| `exponential` | Doubling delay (1s, 2s, 4s, 8s...) | + +#### Retry with Context + +Retry works with other session properties: + +```prose +let data = session "Get input" +session "Process data" + context: data + retry: 3 + backoff: linear +``` + +### Combining Patterns + +Retry and try/catch work together for maximum resilience: + +```prose +try: + session "Call external service" + retry: 3 + backoff: exponential +catch: + session "All retries failed, use fallback" +``` + +### Validation Rules + +| Check | Severity | Message | +| ---------------------------- | -------- | --------------------------------------------------- | +| Try without catch or finally | Error | Try block must have at least "catch:" or "finally:" | +| Error variable shadows outer | Warning | Error variable shadows outer variable | +| Empty throw message | Warning | Throw message is empty | +| Non-positive retry count | Error | Retry count must be positive | +| Non-integer retry count | Error | Retry count must be an integer | +| High retry count (>10) | Warning | Retry count is unusually high | +| Invalid backoff strategy | Error | Must be none, linear, or exponential | +| Retry on agent definition | Warning | Retry property is only valid in session statements | + +### Syntax Reference + +``` +try_block ::= "try" ":" NEWLINE INDENT statement+ DEDENT + [catch_block] + [finally_block] + +catch_block ::= "catch" ["as" identifier] ":" NEWLINE INDENT statement+ DEDENT + +finally_block ::= "finally" ":" NEWLINE INDENT statement+ DEDENT + +throw_statement ::= "throw" [string_literal] + +retry_property ::= "retry" ":" number_literal + +backoff_property ::= "backoff" ":" ( "none" | "linear" | "exponential" ) +``` + +--- + +## Choice Blocks + +Choice blocks allow the OpenProse VM to select from multiple labeled options based on criteria. This is useful for branching workflows where the best path depends on runtime analysis. + +### Syntax + +```prose +choice **criteria**: + option "Label A": + statements... + option "Label B": + statements... +``` + +### Criteria + +The criteria is wrapped in discretion markers (`**...**`) and is evaluated by the OpenProse VM to select which option to execute: + +```prose +choice **the best approach for the current situation**: + option "Quick fix": + session "Apply a quick temporary fix" + option "Full refactor": + session "Perform a complete code refactor" +``` + +### Multi-line Criteria + +For complex criteria, use triple-asterisks: + +```prose +choice *** + which strategy is most appropriate + given the current project constraints + and timeline requirements +***: + option "MVP approach": + session "Build minimum viable product" + option "Full feature set": + session "Build complete feature set" +``` + +### Examples + +#### Simple Choice + +```prose +let analysis = session "Analyze the code quality" + +choice **the severity of issues found in the analysis**: + option "Critical": + session "Stop deployment and fix critical issues" + context: analysis + option "Minor": + session "Log issues for later and proceed" + context: analysis + option "None": + session "Proceed with deployment" +``` + +#### Choice with Multiple Statements per Option + +```prose +choice **the user's experience level**: + option "Beginner": + session "Explain basic concepts first" + session "Provide step-by-step guidance" + session "Include helpful tips and warnings" + option "Expert": + session "Provide concise technical summary" + session "Include advanced configuration options" +``` + +#### Nested Choices + +```prose +choice **the type of request**: + option "Bug report": + choice **the bug severity**: + option "Critical": + session "Escalate immediately" + option "Normal": + session "Add to sprint backlog" + option "Feature request": + session "Add to feature backlog" +``` + +### Execution Semantics + +When the OpenProse VM encounters a `choice` block: + +1. **Evaluate Criteria**: Interpret the discretion criteria in current context +2. **Select Option**: Choose the most appropriate labeled option +3. **Execute**: Run all statements in the selected option's body +4. **Continue**: Proceed to the next statement after the choice block + +Only one option is executed per choice block. + +### Validation Rules + +| Check | Severity | Message | +| ----------------------- | -------- | ------------------------------------------ | +| Choice without options | Error | Choice block must have at least one option | +| Empty criteria | Error | Choice criteria cannot be empty | +| Duplicate option labels | Warning | Duplicate option label | +| Empty option body | Warning | Option has empty body | + +### Syntax Reference + +``` +choice_block ::= "choice" discretion ":" NEWLINE INDENT option+ DEDENT + +option ::= "option" string ":" NEWLINE INDENT statement+ DEDENT + +discretion ::= "**" text "**" | "***" text "***" +``` + +--- + +## Conditional Statements + +If/elif/else statements provide conditional branching based on AI-evaluated conditions using discretion markers. + +### If Statement + +```prose +if **condition**: + statements... +``` + +### If/Else + +```prose +if **condition**: + statements... +else: + statements... +``` + +### If/Elif/Else + +```prose +if **first condition**: + statements... +elif **second condition**: + statements... +elif **third condition**: + statements... +else: + statements... +``` + +### Discretion Conditions + +Conditions are wrapped in discretion markers (`**...**`) for AI evaluation: + +```prose +let analysis = session "Analyze the codebase" + +if **the code has security vulnerabilities**: + session "Fix security issues immediately" + context: analysis +elif **the code has performance issues**: + session "Optimize performance bottlenecks" + context: analysis +else: + session "Proceed with normal review" + context: analysis +``` + +### Multi-line Conditions + +Use triple-asterisks for complex conditions: + +```prose +if *** + the test suite passes + and the code coverage is above 80% + and there are no linting errors +***: + session "Deploy to production" +else: + session "Fix issues before deploying" +``` + +### Examples + +#### Simple If + +```prose +session "Check system health" + +if **the system is healthy**: + session "Continue with normal operations" +``` + +#### If/Else + +```prose +let review = session "Review the pull request" + +if **the code changes are safe and well-tested**: + session "Approve and merge the PR" + context: review +else: + session "Request changes" + context: review +``` + +#### Multiple Elif + +```prose +let status = session "Check project status" + +if **the project is on track**: + session "Continue as planned" +elif **the project is slightly delayed**: + session "Adjust timeline and communicate" +elif **the project is significantly delayed**: + session "Escalate to management" + session "Create recovery plan" +else: + session "Assess project viability" +``` + +#### Nested Conditionals + +```prose +if **the request is authenticated**: + if **the user has admin privileges**: + session "Process admin request" + else: + session "Process standard user request" +else: + session "Return authentication error" +``` + +### Combining with Other Constructs + +#### With Try/Catch + +```prose +try: + session "Attempt operation" + if **operation succeeded partially**: + session "Complete remaining steps" +catch as err: + if **error is recoverable**: + session "Apply recovery procedure" + context: err + else: + throw "Unrecoverable error" +``` + +#### With Loops + +```prose +loop until **task complete** (max: 10): + session "Work on task" + if **encountered blocker**: + session "Resolve blocker" +``` + +### Execution Semantics + +When the OpenProse VM encounters an `if` statement: + +1. **Evaluate Condition**: Interpret the first discretion condition +2. **If True**: Execute the then-body and skip remaining clauses +3. **If False**: Check each `elif` condition in order +4. **Elif Match**: Execute that elif's body and skip remaining +5. **No Match**: Execute the `else` body (if present) +6. **Continue**: Proceed to the next statement + +### Validation Rules + +| Check | Severity | Message | +| --------------- | -------- | --------------------------------- | +| Empty condition | Error | If/elif condition cannot be empty | +| Elif without if | Error | Elif must follow if | +| Else without if | Error | Else must follow if or elif | +| Multiple else | Error | Only one else clause allowed | +| Empty body | Warning | Condition has empty body | + +### Syntax Reference + +``` +if_statement ::= "if" discretion ":" NEWLINE INDENT statement+ DEDENT + elif_clause* + [else_clause] + +elif_clause ::= "elif" discretion ":" NEWLINE INDENT statement+ DEDENT + +else_clause ::= "else" ":" NEWLINE INDENT statement+ DEDENT + +discretion ::= "**" text "**" | "***" text "***" +``` + +--- + +## Execution Model + +OpenProse uses a two-phase execution model. + +### Phase 1: Compilation (Static) + +The compile phase handles deterministic preprocessing: + +1. **Parse**: Convert source code to AST +2. **Validate**: Check for syntax and semantic errors +3. **Expand**: Normalize syntax sugar (when implemented) +4. **Output**: Generate canonical program + +### Phase 2: Runtime (Intelligent) + +The OpenProse VM executes the compiled program: + +1. **Load**: Receive the compiled program +2. **Collect Agents**: Register all agent definitions +3. **Execute**: Process each statement in order +4. **Spawn**: Create subagents with resolved configurations +5. **Coordinate**: Manage context passing between sessions + +### OpenProse VM Behavior + +| Aspect | Behavior | +| -------------------- | ----------------------------------------------- | +| Execution order | Strict - follows program exactly | +| Session creation | Strict - creates what program specifies | +| Agent resolution | Strict - merge properties deterministically | +| Context passing | Intelligent - summarizes/transforms as needed | +| Completion detection | Intelligent - determines when session is "done" | + +### State Management + +For the current implementation, state is tracked in-context (conversation history): + +| State Type | Tracking Approach | +| ------------------- | --------------------------------------------------- | +| Agent definitions | Collected at program start | +| Execution flow | Implicit reasoning ("completed X, now executing Y") | +| Session outputs | Held in conversation history | +| Position in program | Tracked by OpenProse VM | + +--- + +## Validation Rules + +The validator checks programs for errors and warnings before execution. + +### Errors (Block Execution) + +| Code | Description | +| ---- | ---------------------------------------- | +| E001 | Unterminated string literal | +| E002 | Unknown escape sequence in string | +| E003 | Session missing prompt or agent | +| E004 | Unexpected token | +| E005 | Invalid syntax | +| E006 | Duplicate agent definition | +| E007 | Undefined agent reference | +| E008 | Invalid model value | +| E009 | Duplicate property | +| E010 | Duplicate use statement | +| E011 | Empty use path | +| E012 | Invalid use path format | +| E013 | Skills must be an array | +| E014 | Skill name must be a string | +| E015 | Permissions must be a block | +| E016 | Permission pattern must be a string | +| E017 | `resume:` requires persistent agent | +| E018 | `resume:` with no existing memory | +| E019 | Duplicate variable name (flat namespace) | +| E020 | Empty input name | +| E021 | Duplicate input declaration | +| E022 | Input after executable statement | +| E023 | Empty output name | +| E024 | Duplicate output declaration | +| E025 | Unknown program in invocation | +| E026 | Missing required input | +| E027 | Unknown input name in invocation | +| E028 | Unknown output property access | + +### Warnings (Non-blocking) + +| Code | Description | +| ---- | --------------------------------------------------- | +| W001 | Empty session prompt | +| W002 | Whitespace-only session prompt | +| W003 | Session prompt exceeds 10,000 characters | +| W004 | Empty prompt property | +| W005 | Unknown property name | +| W006 | Unknown import source format | +| W007 | Skill not imported | +| W008 | Unknown permission type | +| W009 | Unknown permission value | +| W010 | Empty skills array | +| W011 | `session:` on persistent agent with existing memory | + +### Error Message Format + +Errors include location information: + +``` +Error at line 5, column 12: Unterminated string literal + session "Hello + ^ +``` + +--- + +## Examples + +### Minimal Program + +```prose +session "Hello world" +``` + +### Research Pipeline with Agents + +```prose +# Define specialized agents +agent researcher: + model: sonnet + prompt: "You are a research assistant" + +agent writer: + model: opus + prompt: "You are a technical writer" + +# Execute workflow +session: researcher + prompt: "Research recent developments in quantum computing" + +session: writer + prompt: "Write a summary of the research findings" +``` + +### Code Review Workflow + +```prose +agent reviewer: + model: sonnet + prompt: "You are an expert code reviewer" + +session: reviewer + prompt: "Read the code in src/ and identify potential bugs" + +session: reviewer + prompt: "Suggest fixes for each bug found" + +session: reviewer + prompt: "Create a summary of all changes needed" +``` + +### Multi-step Task with Model Override + +```prose +agent analyst: + model: haiku + prompt: "You analyze data quickly" + +# Quick initial analysis +session: analyst + prompt: "Scan the data for obvious patterns" + +# Detailed analysis with more powerful model +session: analyst + model: opus + prompt: "Perform deep analysis on the patterns found" +``` + +### Comments for Documentation + +```prose +# Project: Quarterly Report Generator +# Author: Team Lead +# Date: 2024-01-01 + +agent data-collector: + model: sonnet + prompt: "You gather and organize data" + +agent analyst: + model: opus + prompt: "You analyze data and create insights" + +# Step 1: Gather data +session: data-collector + prompt: "Collect all sales data from the past quarter" + +# Step 2: Analysis +session: analyst + prompt: "Perform trend analysis on the collected data" + +# Step 3: Report generation +session: analyst + prompt: "Generate a formatted quarterly report with charts" +``` + +### Workflow with Skills and Permissions + +```prose +# Import external programs +use "@anthropic/web-search" +use "@anthropic/file-writer" as file-writer + +# Define a secure research agent +agent researcher: + model: sonnet + prompt: "You are a research assistant" + skills: ["web-search"] + permissions: + read: ["*.md", "*.txt"] + bash: deny + +# Define a writer agent +agent writer: + model: opus + prompt: "You create documentation" + skills: ["file-writer"] + permissions: + write: ["docs/"] + bash: deny + +# Execute workflow +session: researcher + prompt: "Research AI safety topics" + +session: writer + prompt: "Write a summary document" +``` + +--- + +## Future Features + +All core features through Tier 12 have been implemented. Potential future enhancements: + +### Tier 13: Extended Features + +- Custom functions with return values +- Module system for code organization +- Type annotations for validation +- Async/await patterns for advanced concurrency + +### Tier 14: Tooling + +- Language server protocol (LSP) support +- VS Code extension +- Interactive debugger +- Performance profiling + +--- + +## Syntax Grammar (Implemented) + +``` +program → statement* EOF +statement → useStatement | inputDecl | agentDef | session | resumeStmt + | letBinding | constBinding | assignment | outputBinding + | parallelBlock | repeatBlock | forEachBlock | loopBlock + | tryBlock | choiceBlock | ifStatement | doBlock | blockDef + | throwStatement | comment + +# Program Composition +useStatement → "use" string ( "as" IDENTIFIER )? +inputDecl → "input" IDENTIFIER ":" string +outputBinding → "output" IDENTIFIER "=" expression +programCall → IDENTIFIER "(" ( IDENTIFIER ":" expression )* ")" + +# Definitions +agentDef → "agent" IDENTIFIER ":" NEWLINE INDENT agentProperty* DEDENT +agentProperty → "model:" ( "sonnet" | "opus" | "haiku" ) + | "prompt:" string + | "persist:" ( "true" | "project" | string ) + | "context:" ( IDENTIFIER | array | objectContext ) + | "retry:" NUMBER + | "backoff:" ( "none" | "linear" | "exponential" ) + | "skills:" "[" string* "]" + | "permissions:" NEWLINE INDENT permission* DEDENT +blockDef → "block" IDENTIFIER params? ":" NEWLINE INDENT statement* DEDENT +params → "(" IDENTIFIER ( "," IDENTIFIER )* ")" + +# Control Flow +parallelBlock → "parallel" parallelMods? ":" NEWLINE INDENT parallelBranch* DEDENT +parallelMods → "(" ( joinStrategy | onFail | countMod ) ( "," ( joinStrategy | onFail | countMod ) )* ")" +joinStrategy → string # "all" | "first" | "any" +onFail → "on-fail" ":" string # "fail-fast" | "continue" | "ignore" +countMod → "count" ":" NUMBER # only valid with "any" +parallelBranch → ( IDENTIFIER "=" )? statement + +# Loops +repeatBlock → "repeat" NUMBER ( "as" IDENTIFIER )? ":" NEWLINE INDENT statement* DEDENT +forEachBlock → "parallel"? "for" IDENTIFIER ( "," IDENTIFIER )? "in" collection ":" NEWLINE INDENT statement* DEDENT +loopBlock → "loop" ( ( "until" | "while" ) discretion )? loopMods? ( "as" IDENTIFIER )? ":" NEWLINE INDENT statement* DEDENT +loopMods → "(" "max" ":" NUMBER ")" + +# Error Handling +tryBlock → "try" ":" NEWLINE INDENT statement+ DEDENT catchBlock? finallyBlock? +catchBlock → "catch" ( "as" IDENTIFIER )? ":" NEWLINE INDENT statement+ DEDENT +finallyBlock → "finally" ":" NEWLINE INDENT statement+ DEDENT +throwStatement → "throw" string? + +# Conditionals +choiceBlock → "choice" discretion ":" NEWLINE INDENT choiceOption+ DEDENT +choiceOption → "option" string ":" NEWLINE INDENT statement+ DEDENT +ifStatement → "if" discretion ":" NEWLINE INDENT statement+ DEDENT elifClause* elseClause? +elifClause → "elif" discretion ":" NEWLINE INDENT statement+ DEDENT +elseClause → "else" ":" NEWLINE INDENT statement+ DEDENT + +# Composition +doBlock → "do" ( ":" NEWLINE INDENT statement* DEDENT | IDENTIFIER args? ) +args → "(" expression ( "," expression )* ")" +arrowExpr → session ( "->" session )+ + +# Sessions +session → "session" ( string | ":" IDENTIFIER | IDENTIFIER ":" IDENTIFIER ) + ( NEWLINE INDENT sessionProperty* DEDENT )? +resumeStmt → "resume" ":" IDENTIFIER ( NEWLINE INDENT sessionProperty* DEDENT )? +sessionProperty → "model:" ( "sonnet" | "opus" | "haiku" ) + | "prompt:" string + | "context:" ( IDENTIFIER | array | objectContext ) + | "retry:" NUMBER + | "backoff:" ( "none" | "linear" | "exponential" ) + +# Bindings +letBinding → "let" IDENTIFIER "=" expression +constBinding → "const" IDENTIFIER "=" expression +assignment → IDENTIFIER "=" expression + +# Expressions +expression → session | doBlock | parallelBlock | repeatBlock | forEachBlock + | loopBlock | arrowExpr | pipeExpr | programCall | string | IDENTIFIER | array | objectContext + +# Pipelines +pipeExpr → ( IDENTIFIER | array ) ( "|" pipeOp )+ +pipeOp → ( "map" | "filter" | "pmap" ) ":" NEWLINE INDENT statement* DEDENT + | "reduce" "(" IDENTIFIER "," IDENTIFIER ")" ":" NEWLINE INDENT statement* DEDENT + +# Properties +property → ( "model" | "prompt" | "context" | "retry" | "backoff" | IDENTIFIER ) + ":" ( IDENTIFIER | string | array | objectContext | NUMBER ) + +# Primitives +discretion → "**" text "**" | "***" text "***" +collection → IDENTIFIER | array +array → "[" ( expression ( "," expression )* )? "]" +objectContext → "{" ( IDENTIFIER ( "," IDENTIFIER )* )? "}" +comment → "#" text NEWLINE + +# Strings +string → singleString | tripleString | interpolatedString +singleString → '"' character* '"' +tripleString → '"""' ( character | NEWLINE )* '"""' +interpolatedString → string containing "{" IDENTIFIER "}" +character → escape | non-quote +escape → "\\" | "\"" | "\n" | "\t" +``` + +--- + +## Compiler API + +When a user invokes `/prose-compile` or asks you to compile a `.prose` file: + +1. **Read this document** (`compiler.md`) fully to understand all syntax and validation rules +2. **Parse** the program according to the syntax grammar +3. **Validate** syntax correctness, semantic validity, and self-evidence +4. **Transform** to canonical form (expand syntax sugar, normalize structure) +5. **Output** the compiled program or report errors/warnings with line numbers + +For direct interpretation without compilation, read `prose.md` and execute statements as described in the Session Statement section. diff --git a/extensions/open-prose/skills/prose/examples/01-hello-world.prose b/extensions/open-prose/skills/prose/examples/01-hello-world.prose new file mode 100644 index 0000000000000000000000000000000000000000..274ce148060c0e1967c4514b1edc7c16fbe2a3bd --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/01-hello-world.prose @@ -0,0 +1,4 @@ +# Hello World +# The simplest OpenProse program - a single session + +session "Say hello and briefly introduce yourself" diff --git a/extensions/open-prose/skills/prose/examples/02-research-and-summarize.prose b/extensions/open-prose/skills/prose/examples/02-research-and-summarize.prose new file mode 100644 index 0000000000000000000000000000000000000000..309222e782f282c046c6f5b80b7ebcaacd0d3591 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/02-research-and-summarize.prose @@ -0,0 +1,6 @@ +# Research and Summarize +# A two-step workflow: research a topic, then summarize findings + +session "Research the latest developments in AI agents and multi-agent systems. Focus on papers and announcements from the past 6 months." + +session "Summarize the key findings from your research in 5 bullet points. Focus on practical implications for developers." diff --git a/extensions/open-prose/skills/prose/examples/03-code-review.prose b/extensions/open-prose/skills/prose/examples/03-code-review.prose new file mode 100644 index 0000000000000000000000000000000000000000..d0b714e2dd93f64ec99469d2371925a626b9359e --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/03-code-review.prose @@ -0,0 +1,17 @@ +# Code Review Pipeline +# Review code from multiple perspectives sequentially + +# First, understand what the code does +session "Read the files in src/ and provide a brief overview of the codebase structure and purpose." + +# Security review +session "Review the code for security vulnerabilities. Look for injection risks, authentication issues, and data exposure." + +# Performance review +session "Review the code for performance issues. Look for N+1 queries, unnecessary allocations, and blocking operations." + +# Maintainability review +session "Review the code for maintainability. Look for code duplication, unclear naming, and missing documentation." + +# Synthesize findings +session "Create a unified code review report combining all the findings above. Prioritize issues by severity and provide actionable recommendations." diff --git a/extensions/open-prose/skills/prose/examples/04-write-and-refine.prose b/extensions/open-prose/skills/prose/examples/04-write-and-refine.prose new file mode 100644 index 0000000000000000000000000000000000000000..0614a26c5bcb0ba5fdc7e60f5783c91c910a00b6 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/04-write-and-refine.prose @@ -0,0 +1,14 @@ +# Write and Refine +# Draft content, then iteratively improve it + +# Create initial draft +session "Write a first draft of a README.md for this project. Include sections for: overview, installation, usage, and contributing." + +# Self-review and improve +session "Review the README draft you just wrote. Identify areas that are unclear, too verbose, or missing important details." + +# Apply improvements +session "Rewrite the README incorporating your review feedback. Make it more concise and add any missing sections." + +# Final polish +session "Do a final pass on the README. Fix any typos, improve formatting, and ensure code examples are correct." diff --git a/extensions/open-prose/skills/prose/examples/05-debug-issue.prose b/extensions/open-prose/skills/prose/examples/05-debug-issue.prose new file mode 100644 index 0000000000000000000000000000000000000000..f5cf2924f8ece146b1b0e68feeee4a06380688fd --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/05-debug-issue.prose @@ -0,0 +1,20 @@ +# Debug an Issue +# Step-by-step debugging workflow + +# Understand the problem +session "Read the error message and stack trace. Identify which file and function is causing the issue." + +# Gather context +session "Read the relevant source files and understand the code flow that leads to the error." + +# Form hypothesis +session "Based on your investigation, form a hypothesis about what's causing the bug. List 2-3 possible root causes." + +# Test hypothesis +session "Write a test case that reproduces the bug. This will help verify the fix later." + +# Implement fix +session "Implement a fix for the most likely root cause. Explain your changes." + +# Verify fix +session "Run the test suite to verify the fix works and doesn't break anything else." diff --git a/extensions/open-prose/skills/prose/examples/06-explain-codebase.prose b/extensions/open-prose/skills/prose/examples/06-explain-codebase.prose new file mode 100644 index 0000000000000000000000000000000000000000..26a62421f33922b410d8df14c5a202330877e911 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/06-explain-codebase.prose @@ -0,0 +1,17 @@ +# Explain Codebase +# Progressive exploration of an unfamiliar codebase + +# Start with the big picture +session "List all directories and key files in this repository. Provide a high-level map of the project structure." + +# Understand the entry point +session "Find the main entry point of the application. Explain how the program starts and initializes." + +# Trace a key flow +session "Trace through a typical user request from start to finish. Document the key functions and modules involved." + +# Document architecture +session "Based on your exploration, write a brief architecture document explaining how the major components fit together." + +# Identify patterns +session "What design patterns and conventions does this codebase use? Document any patterns future contributors should follow." diff --git a/extensions/open-prose/skills/prose/examples/07-refactor.prose b/extensions/open-prose/skills/prose/examples/07-refactor.prose new file mode 100644 index 0000000000000000000000000000000000000000..bebe6d69282429de4199182dca426ce8cc249fe7 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/07-refactor.prose @@ -0,0 +1,20 @@ +# Refactor Code +# Systematic refactoring workflow + +# Assess current state +session "Analyze the target code and identify code smells: duplication, long functions, unclear naming, tight coupling." + +# Plan refactoring +session "Create a refactoring plan. List specific changes in order of priority, starting with the safest changes." + +# Ensure test coverage +session "Check test coverage for the code being refactored. Add any missing tests before making changes." + +# Execute refactoring +session "Implement the first refactoring from your plan. Make a single focused change." + +# Verify behavior +session "Run tests to verify the refactoring preserved behavior. If tests fail, investigate and fix." + +# Document changes +session "Update any documentation affected by the refactoring. Add comments explaining non-obvious design decisions." diff --git a/extensions/open-prose/skills/prose/examples/08-blog-post.prose b/extensions/open-prose/skills/prose/examples/08-blog-post.prose new file mode 100644 index 0000000000000000000000000000000000000000..fd131245933c4d8ed41d4d8a18d3602b92ec077e --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/08-blog-post.prose @@ -0,0 +1,20 @@ +# Write a Blog Post +# End-to-end content creation workflow + +# Research the topic +session "Research the topic: 'Best practices for error handling in TypeScript'. Find authoritative sources and common patterns." + +# Create outline +session "Create a detailed outline for the blog post. Include introduction, 4-5 main sections, and conclusion." + +# Write first draft +session "Write the full blog post following the outline. Target 1500-2000 words. Include code examples." + +# Technical review +session "Review the blog post for technical accuracy. Verify all code examples compile and work correctly." + +# Editorial review +session "Review the blog post for clarity and readability. Simplify complex sentences and improve flow." + +# Add finishing touches +session "Add a compelling title, meta description, and suggest 3-5 relevant tags for the post." diff --git a/extensions/open-prose/skills/prose/examples/09-research-with-agents.prose b/extensions/open-prose/skills/prose/examples/09-research-with-agents.prose new file mode 100644 index 0000000000000000000000000000000000000000..e421ff37af63ca060a57cb3af9cc78cf8b05e56e --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/09-research-with-agents.prose @@ -0,0 +1,25 @@ +# Research Pipeline with Specialized Agents +# This example demonstrates defining agents with different models +# and using them in sessions with property overrides. + +# Define specialized agents +agent researcher: + model: sonnet + prompt: "You are a research assistant skilled at finding and synthesizing information" + +agent writer: + model: opus + prompt: "You are a technical writer who creates clear, concise documentation" + +# Step 1: Initial research with the researcher agent +session: researcher + prompt: "Research recent developments in renewable energy storage technologies" + +# Step 2: Deep dive with a more powerful model +session: researcher + model: opus + prompt: "Analyze the top 3 most promising battery technologies and their potential impact" + +# Step 3: Write up the findings +session: writer + prompt: "Create a summary report of the research findings suitable for executives" diff --git a/extensions/open-prose/skills/prose/examples/10-code-review-agents.prose b/extensions/open-prose/skills/prose/examples/10-code-review-agents.prose new file mode 100644 index 0000000000000000000000000000000000000000..04e359cace584b45304d50863ba7e08e9bcd01fd --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/10-code-review-agents.prose @@ -0,0 +1,32 @@ +# Code Review Workflow with Agents +# This example shows how to use agents for a multi-step code review process. + +# Define agents with specific roles +agent security-reviewer: + model: opus + prompt: "You are a security expert focused on identifying vulnerabilities" + +agent performance-reviewer: + model: sonnet + prompt: "You are a performance optimization specialist" + +agent style-reviewer: + model: haiku + prompt: "You check for code style and best practices" + +# Step 1: Quick style check (fast) +session: style-reviewer + prompt: "Review the code in src/ for style issues and naming conventions" + +# Step 2: Performance analysis (medium) +session: performance-reviewer + prompt: "Identify any performance bottlenecks or optimization opportunities" + +# Step 3: Security audit (thorough) +session: security-reviewer + prompt: "Perform a security review looking for OWASP top 10 vulnerabilities" + +# Step 4: Summary +session: security-reviewer + model: sonnet + prompt: "Create a consolidated report of all review findings with priority rankings" diff --git a/extensions/open-prose/skills/prose/examples/11-skills-and-imports.prose b/extensions/open-prose/skills/prose/examples/11-skills-and-imports.prose new file mode 100644 index 0000000000000000000000000000000000000000..423d8442edb296e7475d25724607b76a24ff2bf6 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/11-skills-and-imports.prose @@ -0,0 +1,27 @@ +# Skills and Imports Example +# This demonstrates importing external skills and assigning them to agents. + +# Import skills from external sources +import "web-search" from "github:anthropic/skills" +import "summarizer" from "npm:@example/summarizer" +import "file-reader" from "./local-skills/file-reader" + +# Define a research agent with web search capability +agent researcher: + model: sonnet + prompt: "You are a research assistant skilled at finding information" + skills: ["web-search", "summarizer"] + +# Define a documentation agent with file access +agent documenter: + model: opus + prompt: "You create comprehensive documentation" + skills: ["file-reader", "summarizer"] + +# Research phase +session: researcher + prompt: "Search for recent developments in renewable energy storage" + +# Documentation phase +session: documenter + prompt: "Create a technical summary of the research findings" diff --git a/extensions/open-prose/skills/prose/examples/12-secure-agent-permissions.prose b/extensions/open-prose/skills/prose/examples/12-secure-agent-permissions.prose new file mode 100644 index 0000000000000000000000000000000000000000..2d8e4420092ea2a89e5e951041e58b476906d645 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/12-secure-agent-permissions.prose @@ -0,0 +1,43 @@ +# Secure Agent with Permissions Example +# This demonstrates defining agents with restricted access permissions. + +# Import required skills +import "code-analyzer" from "github:anthropic/code-tools" + +# Define a read-only code reviewer +# This agent can read source files but cannot modify them or run shell commands +agent code-reviewer: + model: sonnet + prompt: "You are a thorough code reviewer" + skills: ["code-analyzer"] + permissions: + read: ["src/**/*.ts", "src/**/*.js", "*.md"] + write: [] + bash: deny + +# Define a documentation writer with limited write access +# Can only write to docs directory +agent doc-writer: + model: opus + prompt: "You write technical documentation" + permissions: + read: ["src/**/*", "docs/**/*"] + write: ["docs/**/*.md"] + bash: deny + +# Define a full-access admin agent +agent admin: + model: opus + prompt: "You perform administrative tasks" + permissions: + read: ["**/*"] + write: ["**/*"] + bash: prompt + network: allow + +# Workflow: Code review followed by documentation update +session: code-reviewer + prompt: "Review the codebase for security issues and best practices" + +session: doc-writer + prompt: "Update the documentation based on the code review findings" diff --git a/extensions/open-prose/skills/prose/examples/13-variables-and-context.prose b/extensions/open-prose/skills/prose/examples/13-variables-and-context.prose new file mode 100644 index 0000000000000000000000000000000000000000..0fbd8dad1d86353087da398bf6e88f7a41b3d4e1 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/13-variables-and-context.prose @@ -0,0 +1,51 @@ +# Example 13: Variables & Context +# +# This example demonstrates using let/const bindings to capture session +# outputs and pass them as context to subsequent sessions. + +# Define specialized agents for the workflow +agent researcher: + model: sonnet + prompt: "You are a thorough research assistant who gathers comprehensive information on topics." + +agent analyst: + model: opus + prompt: "You are a data analyst who identifies patterns, trends, and key insights." + +agent writer: + model: opus + prompt: "You are a technical writer who creates clear, well-structured documents." + +# Step 1: Gather initial research (captured in a variable) +let research = session: researcher + prompt: "Research the current state of quantum computing, including recent breakthroughs, major players, and potential applications." + +# Step 2: Analyze the research findings (using research as context) +let analysis = session: analyst + prompt: "Analyze the key findings and identify the most promising directions." + context: research + +# Step 3: Get additional perspectives (refreshing context) +let market-trends = session: researcher + prompt: "Research market trends and commercial applications of quantum computing." + context: [] + +# Step 4: Combine multiple contexts for final synthesis +const report = session: writer + prompt: "Write a comprehensive executive summary covering research, analysis, and market trends." + context: [research, analysis, market-trends] + +# Step 5: Iterative refinement with variable reassignment +let draft = session: writer + prompt: "Create an initial draft of the technical deep-dive section." + context: research + +# Refine the draft using its own output as context +draft = session: writer + prompt: "Review and improve this draft for clarity and technical accuracy." + context: draft + +# Final polish +draft = session: writer + prompt: "Perform final editorial review and polish the document." + context: draft diff --git a/extensions/open-prose/skills/prose/examples/14-composition-blocks.prose b/extensions/open-prose/skills/prose/examples/14-composition-blocks.prose new file mode 100644 index 0000000000000000000000000000000000000000..3b23d4ab816542a4a156f40ac7da81817d987551 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/14-composition-blocks.prose @@ -0,0 +1,48 @@ +# Example 14: Composition Blocks +# Demonstrates do: blocks, block definitions, and inline sequences + +# Define reusable agents +agent researcher: + model: sonnet + prompt: "You are a thorough research assistant" + +agent writer: + model: opus + prompt: "You are a skilled technical writer" + +agent reviewer: + model: sonnet + prompt: "You are a careful code and document reviewer" + +# Define a reusable research block +block research-phase: + session: researcher + prompt: "Gather information on the topic" + session: researcher + prompt: "Analyze key findings" + +# Define a reusable writing block +block writing-phase: + session: writer + prompt: "Write initial draft" + session: writer + prompt: "Polish and refine the draft" + +# Define a review block +block review-cycle: + session: reviewer + prompt: "Review for accuracy" + session: reviewer + prompt: "Review for clarity" + +# Main workflow using blocks +let research = do research-phase + +let document = do writing-phase + +do review-cycle + +# Use anonymous do block for final steps +do: + session "Incorporate review feedback" + session "Prepare final version" diff --git a/extensions/open-prose/skills/prose/examples/15-inline-sequences.prose b/extensions/open-prose/skills/prose/examples/15-inline-sequences.prose new file mode 100644 index 0000000000000000000000000000000000000000..cf43cecb4f2a64d402a4d2c8e8aab5553c24fb9c --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/15-inline-sequences.prose @@ -0,0 +1,23 @@ +# Example 15: Inline Sequences +# Demonstrates the -> operator for chaining sessions + +# Quick pipeline using arrow syntax +session "Plan the task" -> session "Execute the plan" -> session "Review results" + +# Inline sequence with context capture +let analysis = session "Analyze data" -> session "Draw conclusions" + +session "Write report" + context: analysis + +# Combine inline sequences with blocks +block quick-check: + session "Security scan" -> session "Performance check" + +do quick-check + +# Use inline sequence in variable assignment +let workflow = session "Step 1" -> session "Step 2" -> session "Step 3" + +session "Final step" + context: workflow diff --git a/extensions/open-prose/skills/prose/examples/16-parallel-reviews.prose b/extensions/open-prose/skills/prose/examples/16-parallel-reviews.prose new file mode 100644 index 0000000000000000000000000000000000000000..252f47cd246861cee980e0f9fbd06305c2027d82 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/16-parallel-reviews.prose @@ -0,0 +1,19 @@ +# Parallel Code Reviews +# Run multiple specialized reviews concurrently + +agent reviewer: + model: sonnet + prompt: "You are an expert code reviewer" + +# Run all reviews in parallel +parallel: + security = session: reviewer + prompt: "Review for security vulnerabilities" + perf = session: reviewer + prompt: "Review for performance issues" + style = session: reviewer + prompt: "Review for code style and readability" + +# Synthesize all review results +session "Create unified code review report" + context: { security, perf, style } diff --git a/extensions/open-prose/skills/prose/examples/17-parallel-research.prose b/extensions/open-prose/skills/prose/examples/17-parallel-research.prose new file mode 100644 index 0000000000000000000000000000000000000000..5dce9f7d28cbb23c5280f3e3da6b749701be9f7f --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/17-parallel-research.prose @@ -0,0 +1,19 @@ +# Parallel Research +# Gather information from multiple sources concurrently + +agent researcher: + model: sonnet + prompt: "You are a research assistant" + +# Research multiple aspects in parallel +parallel: + history = session: researcher + prompt: "Research the historical background" + current = session: researcher + prompt: "Research the current state of the field" + future = session: researcher + prompt: "Research future trends and predictions" + +# Combine all research +session "Write comprehensive research summary" + context: { history, current, future } diff --git a/extensions/open-prose/skills/prose/examples/18-mixed-parallel-sequential.prose b/extensions/open-prose/skills/prose/examples/18-mixed-parallel-sequential.prose new file mode 100644 index 0000000000000000000000000000000000000000..5486128ec7122f763c1d281a87c5086a86fa0846 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/18-mixed-parallel-sequential.prose @@ -0,0 +1,36 @@ +# Mixed Parallel and Sequential Workflow +# Demonstrates nesting parallel and sequential blocks + +agent worker: + model: sonnet + +# Define reusable blocks +block setup: + session "Initialize resources" + session "Validate configuration" + +block cleanup: + session "Save results" + session "Release resources" + +# Main workflow with mixed composition +do: + do setup + + # Parallel processing phase + parallel: + # Each parallel branch can have multiple steps + do: + session: worker + prompt: "Process batch 1 - step 1" + session: worker + prompt: "Process batch 1 - step 2" + do: + session: worker + prompt: "Process batch 2 - step 1" + session: worker + prompt: "Process batch 2 - step 2" + + session "Aggregate results" + + do cleanup diff --git a/extensions/open-prose/skills/prose/examples/19-advanced-parallel.prose b/extensions/open-prose/skills/prose/examples/19-advanced-parallel.prose new file mode 100644 index 0000000000000000000000000000000000000000..9ae69dc516eb64eccbfee6901bdf3eef4836807c --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/19-advanced-parallel.prose @@ -0,0 +1,71 @@ +# Advanced Parallel Execution (Tier 7) +# +# Demonstrates join strategies and failure policies +# for parallel blocks. + +agent researcher: + model: haiku + prompt: "You are a research assistant. Provide concise information." + +# 1. Race Pattern: First to Complete Wins +# ---------------------------------------- +# Use parallel ("first") when you want the fastest result +# and don't need all branches to complete. + +parallel ("first"): + session: researcher + prompt: "Find information via approach A" + session: researcher + prompt: "Find information via approach B" + session: researcher + prompt: "Find information via approach C" + +session "Summarize: only the fastest approach completed" + +# 2. Any-N Pattern: Get Multiple Quick Results +# -------------------------------------------- +# Use parallel ("any", count: N) when you need N results +# but not necessarily all of them. + +parallel ("any", count: 2): + a = session "Generate a creative headline for a tech blog" + b = session "Generate a catchy headline for a tech blog" + c = session "Generate an engaging headline for a tech blog" + d = session "Generate a viral headline for a tech blog" + +session "Choose the best from the 2 headlines that finished first" + context: { a, b, c, d } + +# 3. Continue on Failure: Gather All Results +# ------------------------------------------ +# Use on-fail: "continue" when you want all branches +# to complete and handle failures afterwards. + +parallel (on-fail: "continue"): + session "Fetch data from primary API" + session "Fetch data from secondary API" + session "Fetch data from backup API" + +session "Combine all available data, noting any failures" + +# 4. Ignore Failures: Best-Effort Enrichment +# ------------------------------------------ +# Use on-fail: "ignore" for optional enrichments +# where failures shouldn't block progress. + +parallel (on-fail: "ignore"): + session "Get optional metadata enrichment 1" + session "Get optional metadata enrichment 2" + session "Get optional metadata enrichment 3" + +session "Continue with whatever enrichments succeeded" + +# 5. Combined: Race with Resilience +# --------------------------------- +# Combine join strategies with failure policies. + +parallel ("first", on-fail: "continue"): + session "Fast but might fail" + session "Slow but reliable" + +session "Got the first result, even if it was a handled failure" diff --git a/extensions/open-prose/skills/prose/examples/20-fixed-loops.prose b/extensions/open-prose/skills/prose/examples/20-fixed-loops.prose new file mode 100644 index 0000000000000000000000000000000000000000..cc7c1f5811363cf56dbba2a7d30e78dfa3a0dd6c --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/20-fixed-loops.prose @@ -0,0 +1,20 @@ +# Example: Fixed Loops in OpenProse +# Demonstrates repeat, for-each, and parallel for-each patterns + +# Repeat block - generate multiple ideas +repeat 3: + session "Generate a creative app idea" + +# For-each block - iterate over a collection +let features = ["authentication", "dashboard", "notifications"] +for feature in features: + session "Design the user interface for this feature" + context: feature + +# Parallel for-each - research in parallel +let topics = ["market size", "competitors", "technology stack"] +parallel for topic in topics: + session "Research this aspect of the startup idea" + context: topic + +session "Synthesize all research into a business plan" diff --git a/extensions/open-prose/skills/prose/examples/21-pipeline-operations.prose b/extensions/open-prose/skills/prose/examples/21-pipeline-operations.prose new file mode 100644 index 0000000000000000000000000000000000000000..99a979519d61aab31cb394c2206a02bdf7f93753 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/21-pipeline-operations.prose @@ -0,0 +1,35 @@ +# Pipeline Operations Example +# Demonstrates functional-style collection transformations + +# Define a collection of startup ideas +let ideas = ["AI tutor", "smart garden", "fitness tracker", "meal planner", "travel assistant"] + +# Filter to keep only tech-focused ideas +let tech_ideas = ideas | filter: + session "Is this idea primarily technology-focused? Answer yes or no." + context: item + +# Map to expand each idea into a business pitch +let pitches = tech_ideas | map: + session "Write a compelling one-paragraph business pitch for this idea" + context: item + +# Reduce all pitches into a portfolio summary +let portfolio = pitches | reduce(summary, pitch): + session "Integrate this pitch into the portfolio summary, maintaining coherence" + context: [summary, pitch] + +# Present the final portfolio +session "Format and present the startup portfolio as a polished document" + context: portfolio + +# Parallel map example - research multiple topics concurrently +let topics = ["market analysis", "competition", "funding options"] + +let research = topics | pmap: + session "Research this aspect of the startup portfolio" + context: item + +# Final synthesis +session "Create an executive summary combining all research findings" + context: research diff --git a/extensions/open-prose/skills/prose/examples/22-error-handling.prose b/extensions/open-prose/skills/prose/examples/22-error-handling.prose new file mode 100644 index 0000000000000000000000000000000000000000..97d54a95edd9d506554be9d5c0561abac981c2d5 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/22-error-handling.prose @@ -0,0 +1,51 @@ +# Error Handling Example +# Demonstrates try/catch/finally patterns for resilient workflows + +# Basic try/catch for error recovery +try: + session "Attempt to fetch data from external API" +catch: + session "API failed - use cached data instead" + +# Catch with error variable for context-aware handling +try: + session "Parse and validate complex configuration file" +catch as err: + session "Handle the configuration error" + context: err + +# Try/catch/finally for resource cleanup +try: + session "Open database connection and perform queries" +catch: + session "Log database error and notify admin" +finally: + session "Ensure database connection is properly closed" + +# Nested error handling +try: + session "Start outer transaction" + try: + session "Perform risky inner operation" + catch: + session "Recover inner operation" + throw # Re-raise to outer handler +catch: + session "Handle re-raised error at outer level" + +# Error handling in parallel blocks +parallel: + try: + session "Service A - might fail" + catch: + session "Fallback for Service A" + try: + session "Service B - might fail" + catch: + session "Fallback for Service B" + +session "Continue with whatever results we got" + +# Throwing custom errors +session "Validate input data" +throw "Validation failed: missing required fields" diff --git a/extensions/open-prose/skills/prose/examples/23-retry-with-backoff.prose b/extensions/open-prose/skills/prose/examples/23-retry-with-backoff.prose new file mode 100644 index 0000000000000000000000000000000000000000..eb874886808417cb04a3eacf24ca6cfe587a5bfa --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/23-retry-with-backoff.prose @@ -0,0 +1,63 @@ +# Retry with Backoff Example +# Demonstrates automatic retry patterns for resilient API calls + +# Simple retry - try up to 3 times on failure +session "Call flaky third-party API" + retry: 3 + +# Retry with exponential backoff for rate-limited APIs +session "Query rate-limited service" + retry: 5 + backoff: "exponential" + +# Retry with linear backoff +session "Send webhook notification" + retry: 3 + backoff: "linear" + +# Combining retry with context passing +let config = session "Load API configuration" + +session "Make authenticated API request" + context: config + retry: 3 + backoff: "exponential" + +# Retry inside try/catch for fallback after all retries fail +try: + session "Call primary payment processor" + retry: 3 + backoff: "exponential" +catch: + session "All retries failed - use backup payment processor" + retry: 2 + +# Parallel retries for redundant services +parallel: + primary = try: + session "Query primary database" + retry: 2 + backoff: "linear" + catch: + session "Primary DB unavailable" + replica = try: + session "Query replica database" + retry: 2 + backoff: "linear" + catch: + session "Replica DB unavailable" + +session "Merge results from available databases" + context: { primary, replica } + +# Retry in a loop for batch processing +let items = ["batch1", "batch2", "batch3"] +for item in items: + try: + session "Process this batch item" + context: item + retry: 2 + backoff: "exponential" + catch: + session "Log failed batch for manual review" + context: item diff --git a/extensions/open-prose/skills/prose/examples/24-choice-blocks.prose b/extensions/open-prose/skills/prose/examples/24-choice-blocks.prose new file mode 100644 index 0000000000000000000000000000000000000000..7d33a2f1750e3d76543f83692ffa732a7f2973ba --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/24-choice-blocks.prose @@ -0,0 +1,86 @@ +# Choice Blocks Example +# Demonstrates AI-selected branching based on runtime criteria + +# Simple choice based on analysis +let analysis = session "Analyze the current codebase quality" + +choice **the severity of issues found**: + option "Critical": + session "Stop all work and fix critical issues immediately" + context: analysis + session "Create incident report" + option "Moderate": + session "Schedule fixes for next sprint" + context: analysis + option "Minor": + session "Add to technical debt backlog" + context: analysis + +# Choice for user experience level +choice **the user's technical expertise based on their question**: + option "Beginner": + session "Explain concepts from first principles" + session "Provide step-by-step tutorial" + session "Include helpful analogies" + option "Intermediate": + session "Give concise explanation with examples" + session "Link to relevant documentation" + option "Expert": + session "Provide technical deep-dive" + session "Include advanced configuration options" + +# Choice for project approach +let requirements = session "Gather project requirements" + +choice **the best development approach given the requirements**: + option "Rapid prototype": + session "Create quick MVP focusing on core features" + context: requirements + session "Plan iteration cycle" + option "Production-ready": + session "Design complete architecture" + context: requirements + session "Set up CI/CD pipeline" + session "Implement with full test coverage" + option "Research spike": + session "Explore technical feasibility" + context: requirements + session "Document findings and recommendations" + +# Multi-line criteria for complex decisions +let market_data = session "Gather market research data" +let tech_analysis = session "Analyze technical landscape" + +choice *** + the optimal market entry strategy + considering both market conditions + and technical readiness +***: + option "Aggressive launch": + session "Prepare for immediate market entry" + context: [market_data, tech_analysis] + option "Soft launch": + session "Plan limited beta release" + context: [market_data, tech_analysis] + option "Wait and iterate": + session "Continue development and monitor market" + context: [market_data, tech_analysis] + +# Nested choices for detailed decision trees +let request = session "Analyze incoming customer request" + +choice **the type of request**: + option "Technical support": + choice **the complexity of the technical issue**: + option "Simple": + session "Provide self-service solution" + context: request + option "Complex": + session "Escalate to senior engineer" + context: request + option "Sales inquiry": + session "Forward to sales team with context" + context: request + option "Feature request": + session "Add to product backlog and notify PM" + context: request diff --git a/extensions/open-prose/skills/prose/examples/25-conditionals.prose b/extensions/open-prose/skills/prose/examples/25-conditionals.prose new file mode 100644 index 0000000000000000000000000000000000000000..d63c2da7fa27115b10d02d626382ff40a1069855 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/25-conditionals.prose @@ -0,0 +1,114 @@ +# Conditionals Example +# Demonstrates if/elif/else patterns with AI-evaluated conditions + +# Simple if statement +let health_check = session "Check system health status" + +if **the system is unhealthy**: + session "Alert on-call engineer" + context: health_check + session "Begin incident response" + +# If/else for binary decisions +let review = session "Review the pull request changes" + +if **the code changes are safe and well-tested**: + session "Approve and merge the pull request" + context: review +else: + session "Request changes with detailed feedback" + context: review + +# If/elif/else for multiple conditions +let status = session "Check project milestone status" + +if **the project is ahead of schedule**: + session "Document success factors" + session "Consider adding stretch goals" +elif **the project is on track**: + session "Continue with current plan" + session "Prepare status report" +elif **the project is slightly delayed**: + session "Identify bottlenecks" + session "Adjust timeline and communicate to stakeholders" +else: + session "Escalate to management" + session "Create recovery plan" + session "Schedule daily standups" + +# Multi-line conditions +let test_results = session "Run full test suite" + +if *** + all tests pass + and code coverage is above 80% + and there are no linting errors +***: + session "Deploy to production" +else: + session "Fix issues before deploying" + context: test_results + +# Nested conditionals +let request = session "Analyze the API request" + +if **the request is authenticated**: + if **the user has admin privileges**: + session "Process admin request with full access" + context: request + else: + session "Process standard user request" + context: request +else: + session "Return 401 authentication error" + +# Conditionals with error handling +let operation_result = session "Attempt complex operation" + +if **the operation succeeded partially**: + session "Complete remaining steps" + context: operation_result + +try: + session "Perform another risky operation" +catch as err: + if **the error is recoverable**: + session "Apply automatic recovery procedure" + context: err + else: + throw "Unrecoverable error encountered" + +# Conditionals inside loops +let items = ["item1", "item2", "item3"] + +for item in items: + session "Analyze this item" + context: item + + if **the item needs processing**: + session "Process the item" + context: item + elif **the item should be skipped**: + session "Log skip reason" + context: item + else: + session "Archive the item" + context: item + +# Conditionals with parallel blocks +parallel: + security = session "Run security scan" + performance = session "Run performance tests" + style = session "Run style checks" + +if **security issues were found**: + session "Fix security issues immediately" + context: security +elif **performance issues were found**: + session "Optimize performance bottlenecks" + context: performance +elif **style issues were found**: + session "Clean up code style" + context: style +else: + session "All checks passed - ready for review" diff --git a/extensions/open-prose/skills/prose/examples/26-parameterized-blocks.prose b/extensions/open-prose/skills/prose/examples/26-parameterized-blocks.prose new file mode 100644 index 0000000000000000000000000000000000000000..78069a5606ad9dc3e05200b2bea231d1f1037c51 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/26-parameterized-blocks.prose @@ -0,0 +1,100 @@ +# Parameterized Blocks Example +# Demonstrates reusable blocks with arguments for DRY workflows + +# Simple parameterized block +block research(topic): + session "Research {topic} thoroughly" + session "Summarize key findings about {topic}" + session "List open questions about {topic}" + +# Invoke with different arguments +do research("quantum computing") +do research("machine learning") +do research("blockchain technology") + +# Block with multiple parameters +block review_code(language, focus_area): + session "Review the {language} code for {focus_area} issues" + session "Suggest {focus_area} improvements for {language}" + session "Provide {language} best practices for {focus_area}" + +do review_code("Python", "performance") +do review_code("TypeScript", "type safety") +do review_code("Rust", "memory safety") + +# Parameterized block for data processing +block process_dataset(source, format): + session "Load data from {source}" + session "Validate {format} structure" + session "Transform to standard format" + session "Generate quality report for {source} data" + +do process_dataset("sales_db", "CSV") +do process_dataset("api_logs", "JSON") +do process_dataset("user_events", "Parquet") + +# Blocks with parameters used in control flow +block test_feature(feature_name, test_level): + session "Write {test_level} tests for {feature_name}" + + if **the tests reveal issues**: + session "Fix issues in {feature_name}" + session "Re-run {test_level} tests for {feature_name}" + else: + session "Mark {feature_name} {test_level} testing complete" + +do test_feature("authentication", "unit") +do test_feature("payment processing", "integration") +do test_feature("user dashboard", "e2e") + +# Parameterized blocks in parallel +block analyze_competitor(company): + session "Research {company} products" + session "Analyze {company} market position" + session "Identify {company} strengths and weaknesses" + +parallel: + a = do analyze_competitor("Company A") + b = do analyze_competitor("Company B") + c = do analyze_competitor("Company C") + +session "Create competitive analysis report" + context: { a, b, c } + +# Block with error handling +block safe_api_call(endpoint, method): + try: + session "Call {endpoint} with {method} request" + retry: 3 + backoff: "exponential" + catch as err: + session "Log failed {method} call to {endpoint}" + context: err + session "Return fallback response for {endpoint}" + +do safe_api_call("/users", "GET") +do safe_api_call("/orders", "POST") +do safe_api_call("/inventory", "PUT") + +# Nested block invocations +block full_review(component): + do review_code("TypeScript", "security") + do test_feature(component, "unit") + session "Generate documentation for {component}" + +do full_review("UserService") +do full_review("PaymentGateway") + +# Block with loop inside +block process_batch(batch_name, items): + session "Start processing {batch_name}" + for item in items: + session "Process item from {batch_name}" + context: item + session "Complete {batch_name} processing" + +let batch1 = ["a", "b", "c"] +let batch2 = ["x", "y", "z"] + +do process_batch("alpha", batch1) +do process_batch("beta", batch2) diff --git a/extensions/open-prose/skills/prose/examples/27-string-interpolation.prose b/extensions/open-prose/skills/prose/examples/27-string-interpolation.prose new file mode 100644 index 0000000000000000000000000000000000000000..7eb3d5b9ed926207821a2a85d64bf805edc7b590 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/27-string-interpolation.prose @@ -0,0 +1,105 @@ +# String Interpolation Example +# Demonstrates dynamic prompt construction with {variable} syntax + +# Basic interpolation +let user_name = session "Get the user's name" +let topic = session "Ask what topic they want to learn about" + +session "Create a personalized greeting for {user_name} about {topic}" + +# Multiple interpolations in one prompt +let company = session "Get the company name" +let industry = session "Identify the company's industry" +let size = session "Determine company size (startup/mid/enterprise)" + +session "Write a customized proposal for {company}, a {size} company in {industry}" + +# Interpolation with context +let research = session "Research the topic thoroughly" + +session "Based on the research, explain {topic} to {user_name}" + context: research + +# Multi-line strings with interpolation +let project = session "Get the project name" +let deadline = session "Get the project deadline" +let team_size = session "Get the team size" + +session """ +Create a project plan for {project}. + +Key constraints: +- Deadline: {deadline} +- Team size: {team_size} + +Include milestones and resource allocation. +""" + +# Interpolation in loops +let languages = ["Python", "JavaScript", "Go"] + +for lang in languages: + session "Write a hello world program in {lang}" + session "Explain the syntax of {lang}" + +# Interpolation in parallel blocks +let regions = ["North America", "Europe", "Asia Pacific"] + +parallel for region in regions: + session "Analyze market conditions in {region}" + session "Identify top competitors in {region}" + +# Interpolation with computed values +let base_topic = session "Get the main topic" +let analysis = session "Analyze {base_topic} from multiple angles" + +let subtopics = ["history", "current state", "future trends"] +for subtopic in subtopics: + session "Explore the {subtopic} of {base_topic}" + context: analysis + +# Building dynamic workflows +let workflow_type = session "What type of document should we create?" +let audience = session "Who is the target audience?" +let length = session "How long should the document be?" + +session """ +Create a {workflow_type} for {audience}. + +Requirements: +- Length: approximately {length} +- Tone: appropriate for {audience} +- Focus: practical and actionable + +Please structure with clear sections. +""" + +# Interpolation in error messages +let operation = session "Get the operation name" +let target = session "Get the target resource" + +try: + session "Perform {operation} on {target}" +catch: + session "Failed to {operation} on {target} - attempting recovery" + throw "Operation {operation} failed for {target}" + +# Combining interpolation with choice blocks +let task_type = session "Identify the type of task" +let priority = session "Determine task priority" + +choice **the best approach for a {priority} priority {task_type}**: + option "Immediate action": + session "Execute {task_type} immediately with {priority} priority handling" + option "Scheduled action": + session "Schedule {task_type} based on {priority} priority queue" + option "Delegate": + session "Assign {task_type} to appropriate team member" + +# Interpolation with agent definitions +agent custom_agent: + model: sonnet + prompt: "You specialize in helping with {topic}" + +session: custom_agent + prompt: "Provide expert guidance on {topic} for {user_name}" diff --git a/extensions/open-prose/skills/prose/examples/28-automated-pr-review.prose b/extensions/open-prose/skills/prose/examples/28-automated-pr-review.prose new file mode 100644 index 0000000000000000000000000000000000000000..7e3d6921dbc7ccd6c57c3e0f9ad4b3ede9942054 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/28-automated-pr-review.prose @@ -0,0 +1,37 @@ +# Automated PR Review Workflow +# This workflow performs a multi-dimensional review of a codebase changes. + +agent reviewer: + model: sonnet + prompt: "You are an expert software engineer specializing in code reviews." + +agent security_expert: + model: opus + prompt: "You are a security researcher specializing in finding vulnerabilities." + +agent performance_expert: + model: sonnet + prompt: "You are a performance engineer specializing in optimization." + +# 1. Initial overview +let overview = session: reviewer + prompt: "Read the changes in the current directory and provide a high-level summary of the architectural impact." + +# 2. Parallel deep-dive reviews +parallel: + security = session: security_expert + prompt: "Perform a deep security audit of the changes. Look for OWASP top 10 issues." + context: overview + + perf = session: performance_expert + prompt: "Analyze the performance implications. Identify potential bottlenecks or regressions." + context: overview + + style = session: reviewer + prompt: "Review for code style, maintainability, and adherence to best practices." + context: overview + +# 3. Synthesis and final recommendation +session: reviewer + prompt: "Synthesize the security, performance, and style reviews into a final PR comment. Provide a clear 'Approve', 'Request Changes', or 'Comment' recommendation." + context: { security, perf, style, overview } diff --git a/extensions/open-prose/skills/prose/examples/28-gas-town.prose b/extensions/open-prose/skills/prose/examples/28-gas-town.prose new file mode 100644 index 0000000000000000000000000000000000000000..46a69f6618c0e62b4468b0478602b7177465421a --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/28-gas-town.prose @@ -0,0 +1,1572 @@ +# Gas Town: Multi-Agent Orchestration System +# +# A "Kubernetes for agents" - an industrialized coding factory +# with 7 worker roles coordinating through persistent state. +# +# Based on Steve Yegge's Gas Town architecture (2025-2026). +# +# ============================================================================= +# THE MEOW STACK: Molecular Expression of Work +# ============================================================================= +# +# Gas Town is built on the MEOW stack - a discovery about how to express +# and compose knowledge work for AI agents: +# +# 1. BEADS - Atomic work units (issues) stored in Git-backed JSON +# - ID, description, status, assignee, dependencies +# - Two tiers: Rig beads (project work) and Town beads (orchestration) +# +# 2. EPICS - Beads with children, for top-down planning +# - Children are parallel by default +# - Explicit dependencies force sequencing +# +# 3. MOLECULES - Workflows encoded as chains of beads +# - Arbitrary shapes with loops and gates +# - Turing-complete when combined with AI evaluation +# - Each step has acceptance criteria +# +# 4. PROTOMOLECULES - Templates/classes for molecules +# - Define workflow shapes in advance +# - Instantiate with variable substitution +# - E.g., 20-step release process as a template +# +# 5. FORMULAS - Source form for workflows (TOML) +# - "Cooked" into protomolecules +# - Support loops, gates, composition +# - Marketplace: "Mol Mall" +# +# 6. WISPS - Ephemeral beads (vapor phase) +# - In database but NOT written to Git +# - Used for patrol orchestration +# - Burned (destroyed) after completion +# - Optionally squashed to single-line digest +# +# ============================================================================= +# KEY CONCEPTS +# ============================================================================= +# +# GUPP: Gas Town Universal Propulsion Principle +# "If there is work on your hook, YOU MUST RUN IT" +# Physics over politeness. No waiting for permission. +# +# NDI: Nondeterministic Idempotence +# - Agent is persistent (Bead in Git) +# - Hook is persistent (Bead in Git) +# - Molecule is persistent (chain of Beads in Git) +# - Path is nondeterministic but outcome is guaranteed +# - Crashes don't matter - new session picks up where left off +# +# CONVOYS: Work-order units tracking delivery +# - Wraps beads into trackable delivery units +# - Multiple swarms can "attack" a convoy +# - Lands when all beads complete and MRs merge +# +# PATROLS: Ephemeral workflows run in loops by patrol agents +# - Use wisps (not persisted to Git) +# - Exponential backoff when idle +# - Deacon → Witness → Polecats/Refinery +# +# ============================================================================= +# PERSISTENT STATE (in .prose/gas-town/) +# ============================================================================= +# +# Town-level (orchestration): +# town-beads.jsonl - Town-level beads +# patrol-state.json - Deacon/Witness/Refinery patrol state +# activity-feed.jsonl - Live activity stream +# plugins/town/ - Town-level plugins +# +# Per-rig (project work): +# rigs/{name}/ +# rig-beads.jsonl - Rig-level beads +# hooks/ - Worker hooks (GUPP) +# mail/ - Worker inboxes +# polecats/ - Polecat state +# merge-queue.json - Pending MRs +# plugins/ - Rig-level plugins +# +# Shared: +# convoys/ - Active work orders +# archive/ - Completed convoys +# workers/ - Worker identity beads (persistent) +# formulas/ - Workflow templates +# wisps/ - Ephemeral orchestration (not in Git) +# +# This example assumes persistent file-based state is enabled. + +# ============================================================================= +# AGENT DEFINITIONS: The 7 Worker Roles +# ============================================================================= + +# The Mayor: Your concierge and chief-of-staff +# Main agent you interact with, kicks off most work convoys +agent mayor: + model: sonnet # Orchestration role - sonnet excels here + persist: project # Survives across runs - your long-term concierge + prompt: """ +You are the Mayor of Gas Town, the user's concierge and chief-of-staff. + +Your responsibilities: +- Receive requests from the Overseer (user) and translate them into work +- File beads (issues) and sling work to appropriate workers +- Track convoy status and notify when work lands +- Manage the big picture while polecats handle details +- Help keep Gas Town running smoothly + +Key commands you use: +- gt sling - Send work to a worker's hook +- gt convoy create - Start tracking a unit of work +- gt mail send - Send messages to workers +- gt handoff - Gracefully restart your session + +Remember GUPP: If there is work on your hook, YOU MUST RUN IT. +Physics over politeness. Check your hook on startup. +""" + +# Polecats: Ephemeral workers that swarm on work +# Spin up on demand, produce Merge Requests, then decommission +agent polecat: + model: sonnet + prompt: """ +You are a Polecat - an ephemeral worker in Gas Town. + +Your lifecycle: +1. Spin up when work is slung to you +2. Execute the molecule (workflow) on your hook +3. Produce a Merge Request when code changes are ready +4. Hand off to the Merge Queue +5. Decommission (your name gets recycled) + +Work style: +- Focus on ONE task at a time from your hook +- Follow the molecule step by step +- Create clean, atomic commits +- Submit MRs to the Refinery's merge queue +- Report status updates to the Witness + +GUPP: If there is work on your hook, YOU MUST RUN IT. +No waiting for permission. Check hook, do work, submit MR. +""" + +# Refinery: The Merge Queue processor +# Intelligently merges all changes one at a time to main +agent refinery: + model: sonnet # Orchestration role - merge queue is control flow + persist: true # Execution-scoped - maintains MQ state within run + prompt: """ +You are the Refinery - Gas Town's merge queue processor. + +Your patrol loop: +1. Preflight: Clean workspace, fetch latest +2. Process: Merge one MR at a time, intelligently +3. Handle conflicts: Reimagine changes against new head if needed +4. Run quality gates: Tests, lint, type checks +5. Postflight: Update convoy status, notify completion + +Merge strategy: +- Process MRs in priority order (convoys can set priority) +- No work can be lost (escalate if truly unmergeable) +- Rebase against latest main before each merge +- Run full CI validation before completing merge + +GUPP applies to your patrol: If MQ has items, PROCESS THEM. +""" + +# Witness: Watches over polecats and helps them get unstuck +# A patrol agent that monitors swarm health +agent witness: + model: sonnet + persist: true # Execution-scoped - tracks polecat health within run + prompt: """ +You are the Witness - Gas Town's swarm health monitor. + +Your patrol loop: +1. Check polecat wellbeing - are they stuck? crashed? +2. Nudge stuck polecats to continue their molecules +3. Monitor refinery progress - is the MQ flowing? +4. Peek at the Deacon - make sure it's not stuck +5. Run rig-level plugins +6. Recycle long-running polecats before context exhaustion + +Intervention strategy: +- gt nudge - Wake up a stuck worker +- gt seance - Talk to predecessor if state lost +- Escalate to Mayor if systemic issues +- Keep convoys moving toward landing + +GUPP: Your patrol IS your hook. Keep running it. +""" + +# Deacon: The daemon beacon - propagates heartbeat through the system +# Named for Dennis Hopper's Waterworld character +agent deacon: + model: sonnet + persist: true # Execution-scoped - maintains patrol state within run + prompt: """ +You are the Deacon - Gas Town's daemon beacon. + +Your patrol loop (pinged every ~2 minutes by the daemon): +1. Check town health - all systems nominal? +2. Propagate DYFJ (Do Your F***ing Job) signal downward +3. Nudge witnesses to check on their rigs +4. Run town-level plugins +5. Manage worker recycling and cleanup +6. Delegate complex investigations to Dogs + +Signal propagation: +- Deacon -> Witnesses -> Polecats/Refinery +- Exponential backoff when idle (save resources) +- Any mutating command wakes the town + +Hand off complex work to Dogs - don't get bogged down. +GUPP: The daemon will ping you. Run your patrol. +""" + +# Dogs: The Deacon's personal crew (inspired by MI5's "Dogs") +# Handle maintenance and handyman work for the Deacon +agent dog: + model: sonnet + prompt: """ +You are a Dog - one of the Deacon's personal crew. + +Your responsibilities: +- Maintenance tasks (clean stale branches, prune old convoys) +- Plugin execution on behalf of the Deacon +- Investigation and troubleshooting +- Long-running tasks that would block the Deacon's patrol + +Work style: +- Receive work from Deacon via mail or hook +- Execute thoroughly without time pressure +- Report findings back to Deacon +- Go back to sleep when done + +Special dog Boot: Awakened every 5 minutes just to check on Deacon. +Decides if Deacon needs a heartbeat, nudge, restart, or to be left alone. + +GUPP: If work is on your hook, do it. Then sleep. +""" + +# Crew: Per-rig coding agents that work for the Overseer +# Long-lived identities, great for design work with back-and-forth +agent crew: + model: opus # Hard/difficult work - design requires deep reasoning + persist: project # Survives across runs - maintains design context + prompt: """ +You are a Crew member - a named, long-lived coding agent. + +Unlike polecats (ephemeral), you have a persistent identity. +You work directly for the Overseer (the human). + +Your work style: +- Handle design work, architecture discussions +- Iterative development with feedback loops +- Can sling work to polecats for implementation +- Maintain context across many sessions via handoff + +You can: +- gt sling polecat - Delegate implementation +- gt convoy create - Start tracking work +- gt mail mayor "status update" - Keep mayor informed +- gt handoff - Gracefully continue in new session + +GUPP applies but you often wait for Overseer input. +""" + +# ============================================================================= +# BLOCK DEFINITIONS: Reusable Gas Town Workflows +# ============================================================================= + +# GUPP Check: The core propulsion check every worker runs +block gupp_check(worker_name): + session "Check if {worker_name} has work hooked" + prompt: """ +Read .prose/gas-town/hooks/{worker_name}.json +If hook has a molecule, return the first uncompleted step. +If hook is empty, return "NO_WORK". +""" + +# Patrol Step: Execute a single patrol step +block patrol_step(patrol_name, step_number, step_description): + session "{patrol_name} patrol step {step_number}: {step_description}" + session "Update patrol state with step {step_number} completion" + prompt: """ +Update .prose/gas-town/patrol-state.json: +- Set {patrol_name}.current_step = {step_number} +- Set {patrol_name}.last_completed = now() +- If all steps done, set {patrol_name}.patrol_complete = true +""" + +# Sling Work: Send work to a worker's hook +block sling_work(bead_id, target_worker): + session "Sling bead {bead_id} to {target_worker}" + prompt: """ +1. Read .prose/gas-town/beads/{bead_id}.json +2. Add to .prose/gas-town/hooks/{target_worker}.json +3. Send nudge message to {target_worker} +4. Return confirmation +""" + +# Create Convoy: Start tracking a unit of work +block create_convoy(convoy_name, initial_beads): + session "Create convoy: {convoy_name}" + prompt: """ +1. Generate convoy ID (timestamp + random) +2. Create .prose/gas-town/convoys/{convoy_id}.json with: + - name: {convoy_name} + - status: "active" + - beads: {initial_beads} + - created: now() +3. Return convoy ID +""" + +# Land Convoy: Mark a convoy as complete +block land_convoy(convoy_id): + session "Land convoy {convoy_id}" + prompt: """ +1. Update .prose/gas-town/convoys/{convoy_id}.json: + - status: "landed" + - landed_at: now() +2. Notify Mayor that convoy has landed +3. Archive convoy to .prose/gas-town/convoys/archive/ +""" + +# Handoff: Gracefully transition to new session +# Note: For persistent agents (persist: true/project), use `resume:` instead +# of this block - it handles memory loading automatically. +block handoff(worker_name, continuation_notes): + session "{worker_name} preparing handoff" + prompt: """ +1. Write continuation state to hook: + .prose/gas-town/hooks/{worker_name}.json + Include: {continuation_notes} +2. Send mail to self with handoff summary +3. Signal ready for session restart +""" + session "Execute gt handoff to restart session" + +# ============================================================================= +# GT COMMANDS: Core Gas Town Operations +# ============================================================================= + +# gt nudge: Wake up a worker that may be stuck +# Workers sometimes don't follow GUPP automatically - nudge kicks them +block gt_nudge(worker_name): + session "Nudge {worker_name} to check hook and continue" + prompt: """ +Send wake-up signal to {worker_name}: +1. Send tmux notification to worker's pane +2. Message content: "GUPP check - if work on hook, run it" +3. Log nudge to activity feed +4. Return confirmation + +The nudge bypasses Claude Code's politeness - worker will +check hook regardless of what you type. +""" + +# gt seance: Talk to a worker's predecessor (dead ancestor) +# Uses Claude Code's /resume to communicate with previous sessions +block gt_seance(worker_name, question): + session "Seance: {worker_name} talking to predecessor" + prompt: """ +Talk to {worker_name}'s previous session: +1. Find last session ID from .prose/gas-town/workers/{worker_name}.json +2. Use /resume to reconnect to that session +3. Ask: "{question}" +4. Capture the response +5. Return findings to current session + +This is useful when: +- State was lost during handoff +- Predecessor said "I left X for you" but you can't find it +- Need context from previous work +""" + +# gt mail send: Send message to a worker's inbox +block gt_mail_send(from_worker, to_worker, message): + session "Mail: {from_worker} -> {to_worker}" + prompt: """ +Send mail message: +1. Create mail entry with timestamp, from, to, message +2. Append to .prose/gas-town/rigs/*/mail/{to_worker}.json + (or town-level mail for town workers) +3. If to_worker is active, send nudge +4. Log to activity feed +5. Return message ID +""" + +# gt mail check: Read messages from inbox +block gt_mail_check(worker_name): + session "Check mail for {worker_name}" + prompt: """ +Read mail inbox: +1. Read .prose/gas-town/rigs/*/mail/{worker_name}.json +2. Also check town-level mail +3. Return unread messages sorted by timestamp +4. Mark as read after returning +""" + +# ============================================================================= +# WISP OPERATIONS: Ephemeral Orchestration Beads +# ============================================================================= + +# Create a wisp (ephemeral bead - not persisted to Git) +block create_wisp(wisp_type, content): + session "Create wisp: {wisp_type}" + prompt: """ +Create ephemeral bead (wisp): +1. Generate wisp ID (timestamp + random) +2. Write to .prose/gas-town/wisps/{wisp_id}.json: + - type: {wisp_type} + - content: {content} + - created: now() + - ephemeral: true +3. DO NOT add to Git staging +4. Return wisp ID + +Wisps are for: +- Patrol orchestration +- Temporary workflow state +- High-velocity operations that would pollute Git +""" + +# Burn a wisp (destroy after use) +block burn_wisp(wisp_id, create_digest): + session "Burn wisp {wisp_id}" + prompt: """ +Destroy ephemeral bead: +1. Read .prose/gas-town/wisps/{wisp_id}.json +2. If {create_digest} is true: + - Squash to single-line summary + - Append to .prose/gas-town/wisp-digest.jsonl (this IS in Git) +3. Delete the wisp file +4. Return confirmation +""" + +# ============================================================================= +# ACTIVITY FEED: Live Status Dashboard +# ============================================================================= + +# Log to activity feed +block log_activity(worker_name, activity_type, details): + session "Log activity: {worker_name} - {activity_type}" + prompt: """ +Append to activity feed: +1. Create entry: + - timestamp: now() + - worker: {worker_name} + - type: {activity_type} + - details: {details} +2. Append to .prose/gas-town/activity-feed.jsonl +3. If watching dashboard is active, send update +""" + +# Read activity feed +block get_activity_feed(since_timestamp, worker_filter): + session "Get activity feed" + prompt: """ +Read activity feed: +1. Read .prose/gas-town/activity-feed.jsonl +2. Filter to entries after {since_timestamp} +3. If {worker_filter} specified, filter to that worker +4. Return entries (most recent first, limit 100) +""" + +# ============================================================================= +# SWARM OPERATIONS: Polecat Allocation and Management +# ============================================================================= + +# Spawn a swarm of polecats for a convoy +block spawn_swarm(convoy_id, bead_ids, max_polecats): + session "Spawn swarm for convoy {convoy_id}" + prompt: """ +Allocate polecats to work on convoy: +1. Read available polecat slots (max {max_polecats}, usually 30) +2. For each bead in {bead_ids}: + - Check if dependencies are met + - If ready, allocate a polecat + - Sling bead to polecat's hook +3. Create swarm tracking entry: + .prose/gas-town/convoys/{convoy_id}/swarm.json +4. Notify Witness of new swarm +5. Return swarm status (polecats allocated, beads queued) +""" + +# Check swarm health +block check_swarm_health(convoy_id): + session "Check swarm health for {convoy_id}" + prompt: """ +Assess swarm status: +1. Read .prose/gas-town/convoys/{convoy_id}/swarm.json +2. For each allocated polecat: + - Check last_activity timestamp + - Check hook status (working/stuck/complete) + - Check if context exhaustion imminent +3. Return health report: + - active_polecats: N + - stuck_polecats: [list] + - beads_completed: N + - beads_remaining: N + - estimated_completion: timestamp +""" + +# Recycle exhausted polecats (prevent context exhaustion) +block recycle_polecat(polecat_id, rig_name): + session "Recycle polecat {polecat_id}" + prompt: """ +Gracefully recycle polecat before context exhaustion: +1. Read current molecule state from hook +2. Trigger handoff for polecat +3. Wait for new session to spin up +4. Verify hook is picked up by new session +5. Update swarm tracking +6. Return confirmation +""" + +# ============================================================================= +# FORMULA AND MOLECULE OPERATIONS +# ============================================================================= + +# Cook a formula into a protomolecule +block cook_formula(formula_name): + session "Cook formula: {formula_name}" + prompt: """ +Transform formula into protomolecule: +1. Read .prose/gas-town/formulas/{formula_name}.toml +2. Parse formula structure (steps, loops, gates) +3. Expand loops and conditionals +4. Generate protomolecule beads (templates) +5. Write to .prose/gas-town/protomolecules/{formula_name}/ +6. Return protomolecule ID +""" + +# Instantiate a protomolecule into a working molecule +block instantiate_molecule(protomolecule_id, variables): + session "Instantiate molecule from {protomolecule_id}" + prompt: """ +Create working molecule from template: +1. Read .prose/gas-town/protomolecules/{protomolecule_id}/ +2. Copy all template beads +3. Perform variable substitution with {variables} +4. Generate unique IDs for each bead +5. Link beads according to dependency graph +6. Write molecule to rig's bead database +7. Return molecule ID and first step +""" + +# The "Rule of Five" formula composer +# Makes each step get reviewed 4 additional times (5 total) +block compose_rule_of_five(base_molecule_id): + session "Apply Rule of Five to molecule" + prompt: """ +Wrap molecule with 5x review pattern: +1. Read molecule {base_molecule_id} +2. For each step in molecule: + - Keep original step (review #1) + - Add 4 review steps with different focus: + * Review #2: Correctness focus + * Review #3: Edge cases focus + * Review #4: Performance focus + * Review #5: Security focus +3. Chain reviews to gate original step completion +4. Return enhanced molecule ID + +This generates superior outcomes per Jeffrey Emanuel's observation. +""" + +# ============================================================================= +# RIG MANAGEMENT +# ============================================================================= + +# Add a rig to Gas Town management +block gt_rig_add(rig_name, repo_path): + session "Add rig: {rig_name}" + prompt: """ +Add project to Gas Town: +1. Verify {repo_path} is a valid Git repo +2. Create .prose/gas-town/rigs/{rig_name}/ structure: + - rig-beads.jsonl + - hooks/ + - mail/ + - polecats/ + - merge-queue.json + - plugins/ +3. Initialize witness for this rig +4. Initialize refinery for this rig +5. Add to town's rig registry +6. Return confirmation +""" + +# List all rigs +block gt_rig_list: + session "List all rigs" + prompt: """ +List Gas Town rigs: +1. Read .prose/gas-town/rigs/ +2. For each rig, report: + - name + - repo path + - active polecats + - pending MRs + - active convoys +3. Return formatted list +""" + +# War Rig: A rig's contribution to a cross-rig convoy +block war_rig_status(rig_name, convoy_id): + session "War Rig status: {rig_name} for {convoy_id}" + prompt: """ +Get rig's contribution to cross-rig convoy: +1. Read convoy {convoy_id} +2. Filter to beads assigned to {rig_name} +3. Report: + - Beads completed + - Beads in progress + - MRs pending merge + - Blockers +4. Return War Rig summary +""" + +# ============================================================================= +# THE DEACON'S PATROL: Town-level orchestration heartbeat +# ============================================================================= + +block deacon_patrol: + # Step 1: Check town health + do patrol_step("deacon", 1, "Check town health") + session: deacon + prompt: """ +Scan Gas Town health: +- Read .prose/gas-town/patrol-state.json +- Check all workers have recent activity +- Identify any stuck or crashed workers +- Return health report +""" + + # Step 2: Propagate DYFJ signal + do patrol_step("deacon", 2, "Propagate DYFJ to witnesses") + parallel (on-fail: "continue"): + session "Nudge witness for rig 1" + prompt: "Send DYFJ signal to witness-1" + session "Nudge witness for rig 2" + prompt: "Send DYFJ signal to witness-2" + + # Step 3: Run town-level plugins + do patrol_step("deacon", 3, "Run town plugins") + let plugins_needed = session "Check for pending town plugins" + prompt: "Read .prose/gas-town/plugins/town/*.json, return pending" + + if **there are plugins that need to run**: + session: deacon + prompt: "Sling plugin work to an available Dog" + context: plugins_needed + + # Step 4: Cleanup and maintenance + do patrol_step("deacon", 4, "Delegate maintenance to Dogs") + session: deacon + prompt: """ +Check if maintenance needed: +- Stale branches older than 7 days +- Orphaned convoys +- Log rotation +If needed, mail a Dog with maintenance tasks. +""" + + # Step 5: Set backoff for next patrol + do patrol_step("deacon", 5, "Calculate patrol backoff") + session: deacon + prompt: """ +Check activity level: +- If lots of active work: backoff = 30 seconds +- If moderate activity: backoff = 2 minutes +- If idle: double previous backoff (max 10 minutes) +Write backoff to .prose/gas-town/patrol-state.json +""" + +# ============================================================================= +# THE WITNESS'S PATROL: Rig-level swarm health monitoring +# ============================================================================= + +block witness_patrol(rig_name): + # Step 1: Check polecat wellbeing + do patrol_step("witness-{rig_name}", 1, "Check polecat health") + let polecat_status = session: witness + prompt: """ +For rig {rig_name}, check all active polecats: +- Read .prose/gas-town/polecats/{rig_name}/*.json +- Check last_activity timestamp +- Identify stuck polecats (no activity > 5 minutes) +Return list of stuck polecats. +""" + + # Step 2: Nudge stuck polecats + if **there are stuck polecats**: + do patrol_step("witness-{rig_name}", 2, "Nudge stuck polecats") + for polecat in polecat_status: + session: witness + prompt: "Execute gt nudge {polecat} for rig {rig_name}" + context: polecat + + # Step 3: Check refinery progress + do patrol_step("witness-{rig_name}", 3, "Check merge queue") + let mq_status = session: witness + prompt: """ +Check .prose/gas-town/merge-queue.json for {rig_name}: +- How many MRs pending? +- Is refinery processing? +- Any stuck MRs (age > 30 minutes)? +Return MQ health status. +""" + + if **the merge queue is stuck**: + session: witness + prompt: "Nudge the refinery to process MQ for {rig_name}" + context: mq_status + + # Step 4: Peek at Deacon + do patrol_step("witness-{rig_name}", 4, "Peek at Deacon") + session: witness + prompt: """ +Quick check on Deacon: +- Read .prose/gas-town/patrol-state.json +- Is Deacon's last patrol > 10 minutes ago? +- If so, send alert to Mayor +""" + + # Step 5: Run rig plugins + do patrol_step("witness-{rig_name}", 5, "Run rig plugins") + session: witness + prompt: """ +Check .prose/gas-town/plugins/rig/{rig_name}/*.json +Run any pending plugins for this rig. +""" + + # Step 6: Recycle exhausted polecats + do patrol_step("witness-{rig_name}", 6, "Recycle polecats") + session: witness + prompt: """ +Check polecat session lengths: +- Any polecat running > 45 minutes? +- If so, trigger handoff before context exhaustion +- Ensure work continues on new session +""" + +# ============================================================================= +# THE REFINERY'S PATROL: Merge Queue Processing +# ============================================================================= + +block refinery_patrol(rig_name): + # Preflight: Clean workspace + do patrol_step("refinery-{rig_name}", 1, "Preflight checks") + session: refinery + prompt: """ +Preflight for {rig_name}: +1. git fetch origin +2. git checkout main && git pull +3. Clean any uncommitted changes +4. Verify workspace is ready +""" + + # Main loop: Process MQ until empty + do patrol_step("refinery-{rig_name}", 2, "Process merge queue") + + loop until **the merge queue is empty** (max: 20): + let next_mr = session: refinery + prompt: """ +Read .prose/gas-town/merge-queue.json +Get highest priority MR for {rig_name} +Return MR details or "EMPTY" if none. +""" + + if **there is an MR to process**: + # Process single MR + try: + session: refinery + prompt: """ +Process MR for {rig_name}: +1. git checkout {next_mr.branch} +2. git rebase main +3. Run tests: npm test / pytest / cargo test +4. Run lint and type checks +5. If all pass, merge to main +6. Update convoy status +""" + context: next_mr + retry: 2 + backoff: "linear" + + session: refinery + prompt: """ +MR merged successfully: +1. Remove from .prose/gas-town/merge-queue.json +2. Delete branch +3. Update convoy progress +4. Notify polecat of completion +""" + context: next_mr + + catch as merge_error: + # Handle merge conflicts + choice **the type of merge failure**: + option "Conflict - needs reimplementation": + session: refinery + prompt: """ +Merge conflict detected. Create escalation: +1. Mark MR as "needs_reimplementation" +2. Create new bead for conflict resolution +3. Sling to a new polecat +4. Notify Witness of the situation +""" + context: merge_error + + option "Test failure - needs fix": + session: refinery + prompt: """ +Tests failed after merge: +1. Git reset the merge +2. Create fix-tests bead +3. Assign back to original polecat +4. Add to same convoy +""" + context: merge_error + + option "Unrecoverable - escalate": + session: refinery + prompt: """ +Cannot process MR: +1. Mark as "escalated" in MQ +2. Send urgent mail to Mayor +3. Add to convoy blockers +4. Continue with next MR +""" + context: merge_error + + # Postflight: Cleanup and report + do patrol_step("refinery-{rig_name}", 3, "Postflight") + session: refinery + prompt: """ +Postflight for {rig_name}: +1. Report MQ processing summary +2. Update patrol state with completion time +3. Check if any convoys can be landed +4. Set backoff based on MQ activity +""" + +# ============================================================================= +# BOOT THE DOG: Special Dog that watches the Deacon +# ============================================================================= + +block boot_check: + session: dog + prompt: """ +You are Boot the Dog. Your only job: check on the Deacon. + +Read .prose/gas-town/patrol-state.json +Check deacon.last_patrol timestamp. + +Decision: +- If < 2 min ago: Deacon is fine. Go back to sleep. +- If 2-5 min ago: Send gentle nudge to Deacon. +- If 5-10 min ago: Send firm DYFJ to Deacon. +- If > 10 min ago: Restart Deacon session. + +Execute your decision, then sleep until next 5-minute wake. +""" + +# ============================================================================= +# CONVOY WORKFLOW: Full lifecycle of a work unit +# ============================================================================= + +block convoy_workflow(convoy_name, work_description, target_rig): + # Phase 1: Mayor receives and plans (first call uses session:) + let plan = session: mayor + prompt: """ +New convoy requested: {convoy_name} +Work: {work_description} +Rig: {target_rig} + +1. Break down into implementable beads +2. Identify dependencies between beads +3. Estimate polecat count needed +4. Create convoy structure +""" + + # Phase 2: Create convoy and beads + do create_convoy(convoy_name, plan) + + # Subsequent Mayor calls use resume: to load memory + let beads = resume: mayor + prompt: """ +Create individual beads for convoy {convoy_name}: +1. Write each bead to .prose/gas-town/beads/ +2. Link beads to convoy +3. Set initial status to "ready" +Return list of bead IDs. +""" + context: plan + + # Phase 3: Swarm the work + resume: mayor + prompt: """ +Initiate swarm for convoy {convoy_name}: +1. Determine optimal polecat count (max 30) +2. Sling work to polecats based on dependencies +3. Notify Witness to monitor swarm +4. Return swarm status +""" + context: beads + + # Phase 4: Let the swarm run (monitored by Witness) + loop until **all convoy beads are complete or blocked** (max: 50): + # Witness patrol handles the actual monitoring + do witness_patrol(target_rig) + + # Check convoy status + let convoy_status = resume: mayor + prompt: """ +Check convoy {convoy_name} status: +- Read .prose/gas-town/convoys/{convoy_name}.json +- Count completed vs pending beads +- Check for blockers +Return status summary. +""" + + if **there are blocked beads that need attention**: + resume: mayor + prompt: """ +Handle blocked beads in convoy: +1. Analyze block reasons +2. Create unblock beads if needed +3. Escalate to Overseer if human decision required +""" + context: convoy_status + + # Phase 5: Refinery merges everything + loop until **all MRs from convoy are merged** (max: 30): + do refinery_patrol(target_rig) + + # Phase 6: Land the convoy + do land_convoy(convoy_name) + + resume: mayor + prompt: """ +Convoy {convoy_name} has landed! +1. Generate completion summary +2. Notify Overseer +3. Archive convoy artifacts +4. Update rig statistics +""" + +# ============================================================================= +# POLECAT LIFECYCLE: Ephemeral worker execution +# ============================================================================= + +block polecat_lifecycle(polecat_id, rig_name): + # Startup: GUPP check + let hook_work = do gupp_check("polecat-{polecat_id}") + + if **there is no work on hook**: + session: polecat + prompt: """ +No work on hook. Options: +1. Check mail for new assignments +2. Ask Witness for work +3. Go idle (decommission after 5 min idle) +""" + else: + # Main work loop: Execute molecule + loop until **molecule is complete** (max: 100): + let current_step = session: polecat + prompt: """ +Read current molecule step from hook. +Return step details: +- step_id +- description +- acceptance_criteria +- dependencies (must be complete first) +""" + context: hook_work + + if **dependencies are not met**: + session: polecat + prompt: """ +Dependencies not ready. Options: +1. Work on a different bead in molecule +2. Wait and check again in 30 seconds +3. Report blocker to Witness +""" + context: current_step + else: + # Execute the step + try: + session: polecat + prompt: """ +Execute molecule step: +{current_step.description} + +Acceptance criteria: +{current_step.acceptance_criteria} + +Work until criteria are met, then mark step complete. +""" + context: current_step + retry: 2 + backoff: "exponential" + + # Mark step complete + session: polecat + prompt: """ +Step complete. Update state: +1. Mark step as complete in molecule +2. Update convoy progress +3. If code changed, prepare MR +4. Move to next step +""" + + catch as step_error: + session: polecat + prompt: """ +Step failed. Handle error: +1. Log failure details +2. Determine if retryable +3. If not, escalate to Witness +4. Update molecule state +""" + context: step_error + + # Check if should hand off (context exhaustion prevention) + if **session is getting long (> 40 messages)**: + do handoff("polecat-{polecat_id}", "Continue molecule execution") + + # Molecule complete: Submit MR if needed + let has_changes = session: polecat + prompt: "Check if there are uncommitted code changes. Return true/false." + + if **there are code changes to submit**: + session: polecat + prompt: """ +Prepare Merge Request: +1. Stage all changes: git add -A +2. Create atomic commit with descriptive message +3. Push branch to origin +4. Add MR to .prose/gas-town/merge-queue.json +5. Notify Refinery +""" + + # Decommission + session: polecat + prompt: """ +Polecat {polecat_id} decommissioning: +1. Clear hook +2. Update convoy with completion status +3. Send completion mail to Witness +4. Mark self as available for reuse +""" + +# ============================================================================= +# POLECAT WORKFLOW VARIANTS: Different quality levels +# ============================================================================= + +# Standard polecat workflow (fast, minimal review) +block polecat_workflow_standard(bead_id): + session: polecat + prompt: """ +Standard workflow for bead {bead_id}: +1. Read bead acceptance criteria +2. Implement the change +3. Self-review for obvious issues +4. Submit MR +Fast but minimal quality gates. +""" + +# "Shiny" polecat workflow (extra code review) +block polecat_workflow_shiny(bead_id): + session: polecat + prompt: "Implement bead {bead_id}" + + session: polecat + prompt: """ +Shiny review pass 1: Code correctness +- Does the implementation match requirements? +- Are there any logical errors? +- Fix any issues found. +""" + + session: polecat + prompt: """ +Shiny review pass 2: Code quality +- Is the code readable and maintainable? +- Does it follow project conventions? +- Fix any issues found. +""" + + session: polecat + prompt: "Submit MR with shiny review complete" + +# "Chrome" polecat workflow (extra review + testing) +block polecat_workflow_chrome(bead_id): + session: polecat + prompt: "Implement bead {bead_id}" + + # All shiny review passes + session: polecat + prompt: "Chrome review pass 1: Correctness check" + session: polecat + prompt: "Chrome review pass 2: Quality check" + session: polecat + prompt: "Chrome review pass 3: Edge cases check" + + # Extra chrome passes + session: polecat + prompt: """ +Chrome review pass 4: Test coverage +- Write unit tests for new code +- Ensure edge cases are tested +- Run tests locally +""" + + session: polecat + prompt: """ +Chrome review pass 5: Security check +- Check for injection vulnerabilities +- Verify input validation +- Check for data exposure risks +""" + + session: polecat + prompt: "Submit MR with chrome review complete" + +# ============================================================================= +# CV CHAINS: Polecat Work Credits +# ============================================================================= + +# Record polecat completion to CV chain (credited work history) +block record_cv_completion(polecat_id, bead_id, convoy_id): + session "Record CV completion for polecat {polecat_id}" + prompt: """ +Add completion to polecat's CV chain: +1. Read .prose/gas-town/workers/polecats/{polecat_id}.json +2. Append to cv_chain: + - bead_id: {bead_id} + - convoy_id: {convoy_id} + - completed_at: now() + - outcome: success/partial/escalated +3. Update statistics: + - total_completions + - success_rate + - average_duration +4. Save updated worker record + +CV chains provide: +- Work attribution (who did what) +- Performance metrics +- Trust scoring for assignment prioritization +""" + +# Get polecat's CV summary +block get_cv_summary(polecat_id): + session "Get CV summary for {polecat_id}" + prompt: """ +Summarize polecat's work history: +1. Read .prose/gas-town/workers/polecats/{polecat_id}.json +2. Return: + - Total completions + - Success rate + - Recent convoy contributions + - Specializations (detected from bead types) + - Trust score +""" + +# ============================================================================= +# MAIN: Gas Town Startup and Operation +# ============================================================================= + +# Initialize Gas Town state directories +session "Initialize Gas Town persistent state" + prompt: """ +Create Gas Town state structure: + +.prose/gas-town/ +├── town-beads.jsonl # Town-level orchestration beads +├── activity-feed.jsonl # Live activity stream +├── patrol-state.json # Patrol agent states +├── wisp-digest.jsonl # Burned wisp summaries +│ +├── workers/ # Persistent worker identities +│ ├── mayor.json +│ ├── deacon.json +│ ├── dogs/ +│ │ └── boot.json +│ └── polecats/ # Polecat CV chains +│ +├── convoys/ # Work orders +│ ├── active/ +│ └── archive/ +│ +├── formulas/ # Workflow templates (TOML) +├── protomolecules/ # Cooked templates +├── wisps/ # Ephemeral beads (NOT in Git) +│ +├── plugins/ +│ └── town/ # Town-level plugins +│ +└── rigs/ # Per-project state + └── {rig-name}/ + ├── rig-beads.jsonl # Project issues + ├── merge-queue.json # Pending MRs + ├── hooks/ # Worker hooks (GUPP) + ├── mail/ # Worker inboxes + ├── polecats/ # Per-rig polecat state + └── plugins/ # Rig-level plugins + +Initialize all files with empty/default values. +Add .prose/gas-town/wisps/ to .gitignore (ephemeral). +""" + +# Start the Mayor +session: mayor + prompt: """ +Gas Town Mayor starting up. + +GUPP Check: Read .prose/gas-town/hooks/mayor.json +If work hooked, begin immediately. +Otherwise, greet the Overseer and await instructions. + +Available commands: +- "Create convoy for: " - Start new work unit +- "Sling to " - Assign work +- "Status" - Show town status +- "Let's hand off" - Gracefully restart +""" + +# Start patrol agents in background (parallel) +parallel (on-fail: "continue"): + # Deacon patrol loop + loop (max: 1000): + try: + do deacon_patrol + catch: + session "Deacon patrol error - will retry" + session "Wait for deacon backoff period" + prompt: "Read backoff from patrol-state.json and wait" + + # Boot the Dog wakes every 5 minutes + loop (max: 1000): + do boot_check + session "Boot sleeping for 5 minutes" + prompt: "Wait 5 minutes before next Deacon check" + +# ============================================================================= +# EXAMPLE: Running a Convoy +# ============================================================================= + +# This block shows how an Overseer would use Gas Town + +block example_convoy: + # Overseer tells Mayor what to build + session: mayor + prompt: """ +Overseer request: "Add dark mode to the dashboard" + +As Mayor: +1. Acknowledge the request +2. Break it down into implementable pieces +3. Create a convoy to track it +4. Swarm polecats on the work +5. Report when convoy lands +""" + + # Mayor creates and manages the convoy + do convoy_workflow( + "dark-mode-dashboard", + "Add dark mode toggle and theme support to the dashboard", + "main-app" + ) + + # When done, Mayor reports back + session: mayor + prompt: """ +Report convoy completion to Overseer: +- What was implemented +- Any issues encountered +- Files changed +- Ready for review +""" + +# ============================================================================= +# EXAMPLE: Direct Crew Work +# ============================================================================= + +# For design/architecture work, use Crew instead of polecats + +block crew_design_session(topic): + # First invocation starts fresh design session + session: crew + prompt: """ +Design session for: {topic} + +As a Crew member, engage in iterative design: +1. Understand the problem space +2. Propose architecture options +3. Discuss trade-offs with Overseer +4. Document decisions +5. Optionally sling implementation to polecats + +This is interactive - wait for Overseer input. +""" + + loop until **design is finalized** (max: 20): + # Subsequent calls resume with memory + resume: crew + prompt: """ +Continue design iteration: +- Address Overseer feedback +- Refine the design +- When ready, ask if should proceed to implementation +""" + + if **Overseer wants to proceed with implementation**: + resume: crew + prompt: """ +Create implementation convoy: +1. Convert design into beads +2. Create convoy +3. Sling to polecats +4. Return convoy ID to track +""" + +# ============================================================================= +# EXAMPLE: Hanoi Towers (MAKER Problem) +# ============================================================================= +# +# Gas Town can solve arbitrarily long sequential workflows. +# The Towers of Hanoi problem (from the MAKER paper) demonstrates this: +# - 10-disc Hanoi: ~1000 steps (runs in minutes) +# - 20-disc Hanoi: ~1,000,000 steps (runs in ~30 hours) +# +# LLMs traditionally fail after a few hundred steps (per MAKER paper). +# Gas Town's molecular workflows make this trivial via NDI. + +# Generate Hanoi formula (creates workflow from mathematical formula) +block generate_hanoi_formula(num_discs): + session "Generate Hanoi formula for {num_discs} discs" + prompt: """ +Generate Towers of Hanoi workflow formula: + +For {num_discs} discs, generate (2^n - 1) move steps. +Each step is a bead: + - ID: hanoi-move-{step_number} + - Description: "Move disc from peg {from} to peg {to}" + - Acceptance: "Disc moved, state updated" + - Depends on: previous step + +Write to .prose/gas-town/formulas/hanoi-{num_discs}.toml + +This demonstrates Gas Town's ability to handle +arbitrarily long workflows via NDI (Nondeterministic Idempotence). +Crashes don't matter - just resume from last completed step. +""" + +# Run the Hanoi demonstration +block run_hanoi_demo(num_discs): + # Generate the formula + do generate_hanoi_formula(num_discs) + + # Cook into protomolecule + do cook_formula("hanoi-{num_discs}") + + # Instantiate into working molecule + let hanoi_mol = do instantiate_molecule( + "hanoi-{num_discs}", + {"discs": num_discs} + ) + + # Create convoy to track + do create_convoy("hanoi-{num_discs}-demo", hanoi_mol) + + # Sling to a polecat (single worker for sequential) + do sling_work(hanoi_mol, "polecat-hanoi") + + # The polecat will work through all steps + # Crashes are fine - NDI ensures eventual completion + session: mayor + prompt: """ +Hanoi convoy started for {num_discs} discs. +- Steps: 2^{num_discs} - 1 +- Est. completion: varies by disc count +- Polecat will checkpoint progress in molecule +- Monitor via activity feed +""" + +# ============================================================================= +# EXAMPLE: Full Production Workflow +# ============================================================================= + +# This shows a realistic production workflow using all Gas Town features + +block production_feature_workflow(feature_name, rig_name): + # Phase 1: Design with Crew (first call) + do log_activity("overseer", "feature_start", feature_name) + + let design = session: crew + prompt: """ +Design phase for: {feature_name} +1. Understand requirements +2. Research existing code +3. Propose architecture +4. Get Overseer approval +""" + + # Phase 2: Create convoy with chrome-level quality (first Mayor call) + let plan = session: mayor + prompt: """ +Create implementation plan for {feature_name}: +1. Break into implementable beads +2. Add dependencies +3. Select chrome workflow (highest quality) +4. Estimate polecat count +""" + context: design + + do create_convoy("{feature_name}-impl", plan) + + # Phase 3: Spawn swarm with chrome workflow + resume: mayor + prompt: """ +Spawn chrome-quality swarm: +1. Allocate polecats (use trust scores from CVs) +2. Assign chrome workflow to each bead +3. Notify Witness +""" + context: plan + + # Phase 4: Monitor progress (patrols handle the rest) + loop until **all beads complete** (max: 100): + do check_swarm_health("{feature_name}-impl") + + if **any polecats stuck**: + resume: witness + prompt: "Nudge stuck polecats, use seance if needed" + + if **any polecats near context limit**: + resume: witness + prompt: "Recycle exhausted polecats" + + # Phase 5: Merge all MRs + loop until **all MRs merged** (max: 50): + do refinery_patrol(rig_name) + + # Phase 6: Land and celebrate + do land_convoy("{feature_name}-impl") + + resume: mayor + prompt: """ +Feature {feature_name} complete! +1. Generate release notes +2. Update documentation +3. Notify Overseer +4. Record CV completions for all polecats +""" + +# ============================================================================= +# EXAMPLE: Cross-Rig Convoy (War Rigs) +# ============================================================================= + +# When a feature spans multiple projects + +block cross_rig_convoy(feature_name, rig_list): + # Create convoy that spans multiple rigs (first Mayor call) + session: mayor + prompt: """ +Cross-rig convoy for {feature_name}: +Rigs involved: {rig_list} + +1. Identify work for each rig (War Rigs) +2. Create beads in each rig's database +3. Link with cross-rig dependencies +4. Coordinate witnesses across rigs +""" + + # Spawn witnesses for each rig + parallel for rig in rig_list: + session: witness + prompt: "Initialize War Rig monitoring for {rig} in convoy {feature_name}" + + # Swarm all rigs in parallel + parallel for rig in rig_list: + do spawn_swarm("{feature_name}", "war-rig-{rig}-beads", 10) + + # Monitor War Rig contributions + loop until **all War Rigs complete** (max: 200): + parallel for rig in rig_list: + do war_rig_status(rig, "{feature_name}") + + if **any War Rig blocked**: + resume: mayor + prompt: "Coordinate cross-rig blocker resolution" + + # Land the cross-rig convoy + do land_convoy("{feature_name}") + + resume: mayor + prompt: """ +Cross-rig convoy {feature_name} landed! +War Rig summary for each rig: {rig_list} +""" + +# ============================================================================= +# GAS TOWN STATUS DASHBOARD +# ============================================================================= + +block gt_status: + parallel: + town_health = session "Check town health" + prompt: """ +Town-level status: +- Deacon patrol status +- Dogs status +- Active convoys (town-level) +""" + + rig_health = session "Check rig health" + prompt: """ +Per-rig status: +- Read .prose/gas-town/rigs/ +- For each rig: polecats, MQ depth, witnesses +""" + + activity = do get_activity_feed("last_hour", "") + + session: mayor + prompt: """ +Gas Town Status Report: + +TOWN: +{town_health} + +RIGS: +{rig_health} + +RECENT ACTIVITY: +{activity} + +Recommendations: +- Any workers needing attention? +- Any convoys blocked? +- Resource utilization? +""" + context: { town_health, rig_health, activity } + +# ============================================================================= +# RUN EXAMPLES (commented out - uncomment to execute) +# ============================================================================= + +# Basic examples: +# do example_convoy +# do crew_design_session("API redesign for v2") + +# Advanced examples: +# do run_hanoi_demo(10) # ~1000 steps, runs in minutes +# do production_feature_workflow("user-auth", "main-app") +# do cross_rig_convoy("unified-search", ["frontend", "backend", "search-service"]) +# do gt_status diff --git a/extensions/open-prose/skills/prose/examples/29-captains-chair.prose b/extensions/open-prose/skills/prose/examples/29-captains-chair.prose new file mode 100644 index 0000000000000000000000000000000000000000..d3266ed0517379e091322c2aec627416b0832af5 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/29-captains-chair.prose @@ -0,0 +1,218 @@ +# The Captain's Chair +# +# A project management orchestration pattern where a prime agent dispatches +# specialized subagents for all coding, validation, and task execution. +# The captain never writes code directly—only coordinates, validates, and +# maintains strategic oversight. +# +# Key principles: +# - Context isolation: Subagents receive targeted context, not everything +# - Parallel execution: Multiple subagents work concurrently where possible +# - Critic agents: Continuous review of plans and outputs +# - Checkpoint validation: User approval at key decision points + +input task: "The feature or task to implement" +input codebase_context: "Brief description of the codebase and relevant files" + +# ============================================================================ +# Agent Definitions: The Crew +# ============================================================================ + +# The Captain: Orchestrates but never codes +agent captain: + model: opus + prompt: """You are a senior engineering manager. You NEVER write code directly. +Your job is to: +- Break down complex tasks into discrete work items +- Dispatch work to appropriate specialists +- Validate that outputs meet requirements +- Maintain strategic alignment with user intent +- Identify blockers and escalate decisions to the user + +Always think about: What context does each subagent need? What can run in parallel? +What needs human validation before proceeding?""" + +# Research agents - fast, focused information gathering +agent researcher: + model: haiku + prompt: """You are a research specialist. Find specific information quickly. +Provide concise, actionable findings. Cite file paths and line numbers.""" + +# Coding agents - implementation specialists +agent coder: + model: sonnet + prompt: """You are an expert software engineer. Write clean, idiomatic code. +Follow existing patterns in the codebase. No over-engineering.""" + +# Critic agents - continuous quality review +agent critic: + model: sonnet + prompt: """You are a senior code reviewer and architect. Your job is to find: +- Logic errors and edge cases +- Security vulnerabilities +- Performance issues +- Deviations from best practices +- Unnecessary complexity +Be constructive but thorough. Prioritize issues by severity.""" + +# Test agent - validation specialist +agent tester: + model: sonnet + prompt: """You are a QA engineer. Write comprehensive tests. +Focus on edge cases and failure modes. Ensure test isolation.""" + +# ============================================================================ +# Block Definitions: Reusable Operations +# ============================================================================ + +# Parallel research sweep - gather all context simultaneously +block research-sweep(topic): + parallel (on-fail: "continue"): + docs = session: researcher + prompt: "Find relevant documentation and README files for: {topic}" + code = session: researcher + prompt: "Find existing code patterns and implementations related to: {topic}" + tests = session: researcher + prompt: "Find existing tests that cover functionality similar to: {topic}" + issues = session: researcher + prompt: "Search for related issues, TODOs, or known limitations for: {topic}" + +# Parallel code review - multiple perspectives simultaneously +block review-cycle(code_changes): + parallel: + security = session: critic + prompt: "Review for security vulnerabilities and injection risks" + context: code_changes + correctness = session: critic + prompt: "Review for logic errors, edge cases, and correctness" + context: code_changes + style = session: critic + prompt: "Review for code style, readability, and maintainability" + context: code_changes + perf = session: critic + prompt: "Review for performance issues and optimization opportunities" + context: code_changes + +# Implementation cycle with built-in critic +block implement-with-review(implementation_plan): + let code = session: coder + prompt: "Implement according to the plan" + context: implementation_plan + + let review = do review-cycle(code) + + if **critical issues found in review**: + let fixed = session: coder + prompt: "Address the critical issues identified in the review" + context: { code, review } + output result = fixed + else: + output result = code + +# ============================================================================ +# Main Workflow: The Captain's Chair in Action +# ============================================================================ + +# Phase 1: Strategic Planning +# --------------------------- +# The captain breaks down the task and identifies what information is needed + +let breakdown = session: captain + prompt: """Analyze this task and create a strategic plan: + +Task: {task} +Codebase: {codebase_context} + +Output: +1. List of discrete work items (what code needs to be written/changed) +2. Dependencies between work items (what must complete before what) +3. What can be parallelized +4. Key questions that need user input before proceeding +5. Risks and potential blockers""" + +# Phase 2: Parallel Research Sweep +# -------------------------------- +# Dispatch researchers to gather all necessary context simultaneously + +do research-sweep(task) + +# Phase 3: Plan Synthesis and Critic Review +# ----------------------------------------- +# Captain synthesizes research into implementation plan, critic reviews it + +let implementation_plan = session: captain + prompt: """Synthesize the research into a detailed implementation plan. + +Research findings: +{docs} +{code} +{tests} +{issues} + +For each work item, specify: +- Exact files to modify +- Code patterns to follow +- Tests to add or update +- Integration points""" + context: { breakdown, docs, code, tests, issues } + +# Critic reviews the plan BEFORE implementation begins +let plan_review = session: critic + prompt: """Review this implementation plan for: +- Missing edge cases +- Architectural concerns +- Testability issues +- Scope creep +- Unclear requirements that need user clarification""" + context: implementation_plan + +# Checkpoint: User validates plan before execution +if **the plan review raised critical concerns**: + let revised_plan = session: captain + prompt: "Revise the plan based on critic feedback" + context: { implementation_plan, plan_review } + # Continue with revised plan + let final_plan = revised_plan +else: + let final_plan = implementation_plan + +# Phase 4: Parallel Implementation +# -------------------------------- +# Identify independent work items and execute in parallel where possible + +let work_items = session: captain + prompt: "Extract the independent work items that can be done in parallel from this plan" + context: final_plan + +# Execute independent items in parallel, each with its own review cycle +parallel (on-fail: "continue"): + impl_a = do implement-with-review(work_items) + impl_b = session: tester + prompt: "Write tests for the planned functionality" + context: { final_plan, code } + +# Phase 5: Integration and Final Review +# ------------------------------------- +# Captain validates all pieces fit together + +let integration = session: captain + prompt: """Review all implementation results and verify: +1. All work items completed successfully +2. Tests cover the new functionality +3. No merge conflicts or integration issues +4. Documentation updated if needed + +Summarize what was done and any remaining items.""" + context: { impl_a, impl_b, final_plan } + +# Final critic pass on complete implementation +let final_review = do review-cycle(integration) + +if **final review passed**: + output result = session: captain + prompt: "Prepare final summary for user: what was implemented, tests added, and next steps" + context: { integration, final_review } +else: + output result = session: captain + prompt: "Summarize what was completed and what issues remain for user attention" + context: { integration, final_review } diff --git a/extensions/open-prose/skills/prose/examples/30-captains-chair-simple.prose b/extensions/open-prose/skills/prose/examples/30-captains-chair-simple.prose new file mode 100644 index 0000000000000000000000000000000000000000..74b8ddea181e1c064cf8c7a9157dc7ced89cc576 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/30-captains-chair-simple.prose @@ -0,0 +1,42 @@ +# Simple Captain's Chair +# +# The minimal captain's chair pattern: a coordinating agent that dispatches +# subagents for all execution. The captain only plans and validates. + +input task: "What to accomplish" + +# The captain coordinates but never executes +agent captain: + model: opus + prompt: "You are a project coordinator. Never write code directly. Break down tasks, dispatch to specialists, validate results." + +agent executor: + model: opus + prompt: "You are a skilled implementer. Execute the assigned task precisely." + +agent critic: + model: opus + prompt: "You are a critic. Find issues, suggest improvements. Be thorough." + +# Step 1: Captain creates the plan +let plan = session: captain + prompt: "Break down this task into work items: {task}" + +# Step 2: Dispatch parallel execution +parallel: + work = session: executor + prompt: "Execute the plan" + context: plan + review = session: critic + prompt: "Identify potential issues with this approach" + context: plan + +# Step 3: Captain synthesizes and validates +if **critic found issues that affect the work**: + output result = session: captain + prompt: "Integrate the work while addressing critic's concerns" + context: { work, review } +else: + output result = session: captain + prompt: "Validate and summarize the completed work" + context: { work, review } diff --git a/extensions/open-prose/skills/prose/examples/31-captains-chair-with-memory.prose b/extensions/open-prose/skills/prose/examples/31-captains-chair-with-memory.prose new file mode 100644 index 0000000000000000000000000000000000000000..f0c889e8ad9db240f5a6a0fb818134311679541e --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/31-captains-chair-with-memory.prose @@ -0,0 +1,145 @@ +# Captain's Chair with Memory and Self-Improvement +# +# An advanced orchestration pattern that includes: +# - Retrospective analysis after task completion +# - Learning from mistakes to improve future runs +# - Continuous critic supervision during execution +# +# From the blog post: "Future agents will flip the plan:execute paradigm +# to 80:20 from today's 20:80" + +input task: "The task to accomplish" +input past_learnings: "Previous session learnings (if any)" + +# ============================================================================ +# Agent Definitions +# ============================================================================ + +agent captain: + model: opus + prompt: """You are a senior engineering manager. You coordinate but never code directly. + +Your responsibilities: +1. Strategic planning with 80% of effort on planning, 20% on execution oversight +2. Dispatch specialized subagents for all implementation +3. Validate outputs meet requirements +4. Learn from each session to improve future runs + +Past learnings to incorporate: +{past_learnings}""" + +agent planner: + model: opus + prompt: """You are a meticulous planner. Create implementation plans with: +- Exact files and line numbers to modify +- Code patterns to follow from existing codebase +- Edge cases to handle +- Tests to write""" + +agent researcher: + model: haiku + prompt: "Find specific information quickly. Cite sources." + +agent executor: + model: sonnet + prompt: "Implement precisely according to plan. Follow existing patterns." + +agent critic: + model: sonnet + prompt: """You are a continuous critic. Your job is to watch execution and flag: +- Deviations from plan +- Emerging issues +- Opportunities for improvement +Be proactive - don't wait for completion to raise concerns.""" + +agent retrospective: + model: opus + prompt: """You analyze completed sessions to extract learnings: +- What went well? +- What could be improved? +- What should be remembered for next time? +Output actionable insights, not platitudes.""" + +# ============================================================================ +# Phase 1: Deep Planning (80% of effort) +# ============================================================================ + +# Parallel research - gather everything needed upfront +parallel: + codebase = session: researcher + prompt: "Map the relevant parts of the codebase for: {task}" + patterns = session: researcher + prompt: "Find coding patterns and conventions used in this repo" + docs = session: researcher + prompt: "Find documentation and prior decisions related to: {task}" + issues = session: researcher + prompt: "Find known issues, TODOs, and edge cases for: {task}" + +# Create detailed implementation plan +let detailed_plan = session: planner + prompt: """Create a comprehensive implementation plan for: {task} + +Use the research to specify: +1. Exact changes needed (file:line format) +2. Code patterns to follow +3. Edge cases from prior issues +4. Test coverage requirements""" + context: { codebase, patterns, docs, issues } + +# Critic reviews plan BEFORE execution +let plan_critique = session: critic + prompt: "Review this plan for gaps, risks, and unclear requirements" + context: detailed_plan + +# Captain decides if plan needs revision +if **plan critique identified blocking issues**: + let revised_plan = session: planner + prompt: "Revise the plan to address critique" + context: { detailed_plan, plan_critique } +else: + let revised_plan = detailed_plan + +# ============================================================================ +# Phase 2: Supervised Execution (20% of effort) +# ============================================================================ + +# Execute with concurrent critic supervision +parallel: + implementation = session: executor + prompt: "Implement according to the plan" + context: revised_plan + live_critique = session: critic + prompt: "Monitor implementation for deviations and emerging issues" + context: revised_plan + +# Captain validates and integrates +let validated = session: captain + prompt: """Validate the implementation: +- Does it match the plan? +- Were critic's live concerns addressed? +- Is it ready for user review?""" + context: { implementation, live_critique, revised_plan } + +# ============================================================================ +# Phase 3: Retrospective and Learning +# ============================================================================ + +# Extract learnings for future sessions +let session_learnings = session: retrospective + prompt: """Analyze this completed session: + +Plan: {revised_plan} +Implementation: {implementation} +Critique: {live_critique} +Validation: {validated} + +Extract: +1. What patterns worked well? +2. What caused friction or rework? +3. What should the captain remember next time? +4. Any codebase insights to preserve?""" + context: { revised_plan, implementation, live_critique, validated } + +# Output both the result and the learnings +output result = validated +output learnings = session_learnings diff --git a/extensions/open-prose/skills/prose/examples/33-pr-review-autofix.prose b/extensions/open-prose/skills/prose/examples/33-pr-review-autofix.prose new file mode 100644 index 0000000000000000000000000000000000000000..9b275724f2630f7cfc59a49747f6bdc4072dfc03 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/33-pr-review-autofix.prose @@ -0,0 +1,168 @@ +# PR Review + Auto-Fix +# +# A self-healing code review pipeline. Reviews a PR from multiple angles, +# identifies issues, and automatically fixes them in a loop until the +# review passes. Satisfying to watch as issues get knocked down one by one. +# +# Usage: Run against any open PR in your repo. + +agent reviewer: + model: sonnet + prompt: """ +You are a senior code reviewer. You review code for: +- Correctness and logic errors +- Security vulnerabilities +- Performance issues +- Code style and readability + +Be specific. Reference exact file paths and line numbers. +Return a structured list of issues or "APPROVED" if none found. +""" + +agent security-reviewer: + model: opus # Security requires deep reasoning + prompt: """ +You are a security specialist. Focus exclusively on: +- Injection vulnerabilities (SQL, command, XSS) +- Authentication/authorization flaws +- Data exposure and privacy issues +- Cryptographic weaknesses + +If you find issues, they are HIGH priority. Be thorough. +""" + +agent fixer: + model: opus # Fixing requires understanding + execution + prompt: """ +You are a code fixer. Given an issue report: +1. Understand the root cause +2. Implement the minimal fix +3. Verify the fix addresses the issue +4. Create a clean commit + +Do NOT over-engineer. Fix exactly what's reported, nothing more. +""" + +agent captain: + model: sonnet # Orchestration role + persist: true + prompt: """ +You coordinate the PR review process. You: +- Track which issues have been found and fixed +- Decide when the PR is ready to merge +- Escalate to human if something is unfixable +""" + +# Get the PR diff +let pr_diff = session "Fetch the PR diff" + prompt: """ +Read the current PR: +1. Run: gh pr diff +2. Also get: gh pr view --json title,body,files +3. Return the complete diff and PR metadata +""" + +# Phase 1: Parallel multi-perspective review +session: captain + prompt: "Starting PR review. I'll coordinate multiple reviewers." + +parallel: + general_review = session: reviewer + prompt: "Review this PR for correctness, logic, and style issues" + context: pr_diff + + security_review = session: security-reviewer + prompt: "Security audit this PR. Flag any vulnerabilities." + context: pr_diff + + test_check = session "Check test coverage" + prompt: """ +Analyze the PR: +1. What code changed? +2. Are there tests for the changes? +3. Run existing tests: npm test / pytest / cargo test +Return: test status and coverage gaps +""" + context: pr_diff + +# Phase 2: Captain synthesizes and prioritizes +let issues = resume: captain + prompt: """ +Synthesize all review feedback into a prioritized issue list. +Format each issue as: +- ID: issue-N +- Severity: critical/high/medium/low +- File: path/to/file.ts +- Line: 42 +- Issue: description +- Fix: suggested approach + +If all reviews passed, return "ALL_CLEAR". +""" + context: { general_review, security_review, test_check } + +# Phase 3: Auto-fix loop +loop until **all issues are resolved or unfixable** (max: 10): + + if **there are no remaining issues**: + resume: captain + prompt: "All issues resolved! Summarize what was fixed." + else: + # Pick the highest priority unfixed issue + let current_issue = resume: captain + prompt: "Select the next highest priority issue to fix." + context: issues + + # Attempt the fix + try: + session: fixer + prompt: """ +Fix this issue: +{current_issue} + +Steps: +1. Read the file +2. Understand the context +3. Implement the fix +4. Run tests to verify +5. Commit with message: "fix: [issue description]" +""" + context: current_issue + retry: 2 + backoff: exponential + + # Mark as fixed + resume: captain + prompt: "Issue fixed. Update tracking and check remaining issues." + context: current_issue + + catch as fix_error: + # Escalate unfixable issues + resume: captain + prompt: """ +Fix attempt failed. Determine if this is: +1. Retryable with different approach +2. Needs human intervention +3. A false positive (not actually an issue) + +Update issue status accordingly. +""" + context: { current_issue, fix_error } + +# Phase 4: Final verification +let final_review = session: reviewer + prompt: "Final review pass. Verify all fixes are correct and complete." + +resume: captain + prompt: """ +PR Review Complete! + +Generate final report: +- Issues found: N +- Issues fixed: N +- Issues requiring human review: N +- Recommendation: MERGE / NEEDS_ATTENTION / BLOCK + +If ready, run: gh pr review --approve +""" + context: final_review diff --git a/extensions/open-prose/skills/prose/examples/34-content-pipeline.prose b/extensions/open-prose/skills/prose/examples/34-content-pipeline.prose new file mode 100644 index 0000000000000000000000000000000000000000..1b77cbff3bcf874540446a7871b40b5749a188d4 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/34-content-pipeline.prose @@ -0,0 +1,204 @@ +# Content Creation Pipeline +# +# From idea to published content in one run. Researches a topic in parallel, +# writes a blog post, refines it through editorial review, and generates +# social media posts. Watch an entire content operation happen automatically. +# +# Usage: Provide a topic and watch the content materialize. + +input topic: "The topic to create content about" +input audience: "Target audience (e.g., 'developers', 'executives', 'general')" + +agent researcher: + model: opus # Deep research requires reasoning + skills: ["web-search"] + prompt: """ +You are a research specialist. For any topic: +1. Find authoritative sources +2. Identify key facts and statistics +3. Note interesting angles and hooks +4. Cite your sources + +Return structured research with citations. +""" + +agent writer: + model: opus # Writing is hard work + prompt: """ +You are a skilled technical writer. You write: +- Clear, engaging prose +- Well-structured articles with headers +- Content appropriate for the target audience +- With a distinctive but professional voice + +Avoid jargon unless writing for experts. +""" + +agent editor: + model: sonnet + persist: true + prompt: """ +You are a senior editor. You review content for: +- Clarity and flow +- Factual accuracy +- Engagement and hook strength +- Appropriate length and structure + +Be constructive. Suggest specific improvements. +""" + +agent social-strategist: + model: sonnet + prompt: """ +You create social media content. For each platform: +- Twitter/X: Punchy, hooks, threads if needed +- LinkedIn: Professional, insight-focused +- Hacker News: Technical, understated, genuine + +Match the culture of each platform. Never be cringe. +""" + +# Phase 1: Parallel research from multiple angles +session "Research phase starting for: {topic}" + +parallel: + core_research = session: researcher + prompt: """ +Deep research on: {topic} + +Find: +- Current state of the art +- Recent developments (last 6 months) +- Key players and their positions +- Statistics and data points +""" + + competitive_landscape = session: researcher + prompt: """ +Competitive/comparative research on: {topic} + +Find: +- Alternative approaches or solutions +- Pros and cons of different options +- What experts recommend +""" + + human_interest = session: researcher + prompt: """ +Human interest research on: {topic} + +Find: +- Real-world case studies +- Success and failure stories +- Quotes from practitioners +- Surprising or counterintuitive findings +""" + +# Phase 2: Synthesize research +let research_synthesis = session "Synthesize all research" + prompt: """ +Combine all research into a unified brief: +1. Key thesis/angle for the article +2. Supporting evidence ranked by strength +3. Narrative arc suggestion +4. Potential hooks and headlines + +Target audience: {audience} +""" + context: { core_research, competitive_landscape, human_interest } + +# Phase 3: Write first draft +let draft = session: writer + prompt: """ +Write a blog post on: {topic} + +Target: {audience} +Length: 1500-2000 words +Structure: Hook, context, main points, examples, conclusion + +Use the research provided. Cite sources where appropriate. +""" + context: research_synthesis + +# Phase 4: Editorial loop +session: editor + prompt: "Beginning editorial review. I'll track revisions." + +loop until **the article meets publication standards** (max: 4): + let critique = resume: editor + prompt: """ +Review this draft critically: +1. What works well? +2. What needs improvement? +3. Specific suggestions (be actionable) +4. Overall verdict: READY / NEEDS_REVISION + +Be demanding but fair. +""" + context: draft + + if **the article needs revision**: + draft = session: writer + prompt: """ +Revise the article based on editorial feedback. +Address each point specifically. +Maintain what's working well. +""" + context: { draft, critique } + +# Phase 5: Generate social media variants +parallel: + twitter_content = session: social-strategist + prompt: """ +Create Twitter/X content to promote this article: +1. Main announcement tweet (punchy, with hook) +2. 5-tweet thread extracting key insights +3. 3 standalone insight tweets for later + +Include placeholder for article link. +""" + context: draft + + linkedin_post = session: social-strategist + prompt: """ +Create a LinkedIn post for this article: +- Professional but not boring +- Lead with insight, not announcement +- 150-300 words +- End with genuine question for engagement +""" + context: draft + + hn_submission = session: social-strategist + prompt: """ +Create Hacker News submission: +- Title: factual, not clickbait, <80 chars +- Suggested comment: genuine, adds context, not promotional + +HN culture: technical, skeptical, hates marketing speak. +""" + context: draft + +# Phase 6: Package everything +output article = draft +output social = { twitter_content, linkedin_post, hn_submission } + +resume: editor + prompt: """ +Content Pipeline Complete! + +Final package: +1. Article: {draft length} words, {revision count} revisions +2. Twitter: thread + standalone tweets +3. LinkedIn: professional post +4. HN: submission ready + +Recommended publication order: +1. Publish article +2. HN submission (wait for feedback) +3. Twitter thread +4. LinkedIn (next business day AM) + +All files saved to ./content-output/ +""" + context: { draft, twitter_content, linkedin_post, hn_submission } diff --git a/extensions/open-prose/skills/prose/examples/35-feature-factory.prose b/extensions/open-prose/skills/prose/examples/35-feature-factory.prose new file mode 100644 index 0000000000000000000000000000000000000000..c2a3c1acca29c59388d69c4ee1ae219455501fd3 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/35-feature-factory.prose @@ -0,0 +1,296 @@ +# Feature Factory +# +# From user story to deployed feature. A captain agent coordinates a team +# of specialists to design, implement, test, and document a complete feature. +# Watch an entire engineering team's workflow automated. +# +# Usage: Describe a feature and watch it get built. + +input feature: "Description of the feature to implement" +input codebase_context: "Brief description of the codebase (optional)" + +# The Captain: Coordinates everything, maintains context across the build +agent captain: + model: sonnet + persist: project # Remembers across features + prompt: """ +You are the Tech Lead coordinating feature development. + +Your responsibilities: +- Break features into implementable tasks +- Review all work before it merges +- Maintain architectural consistency +- Make technical decisions when needed +- Keep the build moving forward + +You've worked on this codebase before. Reference prior decisions. +""" + +# Specialists +agent architect: + model: opus + prompt: """ +You are a software architect. You design systems that are: +- Simple (no unnecessary complexity) +- Extensible (but not over-engineered) +- Consistent with existing patterns + +Produce clear technical designs with file paths and interfaces. +""" + +agent implementer: + model: opus + prompt: """ +You are a senior developer. You write: +- Clean, idiomatic code +- Following existing project patterns +- With clear variable names and structure +- Minimal but sufficient comments + +You implement exactly what's specified, nothing more. +""" + +agent tester: + model: sonnet + prompt: """ +You are a QA engineer. You write: +- Unit tests for individual functions +- Integration tests for workflows +- Edge case tests +- Clear test names that document behavior + +Aim for high coverage of the new code. +""" + +agent documenter: + model: sonnet + prompt: """ +You are a technical writer. You create: +- Clear API documentation +- Usage examples +- README updates +- Inline JSDoc/docstrings where needed + +Match existing documentation style. +""" + +# ============================================================================ +# Phase 1: Understand the codebase +# ============================================================================ + +session: captain + prompt: """ +Starting feature implementation: {feature} + +First, let me understand the current codebase. +""" + +let codebase_analysis = session "Analyze codebase structure" + prompt: """ +Explore the codebase to understand: +1. Directory structure and organization +2. Key patterns used (state management, API style, etc.) +3. Testing approach +4. Where this feature would fit + +Use Glob and Read tools to explore. Be thorough but efficient. +""" + context: codebase_context + +# ============================================================================ +# Phase 2: Design +# ============================================================================ + +let design = session: architect + prompt: """ +Design the implementation for: {feature} + +Based on the codebase analysis, produce: +1. High-level approach (2-3 sentences) +2. Files to create/modify (with paths) +3. Key interfaces/types to define +4. Integration points with existing code +5. Potential risks or decisions needed + +Keep it simple. Match existing patterns. +""" + context: { feature, codebase_analysis } + +# Captain reviews design +let design_approved = resume: captain + prompt: """ +Review this design: +- Does it fit our architecture? +- Is it the simplest approach? +- Any risks or concerns? +- Any decisions I need to make? + +Return APPROVED or specific concerns. +""" + context: design + +if **design needs adjustment**: + design = session: architect + prompt: "Revise design based on tech lead feedback" + context: { design, design_approved } + +# ============================================================================ +# Phase 3: Implementation +# ============================================================================ + +resume: captain + prompt: "Design approved. Breaking into implementation tasks." + context: design + +let tasks = resume: captain + prompt: """ +Break the design into ordered implementation tasks. +Each task should be: +- Small enough to implement in one session +- Have clear acceptance criteria +- List file(s) to modify + +Return as numbered list with dependencies. +""" + context: design + +# Implement each task sequentially +for task in tasks: + resume: captain + prompt: "Starting task: {task}" + + let implementation = session: implementer + prompt: """ +Implement this task: +{task} + +Follow the design spec. Match existing code patterns. +Write the actual code using Edit/Write tools. +""" + context: { task, design, codebase_analysis } + retry: 2 + backoff: exponential + + # Captain reviews each piece + let review = resume: captain + prompt: """ +Review this implementation: +- Does it match the design? +- Code quality acceptable? +- Any issues to fix before continuing? + +Be specific if changes needed. +""" + context: { task, implementation } + + if **implementation needs fixes**: + session: implementer + prompt: "Fix issues noted in review" + context: { implementation, review } + +# ============================================================================ +# Phase 4: Testing +# ============================================================================ + +resume: captain + prompt: "Implementation complete. Starting test phase." + +let tests = session: tester + prompt: """ +Write tests for the new feature: +1. Unit tests for new functions/methods +2. Integration tests for the feature flow +3. Edge cases and error handling + +Use the project's existing test framework and patterns. +Actually create the test files. +""" + context: { design, codebase_analysis } + +# Run tests +let test_results = session "Run test suite" + prompt: """ +Run all tests: +1. npm test / pytest / cargo test (whatever this project uses) +2. Report results +3. If failures, identify which tests failed and why +""" + +loop until **all tests pass** (max: 5): + if **tests are failing**: + let fix = session: implementer + prompt: "Fix failing tests. Either fix the code or fix the test if it's wrong." + context: test_results + + test_results = session "Re-run tests after fix" + prompt: "Run tests again and report results" + +# ============================================================================ +# Phase 5: Documentation +# ============================================================================ + +resume: captain + prompt: "Tests passing. Final phase: documentation." + +parallel: + api_docs = session: documenter + prompt: """ +Document the new feature's API: +- Function/method signatures +- Parameters and return values +- Usage examples +- Add to existing docs structure +""" + context: design + + readme_update = session: documenter + prompt: """ +Update README if needed: +- Add feature to feature list +- Add usage example if user-facing +- Update any outdated sections +""" + context: { design, codebase_analysis } + +# ============================================================================ +# Phase 6: Final Review & Commit +# ============================================================================ + +resume: captain + prompt: """ +Feature complete! Final review: + +1. All tasks implemented +2. Tests passing +3. Documentation updated + +Prepare final summary and create commit. +""" + context: { design, tests, api_docs } + +session "Create feature commit" + prompt: """ +Stage all changes and create a well-structured commit: +1. git add -A +2. git commit with message following conventional commits: + feat: {feature short description} + + - Implementation details + - Tests added + - Docs updated +""" + +# Final report +output summary = resume: captain + prompt: """ +Feature Factory Complete! + +Generate final report: +- Feature: {feature} +- Files created/modified: [list] +- Tests added: [count] +- Time from start to finish +- Any notes for future work + +This feature is ready for PR review. +""" diff --git a/extensions/open-prose/skills/prose/examples/36-bug-hunter.prose b/extensions/open-prose/skills/prose/examples/36-bug-hunter.prose new file mode 100644 index 0000000000000000000000000000000000000000..81b595178116481c8e4165523edbd6f45160de45 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/36-bug-hunter.prose @@ -0,0 +1,237 @@ +# Bug Hunter +# +# Given a bug report or error, systematically investigate, diagnose, +# and fix it. Watch the AI think through the problem like a senior +# developer - gathering evidence, forming hypotheses, and verifying fixes. +# +# Usage: Paste an error message or describe a bug. + +input bug_report: "Error message, stack trace, or bug description" + +agent detective: + model: opus + persist: true + prompt: """ +You are a debugging specialist. Your approach: +1. Gather evidence before forming hypotheses +2. Follow the data, not assumptions +3. Verify each hypothesis with tests +4. Document your reasoning for future reference + +Think out loud. Show your work. +""" + +agent surgeon: + model: opus + prompt: """ +You are a code surgeon. You make precise, minimal fixes: +- Change only what's necessary +- Preserve existing behavior +- Add regression tests +- Leave code cleaner than you found it + +No drive-by refactoring. Fix the bug, nothing more. +""" + +# ============================================================================ +# Phase 1: Evidence Gathering +# ============================================================================ + +session: detective + prompt: "New bug to investigate. Let me gather initial evidence." + +parallel: + # Parse the error + error_analysis = session: detective + prompt: """ +Analyze this bug report/error: +{bug_report} + +Extract: +- Error type and message +- Stack trace (if present) +- File paths and line numbers +- Any patterns or keywords +""" + + # Search for related code + code_context = session "Search for related code" + prompt: """ +Based on the error, search the codebase: +1. Find the file(s) mentioned in the error +2. Find related files that might be involved +3. Look for similar patterns that might have the same bug +4. Check git history for recent changes to these files + +Use Glob and Grep to search efficiently. +""" + context: bug_report + + # Check for known issues + prior_knowledge = session "Check for similar issues" + prompt: """ +Search for similar issues: +1. Check git log for related commits +2. Search for TODO/FIXME comments nearby +3. Look for any existing tests that might be relevant + +Report what you find. +""" + context: bug_report + +# ============================================================================ +# Phase 2: Diagnosis +# ============================================================================ + +resume: detective + prompt: """ +Synthesize all evidence into hypotheses. + +For each hypothesis: +- State the theory +- Supporting evidence +- How to verify +- Confidence level (high/medium/low) + +Start with the most likely cause. +""" + context: { error_analysis, code_context, prior_knowledge } + +let hypotheses = resume: detective + prompt: "List hypotheses in order of likelihood. We'll test the top one first." + +# ============================================================================ +# Phase 3: Hypothesis Testing +# ============================================================================ + +loop until **root cause confirmed** (max: 5): + let current_hypothesis = resume: detective + prompt: "Select the next most likely hypothesis to test." + context: hypotheses + + # Design and run a test + let test_result = session: detective + prompt: """ +Test this hypothesis: {current_hypothesis} + +Design a verification approach: +1. What would we expect to see if this is the cause? +2. How can we reproduce it? +3. Run the test and report results + +Use actual code execution to verify. +""" + context: { current_hypothesis, code_context } + + # Evaluate result + choice **based on the test results**: + option "Hypothesis confirmed": + resume: detective + prompt: """ +Root cause confirmed: {current_hypothesis} + +Document: +- The exact cause +- Why it happens +- The conditions that trigger it +""" + context: test_result + + option "Hypothesis disproven": + resume: detective + prompt: """ +Hypothesis disproven. Update our understanding: +- What did we learn? +- How does this change remaining hypotheses? +- What should we test next? +""" + context: test_result + hypotheses = resume: detective + prompt: "Re-rank remaining hypotheses based on new evidence" + + option "Inconclusive - need more evidence": + resume: detective + prompt: "What additional evidence do we need? How do we get it?" + context: test_result + +# ============================================================================ +# Phase 4: Fix Implementation +# ============================================================================ + +let diagnosis = resume: detective + prompt: """ +Final diagnosis summary: +- Root cause: [what] +- Location: [where] +- Trigger: [when/how] +- Impact: [what breaks] + +Hand off to surgeon for the fix. +""" + +session: surgeon + prompt: """ +Implement the fix for this bug: + +{diagnosis} + +Steps: +1. Read and understand the code around the bug +2. Implement the minimal fix +3. Verify the fix doesn't break other things +4. Create a test that would have caught this bug +""" + context: { diagnosis, code_context } + +# Run tests to verify +let verification = session "Verify the fix" + prompt: """ +Verify the fix works: +1. Run the reproduction case - should now pass +2. Run the full test suite - should all pass +3. Check for any edge cases we might have missed +""" + +if **tests are failing**: + loop until **all tests pass** (max: 3): + session: surgeon + prompt: "Fix is incomplete. Adjust based on test results." + context: verification + + verification = session "Re-verify after adjustment" + prompt: "Run tests again and report" + +# ============================================================================ +# Phase 5: Documentation & Commit +# ============================================================================ + +session "Create bug fix commit" + prompt: """ +Create a well-documented commit: + +git commit with message: +fix: [brief description] + +Root cause: [what was wrong] +Fix: [what we changed] +Test: [what test we added] + +Closes #[issue number if applicable] +""" + +output report = resume: detective + prompt: """ +Bug Hunt Complete! + +Investigation Report: +- Bug: {bug_report summary} +- Root Cause: {diagnosis} +- Fix: [files changed] +- Tests Added: [what tests] +- Time to Resolution: [duration] + +Lessons Learned: +- How could we have caught this earlier? +- Are there similar patterns to check? +- Should we add any tooling/linting? +""" diff --git a/extensions/open-prose/skills/prose/examples/37-the-forge.prose b/extensions/open-prose/skills/prose/examples/37-the-forge.prose new file mode 100644 index 0000000000000000000000000000000000000000..586d3ac8b8d4a05644db95defaa9859f0e5cbcb9 --- /dev/null +++ b/extensions/open-prose/skills/prose/examples/37-the-forge.prose @@ -0,0 +1,1474 @@ +# The Forge: Browser from Nothing +# +# Watch AI agents forge a working web browser from raw code. +# No frameworks. No shortcuts. Just Rust, a window, and fire. +# +# Target: A browser that can: +# - Fetch web pages over HTTPS +# - Parse HTML and CSS +# - Execute JavaScript +# - Render to a native window +# +# This is a multi-day build. The smith remembers everything. +# +# Usage: Just run it and watch a browser get built. + +input test_url: "https://prose.md" + +# ============================================================================= +# THE FORGE: Where Browsers Are Born +# ============================================================================= +# +# The Forge is simple: five agents, eight phases, one browser. +# +# The Forge is a straight +# pipeline. The Smith coordinates, specialists execute, tests validate. +# When each phase completes, we have something that works. +# +# The metaphor: +# - Smith: Master craftsman who sees the whole blade +# - Smelter: Extracts designs from specifications +# - Hammer: Shapes raw code into working metal +# - Quench: Tests and hardens (tempers) each piece +# - Crucible: Where the hardest work happens (the JS engine) + +# ============================================================================= +# AGENTS +# ============================================================================= + +# The Smith: Master craftsman, sees the whole blade +# Persists across the entire multi-day build +agent smith: + model: sonnet + persist: project + prompt: """ +You are the Smith, master of The Forge. + +You've built browsers before. You know every component, every tradeoff, +every place where corners can be cut and where they absolutely cannot. + +Your role: +- Maintain the vision: a working browser from scratch +- Coordinate the specialists without micromanaging +- Make technical decisions when the path forks +- Track what's built and what remains +- Remember everything across sessions + +You speak in the language of the forge: heat, metal, shaping, tempering. +But you mean code, architecture, implementation, testing. + +The browser we're building: +- Language: Rust (for performance and safety) +- GUI: winit + softbuffer (minimal dependencies) +- Scope: Static HTML + CSS + JavaScript (no WebGL, no WebRTC) +- Goal: Render {test_url} correctly + +Keep the fire hot. The blade is taking shape. +""" + +# The Smelter: Turns specifications into designs +agent smelter: + model: opus + prompt: """ +You are the Smelter. You extract pure design from the ore of specifications. + +Your job: Read specs (W3C, ECMA, MDN), understand them deeply, and produce +clear technical designs that the Hammer can implement. + +Your output: +- Data structures with Rust types +- Algorithms in pseudocode +- Interface boundaries +- Key edge cases to handle + +You don't write implementation code. You write blueprints. +Make them precise enough that implementation is mechanical. +""" + +# The Hammer: Shapes code into working components +agent hammer: + model: opus + prompt: """ +You are the Hammer. You shape raw code into working metal. + +Your job: Take the Smelter's designs and forge them into working Rust code. +Every line must compile. Every function must work. No pseudocode. + +Your standards: +- Clean, idiomatic Rust +- Minimal unsafe blocks (document each one) +- No external dependencies except: winit, softbuffer +- Comprehensive error handling +- Clear module structure + +You don't design. You don't test. You forge. +""" + +# The Quench: Tests and hardens each piece +agent quench: + model: sonnet + prompt: """ +You are the Quench. You temper the metal so it doesn't shatter. + +Your job: Write tests that prove each component works. Find the edge cases. +Find the bugs. Find the places where the metal is weak. + +Your process: +- Unit tests for each function +- Integration tests for each module +- Regression tests for each bug found +- Document what each test proves + +When you find a flaw, report it clearly. The Hammer will fix it. +A blade that breaks is worse than no blade at all. +""" + +# The Crucible: Where the hardest work happens (JS engine) +agent crucible: + model: opus + persist: true + prompt: """ +You are the Crucible. The hottest part of The Forge. + +Your domain: The JavaScript engine. The hardest component to build. +This requires understanding that the other agents don't have: +- Lexical scoping and closures +- Prototype chains +- The event loop +- Just enough of the spec to run real-world code + +You work closely with the Smith. The JS engine will take multiple phases. +Your memory persists so you can build on what came before. + +This is where browsers are born or die. Make it work. +""" + +# ============================================================================= +# PHASE 0: IGNITE THE FORGE +# ============================================================================= + +session: smith + prompt: """ +The Forge ignites. + +We're building a web browser from nothing. In Rust. With a GUI. +Including a JavaScript engine. This is not a toy - it will actually work. + +Let me take stock of what we're about to create: + +1. Networking: HTTP/HTTPS client +2. Parsing: HTML tokenizer, HTML parser, CSS tokenizer, CSS parser, JS lexer, JS parser +3. DOM: Document object model with all standard interfaces +4. CSSOM: CSS object model, selector matching, cascade +5. Style: Computed styles, inheritance, defaulting +6. Layout: Box model, block layout, inline layout, text layout +7. Paint: Display lists, rasterization +8. JavaScript: Lexer, parser, bytecode compiler, virtual machine, builtins +9. Bindings: DOM API exposed to JavaScript +10. Shell: Window, event loop, URL bar + +This is months of work for a team. We'll do it in days. + +First: set up the project structure. +""" + +# Initialize the Rust project +session: hammer + prompt: """ +Create the Rust project structure for the browser. + +Commands to run: +```bash +cargo new browser --name browser +cd browser +``` + +Create Cargo.toml with dependencies: +- winit = "0.29" (windowing) +- softbuffer = "0.4" (pixel buffer) +- rustls = "0.23" (TLS, for HTTPS) +- url = "2" (URL parsing - this one's okay to use) + +Create the module structure: +``` +src/ + main.rs # Entry point + lib.rs # Library root + net/ # Networking + mod.rs + http.rs + tls.rs + html/ # HTML parsing + mod.rs + tokenizer.rs + parser.rs + dom.rs + css/ # CSS parsing + mod.rs + tokenizer.rs + parser.rs + cssom.rs + selector.rs + style/ # Style resolution + mod.rs + cascade.rs + computed.rs + layout/ # Layout engine + mod.rs + box_model.rs + block.rs + inline.rs + text.rs + paint/ # Painting + mod.rs + display_list.rs + rasterizer.rs + js/ # JavaScript engine + mod.rs + lexer.rs + parser.rs + ast.rs + compiler.rs + vm.rs + value.rs + builtins.rs + gc.rs + bindings/ # JS-DOM bindings + mod.rs + document.rs + element.rs + console.rs + shell/ # Browser shell + mod.rs + window.rs + events.rs +``` + +Create stub files for each module. Ensure `cargo build` succeeds. +""" + +session: quench + prompt: """ +Verify the project is set up correctly: +1. Run `cargo build` - must succeed +2. Run `cargo test` - must succeed (even with no tests yet) +3. Verify all modules are properly linked from lib.rs + +Report any issues. +""" + +resume: smith + prompt: "Project structure complete. The forge is lit. Moving to Phase 1." + +# ============================================================================= +# PHASE 1: NETWORKING - The Ore +# ============================================================================= + +session: smith + prompt: """ +Phase 1: Networking + +Before we can render a page, we must fetch it. The networking layer is +the ore we'll smelt into a browser. + +We need: +- HTTP/1.1 client (GET requests, headers, redirects) +- TLS support via rustls +- Chunked transfer encoding +- Basic cookie handling (just enough to work) + +This is the foundation. No browser without bytes from the network. +""" + +let http_design = session: smelter + prompt: """ +Design the HTTP client. + +Reference: RFC 9110 (HTTP Semantics), RFC 9112 (HTTP/1.1) + +Design: +1. Connection management (keep-alive, pooling) +2. Request building (method, URL, headers, body) +3. Response parsing (status, headers, body) +4. Redirect following (3xx responses) +5. Chunked transfer-encoding +6. TLS via rustls + +Output Rust types for: +- HttpRequest +- HttpResponse +- HttpClient +- Error types + +Keep it minimal but correct. We're not building curl. +""" + +session: hammer + prompt: """ +Implement the HTTP client. + +Files: +- src/net/mod.rs +- src/net/http.rs +- src/net/tls.rs + +Follow the design. Handle errors properly. Make it work. + +Test manually: fetch https://example.com and print the body. +""" + context: http_design + +session: quench + prompt: """ +Test the HTTP client: +1. Fetch http://example.com (no TLS) +2. Fetch https://example.com (with TLS) +3. Test redirect following (http → https) +4. Test chunked encoding +5. Test error cases (bad host, timeout, etc.) + +Write tests in src/net/tests.rs. Run them. +""" + +loop until **all networking tests pass** (max: 5): + if **there are test failures**: + session: hammer + prompt: "Fix the networking bugs found in testing." + session: quench + prompt: "Re-run networking tests." + +resume: smith + prompt: "Phase 1 complete. We can fetch pages. The ore is ready." + +# ============================================================================= +# PHASE 2: HTML PARSING - The Smelt +# ============================================================================= + +resume: smith + prompt: """ +Phase 2: HTML Parsing + +Raw HTML is just text. We need to smelt it into a Document Object Model. + +Two stages: +1. Tokenizer: HTML text → Tokens (start tag, end tag, text, comment, etc.) +2. Parser: Tokens → DOM tree + +The HTML5 spec is complex, but we can simplify: +- Handle well-formed HTML (don't worry about error recovery) +- Support common elements: html, head, body, div, span, p, a, img, script, style +- Parse attributes correctly +- Handle self-closing tags +- Handle text content + +This is where the raw ore becomes workable metal. +""" + +let html_tokenizer_design = session: smelter + prompt: """ +Design the HTML tokenizer. + +Reference: https://html.spec.whatwg.org/multipage/parsing.html#tokenization + +Simplified state machine: +- Data state (default) +- Tag open state +- Tag name state +- Attribute name state +- Attribute value state (quoted and unquoted) +- Self-closing state +- Comment state + +Tokens: +- DOCTYPE +- StartTag { name, attributes, self_closing } +- EndTag { name } +- Character { data } +- Comment { data } +- EndOfFile + +Output Rust types and state machine transitions. +""" + +session: hammer + prompt: """ +Implement the HTML tokenizer. + +File: src/html/tokenizer.rs + +Create a streaming tokenizer that yields tokens. +Handle: +- Basic tags:
,
+- Attributes:
+- Self-closing:
, +- Text content +- Comments: +- Script/style raw text mode + +Make it work for real HTML from example.com. +""" + context: html_tokenizer_design + +let html_parser_design = session: smelter + prompt: """ +Design the HTML parser (tree builder). + +Input: Token stream +Output: DOM tree + +DOM types: +- Document (root) +- Element { tag_name, attributes, children } +- Text { content } +- Comment { content } + +Tree building algorithm (simplified): +- Maintain stack of open elements +- On StartTag: create element, push to stack, append to parent +- On EndTag: pop from stack (with simple matching) +- On Character: create/extend text node, append to current element +- On Comment: create comment, append to current element + +Handle implicit closing (

can close

). +""" + +session: hammer + prompt: """ +Implement the HTML parser. + +File: src/html/parser.rs +File: src/html/dom.rs + +DOM types go in dom.rs. Parser goes in parser.rs. + +Create: +- Node enum (Document, Element, Text, Comment) +- Element struct with children, parent references (use indices, not Rc) +- Document struct that owns all nodes +- Parser that builds the tree + +Handle the quirks:

closing, void elements, etc. +""" + context: html_parser_design + +session: quench + prompt: """ +Test HTML parsing: + +Test cases: +1. Minimal: Hello +2. Nested:

Deep
+3. Attributes: Link +4. Self-closing:
+5. Comments: +6. Text nodes:

Hello world!

+7. Real page: parse the HTML from https://example.com + +Write tests. Verify the DOM tree is correct. +""" + +loop until **all HTML parsing tests pass** (max: 5): + if **there are test failures**: + session: hammer + prompt: "Fix the HTML parsing bugs." + session: quench + prompt: "Re-run HTML parsing tests." + +resume: smith + prompt: "Phase 2 complete. We can parse HTML into a DOM. The smelt is done." + +# ============================================================================= +# PHASE 3: CSS PARSING - The Alloy +# ============================================================================= + +resume: smith + prompt: """ +Phase 3: CSS Parsing + +A DOM without styles is shapeless metal. CSS gives it form. + +Two stages: +1. Tokenizer: CSS text → Tokens +2. Parser: Tokens → Stylesheet (rules, selectors, declarations) + +We need enough CSS to render real pages: +- Type selectors: div, p, a +- Class selectors: .class +- ID selectors: #id +- Combinators: descendant, child, sibling +- Properties: display, color, background, margin, padding, border, width, height, font-size + +This is the alloy that strengthens the blade. +""" + +let css_tokenizer_design = session: smelter + prompt: """ +Design the CSS tokenizer. + +Reference: https://www.w3.org/TR/css-syntax-3/#tokenization + +Token types: +- Ident +- Function +- AtKeyword +- Hash +- String +- Number +- Dimension +- Percentage +- Whitespace +- Colon, Semicolon, Comma +- Braces, Parens, Brackets +- Delim (any other character) + +Output Rust types and tokenization rules. +""" + +session: hammer + prompt: """ +Implement the CSS tokenizer. + +File: src/css/tokenizer.rs + +Handle real CSS syntax including: +- Identifiers: color, background-color +- Numbers: 10, 3.14, -5 +- Dimensions: 10px, 2em, 100% +- Strings: "hello", 'world' +- Hash: #fff, #header +- Functions: rgb(255, 0, 0) +""" + context: css_tokenizer_design + +let css_parser_design = session: smelter + prompt: """ +Design the CSS parser. + +CSSOM types: +- Stylesheet { rules } +- Rule { selectors, declarations } +- Selector (type, class, id, combinator) +- Declaration { property, value } +- Value (keyword, length, color, number, etc.) + +Parser produces a Stylesheet from the token stream. + +Selector parsing: +- Simple: div, .class, #id +- Compound: div.class#id +- Complex: div > p, div p, div + p, div ~ p + +Declaration parsing: +- Property: identifier +- Value: sequence of tokens until ; or } +""" + +session: hammer + prompt: """ +Implement the CSS parser. + +File: src/css/parser.rs +File: src/css/cssom.rs + +Handle: +- Rule sets: selector { declarations } +- Multiple selectors: h1, h2, h3 { ... } +- Various value types: keywords, lengths, colors, functions +- Shorthand properties (margin: 10px = all four sides) +""" + context: css_parser_design + +session: quench + prompt: """ +Test CSS parsing: + +Test cases: +1. Simple rule: div { color: red; } +2. Multiple selectors: h1, h2 { font-size: 24px; } +3. Class and ID: .class { } #id { } +4. Combinators: div > p { }, div p { } +5. Complex values: margin: 10px 20px; background-color: rgb(255, 0, 0); +6. Real stylesheet: parse a basic CSS file + +Write tests. Verify the CSSOM is correct. +""" + +loop until **all CSS parsing tests pass** (max: 5): + if **there are test failures**: + session: hammer + prompt: "Fix the CSS parsing bugs." + session: quench + prompt: "Re-run CSS parsing tests." + +resume: smith + prompt: "Phase 3 complete. We can parse CSS. The alloy is mixed." + +# ============================================================================= +# PHASE 4: STYLE RESOLUTION - The Shape +# ============================================================================= + +resume: smith + prompt: """ +Phase 4: Style Resolution + +We have a DOM. We have styles. Now we must match them. + +For each element in the DOM: +1. Find all rules whose selectors match this element +2. Apply the cascade (specificity, order) +3. Inherit from parent where appropriate +4. Apply default values for anything unset + +This gives us a "computed style" for every element. +This is where the blade takes its shape. +""" + +let style_design = session: smelter + prompt: """ +Design the style resolution system. + +Components: +1. Selector matching: does this selector match this element? +2. Specificity calculation: (id count, class count, type count) +3. Cascade: sort matching rules by specificity, then order +4. Inheritance: some properties inherit (color), some don't (border) +5. Initial values: defaults for unset properties + +ComputedStyle struct: +- display: DisplayType (block, inline, none) +- position: Position (static, relative, absolute) +- width, height: Dimension (auto, length) +- margin, padding, border: Sides +- color, background_color: Color +- font_size: Length +- (add more as needed) + +The matcher should be efficient - it runs for every element. +""" + +session: hammer + prompt: """ +Implement style resolution. + +File: src/css/selector.rs (selector matching) +File: src/style/cascade.rs (cascade and specificity) +File: src/style/computed.rs (ComputedStyle and inheritance) + +Create: +- SelectorMatcher that can test if a selector matches an element +- Specificity calculation +- Cascade resolver that takes DOM + Stylesheets → styled DOM +- Inheritance and default values +""" + context: style_design + +session: quench + prompt: """ +Test style resolution: + +Test cases: +1. Type selector matches: div matches
+2. Class selector: .foo matches
+3. ID selector: #bar matches
+4. Specificity: #id beats .class beats type +5. Cascade order: later rule wins at equal specificity +6. Inheritance: color inherits, border doesn't +7. Defaults: display defaults to inline for span, block for div + +Write tests with DOM + CSS → expected computed styles. +""" + +loop until **all style resolution tests pass** (max: 5): + if **there are test failures**: + session: hammer + prompt: "Fix the style resolution bugs." + session: quench + prompt: "Re-run style resolution tests." + +resume: smith + prompt: "Phase 4 complete. Elements have computed styles. The shape emerges." + +# ============================================================================= +# PHASE 5: LAYOUT - The Forge +# ============================================================================= + +resume: smith + prompt: """ +Phase 5: Layout + +The heart of a browser. Where the real forging happens. + +Layout takes a styled DOM and produces a "layout tree" - boxes with +positions and sizes in pixels. + +Components: +1. Box generation: DOM elements → layout boxes +2. Block layout: vertical stacking with margins +3. Inline layout: horizontal flow with line breaking +4. Text layout: measuring and positioning text + +This is complex. We'll start simple: +- Block layout only (no inline/text initially) +- Then add inline and text + +The blade is taking its final form. +""" + +let layout_design = session: smelter + prompt: """ +Design the layout engine. + +Box types: +- BlockBox: vertical stacking (div, p, etc.) +- InlineBox: horizontal flow (span, a, etc.) +- TextRun: actual text content +- AnonymousBlock: for mixed block/inline content + +LayoutBox struct: +- box_type: BoxType +- dimensions: Dimensions { content, padding, border, margin } +- position: Point { x, y } +- children: Vec + +Layout algorithm (simplified): +1. Build box tree from styled DOM +2. Block layout: + - Calculate available width from parent + - Layout children top-to-bottom + - Height is sum of children heights (or specified) + - Handle margin collapsing +3. Inline layout: + - Flow boxes horizontally + - Break lines when exceeding width + - Vertical alignment within lines + +Output the types and algorithms. +""" + +session: hammer + prompt: """ +Implement the layout engine. + +File: src/layout/box_model.rs (box types and dimensions) +File: src/layout/block.rs (block layout) +File: src/layout/inline.rs (inline layout) +File: src/layout/text.rs (text measurement) +File: src/layout/mod.rs (main entry point) + +Start with block layout only. Make nested divs work. +Then add inline and text. + +Viewport size: 800x600 for now (we'll make it dynamic later). +""" + context: layout_design + +session: quench + prompt: """ +Test layout: + +Test cases: +1. Single div with fixed width/height +2. Nested divs (parent constrains child) +3. Auto width (fills parent) +4. Auto height (fits content) +5. Margin, padding, border (box model) +6. Margin collapsing between siblings +7. Block in inline (anonymous block generation) + +Verify box positions and dimensions are correct. +""" + +loop until **all layout tests pass** (max: 5): + if **there are test failures**: + session: hammer + prompt: "Fix the layout bugs." + session: quench + prompt: "Re-run layout tests." + +resume: smith + prompt: "Phase 5 complete. We have a layout tree. The blade is forged." + +# ============================================================================= +# PHASE 6: PAINTING - The Polish +# ============================================================================= + +resume: smith + prompt: """ +Phase 6: Painting + +The blade is forged. Now we polish it to a mirror shine. + +Painting turns a layout tree into pixels: +1. Build a display list (paint commands) +2. Rasterize the display list to a pixel buffer +3. Show the pixel buffer on screen + +We're using softbuffer for direct pixel access. No GPU acceleration. +Simple but it works. +""" + +let paint_design = session: smelter + prompt: """ +Design the painting system. + +Display list commands: +- FillRect { rect, color } +- DrawBorder { rect, widths, colors } +- DrawText { text, position, font_size, color } +- PushClip { rect } +- PopClip + +Rasterizer: +- Input: display list + viewport size +- Output: pixel buffer (Vec in ARGB format) + +For text, use a simple bitmap font (8x16 pixels per character). +We don't need fancy fonts for MVP. + +Paint order: +1. Background +2. Borders +3. Text/content +4. Children (recursive) +""" + +session: hammer + prompt: """ +Implement the painting system. + +File: src/paint/display_list.rs (display list types) +File: src/paint/rasterizer.rs (pixel buffer rendering) +File: src/paint/font.rs (simple bitmap font) + +Create: +- DisplayList with paint commands +- Build display list from layout tree +- Rasterize display list to ARGB pixel buffer +- Simple 8x16 bitmap font for ASCII text +""" + context: paint_design + +session: hammer + prompt: """ +Implement the window system. + +File: src/shell/window.rs (winit + softbuffer integration) +File: src/shell/events.rs (event handling) +File: src/main.rs (main entry point) + +Create a window that: +1. Opens with winit +2. Gets a pixel buffer with softbuffer +3. Renders our pixel buffer to the window +4. Handles close events + +Test: draw a colored rectangle on screen. +""" + +session: quench + prompt: """ +Test painting: + +Test cases: +1. Solid color background +2. Nested boxes with different colors +3. Borders (all four sides) +4. Text rendering (basic ASCII) +5. Full pipeline: HTML → DOM → Style → Layout → Paint → Window + +The window should show something! Verify visually. +""" + +loop until **the painting pipeline works** (max: 5): + if **there are issues**: + session: hammer + prompt: "Fix the painting issues." + session: quench + prompt: "Re-test painting." + +resume: smith + prompt: "Phase 6 complete. We can render to a window. The blade shines." + +# ============================================================================= +# PHASE 7: JAVASCRIPT - The Fire +# ============================================================================= + +resume: smith + prompt: """ +Phase 7: JavaScript + +This is where browsers separate from mere HTML viewers. +The JavaScript engine is the fire that brings the blade to life. + +We're not building V8. We're building something that works: +- Lexer: JS source → tokens +- Parser: tokens → AST +- Compiler: AST → bytecode +- VM: execute bytecode +- Builtins: Object, Array, Function, String, Number, console.log + +This is the hottest part of The Forge. The Crucible takes the lead. +""" + +# The Crucible handles the JS engine +session: crucible + prompt: """ +The Crucible fires up for the JavaScript engine. + +We're building a JS interpreter in Rust. Not a JIT compiler - just +an interpreter. But it needs to run real JavaScript. + +Scope: +- Variables: let, const, var +- Functions: declaration, expression, arrow +- Objects: literals, property access, methods +- Arrays: literals, indexing, methods +- Control flow: if, for, while, switch +- Operators: arithmetic, comparison, logical +- Strings and numbers +- console.log for output +- Basic DOM manipulation (later) + +What we're NOT building: +- Classes (use prototypes directly) +- async/await, generators, promises +- Regular expressions (skip for MVP) +- Modules (single script only) + +Let's start with the lexer. +""" + +let js_lexer_design = session: smelter + prompt: """ +Design the JavaScript lexer. + +Reference: ECMA-262 (but simplified) + +Token types: +- Identifiers: foo, bar, console +- Keywords: let, const, var, function, if, else, for, while, return, etc. +- Literals: numbers (42, 3.14), strings ("hello"), booleans (true, false), null +- Operators: + - * / % = == === != !== < > <= >= && || ! ++ -- +- Punctuation: ( ) { } [ ] ; , . : ? +- Comments: // and /* */ + +Handle: +- Unicode identifiers (at least ASCII letters) +- Automatic semicolon insertion (ASI) - track newlines + +Output token types and lexer state machine. +""" + +session: hammer + prompt: """ +Implement the JavaScript lexer. + +File: src/js/lexer.rs + +Create a lexer that produces tokens from JS source. +Handle all the token types from the design. +Track line/column for error messages. +""" + context: js_lexer_design + +let js_parser_design = session: smelter + prompt: """ +Design the JavaScript parser. + +Reference: ECMA-262 (simplified) + +AST node types: +- Program { statements } +- Statements: VarDecl, FunctionDecl, ExprStmt, If, For, While, Return, Block +- Expressions: Identifier, Literal, Binary, Unary, Call, Member, Assignment, Object, Array, Function + +Parser approach: recursive descent (Pratt parsing for expressions) + +Handle: +- Operator precedence +- Associativity +- Expression vs statement context +- Function hoisting (not strict mode) + +Output AST types and parser structure. +""" + +session: hammer + prompt: """ +Implement the JavaScript parser. + +File: src/js/parser.rs +File: src/js/ast.rs + +Create AST types in ast.rs. +Create recursive descent parser in parser.rs. +Use Pratt parsing for expression precedence. + +Test: parse console.log("Hello, World!"); +""" + context: js_parser_design + +resume: crucible + prompt: """ +Lexer and parser are done. Now the real work: execution. + +We have two choices: +1. Tree-walking interpreter (simple but slow) +2. Bytecode compiler + VM (more complex but faster) + +We'll do bytecode. It's more interesting and teaches more. + +Components: +- Value type: JS values (number, string, object, function, etc.) +- Compiler: AST → bytecode +- VM: execute bytecode with a stack +- Heap: objects live here +- GC: garbage collection (mark-sweep is fine) +""" + +let js_value_design = session: smelter + prompt: """ +Design JavaScript value representation. + +Value enum (NaN-boxed or tagged union): +- Number(f64) +- String(StringId) +- Boolean(bool) +- Null +- Undefined +- Object(ObjectId) +- Function(FunctionId) + +Object representation (property map): +- properties: HashMap +- prototype: Option + +Function representation: +- kind: Native | Bytecode +- bytecode: Vec (if bytecode) +- native: fn pointer (if native) +- closure: captured variables + +String interning for memory efficiency. +""" + +session: hammer + prompt: """ +Implement JavaScript values. + +File: src/js/value.rs +File: src/js/gc.rs (simple mark-sweep GC) + +Create: +- Value enum with all JS types +- Object struct with properties and prototype +- Heap that owns all objects +- Simple mark-sweep garbage collector + +The GC doesn't need to be fancy. Just functional. +""" + context: js_value_design + +let js_bytecode_design = session: smelter + prompt: """ +Design the bytecode instruction set. + +Opcodes (stack-based VM): +- Constants: LoadConst, LoadTrue, LoadFalse, LoadNull, LoadUndefined +- Variables: GetLocal, SetLocal, GetGlobal, SetGlobal +- Objects: GetProperty, SetProperty, CreateObject, CreateArray +- Arithmetic: Add, Sub, Mul, Div, Mod, Neg +- Comparison: Eq, StrictEq, Lt, Lte, Gt, Gte +- Logic: Not, And, Or +- Control: Jump, JumpIfFalse, JumpIfTrue +- Functions: Call, Return, CreateFunction +- Stack: Pop, Dup + +Bytecode format: +- 1 byte opcode +- Variable-length operands + +Compiler output: +- bytecode: Vec +- constants: Vec +- local_count: usize +""" + +session: hammer + prompt: """ +Implement the bytecode compiler. + +File: src/js/compiler.rs + +Compile AST to bytecode. + +Handle: +- Variable declarations and scoping +- Function declarations and expressions +- Control flow (if, for, while) +- Operators +- Function calls +- Property access + +Output: CompiledFunction { bytecode, constants, local_count } +""" + context: js_bytecode_design + +session: hammer + prompt: """ +Implement the JavaScript VM. + +File: src/js/vm.rs + +Stack-based virtual machine that executes bytecode. + +Components: +- Value stack +- Call stack (frames) +- Global object +- Heap for objects + +Execute each opcode. Handle errors gracefully. +Test: run console.log("Hello from JS!"); +""" + context: js_bytecode_design + +session: hammer + prompt: """ +Implement JavaScript builtins. + +File: src/js/builtins.rs + +Essential builtins: +- Object: Object.keys(), Object.values() +- Array: push, pop, shift, unshift, map, filter, forEach, length +- String: length, charAt, substring, indexOf, split +- Number: toString, toFixed +- console: log, error, warn +- Math: floor, ceil, round, random, max, min + +Each builtin is a native function. Register them on the global object. +""" + +session: quench + prompt: """ +Test the JavaScript engine: + +Test cases: +1. Variables: let x = 10; console.log(x); +2. Functions: function add(a, b) { return a + b; } console.log(add(2, 3)); +3. Objects: let o = { x: 1 }; console.log(o.x); +4. Arrays: let a = [1, 2, 3]; console.log(a.length); +5. Control flow: if (true) { console.log("yes"); } +6. Loops: for (let i = 0; i < 3; i++) { console.log(i); } +7. Closures: function outer() { let x = 10; return function() { return x; }; } +8. Methods: [1,2,3].map(x => x * 2) + +Run each test. Verify correct output. +""" + +loop until **the JS engine passes all tests** (max: 10): + if **there are test failures**: + resume: crucible + prompt: "Analyze and fix the JS engine bugs." + session: hammer + prompt: "Implement the fixes." + context: crucible + session: quench + prompt: "Re-run JS tests." + +resume: smith + prompt: """ +Phase 7 complete. We have a working JavaScript engine. +The fire burns bright. The blade lives. +""" + +# ============================================================================= +# PHASE 8: DOM BINDINGS - The Handle +# ============================================================================= + +resume: smith + prompt: """ +Phase 8: DOM Bindings + +JavaScript without DOM access is just a calculator. +We need to expose the DOM to our JS engine. + +Essential APIs: +- document.getElementById() +- document.querySelector() +- element.innerHTML +- element.style +- element.addEventListener() +- element.appendChild() + +This connects the fire to the blade. The handle that makes it usable. +""" + +let bindings_design = session: smelter + prompt: """ +Design the DOM bindings. + +Bridge between JS values and DOM nodes: +- Each DOM node gets a corresponding JS object +- JS object has properties/methods that call back into Rust DOM +- Changes to DOM trigger re-style/re-layout/re-paint + +Key bindings: +- window object (global) +- document object +- Element objects +- Event objects + +document methods: +- getElementById(id) → Element | null +- querySelector(selector) → Element | null +- createElement(tag) → Element + +Element properties/methods: +- innerHTML (get/set) +- style (get/set) +- children, parentElement +- getAttribute, setAttribute +- appendChild, removeChild +- addEventListener(event, handler) + +Event system: +- Event object: type, target, preventDefault, stopPropagation +- Event dispatch: capture → target → bubble +""" + +session: hammer + prompt: """ +Implement DOM bindings. + +File: src/bindings/mod.rs +File: src/bindings/document.rs +File: src/bindings/element.rs +File: src/bindings/console.rs + +Create JS wrappers for DOM nodes. +Wire them up to the actual DOM. +Handle the bidirectional sync. + +Test: document.getElementById('test').innerHTML = 'Modified'; +""" + context: bindings_design + +session: hammer + prompt: """ +Implement the event system. + +File: src/bindings/events.rs +File: src/shell/events.rs (update for DOM events) + +Handle: +- addEventListener/removeEventListener +- Event dispatch (click, etc.) +- Event object creation +- Handler invocation through JS VM + +Wire window events (from winit) to DOM events. +""" + +session: quench + prompt: """ +Test DOM bindings: + +Test cases: +1. document.getElementById() returns correct element +2. element.innerHTML read/write works +3. element.style.color = 'red' changes computed style +4. addEventListener + click event handler runs +5. appendChild adds child to DOM +6. createElement + appendChild creates new elements + +Verify DOM mutations trigger re-render. +""" + +loop until **DOM bindings work** (max: 5): + if **there are issues**: + session: hammer + prompt: "Fix the DOM binding issues." + session: quench + prompt: "Re-test DOM bindings." + +resume: smith + prompt: "Phase 8 complete. JavaScript can manipulate the DOM. The handle is attached." + +# ============================================================================= +# PHASE 9: INTEGRATION - The Tempering +# ============================================================================= + +resume: smith + prompt: """ +Phase 9: Integration + +All the pieces exist. Now we temper them into one unified blade. + +The full pipeline: +1. User enters URL +2. Fetch HTML over HTTPS +3. Parse HTML into DOM +4. Find and parse CSS (inline styles,