From 0fd77c9856a22e01f010de3eef7439cf82572e80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 02:06:50 +0000 Subject: [PATCH] refactor: modularize plugin runtime and test hooks --- .github/workflows/ci.yml | 4 +- docs/ci.md | 28 +- extensions/bluebubbles/src/monitor.test.ts | 138 +----- extensions/test-utils/plugin-runtime-mock.ts | 244 ++++++++++ .../reply/session-hooks-context.test.ts | 10 +- src/auto-reply/reply/session.ts | 105 ++-- src/plugins/runtime/index.ts | 449 +----------------- src/plugins/runtime/runtime-channel.ts | 263 ++++++++++ src/plugins/runtime/runtime-config.ts | 9 + src/plugins/runtime/runtime-events.ts | 10 + src/plugins/runtime/runtime-logging.ts | 21 + src/plugins/runtime/runtime-media.ts | 17 + src/plugins/runtime/runtime-system.ts | 14 + src/plugins/runtime/runtime-tools.ts | 11 + src/plugins/runtime/runtime-whatsapp.ts | 108 +++++ 15 files changed, 812 insertions(+), 619 deletions(-) create mode 100644 extensions/test-utils/plugin-runtime-mock.ts create mode 100644 src/plugins/runtime/runtime-channel.ts create mode 100644 src/plugins/runtime/runtime-config.ts create mode 100644 src/plugins/runtime/runtime-events.ts create mode 100644 src/plugins/runtime/runtime-logging.ts create mode 100644 src/plugins/runtime/runtime-media.ts create mode 100644 src/plugins/runtime/runtime-system.ts create mode 100644 src/plugins/runtime/runtime-tools.ts create mode 100644 src/plugins/runtime/runtime-whatsapp.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed4063cc616..dd08ba25409 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -223,8 +223,8 @@ jobs: # Types, lint, and format check. check: name: "check" - needs: [docs-scope] - if: needs.docs-scope.outputs.docs_only != 'true' + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout diff --git a/docs/ci.md b/docs/ci.md index 51643c87001..dc67454d2a3 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -13,20 +13,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin ## Job Overview -| Job | Purpose | When it runs | -| ----------------- | ----------------------------------------------- | ------------------------- | -| `docs-scope` | Detect docs-only changes | Always | -| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs | -| `check` | TypeScript types, lint, format | Non-docs changes | -| `check-docs` | Markdown lint + broken link check | Docs changed | -| `code-analysis` | LOC threshold check (1000 lines) | PRs only | -| `secrets` | Detect leaked secrets | Always | -| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | -| `release-check` | Validate npm pack contents | After build | -| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | -| `checks-windows` | Windows-specific tests | Non-docs, node changes | -| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | -| `android` | Gradle build + tests | Non-docs, android changes | +| Job | Purpose | When it runs | +| ----------------- | ----------------------------------------------- | ------------------------------------------------- | +| `docs-scope` | Detect docs-only changes | Always | +| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs | +| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes | +| `check-docs` | Markdown lint + broken link check | Docs changed | +| `code-analysis` | LOC threshold check (1000 lines) | PRs only | +| `secrets` | Detect leaked secrets | Always | +| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | +| `release-check` | Validate npm pack contents | After build | +| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | +| `checks-windows` | Windows-specific tests | Non-docs, node changes | +| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | +| `android` | Gradle build + tests | Non-docs, android changes | ## Fail-Fast Order diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 4417c3198b5..f0a3044b7ae 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -1,8 +1,8 @@ import { EventEmitter } from "node:events"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk"; -import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js"; import type { ResolvedBlueBubblesAccount } from "./accounts.js"; import { fetchBlueBubblesHistory } from "./history.js"; import { @@ -94,47 +94,15 @@ const mockResolveChunkMode = vi.fn(() => "length"); const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory); 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"], - }, + return createPluginRuntimeMock({ system: { enqueueSystemEvent: mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"], - requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"], - runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"], - formatNativeDependencyHint: vi.fn( - () => "", - ) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"], - }, - 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"], - }, - tts: { - textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"], - }, - stt: { - transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], - }, - 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"], chunkByNewline: mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"], chunkMarkdownTextWithMode: @@ -143,50 +111,12 @@ function createMockRuntime(): PluginRuntime { mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"], resolveChunkMode: mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"], - 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"], - withReplyDispatcher: vi.fn( - async ({ - dispatcher, - run, - onSettled, - }: Parameters[0]) => { - try { - return await run(); - } finally { - dispatcher.markComplete(); - try { - await dispatcher.waitForIdle(); - } finally { - await onSettled?.(); - } - } - }, - ) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], - finalizeInboundContext: vi.fn( - (ctx: Record) => ctx, - ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], formatAgentEnvelope: mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], formatInboundEnvelope: @@ -207,8 +137,6 @@ function createMockRuntime(): PluginRuntime { 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"], }, @@ -217,12 +145,6 @@ function createMockRuntime(): PluginRuntime { 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: @@ -232,72 +154,18 @@ function createMockRuntime(): PluginRuntime { matchesMentionWithExplicit: mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"], }, - 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"], - activity: {} as PluginRuntime["channel"]["activity"], - line: {} as PluginRuntime["channel"]["line"], - 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"], }, - events: { - onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"], - onSessionTranscriptUpdate: vi.fn( - () => () => {}, - ) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"], - }, - 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( diff --git a/extensions/test-utils/plugin-runtime-mock.ts b/extensions/test-utils/plugin-runtime-mock.ts new file mode 100644 index 00000000000..72974f94afc --- /dev/null +++ b/extensions/test-utils/plugin-runtime-mock.ts @@ -0,0 +1,244 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; +import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk"; +import { vi } from "vitest"; + +type DeepPartial = { + [K in keyof T]?: T[K] extends (...args: never[]) => unknown + ? T[K] + : T[K] extends ReadonlyArray + ? T[K] + : T[K] extends object + ? DeepPartial + : T[K]; +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function mergeDeep(base: T, overrides: DeepPartial): T { + const result: Record = { ...(base as Record) }; + for (const [key, overrideValue] of Object.entries(overrides as Record)) { + if (overrideValue === undefined) { + continue; + } + const baseValue = result[key]; + if (isObject(baseValue) && isObject(overrideValue)) { + result[key] = mergeDeep(baseValue, overrideValue); + continue; + } + result[key] = overrideValue; + } + return result as T; +} + +export function createPluginRuntimeMock(overrides: DeepPartial = {}): PluginRuntime { + const base: PluginRuntime = { + version: "1.0.0-test", + config: { + loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"], + writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"], + }, + system: { + enqueueSystemEvent: vi.fn() as unknown as PluginRuntime["system"]["enqueueSystemEvent"], + requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"], + runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"], + formatNativeDependencyHint: vi.fn( + () => "", + ) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"], + }, + 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"], + }, + tts: { + textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"], + }, + stt: { + transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"], + }, + 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: { + chunkByNewline: vi.fn((text: string) => (text ? [text] : [])), + chunkMarkdownText: vi.fn((text: string) => [text]), + chunkMarkdownTextWithMode: vi.fn((text: string) => (text ? [text] : [])), + chunkText: vi.fn((text: string) => (text ? [text] : [])), + chunkTextWithMode: vi.fn((text: string) => (text ? [text] : [])), + resolveChunkMode: vi.fn(() => "length"), + resolveTextChunkLimit: vi.fn(() => 4000), + hasControlCommand: vi.fn(() => false), + resolveMarkdownTableMode: vi.fn(() => "code"), + convertMarkdownTables: vi.fn((text: string) => text), + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => undefined, + ) 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"], + withReplyDispatcher: vi.fn(async ({ dispatcher, run, onSettled }) => { + try { + return await run(); + } finally { + dispatcher.markComplete(); + try { + await dispatcher.waitForIdle(); + } finally { + await onSettled?.(); + } + } + }) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"], + finalizeInboundContext: vi.fn( + (ctx: Record) => ctx, + ) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + formatAgentEnvelope: vi.fn( + (opts: { body: string }) => opts.body, + ) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"], + formatInboundEnvelope: vi.fn( + (opts: { body: string }) => opts.body, + ) as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"], + resolveEnvelopeFormatOptions: vi.fn(() => ({ + template: "channel+name+time", + })) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"], + }, + routing: { + resolveAgentRoute: vi.fn(() => ({ + agentId: "main", + accountId: "default", + sessionKey: "agent:main:test:dm:peer", + })) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], + }, + pairing: { + buildPairingReply: vi.fn( + () => "Pairing code: TESTCODE", + ) as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"], + readAllowFromStore: vi + .fn() + .mockResolvedValue( + [], + ) as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"], + upsertPairingRequest: vi.fn().mockResolvedValue({ + code: "TESTCODE", + created: true, + }) as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"], + }, + media: { + fetchRemoteMedia: + vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test-media.jpg", + contentType: "image/jpeg", + }) as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + session: { + resolveStorePath: vi.fn( + () => "/tmp/sessions.json", + ) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"], + readSessionUpdatedAt: vi.fn( + () => undefined, + ) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"], + recordSessionMetaFromInbound: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"], + recordInboundSession: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + updateLastRoute: + vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"], + }, + mentions: { + buildMentionRegexes: vi.fn(() => [ + /\bbert\b/i, + ]) as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"], + matchesMentionPatterns: vi.fn((text: string, regexes: RegExp[]) => + regexes.some((regex) => regex.test(text)), + ) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"], + matchesMentionWithExplicit: vi.fn( + (params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => + params.explicitWasMentioned === true + ? true + : params.mentionRegexes.some((regex) => regex.test(params.text)), + ) as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"], + }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, + }, + groups: { + resolveGroupPolicy: vi.fn( + () => "open", + ) as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"], + resolveRequireMention: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], + }, + debounce: { + 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: vi.fn( + () => false, + ) 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"], + activity: {} as PluginRuntime["channel"]["activity"], + line: {} as PluginRuntime["channel"]["line"], + 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"], + }, + events: { + onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"], + onSessionTranscriptUpdate: vi.fn( + () => () => {}, + ) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"], + }, + logging: { + shouldLogVerbose: vi.fn(() => false), + getChildLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }, + state: { + resolveStateDir: vi.fn(() => "/tmp/openclaw"), + }, + }; + + return mergeDeep(base, overrides); +} diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index c3b0ae6cc2d..ee8137d3ddc 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -67,7 +67,8 @@ describe("session hook context wiring", () => { await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1)); const [event, context] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? []; expect(event).toMatchObject({ sessionKey }); - expect(context).toMatchObject({ sessionKey }); + expect(context).toMatchObject({ sessionKey, agentId: "main" }); + expect(context).toMatchObject({ sessionId: event?.sessionId }); }); it("passes sessionKey to session_end hook context on reset", async () => { @@ -88,8 +89,13 @@ describe("session hook context wiring", () => { }); await vi.waitFor(() => expect(hookRunnerMocks.runSessionEnd).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(hookRunnerMocks.runSessionStart).toHaveBeenCalledTimes(1)); const [event, context] = hookRunnerMocks.runSessionEnd.mock.calls[0] ?? []; expect(event).toMatchObject({ sessionKey }); - expect(context).toMatchObject({ sessionKey }); + expect(context).toMatchObject({ sessionKey, agentId: "main" }); + expect(context).toMatchObject({ sessionId: event?.sessionId }); + + const [startEvent] = hookRunnerMocks.runSessionStart.mock.calls[0] ?? []; + expect(startEvent).toMatchObject({ resumedFrom: "old-session" }); }); }); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index e071a26828c..0af56ec6118 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -146,6 +146,70 @@ type LegacyMainDeliveryRetirement = { entry: SessionEntry; }; +type SessionHookContext = { + sessionId: string; + sessionKey: string; + agentId: string; +}; + +function buildSessionHookContext(params: { + sessionId: string; + sessionKey: string; + cfg: OpenClawConfig; +}): SessionHookContext { + return { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }), + }; +} + +function buildSessionStartHookPayload(params: { + sessionId: string; + sessionKey: string; + cfg: OpenClawConfig; + resumedFrom?: string; +}): { + event: { sessionId: string; sessionKey: string; resumedFrom?: string }; + context: SessionHookContext; +} { + return { + event: { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + resumedFrom: params.resumedFrom, + }, + context: buildSessionHookContext({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + cfg: params.cfg, + }), + }; +} + +function buildSessionEndHookPayload(params: { + sessionId: string; + sessionKey: string; + cfg: OpenClawConfig; + messageCount?: number; +}): { + event: { sessionId: string; sessionKey: string; messageCount: number }; + context: SessionHookContext; +} { + return { + event: { + sessionId: params.sessionId, + sessionKey: params.sessionKey, + messageCount: params.messageCount ?? 0, + }, + context: buildSessionHookContext({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + cfg: params.cfg, + }), + }; +} + function resolveParentForkMaxTokens(cfg: OpenClawConfig): number { const configured = cfg.session?.parentForkMaxTokens; if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0) { @@ -643,39 +707,24 @@ export async function initSessionState(params: { // If replacing an existing session, fire session_end for the old one if (previousSessionEntry?.sessionId && previousSessionEntry.sessionId !== effectiveSessionId) { if (hookRunner.hasHooks("session_end")) { - void hookRunner - .runSessionEnd( - { - sessionId: previousSessionEntry.sessionId, - sessionKey, - messageCount: 0, - }, - { - sessionId: previousSessionEntry.sessionId, - sessionKey, - agentId: resolveSessionAgentId({ sessionKey, config: cfg }), - }, - ) - .catch(() => {}); + const payload = buildSessionEndHookPayload({ + sessionId: previousSessionEntry.sessionId, + sessionKey, + cfg, + }); + void hookRunner.runSessionEnd(payload.event, payload.context).catch(() => {}); } } // Fire session_start for the new session if (hookRunner.hasHooks("session_start")) { - void hookRunner - .runSessionStart( - { - sessionId: effectiveSessionId, - sessionKey, - resumedFrom: previousSessionEntry?.sessionId, - }, - { - sessionId: effectiveSessionId, - sessionKey, - agentId: resolveSessionAgentId({ sessionKey, config: cfg }), - }, - ) - .catch(() => {}); + const payload = buildSessionStartHookPayload({ + sessionId: effectiveSessionId, + sessionKey, + cfg, + resumedFrom: previousSessionEntry?.sessionId, + }); + void hookRunner.runSessionStart(payload.event, payload.context).catch(() => {}); } } diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index d39d3766d21..3db2f68ad92 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -1,148 +1,14 @@ import { createRequire } from "node:module"; -import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; -import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; -import { handleSlackAction } from "../../agents/tools/slack-actions.js"; -import { - chunkByNewline, - chunkMarkdownText, - chunkMarkdownTextWithMode, - chunkText, - chunkTextWithMode, - resolveChunkMode, - resolveTextChunkLimit, -} from "../../auto-reply/chunk.js"; -import { - hasControlCommand, - isControlCommandMessage, - shouldComputeCommandAuthorized, -} from "../../auto-reply/command-detection.js"; -import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; -import { withReplyDispatcher } from "../../auto-reply/dispatch.js"; -import { - formatAgentEnvelope, - formatInboundEnvelope, - resolveEnvelopeFormatOptions, -} from "../../auto-reply/envelope.js"; -import { - createInboundDebouncer, - resolveInboundDebounceMs, -} from "../../auto-reply/inbound-debounce.js"; -import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { - buildMentionRegexes, - matchesMentionPatterns, - matchesMentionWithExplicit, -} from "../../auto-reply/reply/mentions.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; -import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; -import { signalMessageActions } from "../../channels/plugins/actions/signal.js"; -import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; -import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; -import { recordInboundSession } from "../../channels/session.js"; -import { registerMemoryCli } from "../../cli/memory-cli.js"; -import { loadConfig, writeConfigFile } from "../../config/config.js"; -import { - resolveChannelGroupPolicy, - resolveChannelGroupRequireMention, -} from "../../config/group-policy.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveStateDir } from "../../config/paths.js"; -import { - readSessionUpdatedAt, - recordSessionMetaFromInbound, - resolveStorePath, - updateLastRoute, -} from "../../config/sessions.js"; -import { auditDiscordChannelPermissions } from "../../discord/audit.js"; -import { - listDiscordDirectoryGroupsLive, - listDiscordDirectoryPeersLive, -} from "../../discord/directory-live.js"; -import { monitorDiscordProvider } from "../../discord/monitor.js"; -import { probeDiscord } from "../../discord/probe.js"; -import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; -import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; -import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; -import { shouldLogVerbose } from "../../globals.js"; -import { monitorIMessageProvider } from "../../imessage/monitor.js"; -import { probeIMessage } from "../../imessage/probe.js"; -import { sendMessageIMessage } from "../../imessage/send.js"; -import { onAgentEvent } from "../../infra/agent-events.js"; -import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js"; -import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { - listLineAccountIds, - normalizeAccountId as normalizeLineAccountId, - resolveDefaultLineAccountId, - resolveLineAccount, -} from "../../line/accounts.js"; -import { monitorLineProvider } from "../../line/monitor.js"; -import { probeLineBot } from "../../line/probe.js"; -import { - createQuickReplyItems, - pushMessageLine, - pushMessagesLine, - pushFlexMessage, - pushTemplateMessage, - pushLocationMessage, - pushTextMessageWithQuickReplies, - sendMessageLine, -} from "../../line/send.js"; -import { buildTemplateMessageFromPayload } from "../../line/template-messages.js"; -import { getChildLogger } from "../../logging.js"; -import { normalizeLogLevel } from "../../logging/levels.js"; -import { convertMarkdownTables } from "../../markdown/tables.js"; import { transcribeAudioFile } from "../../media-understanding/transcribe-audio.js"; -import { isVoiceCompatibleAudio } from "../../media/audio.js"; -import { mediaKindFromMime } from "../../media/constants.js"; -import { fetchRemoteMedia } from "../../media/fetch.js"; -import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; -import { detectMime } from "../../media/mime.js"; -import { saveMediaBuffer } from "../../media/store.js"; -import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { - readChannelAllowFromStore, - upsertChannelPairingRequest, -} from "../../pairing/pairing-store.js"; -import { runCommandWithTimeout } from "../../process/exec.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { monitorSignalProvider } from "../../signal/index.js"; -import { probeSignal } from "../../signal/probe.js"; -import { sendMessageSignal } from "../../signal/send.js"; -import { - listSlackDirectoryGroupsLive, - listSlackDirectoryPeersLive, -} from "../../slack/directory-live.js"; -import { monitorSlackProvider } from "../../slack/index.js"; -import { probeSlack } from "../../slack/probe.js"; -import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; -import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; -import { sendMessageSlack } from "../../slack/send.js"; -import { - auditTelegramGroupMembership, - collectTelegramUnmentionedGroupIds, -} from "../../telegram/audit.js"; -import { monitorTelegramProvider } from "../../telegram/monitor.js"; -import { probeTelegram } from "../../telegram/probe.js"; -import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js"; -import { resolveTelegramToken } from "../../telegram/token.js"; import { textToSpeechTelephony } from "../../tts/tts.js"; -import { getActiveWebListener } from "../../web/active-listener.js"; -import { - getWebAuthAgeMs, - logoutWeb, - logWebSelfId, - readWebSelfId, - webAuthExists, -} from "../../web/auth-store.js"; -import { loadWebMedia } from "../../web/media.js"; -import { formatNativeDependencyHint } from "./native-deps.js"; +import { createRuntimeChannel } from "./runtime-channel.js"; +import { createRuntimeConfig } from "./runtime-config.js"; +import { createRuntimeEvents } from "./runtime-events.js"; +import { createRuntimeLogging } from "./runtime-logging.js"; +import { createRuntimeMedia } from "./runtime-media.js"; +import { createRuntimeSystem } from "./runtime-system.js"; +import { createRuntimeTools } from "./runtime-tools.js"; import type { PluginRuntime } from "./types.js"; let cachedVersion: string | null = null; @@ -162,87 +28,8 @@ function resolveVersion(): string { } } -const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( - ...args -) => { - const { sendMessageWhatsApp } = await loadWebOutbound(); - return sendMessageWhatsApp(...args); -}; - -const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async ( - ...args -) => { - const { sendPollWhatsApp } = await loadWebOutbound(); - return sendPollWhatsApp(...args); -}; - -const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => { - const { loginWeb } = await loadWebLogin(); - return loginWeb(...args); -}; - -const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( - ...args -) => { - const { startWebLoginWithQr } = await loadWebLoginQr(); - return startWebLoginWithQr(...args); -}; - -const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async ( - ...args -) => { - const { waitForWebLogin } = await loadWebLoginQr(); - return waitForWebLogin(...args); -}; - -const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async ( - ...args -) => { - const { monitorWebChannel } = await loadWebChannel(); - return monitorWebChannel(...args); -}; - -const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] = - async (...args) => { - const { handleWhatsAppAction } = await loadWhatsAppActions(); - return handleWhatsAppAction(...args); - }; - -let webOutboundPromise: Promise | null = null; -let webLoginPromise: Promise | null = null; -let webLoginQrPromise: Promise | null = null; -let webChannelPromise: Promise | null = null; -let whatsappActionsPromise: Promise< - typeof import("../../agents/tools/whatsapp-actions.js") -> | null = null; - -function loadWebOutbound() { - webOutboundPromise ??= import("../../web/outbound.js"); - return webOutboundPromise; -} - -function loadWebLogin() { - webLoginPromise ??= import("../../web/login.js"); - return webLoginPromise; -} - -function loadWebLoginQr() { - webLoginQrPromise ??= import("../../web/login-qr.js"); - return webLoginQrPromise; -} - -function loadWebChannel() { - webChannelPromise ??= import("../../channels/web/index.js"); - return webChannelPromise; -} - -function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js"); - return whatsappActionsPromise; -} - export function createPluginRuntime(): PluginRuntime { - return { + const runtime = { version: resolveVersion(), config: createRuntimeConfig(), system: createRuntimeSystem(), @@ -251,226 +38,12 @@ export function createPluginRuntime(): PluginRuntime { stt: { transcribeAudioFile }, tools: createRuntimeTools(), channel: createRuntimeChannel(), - events: { - onAgentEvent, - onSessionTranscriptUpdate, - }, + events: createRuntimeEvents(), logging: createRuntimeLogging(), state: { resolveStateDir }, - }; -} + } satisfies PluginRuntime; -function createRuntimeConfig(): PluginRuntime["config"] { - return { - loadConfig, - writeConfigFile, - }; -} - -function createRuntimeSystem(): PluginRuntime["system"] { - return { - enqueueSystemEvent, - requestHeartbeatNow, - runCommandWithTimeout, - formatNativeDependencyHint, - }; -} - -function createRuntimeMedia(): PluginRuntime["media"] { - return { - loadWebMedia, - detectMime, - mediaKindFromMime, - isVoiceCompatibleAudio, - getImageMetadata, - resizeToJpeg, - }; -} - -function createRuntimeTools(): PluginRuntime["tools"] { - return { - createMemoryGetTool, - createMemorySearchTool, - registerMemoryCli, - }; -} - -function createRuntimeChannel(): PluginRuntime["channel"] { - return { - text: { - chunkByNewline, - chunkMarkdownText, - chunkMarkdownTextWithMode, - chunkText, - chunkTextWithMode, - resolveChunkMode, - resolveTextChunkLimit, - hasControlCommand, - resolveMarkdownTableMode, - convertMarkdownTables, - }, - reply: { - dispatchReplyWithBufferedBlockDispatcher, - createReplyDispatcherWithTyping, - resolveEffectiveMessagesConfig, - resolveHumanDelayConfig, - dispatchReplyFromConfig, - withReplyDispatcher, - finalizeInboundContext, - formatAgentEnvelope, - /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ - formatInboundEnvelope, - resolveEnvelopeFormatOptions, - }, - routing: { - resolveAgentRoute, - }, - pairing: { - buildPairingReply, - readAllowFromStore: ({ channel, accountId, env }) => - readChannelAllowFromStore(channel, env, accountId), - upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) => - upsertChannelPairingRequest({ - channel, - id, - accountId, - meta, - env, - pairingAdapter, - }), - }, - media: { - fetchRemoteMedia, - saveMediaBuffer, - }, - activity: { - record: recordChannelActivity, - get: getChannelActivity, - }, - session: { - resolveStorePath, - readSessionUpdatedAt, - recordSessionMetaFromInbound, - recordInboundSession, - updateLastRoute, - }, - mentions: { - buildMentionRegexes, - matchesMentionPatterns, - matchesMentionWithExplicit, - }, - reactions: { - shouldAckReaction, - removeAckReactionAfterReply, - }, - groups: { - resolveGroupPolicy: resolveChannelGroupPolicy, - resolveRequireMention: resolveChannelGroupRequireMention, - }, - debounce: { - createInboundDebouncer, - resolveInboundDebounceMs, - }, - commands: { - resolveCommandAuthorizedFromAuthorizers, - isControlCommandMessage, - shouldComputeCommandAuthorized, - shouldHandleTextCommands, - }, - discord: { - messageActions: discordMessageActions, - auditChannelPermissions: auditDiscordChannelPermissions, - listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, - listDirectoryPeersLive: listDiscordDirectoryPeersLive, - probeDiscord, - resolveChannelAllowlist: resolveDiscordChannelAllowlist, - resolveUserAllowlist: resolveDiscordUserAllowlist, - sendMessageDiscord, - sendPollDiscord, - monitorDiscordProvider, - }, - slack: { - listDirectoryGroupsLive: listSlackDirectoryGroupsLive, - listDirectoryPeersLive: listSlackDirectoryPeersLive, - probeSlack, - resolveChannelAllowlist: resolveSlackChannelAllowlist, - resolveUserAllowlist: resolveSlackUserAllowlist, - sendMessageSlack, - monitorSlackProvider, - handleSlackAction, - }, - telegram: { - auditGroupMembership: auditTelegramGroupMembership, - collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, - probeTelegram, - resolveTelegramToken, - sendMessageTelegram, - sendPollTelegram, - monitorTelegramProvider, - messageActions: telegramMessageActions, - }, - signal: { - probeSignal, - sendMessageSignal, - monitorSignalProvider, - messageActions: signalMessageActions, - }, - imessage: { - monitorIMessageProvider, - probeIMessage, - sendMessageIMessage, - }, - whatsapp: { - getActiveWebListener, - getWebAuthAgeMs, - logoutWeb, - logWebSelfId, - readWebSelfId, - webAuthExists, - sendMessageWhatsApp: sendMessageWhatsAppLazy, - sendPollWhatsApp: sendPollWhatsAppLazy, - loginWeb: loginWebLazy, - startWebLoginWithQr: startWebLoginWithQrLazy, - waitForWebLogin: waitForWebLoginLazy, - monitorWebChannel: monitorWebChannelLazy, - handleWhatsAppAction: handleWhatsAppActionLazy, - createLoginTool: createWhatsAppLoginTool, - }, - line: { - listLineAccountIds, - resolveDefaultLineAccountId, - resolveLineAccount, - normalizeAccountId: normalizeLineAccountId, - probeLineBot, - sendMessageLine, - pushMessageLine, - pushMessagesLine, - pushFlexMessage, - pushTemplateMessage, - pushLocationMessage, - pushTextMessageWithQuickReplies, - createQuickReplyItems, - buildTemplateMessageFromPayload, - monitorLineProvider, - }, - }; -} - -function createRuntimeLogging(): PluginRuntime["logging"] { - return { - shouldLogVerbose, - getChildLogger: (bindings, opts) => { - const logger = getChildLogger(bindings, { - level: opts?.level ? normalizeLogLevel(opts.level) : undefined, - }); - return { - debug: (message) => logger.debug?.(message), - info: (message) => logger.info(message), - warn: (message) => logger.warn(message), - error: (message) => logger.error(message), - }; - }, - }; + return runtime; } export type { PluginRuntime } from "./types.js"; diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts new file mode 100644 index 00000000000..46a7813a9df --- /dev/null +++ b/src/plugins/runtime/runtime-channel.ts @@ -0,0 +1,263 @@ +import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; +import { handleSlackAction } from "../../agents/tools/slack-actions.js"; +import { + chunkByNewline, + chunkMarkdownText, + chunkMarkdownTextWithMode, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, +} from "../../auto-reply/chunk.js"; +import { + hasControlCommand, + isControlCommandMessage, + shouldComputeCommandAuthorized, +} from "../../auto-reply/command-detection.js"; +import { shouldHandleTextCommands } from "../../auto-reply/commands-registry.js"; +import { withReplyDispatcher } from "../../auto-reply/dispatch.js"; +import { + formatAgentEnvelope, + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../auto-reply/envelope.js"; +import { + createInboundDebouncer, + resolveInboundDebounceMs, +} from "../../auto-reply/inbound-debounce.js"; +import { dispatchReplyFromConfig } from "../../auto-reply/reply/dispatch-from-config.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +import { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, +} from "../../auto-reply/reply/mentions.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js"; +import { removeAckReactionAfterReply, shouldAckReaction } from "../../channels/ack-reactions.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { discordMessageActions } from "../../channels/plugins/actions/discord.js"; +import { signalMessageActions } from "../../channels/plugins/actions/signal.js"; +import { telegramMessageActions } from "../../channels/plugins/actions/telegram.js"; +import { recordInboundSession } from "../../channels/session.js"; +import { + resolveChannelGroupPolicy, + resolveChannelGroupRequireMention, +} from "../../config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; +import { + readSessionUpdatedAt, + recordSessionMetaFromInbound, + resolveStorePath, + updateLastRoute, +} from "../../config/sessions.js"; +import { auditDiscordChannelPermissions } from "../../discord/audit.js"; +import { + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, +} from "../../discord/directory-live.js"; +import { monitorDiscordProvider } from "../../discord/monitor.js"; +import { probeDiscord } from "../../discord/probe.js"; +import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; +import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; +import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; +import { monitorIMessageProvider } from "../../imessage/monitor.js"; +import { probeIMessage } from "../../imessage/probe.js"; +import { sendMessageIMessage } from "../../imessage/send.js"; +import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js"; +import { + listLineAccountIds, + normalizeAccountId as normalizeLineAccountId, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../line/accounts.js"; +import { monitorLineProvider } from "../../line/monitor.js"; +import { probeLineBot } from "../../line/probe.js"; +import { + createQuickReplyItems, + pushFlexMessage, + pushLocationMessage, + pushMessageLine, + pushMessagesLine, + pushTemplateMessage, + pushTextMessageWithQuickReplies, + sendMessageLine, +} from "../../line/send.js"; +import { buildTemplateMessageFromPayload } from "../../line/template-messages.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; +import { fetchRemoteMedia } from "../../media/fetch.js"; +import { saveMediaBuffer } from "../../media/store.js"; +import { buildPairingReply } from "../../pairing/pairing-messages.js"; +import { + readChannelAllowFromStore, + upsertChannelPairingRequest, +} from "../../pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../routing/resolve-route.js"; +import { monitorSignalProvider } from "../../signal/index.js"; +import { probeSignal } from "../../signal/probe.js"; +import { sendMessageSignal } from "../../signal/send.js"; +import { + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, +} from "../../slack/directory-live.js"; +import { monitorSlackProvider } from "../../slack/index.js"; +import { probeSlack } from "../../slack/probe.js"; +import { resolveSlackChannelAllowlist } from "../../slack/resolve-channels.js"; +import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js"; +import { sendMessageSlack } from "../../slack/send.js"; +import { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, +} from "../../telegram/audit.js"; +import { monitorTelegramProvider } from "../../telegram/monitor.js"; +import { probeTelegram } from "../../telegram/probe.js"; +import { sendMessageTelegram, sendPollTelegram } from "../../telegram/send.js"; +import { resolveTelegramToken } from "../../telegram/token.js"; +import { createRuntimeWhatsApp } from "./runtime-whatsapp.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeChannel(): PluginRuntime["channel"] { + return { + text: { + chunkByNewline, + chunkMarkdownText, + chunkMarkdownTextWithMode, + chunkText, + chunkTextWithMode, + resolveChunkMode, + resolveTextChunkLimit, + hasControlCommand, + resolveMarkdownTableMode, + convertMarkdownTables, + }, + reply: { + dispatchReplyWithBufferedBlockDispatcher, + createReplyDispatcherWithTyping, + resolveEffectiveMessagesConfig, + resolveHumanDelayConfig, + dispatchReplyFromConfig, + withReplyDispatcher, + finalizeInboundContext, + formatAgentEnvelope, + /** @deprecated Prefer `BodyForAgent` + structured user-context blocks (do not build plaintext envelopes for prompts). */ + formatInboundEnvelope, + resolveEnvelopeFormatOptions, + }, + routing: { + resolveAgentRoute, + }, + pairing: { + buildPairingReply, + readAllowFromStore: ({ channel, accountId, env }) => + readChannelAllowFromStore(channel, env, accountId), + upsertPairingRequest: ({ channel, id, accountId, meta, env, pairingAdapter }) => + upsertChannelPairingRequest({ + channel, + id, + accountId, + meta, + env, + pairingAdapter, + }), + }, + media: { + fetchRemoteMedia, + saveMediaBuffer, + }, + activity: { + record: recordChannelActivity, + get: getChannelActivity, + }, + session: { + resolveStorePath, + readSessionUpdatedAt, + recordSessionMetaFromInbound, + recordInboundSession, + updateLastRoute, + }, + mentions: { + buildMentionRegexes, + matchesMentionPatterns, + matchesMentionWithExplicit, + }, + reactions: { + shouldAckReaction, + removeAckReactionAfterReply, + }, + groups: { + resolveGroupPolicy: resolveChannelGroupPolicy, + resolveRequireMention: resolveChannelGroupRequireMention, + }, + debounce: { + createInboundDebouncer, + resolveInboundDebounceMs, + }, + commands: { + resolveCommandAuthorizedFromAuthorizers, + isControlCommandMessage, + shouldComputeCommandAuthorized, + shouldHandleTextCommands, + }, + discord: { + messageActions: discordMessageActions, + auditChannelPermissions: auditDiscordChannelPermissions, + listDirectoryGroupsLive: listDiscordDirectoryGroupsLive, + listDirectoryPeersLive: listDiscordDirectoryPeersLive, + probeDiscord, + resolveChannelAllowlist: resolveDiscordChannelAllowlist, + resolveUserAllowlist: resolveDiscordUserAllowlist, + sendMessageDiscord, + sendPollDiscord, + monitorDiscordProvider, + }, + slack: { + listDirectoryGroupsLive: listSlackDirectoryGroupsLive, + listDirectoryPeersLive: listSlackDirectoryPeersLive, + probeSlack, + resolveChannelAllowlist: resolveSlackChannelAllowlist, + resolveUserAllowlist: resolveSlackUserAllowlist, + sendMessageSlack, + monitorSlackProvider, + handleSlackAction, + }, + telegram: { + auditGroupMembership: auditTelegramGroupMembership, + collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds, + probeTelegram, + resolveTelegramToken, + sendMessageTelegram, + sendPollTelegram, + monitorTelegramProvider, + messageActions: telegramMessageActions, + }, + signal: { + probeSignal, + sendMessageSignal, + monitorSignalProvider, + messageActions: signalMessageActions, + }, + imessage: { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, + }, + whatsapp: createRuntimeWhatsApp(), + line: { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, + normalizeAccountId: normalizeLineAccountId, + probeLineBot, + sendMessageLine, + pushMessageLine, + pushMessagesLine, + pushFlexMessage, + pushTemplateMessage, + pushLocationMessage, + pushTextMessageWithQuickReplies, + createQuickReplyItems, + buildTemplateMessageFromPayload, + monitorLineProvider, + }, + }; +} diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts new file mode 100644 index 00000000000..c25646f830d --- /dev/null +++ b/src/plugins/runtime/runtime-config.ts @@ -0,0 +1,9 @@ +import { loadConfig, writeConfigFile } from "../../config/config.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeConfig(): PluginRuntime["config"] { + return { + loadConfig, + writeConfigFile, + }; +} diff --git a/src/plugins/runtime/runtime-events.ts b/src/plugins/runtime/runtime-events.ts new file mode 100644 index 00000000000..31c6388a092 --- /dev/null +++ b/src/plugins/runtime/runtime-events.ts @@ -0,0 +1,10 @@ +import { onAgentEvent } from "../../infra/agent-events.js"; +import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeEvents(): PluginRuntime["events"] { + return { + onAgentEvent, + onSessionTranscriptUpdate, + }; +} diff --git a/src/plugins/runtime/runtime-logging.ts b/src/plugins/runtime/runtime-logging.ts new file mode 100644 index 00000000000..a3fc86d7008 --- /dev/null +++ b/src/plugins/runtime/runtime-logging.ts @@ -0,0 +1,21 @@ +import { shouldLogVerbose } from "../../globals.js"; +import { getChildLogger } from "../../logging.js"; +import { normalizeLogLevel } from "../../logging/levels.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeLogging(): PluginRuntime["logging"] { + return { + shouldLogVerbose, + getChildLogger: (bindings, opts) => { + const logger = getChildLogger(bindings, { + level: opts?.level ? normalizeLogLevel(opts.level) : undefined, + }); + return { + debug: (message) => logger.debug?.(message), + info: (message) => logger.info(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + }; + }, + }; +} diff --git a/src/plugins/runtime/runtime-media.ts b/src/plugins/runtime/runtime-media.ts new file mode 100644 index 00000000000..b52822e142b --- /dev/null +++ b/src/plugins/runtime/runtime-media.ts @@ -0,0 +1,17 @@ +import { isVoiceCompatibleAudio } from "../../media/audio.js"; +import { mediaKindFromMime } from "../../media/constants.js"; +import { getImageMetadata, resizeToJpeg } from "../../media/image-ops.js"; +import { detectMime } from "../../media/mime.js"; +import { loadWebMedia } from "../../web/media.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeMedia(): PluginRuntime["media"] { + return { + loadWebMedia, + detectMime, + mediaKindFromMime, + isVoiceCompatibleAudio, + getImageMetadata, + resizeToJpeg, + }; +} diff --git a/src/plugins/runtime/runtime-system.ts b/src/plugins/runtime/runtime-system.ts new file mode 100644 index 00000000000..06b9c72f8ec --- /dev/null +++ b/src/plugins/runtime/runtime-system.ts @@ -0,0 +1,14 @@ +import { requestHeartbeatNow } from "../../infra/heartbeat-wake.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { runCommandWithTimeout } from "../../process/exec.js"; +import { formatNativeDependencyHint } from "./native-deps.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeSystem(): PluginRuntime["system"] { + return { + enqueueSystemEvent, + requestHeartbeatNow, + runCommandWithTimeout, + formatNativeDependencyHint, + }; +} diff --git a/src/plugins/runtime/runtime-tools.ts b/src/plugins/runtime/runtime-tools.ts new file mode 100644 index 00000000000..66d98af02b2 --- /dev/null +++ b/src/plugins/runtime/runtime-tools.ts @@ -0,0 +1,11 @@ +import { createMemoryGetTool, createMemorySearchTool } from "../../agents/tools/memory-tool.js"; +import { registerMemoryCli } from "../../cli/memory-cli.js"; +import type { PluginRuntime } from "./types.js"; + +export function createRuntimeTools(): PluginRuntime["tools"] { + return { + createMemoryGetTool, + createMemorySearchTool, + registerMemoryCli, + }; +} diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts new file mode 100644 index 00000000000..976c83b2871 --- /dev/null +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -0,0 +1,108 @@ +import { createWhatsAppLoginTool } from "../../channels/plugins/agent-tools/whatsapp-login.js"; +import { getActiveWebListener } from "../../web/active-listener.js"; +import { + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, +} from "../../web/auth-store.js"; +import type { PluginRuntime } from "./types.js"; + +const sendMessageWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendMessageWhatsApp"] = async ( + ...args +) => { + const { sendMessageWhatsApp } = await loadWebOutbound(); + return sendMessageWhatsApp(...args); +}; + +const sendPollWhatsAppLazy: PluginRuntime["channel"]["whatsapp"]["sendPollWhatsApp"] = async ( + ...args +) => { + const { sendPollWhatsApp } = await loadWebOutbound(); + return sendPollWhatsApp(...args); +}; + +const loginWebLazy: PluginRuntime["channel"]["whatsapp"]["loginWeb"] = async (...args) => { + const { loginWeb } = await loadWebLogin(); + return loginWeb(...args); +}; + +const startWebLoginWithQrLazy: PluginRuntime["channel"]["whatsapp"]["startWebLoginWithQr"] = async ( + ...args +) => { + const { startWebLoginWithQr } = await loadWebLoginQr(); + return startWebLoginWithQr(...args); +}; + +const waitForWebLoginLazy: PluginRuntime["channel"]["whatsapp"]["waitForWebLogin"] = async ( + ...args +) => { + const { waitForWebLogin } = await loadWebLoginQr(); + return waitForWebLogin(...args); +}; + +const monitorWebChannelLazy: PluginRuntime["channel"]["whatsapp"]["monitorWebChannel"] = async ( + ...args +) => { + const { monitorWebChannel } = await loadWebChannel(); + return monitorWebChannel(...args); +}; + +const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhatsAppAction"] = + async (...args) => { + const { handleWhatsAppAction } = await loadWhatsAppActions(); + return handleWhatsAppAction(...args); + }; + +let webOutboundPromise: Promise | null = null; +let webLoginPromise: Promise | null = null; +let webLoginQrPromise: Promise | null = null; +let webChannelPromise: Promise | null = null; +let whatsappActionsPromise: Promise< + typeof import("../../agents/tools/whatsapp-actions.js") +> | null = null; + +function loadWebOutbound() { + webOutboundPromise ??= import("../../web/outbound.js"); + return webOutboundPromise; +} + +function loadWebLogin() { + webLoginPromise ??= import("../../web/login.js"); + return webLoginPromise; +} + +function loadWebLoginQr() { + webLoginQrPromise ??= import("../../web/login-qr.js"); + return webLoginQrPromise; +} + +function loadWebChannel() { + webChannelPromise ??= import("../../channels/web/index.js"); + return webChannelPromise; +} + +function loadWhatsAppActions() { + whatsappActionsPromise ??= import("../../agents/tools/whatsapp-actions.js"); + return whatsappActionsPromise; +} + +export function createRuntimeWhatsApp(): PluginRuntime["channel"]["whatsapp"] { + return { + getActiveWebListener, + getWebAuthAgeMs, + logoutWeb, + logWebSelfId, + readWebSelfId, + webAuthExists, + sendMessageWhatsApp: sendMessageWhatsAppLazy, + sendPollWhatsApp: sendPollWhatsAppLazy, + loginWeb: loginWebLazy, + startWebLoginWithQr: startWebLoginWithQrLazy, + waitForWebLogin: waitForWebLoginLazy, + monitorWebChannel: monitorWebChannelLazy, + handleWhatsAppAction: handleWhatsAppActionLazy, + createLoginTool: createWhatsAppLoginTool, + }; +}