mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor(extensions): dedupe channel config, onboarding, and monitors
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:"];
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
12
extensions/feishu/src/monitor.test-mocks.ts
Normal file
12
extensions/feishu/src/monitor.test-mocks.ts
Normal 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() })),
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
extensions/nextcloud-talk/src/monitor.test-fixtures.ts
Normal file
30
extensions/nextcloud-talk/src/monitor.test-fixtures.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
33
extensions/synology-chat/src/test-http-utils.ts
Normal file
33
extensions/synology-chat/src/test-http-utils.ts
Normal 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("&");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
33
extensions/test-utils/start-account-context.ts
Normal file
33
extensions/test-utils/start-account-context.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:"];
|
||||
|
||||
44
src/plugin-sdk/channel-config-helpers.ts
Normal file
44
src/plugin-sdk/channel-config-helpers.ts
Normal 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;
|
||||
}
|
||||
41
src/plugin-sdk/inbound-envelope.ts
Normal file
41
src/plugin-sdk/inbound-envelope.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
13
src/plugin-sdk/oauth-utils.ts
Normal file
13
src/plugin-sdk/oauth-utils.ts
Normal 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 };
|
||||
}
|
||||
16
src/plugin-sdk/resolution-notes.ts
Normal file
16
src/plugin-sdk/resolution-notes.ts
Normal 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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user