refactor(extensions): dedupe channel config, onboarding, and monitors

This commit is contained in:
Peter Steinberger
2026-03-02 08:53:11 +00:00
parent d358b3ac88
commit ad8d766f65
43 changed files with 677 additions and 776 deletions

View File

@@ -2,6 +2,7 @@ import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "open
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
@@ -356,16 +357,8 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
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,
}),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({
baseUrl: account.baseUrl,

View File

@@ -2,6 +2,7 @@ import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "openclaw/plugin-sdk";
@@ -14,11 +15,7 @@ export type BlueBubblesTarget =
| { 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 };
export type BlueBubblesAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];

View File

@@ -41,14 +41,7 @@ describe("diffs tool", () => {
it("returns an image artifact in image mode", async () => {
const cleanupSpy = vi.spyOn(store, "scheduleCleanup");
const screenshotter = {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const screenshotter = createScreenshotter();
const tool = createDiffsTool({
api: createApi(),
@@ -178,14 +171,7 @@ describe("diffs tool", () => {
});
it("prefers explicit tool params over configured defaults", async () => {
const screenshotter = {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
const screenshotter = createScreenshotter();
const tool = createDiffsTool({
api: createApi(),
store,
@@ -256,3 +242,14 @@ function readTextContent(result: unknown, index: number): string {
const entry = content?.[index];
return entry?.type === "text" ? (entry.text ?? "") : "";
}
function createScreenshotter() {
return {
screenshotHtml: vi.fn(async ({ html, outputPath }: { html: string; outputPath: string }) => {
expect(html).not.toContain("/plugins/diffs/assets/viewer.js");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("png"));
return outputPath;
}),
};
}

View File

@@ -1,5 +1,19 @@
import { Type, type Static } from "@sinclair/typebox";
const tableCreationProperties = {
doc_token: Type.String({ description: "Document token" }),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
column_width: Type.Optional(
Type.Array(Type.Number({ minimum: 1 }), {
description: "Column widths in px (length should match column_size)",
}),
),
};
export const FeishuDocSchema = Type.Union([
Type.Object({
action: Type.Literal("read"),
@@ -59,17 +73,7 @@ export const FeishuDocSchema = Type.Union([
// Table creation (explicit structure)
Type.Object({
action: Type.Literal("create_table"),
doc_token: Type.String({ description: "Document token" }),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
column_width: Type.Optional(
Type.Array(Type.Number({ minimum: 1 }), {
description: "Column widths in px (length should match column_size)",
}),
),
...tableCreationProperties,
}),
Type.Object({
action: Type.Literal("write_table_cells"),
@@ -82,17 +86,7 @@ export const FeishuDocSchema = Type.Union([
}),
Type.Object({
action: Type.Literal("create_table_with_values"),
doc_token: Type.String({ description: "Document token" }),
parent_block_id: Type.Optional(
Type.String({ description: "Parent block ID (default: document root)" }),
),
row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
column_width: Type.Optional(
Type.Array(Type.Number({ minimum: 1 }), {
description: "Column widths in px (length should match column_size)",
}),
),
...tableCreationProperties,
values: Type.Array(Type.Array(Type.String()), {
description: "2D matrix values[row][col] to write into table cells",
minItems: 1,

View File

@@ -21,8 +21,8 @@ vi.mock("@larksuiteoapi/node-sdk", () => {
});
describe("feishu_doc account selection", () => {
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = {
function createDocEnabledConfig(): OpenClawPluginApi["config"] {
return {
channels: {
feishu: {
enabled: true,
@@ -33,6 +33,10 @@ describe("feishu_doc account selection", () => {
},
},
} as OpenClawPluginApi["config"];
}
test("uses agentAccountId context when params omit accountId", async () => {
const cfg = createDocEnabledConfig();
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);
@@ -49,17 +53,7 @@ describe("feishu_doc account selection", () => {
});
test("explicit accountId param overrides agentAccountId context", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
},
},
},
} as OpenClawPluginApi["config"];
const cfg = createDocEnabledConfig();
const { api, resolveTool } = createToolFactoryHarness(cfg);
registerFeishuDocTools(api);

View File

@@ -114,6 +114,29 @@ describe("feishu_doc image fetch hardening", () => {
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
});
function resolveFeishuDocTool(context: Record<string, unknown> = {}) {
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const tool = registerTool.mock.calls
.map((call) => call[0])
.map((candidate) => (typeof candidate === "function" ? candidate(context) : candidate))
.find((candidate) => candidate.name === "feishu_doc");
expect(tool).toBeDefined();
return tool as { execute: (callId: string, params: Record<string, unknown>) => Promise<any> };
}
it("inserts blocks sequentially to preserve document order", async () => {
const blocks = [
{ block_type: 3, block_id: "h1" },
@@ -135,22 +158,7 @@ describe("feishu_doc image fetch hardening", () => {
data: { children: [{ block_type: 3, block_id: "h1" }] },
});
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: { appId: "app_id", appSecret: "app_secret" },
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const result = await feishuDocTool.execute("tool-call", {
action: "append",
@@ -194,22 +202,7 @@ describe("feishu_doc image fetch hardening", () => {
},
}));
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: { appId: "app_id", appSecret: "app_secret" },
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const longMarkdown = Array.from(
{ length: 120 },
@@ -254,22 +247,7 @@ describe("feishu_doc image fetch hardening", () => {
data: { children: data.children },
}));
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: { appId: "app_id", appSecret: "app_secret" },
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const fencedMarkdown = [
"## Section",
@@ -306,25 +284,7 @@ describe("feishu_doc image fetch hardening", () => {
new Error("Blocked: resolves to private/internal IP address"),
);
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const result = await feishuDocTool.execute("tool-call", {
action: "write",
@@ -341,29 +301,10 @@ describe("feishu_doc image fetch hardening", () => {
});
it("create grants permission only to trusted Feishu requester", async () => {
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) =>
typeof tool === "function"
? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
: tool,
)
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
requesterSenderId: "ou_123",
});
const result = await feishuDocTool.execute("tool-call", {
action: "create",
@@ -386,25 +327,9 @@ describe("feishu_doc image fetch hardening", () => {
});
it("create skips requester grant when trusted requester identity is unavailable", async () => {
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({ messageChannel: "feishu" }) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
});
const result = await feishuDocTool.execute("tool-call", {
action: "create",
@@ -417,29 +342,10 @@ describe("feishu_doc image fetch hardening", () => {
});
it("create never grants permissions when grant_to_requester is false", async () => {
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) =>
typeof tool === "function"
? tool({ messageChannel: "feishu", requesterSenderId: "ou_123" })
: tool,
)
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool({
messageChannel: "feishu",
requesterSenderId: "ou_123",
});
const result = await feishuDocTool.execute("tool-call", {
action: "create",
@@ -457,25 +363,7 @@ describe("feishu_doc image fetch hardening", () => {
data: { document: { title: "Created Doc" } },
});
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const result = await feishuDocTool.execute("tool-call", {
action: "create",
@@ -496,25 +384,7 @@ describe("feishu_doc image fetch hardening", () => {
const localPath = join(tmpdir(), `feishu-docx-upload-${Date.now()}.txt`);
await fs.writeFile(localPath, "hello from local file", "utf8");
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const result = await feishuDocTool.execute("tool-call", {
action: "upload_file",
@@ -557,25 +427,7 @@ describe("feishu_doc image fetch hardening", () => {
await fs.writeFile(localPath, "hello from local file", "utf8");
try {
const registerTool = vi.fn();
registerFeishuDocTools({
config: {
channels: {
feishu: {
appId: "app_id",
appSecret: "app_secret",
},
},
} as any,
logger: { debug: vi.fn(), info: vi.fn() } as any,
registerTool,
} as any);
const feishuDocTool = registerTool.mock.calls
.map((call) => call[0])
.map((tool) => (typeof tool === "function" ? tool({}) : tool))
.find((tool) => tool.name === "feishu_doc");
expect(feishuDocTool).toBeDefined();
const feishuDocTool = resolveFeishuDocTool();
const result = await feishuDocTool.execute("tool-call", {
action: "upload_file",

View File

@@ -1,18 +1,7 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
import { probeFeishuMock } from "./monitor.test-mocks.js";
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
return {

View File

@@ -0,0 +1,12 @@
import { vi } from "vitest";
export const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));

View File

@@ -2,8 +2,7 @@ import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
import { probeFeishuMock } from "./monitor.test-mocks.js";
vi.mock("@larksuiteoapi/node-sdk", () => ({
adaptDefault: vi.fn(
@@ -14,15 +13,6 @@ vi.mock("@larksuiteoapi/node-sdk", () => ({
),
}));
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
import {
clearFeishuWebhookRateLimitStateForTest,
getFeishuWebhookRateLimitStateSizeForTest,

View File

@@ -273,6 +273,36 @@ describe("loginGeminiCliOAuth", () => {
});
}
async function runRemoteLoginWithCapturedAuthUrl(
loginGeminiCliOAuth: (options: {
isRemote: boolean;
openUrl: () => Promise<void>;
log: (msg: string) => void;
note: () => Promise<void>;
prompt: () => Promise<string>;
progress: { update: () => void; stop: () => void };
}) => Promise<{ projectId: string }>,
) {
let authUrl = "";
const result = await loginGeminiCliOAuth({
isRemote: true,
openUrl: async () => {},
log: (msg) => {
const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/);
if (found?.[0]) {
authUrl = found[0];
}
},
note: async () => {},
prompt: async () => {
const state = new URL(authUrl).searchParams.get("state");
return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`;
},
progress: { update: () => {}, stop: () => {} },
});
return { result, authUrl };
}
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
beforeEach(() => {
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
@@ -325,24 +355,8 @@ describe("loginGeminiCliOAuth", () => {
});
vi.stubGlobal("fetch", fetchMock);
let authUrl = "";
const { loginGeminiCliOAuth } = await import("./oauth.js");
const result = await loginGeminiCliOAuth({
isRemote: true,
openUrl: async () => {},
log: (msg) => {
const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/);
if (found?.[0]) {
authUrl = found[0];
}
},
note: async () => {},
prompt: async () => {
const state = new URL(authUrl).searchParams.get("state");
return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`;
},
progress: { update: () => {}, stop: () => {} },
});
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
expect(result.projectId).toBe("daily-project");
const loadRequests = requests.filter((request) =>
@@ -398,24 +412,8 @@ describe("loginGeminiCliOAuth", () => {
});
vi.stubGlobal("fetch", fetchMock);
let authUrl = "";
const { loginGeminiCliOAuth } = await import("./oauth.js");
const result = await loginGeminiCliOAuth({
isRemote: true,
openUrl: async () => {},
log: (msg) => {
const found = msg.match(/https:\/\/accounts\.google\.com\/o\/oauth2\/v2\/auth\?[^\s]+/);
if (found?.[0]) {
authUrl = found[0];
}
},
note: async () => {},
prompt: async () => {
const state = new URL(authUrl).searchParams.get("state");
return `${"http://localhost:8085/oauth2callback"}?code=oauth-code&state=${state}`;
},
progress: { update: () => {}, stop: () => {} },
});
const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth);
expect(result.projectId).toBe("env-project");
expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3);

View File

@@ -1,10 +1,6 @@
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
@@ -21,32 +17,6 @@ vi.mock("./monitor.js", async () => {
import { googlechatPlugin } from "./channel.js";
function createStartAccountCtx(params: {
account: ResolvedGoogleChatAccount;
abortSignal: AbortSignal;
statusPatchSink?: (next: ChannelAccountSnapshot) => void;
}): ChannelGatewayContext<ResolvedGoogleChatAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: params.account.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.account.accountId,
account: params.account,
cfg: {} as OpenClawConfig,
runtime: createRuntimeEnv(),
abortSignal: params.abortSignal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: (next) => {
Object.assign(snapshot, next);
params.statusPatchSink?.(snapshot);
},
};
}
describe("googlechatPlugin gateway.startAccount", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -72,7 +42,7 @@ describe("googlechatPlugin gateway.startAccount", () => {
const patches: ChannelAccountSnapshot[] = [];
const abort = new AbortController();
const task = googlechatPlugin.gateway!.startAccount!(
createStartAccountCtx({
createStartAccountContext({
account,
abortSignal: abort.signal,
statusPatchSink: (next) => patches.push({ ...next }),

View File

@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
GROUP_POLICY_BLOCKED_LABEL,
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
readJsonBodyWithLimit,
@@ -646,6 +647,15 @@ async function processMessageWithPipeline(params: {
id: spaceId,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
let mediaPath: string | undefined;
let mediaType: string | undefined;
@@ -661,20 +671,10 @@ async function processMessageWithPipeline(params: {
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({
const { storePath, body } = buildEnvelope({
channel: "Google Chat",
from: fromLabel,
timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});

View File

@@ -4,6 +4,7 @@ import {
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
formatTrimmedAllowFromEntries,
getChatChannelMeta,
imessageOnboardingAdapter,
IMessageConfigSchema,
@@ -16,6 +17,8 @@ import {
resolveChannelMediaMaxBytes,
resolveDefaultIMessageAccountId,
resolveIMessageAccount,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -28,6 +31,50 @@ import { getIMessageRuntime } from "./runtime.js";
const meta = getChatChannelMeta("imessage");
function buildIMessageSetupPatch(input: {
cliPath?: string;
dbPath?: string;
service?: string;
region?: string;
}) {
return {
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.dbPath ? { dbPath: input.dbPath } : {}),
...(input.service ? { service: input.service } : {}),
...(input.region ? { region: input.region } : {}),
};
}
type IMessageSendFn = ReturnType<
typeof getIMessageRuntime
>["channel"]["imessage"]["sendMessageIMessage"];
async function sendIMessageOutbound(params: {
cfg: Parameters<typeof resolveIMessageAccount>[0]["cfg"];
to: string;
text: string;
mediaUrl?: string;
accountId?: string;
deps?: { sendIMessage?: IMessageSendFn };
replyToId?: string;
}) {
const send =
params.deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId: params.accountId,
});
return await send(params.to, params.text, {
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
replyToId: params.replyToId ?? undefined,
});
}
export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
id: "imessage",
meta: {
@@ -74,14 +121,9 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
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),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
@@ -155,10 +197,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
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 } : {}),
...buildIMessageSetupPatch(input),
},
},
};
@@ -175,10 +214,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
[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 } : {}),
...buildIMessageSetupPatch(input),
},
},
},
@@ -192,35 +228,25 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => {
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
const result = await sendIMessageOutbound({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
to,
text,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
deps,
replyToId,
});
return { channel: "imessage", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps, replyToId }) => {
const send = deps?.sendIMessage ?? getIMessageRuntime().channel.imessage.sendMessageIMessage;
const maxBytes = resolveChannelMediaMaxBytes({
const result = await sendIMessageOutbound({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.imessage?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.imessage?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
to,
text,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
accountId,
deps,
replyToId,
});
return { channel: "imessage", ...result };
},

View File

@@ -11,14 +11,23 @@ const selectFirstOption = async <T>(params: { options: Array<{ value: T }> }): P
return first.value;
};
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
return {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: selectFirstOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
...overrides,
};
}
describe("irc onboarding", () => {
it("configures host and nick via onboarding prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: selectFirstOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
const prompter = createPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC server host") {
return "irc.libera.chat";
@@ -52,8 +61,7 @@ describe("irc onboarding", () => {
}
return false;
}),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
});
const runtime: RuntimeEnv = {
log: vi.fn(),
@@ -84,12 +92,7 @@ describe("irc onboarding", () => {
});
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: selectFirstOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
const prompter = createPrompter({
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC allowFrom (nick or nick!user@host)") {
return "Alice, Bob!ident@example.org";
@@ -97,8 +100,7 @@ describe("irc onboarding", () => {
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
});
const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom;
expect(promptAllowFrom).toBeTypeOf("function");

View File

@@ -1,6 +1,7 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
@@ -393,16 +394,8 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
},
];
}),
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,
}),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs, cfg }) => {
try {
const auth = await resolveMatrixAuth({

View File

@@ -1,6 +1,7 @@
import type { DmPolicy } from "openclaw/plugin-sdk";
import {
addWildcardAllowFrom,
formatResolvedUnresolvedNote,
formatDocsLink,
mergeAllowFromEntries,
promptChannelAccessConfig,
@@ -408,18 +409,12 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
}
}
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",
);
const resolution = formatResolvedUnresolvedNote({
resolved: resolvedIds,
unresolved,
});
if (resolution) {
await prompter.note(resolution, "Matrix rooms");
}
} catch (err) {
await prompter.note(

View File

@@ -1,4 +1,5 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk";
export type MiniMaxRegion = "cn" | "global";
@@ -49,15 +50,8 @@ type TokenResult =
| TokenPending
| { status: "error"; message: string };
function toFormUrlEncoded(data: Record<string, string>): 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 { verifier, challenge } = generatePkceVerifierChallenge();
const state = randomBytes(16).toString("base64url");
return { verifier, challenge, state };
}

View File

@@ -1,10 +1,5 @@
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
import { createStartAccountContext } from "../../test-utils/start-account-context.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
@@ -21,30 +16,6 @@ vi.mock("./monitor.js", async () => {
import { nextcloudTalkPlugin } from "./channel.js";
function createStartAccountCtx(params: {
account: ResolvedNextcloudTalkAccount;
abortSignal: AbortSignal;
}): ChannelGatewayContext<ResolvedNextcloudTalkAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: params.account.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.account.accountId,
account: params.account,
cfg: {} as OpenClawConfig,
runtime: createRuntimeEnv(),
abortSignal: params.abortSignal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: (next) => {
Object.assign(snapshot, next);
},
};
}
function buildAccount(): ResolvedNextcloudTalkAccount {
return {
accountId: "default",
@@ -72,7 +43,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => {
const abort = new AbortController();
const task = nextcloudTalkPlugin.gateway!.startAccount!(
createStartAccountCtx({
createStartAccountContext({
account: buildAccount(),
abortSignal: abort.signal,
}),
@@ -103,7 +74,7 @@ describe("nextcloudTalkPlugin gateway.startAccount", () => {
abort.abort();
await nextcloudTalkPlugin.gateway!.startAccount!(
createStartAccountCtx({
createStartAccountContext({
account: buildAccount(),
abortSignal: abort.signal,
}),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
import { startWebhookServer } from "./monitor.test-harness.js";
import { generateNextcloudTalkSignature } from "./signature.js";
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
it("rejects requests from unexpected backend origins", async () => {
@@ -11,31 +11,12 @@ describe("createNextcloudTalkWebhookServer backend allowlist", () => {
onMessage,
});
const payload = {
type: "Create",
actor: { type: "Person", id: "alice", name: "Alice" },
object: {
type: "Note",
id: "msg-1",
name: "hello",
content: "hello",
mediaType: "text/plain",
},
target: { type: "Collection", id: "room-1", name: "Room 1" },
};
const body = JSON.stringify(payload);
const { random, signature } = generateNextcloudTalkSignature({
body,
secret: "nextcloud-secret",
const { body, headers } = createSignedCreateMessageRequest({
backend: "https://nextcloud.unexpected",
});
const response = await fetch(harness.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-nextcloud-talk-random": random,
"x-nextcloud-talk-signature": signature,
"x-nextcloud-talk-backend": "https://nextcloud.unexpected",
},
headers,
body,
});

View File

@@ -1,15 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
import { startWebhookServer } from "./monitor.test-harness.js";
import { generateNextcloudTalkSignature } from "./signature.js";
import type { NextcloudTalkInboundMessage } from "./types.js";
function createSignedRequest(body: string): { random: string; signature: string } {
return generateNextcloudTalkSignature({
body,
secret: "nextcloud-secret",
});
}
describe("createNextcloudTalkWebhookServer replay handling", () => {
it("acknowledges replayed requests and skips onMessage side effects", async () => {
const seen = new Set<string>();
@@ -27,26 +20,7 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
onMessage,
});
const payload = {
type: "Create",
actor: { type: "Person", id: "alice", name: "Alice" },
object: {
type: "Note",
id: "msg-1",
name: "hello",
content: "hello",
mediaType: "text/plain",
},
target: { type: "Collection", id: "room-1", name: "Room 1" },
};
const body = JSON.stringify(payload);
const { random, signature } = createSignedRequest(body);
const headers = {
"content-type": "application/json",
"x-nextcloud-talk-random": random,
"x-nextcloud-talk-signature": signature,
"x-nextcloud-talk-backend": "https://nextcloud.example",
};
const { body, headers } = createSignedCreateMessageRequest();
const first = await fetch(harness.webhookUrl, {
method: "POST",

View File

@@ -0,0 +1,30 @@
import { generateNextcloudTalkSignature } from "./signature.js";
export function createSignedCreateMessageRequest(params?: { backend?: string }) {
const payload = {
type: "Create",
actor: { type: "Person", id: "alice", name: "Alice" },
object: {
type: "Note",
id: "msg-1",
name: "hello",
content: "hello",
mediaType: "text/plain",
},
target: { type: "Collection", id: "room-1", name: "Room 1" },
};
const body = JSON.stringify(payload);
const { random, signature } = generateNextcloudTalkSignature({
body,
secret: "nextcloud-secret",
});
return {
body,
headers: {
"content-type": "application/json",
"x-nextcloud-talk-random": random,
"x-nextcloud-talk-signature": signature,
"x-nextcloud-talk-backend": params?.backend ?? "https://nextcloud.example",
},
};
}

View File

@@ -1,4 +1,5 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { randomUUID } from "node:crypto";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk";
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
@@ -30,18 +31,6 @@ type DeviceTokenResult =
| TokenPending
| { status: "error"; message: string };
function toFormUrlEncoded(data: Record<string, string>): string {
return Object.entries(data)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
async function requestDeviceCode(params: { challenge: string }): Promise<QwenDeviceAuthorization> {
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: "POST",
@@ -142,7 +131,7 @@ export async function loginQwenPortalOAuth(params: {
note: (message: string, title?: string) => Promise<void>;
progress: { update: (message: string) => void; stop: (message?: string) => void };
}): Promise<QwenOAuthToken> {
const { verifier, challenge } = generatePkce();
const { verifier, challenge } = generatePkceVerifierChallenge();
const device = await requestDeviceCode({ challenge });
const verificationUrl = device.verification_uri_complete || device.verification_uri;

View File

@@ -45,6 +45,46 @@ const signalMessageActions: ChannelMessageActionAdapter = {
const meta = getChatChannelMeta("signal");
function buildSignalSetupPatch(input: {
signalNumber?: string;
cliPath?: string;
httpUrl?: string;
httpHost?: string;
httpPort?: string;
}) {
return {
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
};
}
type SignalSendFn = ReturnType<typeof getSignalRuntime>["channel"]["signal"]["sendMessageSignal"];
async function sendSignalOutbound(params: {
cfg: Parameters<typeof resolveSignalAccount>[0]["cfg"];
to: string;
text: string;
mediaUrl?: string;
accountId?: string;
deps?: { sendSignal?: SignalSendFn };
}) {
const send = params.deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ?? cfg.channels?.signal?.mediaMaxMb,
accountId: params.accountId,
});
return await send(params.to, params.text, {
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
});
}
export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
id: "signal",
meta: {
@@ -190,11 +230,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
signal: {
...next.channels?.signal,
enabled: true,
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
...buildSignalSetupPatch(input),
},
},
};
@@ -211,11 +247,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
[accountId]: {
...next.channels?.signal?.accounts?.[accountId],
enabled: true,
...(input.signalNumber ? { account: input.signalNumber } : {}),
...(input.cliPath ? { cliPath: input.cliPath } : {}),
...(input.httpUrl ? { httpUrl: input.httpUrl } : {}),
...(input.httpHost ? { httpHost: input.httpHost } : {}),
...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}),
...buildSignalSetupPatch(input),
},
},
},
@@ -229,33 +261,23 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
chunkerMode: "text",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, deps }) => {
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
const result = await sendSignalOutbound({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
to,
text,
accountId,
});
const result = await send(to, text, {
maxBytes,
accountId: accountId ?? undefined,
deps,
});
return { channel: "signal", ...result };
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => {
const send = deps?.sendSignal ?? getSignalRuntime().channel.signal.sendMessageSignal;
const maxBytes = resolveChannelMediaMaxBytes({
const result = await sendSignalOutbound({
cfg,
resolveChannelLimitMb: ({ cfg, accountId }) =>
cfg.channels?.signal?.accounts?.[accountId]?.mediaMaxMb ??
cfg.channels?.signal?.mediaMaxMb,
accountId,
});
const result = await send(to, text, {
to,
text,
mediaUrl,
maxBytes,
accountId: accountId ?? undefined,
accountId,
deps,
});
return { channel: "signal", ...result };
},

View File

@@ -63,6 +63,24 @@ function isSlackAccountConfigured(account: ResolvedSlackAccount): boolean {
return Boolean(account.appToken?.trim());
}
type SlackSendFn = ReturnType<typeof getSlackRuntime>["channel"]["slack"]["sendMessageSlack"];
function resolveSlackSendContext(params: {
cfg: Parameters<typeof resolveSlackAccount>[0]["cfg"];
accountId?: string;
deps?: { sendSlack?: SlackSendFn };
replyToId?: string | null;
threadId?: string | null;
}) {
const send = params.deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = params.replyToId ?? params.threadId;
return { send, threadTsValue, tokenOverride };
}
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
id: "slack",
meta: {
@@ -339,12 +357,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
chunker: null,
textChunkLimit: 4000,
sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => {
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = replyToId ?? threadId;
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId,
deps,
replyToId,
threadId,
});
const result = await send(to, text, {
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,
accountId: accountId ?? undefined,
@@ -353,12 +372,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
return { channel: "slack", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, cfg }) => {
const send = deps?.sendSlack ?? getSlackRuntime().channel.slack.sendMessageSlack;
const account = resolveSlackAccount({ cfg, accountId });
const token = getTokenForOperation(account, "write");
const botToken = account.botToken?.trim();
const tokenOverride = token && token !== botToken ? token : undefined;
const threadTsValue = replyToId ?? threadId;
const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({
cfg,
accountId,
deps,
replyToId,
threadId,
});
const result = await send(to, text, {
mediaUrl,
threadTs: threadTsValue != null ? String(threadTsValue) : undefined,

View File

@@ -1,6 +1,5 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
type RegisteredRoute = {
path: string;
@@ -41,37 +40,6 @@ vi.mock("./client.js", () => ({
const { createSynologyChatPlugin } = await import("./channel.js");
function makeReq(method: string, body: string): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.socket = { remoteAddress: "127.0.0.1" } as any;
process.nextTick(() => {
req.emit("data", Buffer.from(body));
req.emit("end");
});
return req;
}
function makeRes(): ServerResponse & { _status: number; _body: string } {
const res = {
_status: 0,
_body: "",
writeHead(statusCode: number, _headers: Record<string, string>) {
res._status = statusCode;
},
end(body?: string) {
res._body = body ?? "";
},
} as any;
return res;
}
function makeFormBody(fields: Record<string, string>): string {
return Object.entries(fields)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
describe("Synology channel wiring integration", () => {
beforeEach(() => {
registerPluginHttpRouteMock.mockClear();

View File

@@ -0,0 +1,33 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
export function makeReq(method: string, body: string): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.socket = { remoteAddress: "127.0.0.1" } as unknown as IncomingMessage["socket"];
process.nextTick(() => {
req.emit("data", Buffer.from(body));
req.emit("end");
});
return req;
}
export function makeRes(): ServerResponse & { _status: number; _body: string } {
const res = {
_status: 0,
_body: "",
writeHead(statusCode: number, _headers: Record<string, string>) {
res._status = statusCode;
},
end(body?: string) {
res._body = body ?? "";
},
} as unknown as ServerResponse & { _status: number; _body: string };
return res;
}
export function makeFormBody(fields: Record<string, string>): string {
return Object.entries(fields)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}

View File

@@ -1,6 +1,5 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { makeFormBody, makeReq, makeRes } from "./test-http-utils.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
import {
clearSynologyWebhookRateLimiterStateForTest,
@@ -31,40 +30,6 @@ function makeAccount(
};
}
function makeReq(method: string, body: string): IncomingMessage {
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.socket = { remoteAddress: "127.0.0.1" } as any;
// Simulate body delivery
process.nextTick(() => {
req.emit("data", Buffer.from(body));
req.emit("end");
});
return req;
}
function makeRes(): ServerResponse & { _status: number; _body: string } {
const res = {
_status: 0,
_body: "",
writeHead(statusCode: number, _headers: Record<string, string>) {
res._status = statusCode;
},
end(body?: string) {
res._body = body ?? "";
},
} as any;
return res;
}
function makeFormBody(fields: Record<string, string>): string {
return Object.entries(fields)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
const validBody = makeFormBody({
token: "valid-token",
user_id: "123",

View File

@@ -0,0 +1,33 @@
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
OpenClawConfig,
} from "openclaw/plugin-sdk";
import { vi } from "vitest";
import { createRuntimeEnv } from "./runtime-env.js";
export function createStartAccountContext<TAccount extends { accountId: string }>(params: {
account: TAccount;
abortSignal: AbortSignal;
statusPatchSink?: (next: ChannelAccountSnapshot) => void;
}): ChannelGatewayContext<TAccount> {
const snapshot: ChannelAccountSnapshot = {
accountId: params.account.accountId,
configured: true,
enabled: true,
running: false,
};
return {
accountId: params.account.accountId,
account: params.account,
cfg: {} as OpenClawConfig,
runtime: createRuntimeEnv(),
abortSignal: params.abortSignal,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
getStatus: () => snapshot,
setStatus: (next) => {
Object.assign(snapshot, next);
params.statusPatchSink?.(snapshot);
},
};
}

View File

@@ -189,6 +189,16 @@ const voiceCallPlugin = {
respond(false, { error: err instanceof Error ? err.message : String(err) });
};
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
const message = typeof params?.message === "string" ? params.message.trim() : "";
if (!callId || !message) {
return { error: "callId and message required" } as const;
}
const rt = await ensureRuntime();
return { rt, callId, message } as const;
};
api.registerGatewayMethod(
"voicecall.initiate",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
@@ -228,14 +238,12 @@ const voiceCallPlugin = {
"voicecall.continue",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
const message = typeof params?.message === "string" ? params.message.trim() : "";
if (!callId || !message) {
respond(false, { error: "callId and message required" });
const request = await resolveCallMessageRequest(params);
if ("error" in request) {
respond(false, { error: request.error });
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.continueCall(callId, message);
const result = await request.rt.manager.continueCall(request.callId, request.message);
if (!result.success) {
respond(false, { error: result.error || "continue failed" });
return;
@@ -251,14 +259,12 @@ const voiceCallPlugin = {
"voicecall.speak",
async ({ params, respond }: GatewayRequestHandlerOptions) => {
try {
const callId = typeof params?.callId === "string" ? params.callId.trim() : "";
const message = typeof params?.message === "string" ? params.message.trim() : "";
if (!callId || !message) {
respond(false, { error: "callId and message required" });
const request = await resolveCallMessageRequest(params);
if ("error" in request) {
respond(false, { error: request.error });
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.speak(callId, message);
const result = await request.rt.manager.speak(request.callId, request.message);
if (!result.success) {
respond(false, { error: result.error || "speak failed" });
return;

View File

@@ -86,6 +86,18 @@ function twilioSignature(params: { authToken: string; url: string; postBody: str
return crypto.createHmac("sha1", params.authToken).update(dataToSign).digest("base64");
}
function expectReplayResultPair(
first: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
second: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string },
) {
expect(first.ok).toBe(true);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
}
describe("verifyPlivoWebhook", () => {
it("accepts valid V2 signature", () => {
const authToken = "test-auth-token";
@@ -196,12 +208,7 @@ describe("verifyPlivoWebhook", () => {
const first = verifyPlivoWebhook(ctx, authToken);
const second = verifyPlivoWebhook(ctx, authToken);
expect(first.ok).toBe(true);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expectReplayResultPair(first, second);
});
it("returns a stable request key when verification is skipped", () => {
@@ -245,12 +252,7 @@ describe("verifyTelnyxWebhook", () => {
const first = verifyTelnyxWebhook(ctx, pemPublicKey);
const second = verifyTelnyxWebhook(ctx, pemPublicKey);
expect(first.ok).toBe(true);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expectReplayResultPair(first, second);
});
it("returns a stable request key when verification is skipped", () => {

View File

@@ -55,6 +55,21 @@ const createManager = (calls: CallRecord[]) => {
return { manager, endCall, processEvent };
};
async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, body: string) {
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
return await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
}
describe("VoiceCallWebhookServer stale call reaper", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -146,18 +161,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
expect(response.status).toBe(200);
expect(processEvent).not.toHaveBeenCalled();
@@ -193,18 +197,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
expect(response.status).toBe(200);
expect(parseWebhookEvent).toHaveBeenCalledTimes(1);
@@ -231,18 +224,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
expect(response.status).toBe(401);
expect(parseWebhookEvent).not.toHaveBeenCalled();

View File

@@ -13,7 +13,7 @@ import {
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeE164,
normalizeWhatsAppAllowFromEntries,
formatWhatsAppConfigAllowFromEntries,
normalizeWhatsAppMessagingTarget,
readStringParam,
resolveDefaultWhatsAppAccountId,
@@ -21,6 +21,8 @@ import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveWhatsAppAccount,
resolveWhatsAppConfigAllowFrom,
resolveWhatsAppConfigDefaultTo,
resolveWhatsAppGroupRequireMention,
resolveWhatsAppGroupIntroHint,
resolveWhatsAppGroupToolPolicy,
@@ -113,15 +115,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
dmPolicy: account.dmPolicy,
allowFrom: account.allowFrom,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => {
const root = cfg.channels?.whatsapp;
const normalized = normalizeAccountId(accountId);
const account = root?.accounts?.[normalized];
return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined;
},
resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {

View File

@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveSenderCommandAuthorization,
@@ -443,6 +444,15 @@ async function processMessageWithPipeline(params: {
id: chatId,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
if (
isGroup &&
@@ -454,20 +464,10 @@ async function processMessageWithPipeline(params: {
}
const fromLabel = isGroup ? `group:${chatId}` : 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({
const { storePath, body } = buildEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});

View File

@@ -6,6 +6,7 @@ import type {
RuntimeEnv,
} from "openclaw/plugin-sdk";
import {
createInboundEnvelopeBuilder,
createScopedPairingAccess,
createReplyPrefixOptions,
resolveOutboundMediaUrls,
@@ -314,22 +315,21 @@ async function processMessage(
id: peer.id,
},
});
const buildEnvelope = createInboundEnvelopeBuilder({
cfg: config,
route,
sessionStore: config.session?.store,
resolveStorePath: core.channel.session.resolveStorePath,
readSessionUpdatedAt: core.channel.session.readSessionUpdatedAt,
resolveEnvelopeFormatOptions: core.channel.reply.resolveEnvelopeFormatOptions,
formatAgentEnvelope: core.channel.reply.formatAgentEnvelope,
});
const fromLabel = isGroup ? `group:${chatId}` : 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({
const { storePath, body } = buildEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});

View File

@@ -7,6 +7,7 @@ import type {
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatResolvedUnresolvedNote,
mergeAllowFromEntries,
normalizeAccountId,
promptAccountId,
@@ -398,18 +399,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
keys = [...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"),
"Zalo groups",
);
const resolution = formatResolvedUnresolvedNote({
resolved: resolvedIds,
unresolved,
});
if (resolution) {
await prompter.note(resolution, "Zalo groups");
}
} catch (err) {
await prompter.note(

View File

@@ -3,7 +3,14 @@ import {
resolveChannelGroupToolsPolicy,
} from "../config/group-policy.js";
import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import {
formatTrimmedAllowFromEntries,
formatWhatsAppConfigAllowFromEntries,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
resolveWhatsAppConfigAllowFrom,
resolveWhatsAppConfigDefaultTo,
} from "../plugin-sdk/channel-config-helpers.js";
import { requireActivePluginRegistry } from "../plugins/runtime.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveSignalAccount } from "../signal/accounts.js";
@@ -11,7 +18,6 @@ import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
@@ -27,7 +33,6 @@ import {
resolveWhatsAppGroupToolPolicy,
} from "./plugins/group-mentions.js";
import { normalizeSignalMessagingTarget } from "./plugins/normalize/signal.js";
import { normalizeWhatsAppAllowFromEntries } from "./plugins/normalize/whatsapp.js";
import type {
ChannelCapabilities,
ChannelCommandAdapter,
@@ -289,15 +294,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
resolveWhatsAppAccount({ cfg, accountId }).allowFrom ?? [],
formatAllowFrom: ({ allowFrom }) => normalizeWhatsAppAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => {
const root = cfg.channels?.whatsapp;
const normalized = normalizeAccountId(accountId);
const account = root?.accounts?.[normalized];
return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined;
},
resolveAllowFrom: ({ cfg, accountId }) => resolveWhatsAppConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatWhatsAppConfigAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveWhatsAppConfigDefaultTo({ cfg, accountId }),
},
groups: {
resolveRequireMention: resolveWhatsAppGroupRequireMention,
@@ -534,14 +533,9 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
},
outbound: DEFAULT_OUTBOUND_TEXT_CHUNK_LIMIT_4000,
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveIMessageAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
resolveDefaultTo: ({ cfg, accountId }) =>
resolveIMessageAccount({ cfg, accountId }).config.defaultTo?.trim() || undefined,
resolveAllowFrom: ({ cfg, accountId }) => resolveIMessageConfigAllowFrom({ cfg, accountId }),
formatAllowFrom: ({ allowFrom }) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: ({ cfg, accountId }) => resolveIMessageConfigDefaultTo({ cfg, accountId }),
},
groups: {
resolveRequireMention: resolveIMessageGroupRequireMention,

View File

@@ -1,6 +1,7 @@
import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js";
import { normalizeE164 } from "../utils.js";
import {
type ParsedChatTarget,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
@@ -15,11 +16,7 @@ export type IMessageTarget =
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; to: string; service: IMessageService };
export type IMessageAllowTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; handle: string };
export type IMessageAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];

View File

@@ -0,0 +1,44 @@
import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveIMessageAccount } from "../imessage/accounts.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
export function formatTrimmedAllowFromEntries(allowFrom: Array<string | number>): string[] {
return allowFrom.map((entry) => String(entry).trim()).filter(Boolean);
}
export function resolveWhatsAppConfigAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return resolveWhatsAppAccount(params).allowFrom ?? [];
}
export function formatWhatsAppConfigAllowFromEntries(allowFrom: Array<string | number>): string[] {
return normalizeWhatsAppAllowFromEntries(allowFrom);
}
export function resolveWhatsAppConfigDefaultTo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string | undefined {
const root = params.cfg.channels?.whatsapp;
const normalized = normalizeAccountId(params.accountId);
const account = root?.accounts?.[normalized];
return (account?.defaultTo ?? root?.defaultTo)?.trim() || undefined;
}
export function resolveIMessageConfigAllowFrom(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string[] {
return (resolveIMessageAccount(params).config.allowFrom ?? []).map((entry) => String(entry));
}
export function resolveIMessageConfigDefaultTo(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): string | undefined {
return resolveIMessageAccount(params).config.defaultTo?.trim() || undefined;
}

View File

@@ -0,0 +1,41 @@
type RouteLike = {
agentId: string;
sessionKey: string;
};
export function createInboundEnvelopeBuilder<TConfig, TEnvelope>(params: {
cfg: TConfig;
route: RouteLike;
sessionStore?: string;
resolveStorePath: (store: string | undefined, opts: { agentId: string }) => string;
readSessionUpdatedAt: (params: { storePath: string; sessionKey: string }) => number | undefined;
resolveEnvelopeFormatOptions: (cfg: TConfig) => TEnvelope;
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
previousTimestamp?: number;
envelope: TEnvelope;
body: string;
}) => string;
}) {
const storePath = params.resolveStorePath(params.sessionStore, {
agentId: params.route.agentId,
});
const envelopeOptions = params.resolveEnvelopeFormatOptions(params.cfg);
return (input: { channel: string; from: string; body: string; timestamp?: number }) => {
const previousTimestamp = params.readSessionUpdatedAt({
storePath,
sessionKey: params.route.sessionKey,
});
const body = params.formatAgentEnvelope({
channel: input.channel,
from: input.from,
timestamp: input.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: input.body,
});
return { storePath, body };
};
}

View File

@@ -139,11 +139,13 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js";
export {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildProbeChannelStatusSummary,
buildTokenChannelStatusSummary,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
} from "./status-helpers.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export type { ChannelDock } from "../channels/dock.js";
export { getChatChannelMeta } from "../channels/registry.js";
export type {
@@ -223,6 +225,7 @@ export {
} from "./group-access.js";
export { resolveSenderCommandAuthorization } from "./command-auth.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { createInboundEnvelopeBuilder } from "./inbound-envelope.js";
export { issuePairingChallenge } from "../pairing/pairing-challenge.js";
export { handleSlackMessageAction } from "./slack-message-actions.js";
export { extractToolSend } from "./tool-send.js";
@@ -242,6 +245,7 @@ export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-
export { createLoggerBackedRuntime } from "./runtime.js";
export { chunkTextForOutbound } from "./text-chunking.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";
export { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
export {
applyWindowsSpawnProgramPolicy,
@@ -280,6 +284,14 @@ export type { ReplyPayload } from "../auto-reply/types.js";
export type { ChunkMode } from "../auto-reply/chunk.js";
export { SILENT_REPLY_TOKEN, isSilentReplyText } from "../auto-reply/tokens.js";
export { formatInboundFromLabel } from "../auto-reply/envelope.js";
export {
formatTrimmedAllowFromEntries,
formatWhatsAppConfigAllowFromEntries,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
resolveWhatsAppConfigAllowFrom,
resolveWhatsAppConfigDefaultTo,
} from "./channel-config-helpers.js";
export {
approveDevicePairing,
listDevicePairing,
@@ -521,6 +533,7 @@ export {
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
} from "../imessage/target-parsing-helpers.js";
export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js";
// Channel: Slack
export {

View File

@@ -0,0 +1,13 @@
import { createHash, randomBytes } from "node:crypto";
export function toFormUrlEncoded(data: Record<string, string>): string {
return Object.entries(data)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
export function generatePkceVerifierChallenge(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}

View File

@@ -0,0 +1,16 @@
export function formatResolvedUnresolvedNote(params: {
resolved: string[];
unresolved: string[];
}): string | undefined {
if (params.resolved.length === 0 && params.unresolved.length === 0) {
return undefined;
}
return [
params.resolved.length > 0 ? `Resolved: ${params.resolved.join(", ")}` : undefined,
params.unresolved.length > 0
? `Unresolved (kept as typed): ${params.unresolved.join(", ")}`
: undefined,
]
.filter(Boolean)
.join("\n");
}

View File

@@ -45,6 +45,26 @@ export function buildBaseChannelStatusSummary(snapshot: {
};
}
export function buildProbeChannelStatusSummary<TExtra extends Record<string, unknown>>(
snapshot: {
configured?: boolean | null;
running?: boolean | null;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: unknown;
lastProbeAt?: number | null;
},
extra?: TExtra,
) {
return {
...buildBaseChannelStatusSummary(snapshot),
...(extra ?? ({} as TExtra)),
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
};
}
export function buildBaseAccountStatusSnapshot(params: {
account: {
accountId: string;