refactor(extensions): reuse shared helper primitives

This commit is contained in:
Peter Steinberger
2026-03-07 10:40:57 +00:00
parent 3c71e2bd48
commit 1aa77e4603
58 changed files with 1567 additions and 2195 deletions

View File

@@ -6,6 +6,7 @@ import type {
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
@@ -25,6 +26,7 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { bluebubblesMessageActions } from "./actions.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { sendBlueBubblesMedia } from "./media-send.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
@@ -255,40 +257,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl: input.httpUrl,
password: input.password,
webhookPath: input.webhookPath,
},
} as OpenClawConfig;
onlyDefinedFields: true,
});
}
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
},
},
},
return applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl: input.httpUrl,
password: input.password,
webhookPath: input.webhookPath,
},
} as OpenClawConfig;
onlyDefinedFields: true,
});
},
},
pairing: {
@@ -372,20 +361,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
buildAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
return {
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running,
connected: probeOk ?? running,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
runtime,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
});
return {
...base,
baseUrl: account.baseUrl,
connected: probeOk ?? running,
};
},
},

View File

@@ -30,6 +30,39 @@ function resolvePartIndex(partIndex: number | undefined): number {
return typeof partIndex === "number" ? partIndex : 0;
}
async function sendBlueBubblesChatEndpointRequest(params: {
chatGuid: string;
opts: BlueBubblesChatOpts;
endpoint: "read" | "typing";
method: "POST" | "DELETE";
action: "read" | "typing";
}): Promise<void> {
const trimmed = params.chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(params.opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/${params.endpoint}`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: params.method },
params.opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`,
);
}
}
async function sendPrivateApiJsonRequest(params: {
opts: BlueBubblesChatOpts;
feature: string;
@@ -65,24 +98,13 @@ export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
await sendBlueBubblesChatEndpointRequest({
chatGuid,
opts,
endpoint: "read",
method: "POST",
action: "read",
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
}
}
export async function sendBlueBubblesTyping(
@@ -90,28 +112,13 @@ export async function sendBlueBubblesTyping(
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) {
return;
}
const { baseUrl, password, accountId } = resolveAccount(opts);
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
return;
}
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
password,
await sendBlueBubblesChatEndpointRequest({
chatGuid,
opts,
endpoint: "typing",
method: typing ? "POST" : "DELETE",
action: "typing",
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: typing ? "POST" : "DELETE" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
/**

View File

@@ -0,0 +1,77 @@
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
type BlueBubblesConfigPatch = {
serverUrl?: string;
password?: unknown;
webhookPath?: string;
};
type AccountEnabledMode = boolean | "preserve-or-true";
function normalizePatch(
patch: BlueBubblesConfigPatch,
onlyDefinedFields: boolean,
): BlueBubblesConfigPatch {
if (!onlyDefinedFields) {
return patch;
}
const next: BlueBubblesConfigPatch = {};
if (patch.serverUrl !== undefined) {
next.serverUrl = patch.serverUrl;
}
if (patch.password !== undefined) {
next.password = patch.password;
}
if (patch.webhookPath !== undefined) {
next.webhookPath = patch.webhookPath;
}
return next;
}
export function applyBlueBubblesConnectionConfig(params: {
cfg: OpenClawConfig;
accountId: string;
patch: BlueBubblesConfigPatch;
onlyDefinedFields?: boolean;
accountEnabled?: AccountEnabledMode;
}): OpenClawConfig {
const patch = normalizePatch(params.patch, params.onlyDefinedFields === true);
if (params.accountId === DEFAULT_ACCOUNT_ID) {
return {
...params.cfg,
channels: {
...params.cfg.channels,
bluebubbles: {
...params.cfg.channels?.bluebubbles,
enabled: true,
...patch,
},
},
};
}
const currentAccount = params.cfg.channels?.bluebubbles?.accounts?.[params.accountId];
const enabled =
params.accountEnabled === "preserve-or-true"
? (currentAccount?.enabled ?? true)
: (params.accountEnabled ?? true);
return {
...params.cfg,
channels: {
...params.cfg.channels,
bluebubbles: {
...params.cfg.channels?.bluebubbles,
enabled: true,
accounts: {
...params.cfg.channels?.bluebubbles?.accounts,
[params.accountId]: {
...currentAccount,
enabled,
...patch,
},
},
},
},
};
}

View File

@@ -1,3 +1,4 @@
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";
@@ -35,17 +36,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
if (!record) {
return undefined;
}
const value = record[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
return parseFiniteNumber(record[key]);
}
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {

View File

@@ -240,6 +240,15 @@ function getFirstDispatchCall(): DispatchReplyParams {
}
describe("BlueBubbles webhook monitor", () => {
const WEBHOOK_PATH = "/bluebubbles-webhook";
const BASE_WEBHOOK_MESSAGE_DATA = {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
} as const;
let unregister: () => void;
beforeEach(() => {
@@ -261,122 +270,144 @@ describe("BlueBubbles webhook monitor", () => {
unregister?.();
});
function createWebhookPayload(
dataOverrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
type: "new-message",
data: {
...BASE_WEBHOOK_MESSAGE_DATA,
...dataOverrides,
},
};
}
function createWebhookTargetDeps(core?: PluginRuntime): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
} {
const resolvedCore = core ?? createMockRuntime();
setBlueBubblesRuntime(resolvedCore);
return {
config: {},
core: resolvedCore,
runtime: {
log: vi.fn<(message: string) => void>(),
error: vi.fn<(message: string) => void>(),
},
};
}
function registerWebhookTarget(
params: {
account?: ResolvedBlueBubblesAccount;
config?: OpenClawConfig;
core?: PluginRuntime;
runtime?: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
path?: string;
statusSink?: Parameters<typeof registerBlueBubblesWebhookTarget>[0]["statusSink"];
trackForCleanup?: boolean;
} = {},
): {
config: OpenClawConfig;
core: PluginRuntime;
runtime: {
log: ReturnType<typeof vi.fn<(message: string) => void>>;
error: ReturnType<typeof vi.fn<(message: string) => void>>;
};
stop: () => void;
} {
const deps =
params.config && params.core && params.runtime
? { config: params.config, core: params.core, runtime: params.runtime }
: createWebhookTargetDeps(params.core);
const stop = registerBlueBubblesWebhookTarget({
account: params.account ?? createMockAccount(),
...deps,
path: params.path ?? WEBHOOK_PATH,
statusSink: params.statusSink,
});
if (params.trackForCleanup !== false) {
unregister = stop;
}
return { ...deps, stop };
}
async function sendWebhookRequest(params: {
method?: string;
url?: string;
body?: unknown;
headers?: Record<string, string>;
remoteAddress?: string;
}): Promise<{
req: IncomingMessage;
res: ServerResponse & { body: string; statusCode: number };
handled: boolean;
}> {
const req = createMockRequest(
params.method ?? "POST",
params.url ?? WEBHOOK_PATH,
params.body ?? createWebhookPayload(),
params.headers,
);
if (params.remoteAddress) {
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: params.remoteAddress,
};
}
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
return { req, res, handled };
}
describe("webhook parsing + auth handling", () => {
it("rejects non-POST requests", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
method: "GET",
body: {},
});
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(405);
});
it("accepts POST requests with valid JSON payload", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload({ date: Date.now() }),
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("ok");
});
it("rejects requests with invalid JSON", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget();
const { handled, res } = await sendWebhookRequest({
body: "invalid json {{",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
});
it("accepts URL-encoded payload wrappers", async () => {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
date: Date.now(),
},
};
registerWebhookTarget();
const payload = createWebhookPayload({ date: Date.now() });
const encodedBody = new URLSearchParams({
payload: JSON.stringify(payload),
}).toString();
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({ body: encodedBody });
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -386,23 +417,12 @@ describe("BlueBubbles webhook monitor", () => {
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
registerWebhookTarget();
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=test-password";
req.url = `${WEBHOOK_PATH}?password=test-password`;
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
@@ -426,22 +446,13 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects unauthorized requests before reading the body", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook?password=wrong-token";
req.url = `${WEBHOOK_PATH}?password=wrong-token`;
req.headers = {};
const onSpy = vi.spyOn(req, "on");
(req as unknown as { socket: { remoteAddress: string } }).socket = {
@@ -457,112 +468,43 @@ describe("BlueBubbles webhook monitor", () => {
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
// Mock non-localhost request
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("authenticates via x-password header", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
{ "x-password": "secret-token" },
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
headers: { "x-password": "secret-token" },
remoteAddress: "192.168.1.100",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("rejects unauthorized requests with wrong password", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
registerWebhookTarget({
account: createMockAccount({ password: "secret-token" }),
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=wrong-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
};
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
});
@@ -570,50 +512,37 @@ describe("BlueBubbles webhook monitor", () => {
it("rejects ambiguous routing when multiple targets match the same password", async () => {
const accountA = createMockAccount({ password: "secret-token" });
const accountB = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
const sinkA = vi.fn();
const sinkB = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterA = registerBlueBubblesWebhookTarget({
const unregisterA = registerWebhookTarget({
account: accountA,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkA,
});
const unregisterB = registerBlueBubblesWebhookTarget({
}).stop;
const unregisterB = registerWebhookTarget({
account: accountB,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkB,
});
}).stop;
unregister = () => {
unregisterA();
unregisterB();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -624,50 +553,37 @@ describe("BlueBubbles webhook monitor", () => {
it("ignores targets without passwords when a password-authenticated target matches", async () => {
const accountStrict = createMockAccount({ password: "secret-token" });
const accountWithoutPassword = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
const sinkStrict = vi.fn();
const sinkWithoutPassword = vi.fn();
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "192.168.1.100",
};
const unregisterStrict = registerBlueBubblesWebhookTarget({
const unregisterStrict = registerWebhookTarget({
account: accountStrict,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkStrict,
});
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
}).stop;
const unregisterNoPassword = registerWebhookTarget({
account: accountWithoutPassword,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
trackForCleanup: false,
statusSink: sinkWithoutPassword,
});
}).stop;
unregister = () => {
unregisterStrict();
unregisterNoPassword();
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
url: `${WEBHOOK_PATH}?password=secret-token`,
body: createWebhookPayload(),
remoteAddress: "192.168.1.100",
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
@@ -677,34 +593,20 @@ describe("BlueBubbles webhook monitor", () => {
it("requires authentication for loopback requests when password is configured", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { config, core, runtime } = createWebhookTargetDeps();
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
const loopbackUnregister = registerWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
runtime,
core,
path: "/bluebubbles-webhook",
});
trackForCleanup: false,
}).stop;
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
remoteAddress,
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
@@ -713,17 +615,8 @@ describe("BlueBubbles webhook monitor", () => {
});
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
const account = createMockAccount({ password: undefined });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ password: undefined }),
});
const headerVariants: Record<string, string>[] = [
@@ -732,26 +625,11 @@ describe("BlueBubbles webhook monitor", () => {
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
];
for (const headers of headerVariants) {
const req = createMockRequest(
"POST",
"/bluebubbles-webhook",
{
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
},
const { handled, res } = await sendWebhookRequest({
body: createWebhookPayload(),
headers,
);
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
});
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
}
@@ -770,36 +648,18 @@ describe("BlueBubbles webhook monitor", () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = {
type: "new-message",
data: {
await sendWebhookRequest({
body: createWebhookPayload({
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
}),
});
await flushAsync();
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
@@ -819,36 +679,18 @@ describe("BlueBubbles webhook monitor", () => {
return EMPTY_DISPATCH_RESULT;
});
const account = createMockAccount({ groupPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
registerWebhookTarget({
account: createMockAccount({ groupPolicy: "open" }),
});
const payload = {
type: "new-message",
data: {
await sendWebhookRequest({
body: createWebhookPayload({
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
}),
});
await flushAsync();
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();

View File

@@ -18,6 +18,7 @@ import {
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { parseBlueBubblesAllowTarget } from "./targets.js";
import { normalizeBlueBubblesServerUrl } from "./types.js";
@@ -283,42 +284,16 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
}
// Apply config
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
serverUrl,
password,
webhookPath,
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...next.channels?.bluebubbles?.accounts,
[accountId]: {
...next.channels?.bluebubbles?.accounts?.[accountId],
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
serverUrl,
password,
webhookPath,
},
},
},
},
};
}
next = applyBlueBubblesConnectionConfig({
cfg: next,
accountId,
patch: {
serverUrl,
password,
webhookPath,
},
accountEnabled: "preserve-or-true",
});
await prompter.note(
[

View File

@@ -1,12 +1 @@
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return String(input);
}
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";

View File

@@ -108,6 +108,19 @@ function resolvePrivateApiDecision(params: {
};
}
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
@@ -342,16 +355,7 @@ async function createNewChatWithMessage(params: {
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
return parseBlueBubblesMessageResponse(res);
}
export async function sendMessageBlueBubbles(
@@ -464,14 +468,5 @@ export async function sendMessageBlueBubbles(
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) {
return { messageId: "ok" };
}
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractBlueBubblesMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
return parseBlueBubblesMessageResponse(res);
}

View File

@@ -7,6 +7,23 @@ import {
resolveDiffsPluginSecurity,
} from "./config.js";
const FULL_DEFAULTS = {
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
} as const;
describe("resolveDiffsPluginDefaults", () => {
it("returns built-in defaults when config is missing", () => {
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
@@ -15,39 +32,9 @@ describe("resolveDiffsPluginDefaults", () => {
it("applies configured defaults from plugin config", () => {
expect(
resolveDiffsPluginDefaults({
defaults: {
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
},
defaults: FULL_DEFAULTS,
}),
).toEqual({
fontFamily: "JetBrains Mono",
fontSize: 17,
lineSpacing: 1.8,
layout: "split",
showLineNumbers: false,
diffIndicators: "classic",
wordWrap: false,
background: false,
theme: "light",
fileFormat: "pdf",
fileQuality: "hq",
fileScale: 2.6,
fileMaxWidth: 1280,
mode: "file",
});
).toEqual(FULL_DEFAULTS);
});
it("clamps and falls back for invalid line spacing and indicators", () => {

View File

@@ -95,23 +95,11 @@ describe("diffs tool", () => {
});
it("renders PDF output when fileFormat is pdf", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image.format).toBe("pdf");
expect(outputPath).toMatch(/preview\.pdf$/);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
),
};
const screenshotter = createPdfScreenshotter({
assertOutputPath: (outputPath) => {
expect(outputPath).toMatch(/preview\.pdf$/);
},
});
const tool = createDiffsTool({
api: createApi(),
@@ -208,22 +196,7 @@ describe("diffs tool", () => {
});
it("accepts deprecated format alias for fileFormat", async () => {
const screenshotter = {
screenshotHtml: vi.fn(
async ({
outputPath,
image,
}: {
outputPath: string;
image: { format: string; qualityPreset: string; scale: number; maxWidth: number };
}) => {
expect(image.format).toBe("pdf");
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
),
};
const screenshotter = createPdfScreenshotter();
const tool = createDiffsTool({
api: createApi(),
@@ -492,6 +465,23 @@ function createPngScreenshotter(
};
}
function createPdfScreenshotter(
params: {
assertOutputPath?: (outputPath: string) => void;
} = {},
): DiffScreenshotter {
const screenshotHtml: DiffScreenshotter["screenshotHtml"] = vi.fn(
async ({ outputPath, image }: { outputPath: string; image: DiffRenderOptions["image"] }) => {
expect(image.format).toBe("pdf");
params.assertOutputPath?.(outputPath);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, Buffer.from("%PDF-1.7"));
return outputPath;
},
);
return { screenshotHtml };
}
function readTextContent(result: unknown, index: number): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
?.content;

View File

@@ -1,6 +1,7 @@
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import {
buildBaseChannelStatusSummary,
buildProbeChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
@@ -54,6 +55,30 @@ const secretInputJsonSchema = {
],
} as const;
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
enabled: boolean,
): ClawdbotConfig {
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@@ -178,23 +203,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
}
// For named accounts, set enabled in accounts[accountId]
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled,
},
},
},
},
};
return setFeishuNamedAccountEnabled(cfg, accountId, enabled);
},
deleteAccount: ({ cfg, accountId }) => {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
@@ -281,23 +290,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
};
}
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
return {
...cfg,
channels: {
...cfg.channels,
feishu: {
...feishuCfg,
accounts: {
...feishuCfg?.accounts,
[accountId]: {
...feishuCfg?.accounts?.[accountId],
enabled: true,
},
},
},
},
};
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
},
onboarding: feishuOnboardingAdapter,
@@ -342,12 +335,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
outbound: feishuOutbound,
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) => ({
...buildBaseChannelStatusSummary(snapshot),
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, {
port: snapshot.port ?? null,
}),
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
@@ -356,12 +347,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
name: account.name,
appId: account.appId,
domain: account.domain,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
port: runtime?.port ?? null,
probe,
}),
},
gateway: {

View File

@@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
import {
jsonToolResult,
toolExecutionErrorResult,
unknownToolActionResult,
} from "./tool-result.js";
// ============ Actions ============
@@ -206,21 +202,21 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
});
switch (p.action) {
case "list":
return json(await listFolder(client, p.folder_token));
return jsonToolResult(await listFolder(client, p.folder_token));
case "info":
return json(await getFileInfo(client, p.file_token));
return jsonToolResult(await getFileInfo(client, p.file_token));
case "create_folder":
return json(await createFolder(client, p.name, p.folder_token));
return jsonToolResult(await createFolder(client, p.name, p.folder_token));
case "move":
return json(await moveFile(client, p.file_token, p.type, p.folder_token));
return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return json(await deleteFile(client, p.file_token, p.type));
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
return unknownToolActionResult((p as { action?: unknown }).action);
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
return toolExecutionErrorResult(err);
}
},
};

View File

@@ -51,6 +51,30 @@ function makeReactionEvent(
};
}
function createFetchedReactionMessage(chatId: string) {
return {
messageId: "om_msg1",
chatId,
senderOpenId: "ou_bot",
content: "hello",
contentType: "text",
};
}
async function resolveReactionWithLookup(params: {
event?: FeishuReactionCreatedEvent;
lookupChatId: string;
}) {
return await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: params.event ?? makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => createFetchedReactionMessage(params.lookupChatId),
uuid: () => "fixed-uuid",
});
}
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
function buildDebounceConfig(): ClawdbotConfig {
@@ -152,6 +176,30 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
return firstParams.event;
}
function setDedupPassThroughMocks(): void {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
}
function createMention(params: { openId: string; name: string; key?: string }): FeishuMention {
return {
key: params.key ?? "@_user_1",
id: { open_id: params.openId },
name: params.name,
};
}
async function enqueueDebouncedMessage(
onMessage: (data: unknown) => Promise<void>,
event: FeishuMessageEvent,
): Promise<void> {
await onMessage(event);
await Promise.resolve();
await Promise.resolve();
}
describe("resolveReactionSyntheticEvent", () => {
it("filters app self-reactions", async () => {
const event = makeReactionEvent({ operator_type: "app" });
@@ -272,23 +320,12 @@ describe("resolveReactionSyntheticEvent", () => {
});
it("uses event chat context when provided", async () => {
const event = makeReactionEvent({
chat_id: "oc_group_from_event",
chat_type: "group",
});
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_from_lookup",
senderOpenId: "ou_bot",
content: "hello",
contentType: "text",
const result = await resolveReactionWithLookup({
event: makeReactionEvent({
chat_id: "oc_group_from_event",
chat_type: "group",
}),
uuid: () => "fixed-uuid",
lookupChatId: "oc_group_from_lookup",
});
expect(result).toEqual({
@@ -309,20 +346,8 @@ describe("resolveReactionSyntheticEvent", () => {
});
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_from_lookup",
senderOpenId: "ou_bot",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
const result = await resolveReactionWithLookup({
lookupChatId: "oc_group_from_lookup",
});
expect(result?.message.chat_id).toBe("oc_group_from_lookup");
@@ -330,20 +355,8 @@ describe("resolveReactionSyntheticEvent", () => {
});
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
const event = makeReactionEvent();
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event,
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "",
senderOpenId: "ou_bot",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
const result = await resolveReactionWithLookup({
lookupChatId: "",
});
expect(result?.message.chat_id).toBe("p2p:ou_user1");
@@ -396,42 +409,25 @@ describe("Feishu inbound debounce regressions", () => {
});
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
setDedupPassThroughMocks();
const onMessage = await setupDebounceMonitor();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_1",
text: "first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_user_a" },
name: "user-a",
},
],
mentions: [createMention({ openId: "ou_user_a", name: "user-a" })],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_2",
text: "@bot second",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
@@ -473,42 +469,25 @@ describe("Feishu inbound debounce regressions", () => {
});
it("does not synthesize mention-forward intent across separate messages", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
setDedupPassThroughMocks();
const onMessage = await setupDebounceMonitor();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_user_mention",
text: "@alice first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_alice" },
name: "alice",
},
],
mentions: [createMention({ openId: "ou_alice", name: "alice" })],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_bot_mention",
text: "@bot second",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
@@ -521,35 +500,24 @@ describe("Feishu inbound debounce regressions", () => {
});
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
setDedupPassThroughMocks();
const onMessage = await setupDebounceMonitor();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_bot_first",
text: "@bot first",
mentions: [
{
key: "@_user_1",
id: { open_id: "ou_bot" },
name: "bot",
},
],
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
}),
);
await Promise.resolve();
await Promise.resolve();
await onMessage(
await enqueueDebouncedMessage(
onMessage,
createTextEvent({
messageId: "om_plain_second",
text: "plain follow-up",
}),
);
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(25);
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);

View File

@@ -1,6 +1,10 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { afterEach, describe, expect, it, vi } from "vitest";
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
import {
createFeishuClientMockModule,
createFeishuRuntimeMockModule,
} from "./monitor.test-mocks.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
@@ -8,27 +12,8 @@ vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
}),
},
text: {
hasControlCommand: () => false,
},
},
}),
}));
vi.mock("./client.js", () => createFeishuClientMockModule());
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
return {

View File

@@ -1,12 +1,27 @@
import { vi } from "vitest";
export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.fn();
export function createFeishuClientMockModule() {
return {
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
};
}
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
export function createFeishuRuntimeMockModule() {
return {
getFeishuRuntime: () => ({
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
}),
},
text: {
hasControlCommand: () => false,
},
},
}),
};
}

View File

@@ -2,6 +2,10 @@ import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createFeishuClientMockModule,
createFeishuRuntimeMockModule,
} from "./monitor.test-mocks.js";
const probeFeishuMock = vi.hoisted(() => vi.fn());
@@ -9,27 +13,8 @@ vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./client.js", () => ({
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
}));
vi.mock("./runtime.js", () => ({
getFeishuRuntime: () => ({
channel: {
debounce: {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
}),
},
text: {
hasControlCommand: () => false,
},
},
}),
}));
vi.mock("./client.js", () => createFeishuClientMockModule());
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
vi.mock("@larksuiteoapi/node-sdk", () => ({
adaptDefault: vi.fn(

View File

@@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
import {
jsonToolResult,
toolExecutionErrorResult,
unknownToolActionResult,
} from "./tool-result.js";
type ListTokenType =
| "doc"
@@ -154,21 +150,21 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
});
switch (p.action) {
case "list":
return json(await listMembers(client, p.token, p.type));
return jsonToolResult(await listMembers(client, p.token, p.type));
case "add":
return json(
return jsonToolResult(
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
);
case "remove":
return json(
return jsonToolResult(
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
);
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
return unknownToolActionResult((p as { action?: unknown }).action);
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
return toolExecutionErrorResult(err);
}
},
};

View File

@@ -0,0 +1,71 @@
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
type FeishuMessageClient = {
im: {
message: {
reply: (params: {
path: { message_id: string };
data: Record<string, unknown>;
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
create: (params: {
params: { receive_id_type: string };
data: Record<string, unknown>;
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
};
};
};
export async function sendFeishuMessageWithOptionalReply(params: {
client: FeishuMessageClient;
receiveId: string;
receiveIdType: string;
content: string;
msgType: string;
replyToMessageId?: string;
replyInThread?: boolean;
sendErrorPrefix: string;
replyErrorPrefix: string;
fallbackSendErrorPrefix?: string;
shouldFallbackFromReply?: (response: { code?: number; msg?: string }) => boolean;
}): Promise<{ messageId: string; chatId: string }> {
const data = {
content: params.content,
msg_type: params.msgType,
};
if (params.replyToMessageId) {
const response = await params.client.im.message.reply({
path: { message_id: params.replyToMessageId },
data: {
...data,
...(params.replyInThread ? { reply_in_thread: true } : {}),
},
});
if (params.shouldFallbackFromReply?.(response)) {
const fallback = await params.client.im.message.create({
params: { receive_id_type: params.receiveIdType },
data: {
receive_id: params.receiveId,
...data,
},
});
assertFeishuMessageApiSuccess(
fallback,
params.fallbackSendErrorPrefix ?? params.sendErrorPrefix,
);
return toFeishuSendResult(fallback, params.receiveId);
}
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
return toFeishuSendResult(response, params.receiveId);
}
const response = await params.client.im.message.create({
params: { receive_id_type: params.receiveIdType },
data: {
receive_id: params.receiveId,
...data,
},
});
assertFeishuMessageApiSuccess(response, params.sendErrorPrefix);
return toFeishuSendResult(response, params.receiveId);
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
jsonToolResult,
toolExecutionErrorResult,
unknownToolActionResult,
} from "./tool-result.js";
describe("jsonToolResult", () => {
it("formats tool result with text content and details", () => {
const payload = { ok: true, id: "abc" };
expect(jsonToolResult(payload)).toEqual({
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
});
});
it("formats unknown action errors", () => {
expect(unknownToolActionResult("create")).toEqual({
content: [
{ type: "text", text: JSON.stringify({ error: "Unknown action: create" }, null, 2) },
],
details: { error: "Unknown action: create" },
});
});
it("formats execution errors", () => {
expect(toolExecutionErrorResult(new Error("boom"))).toEqual({
content: [{ type: "text", text: JSON.stringify({ error: "boom" }, null, 2) }],
details: { error: "boom" },
});
});
});

View File

@@ -0,0 +1,14 @@
export function jsonToolResult(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
export function unknownToolActionResult(action: unknown) {
return jsonToolResult({ error: `Unknown action: ${String(action)}` });
}
export function toolExecutionErrorResult(error: unknown) {
return jsonToolResult({ error: error instanceof Error ? error.message : String(error) });
}

View File

@@ -2,17 +2,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
jsonToolResult,
toolExecutionErrorResult,
unknownToolActionResult,
} from "./tool-result.js";
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.js";
// ============ Helpers ============
function json(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}
type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides";
// ============ Actions ============
@@ -194,22 +190,22 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
});
switch (p.action) {
case "spaces":
return json(await listSpaces(client));
return jsonToolResult(await listSpaces(client));
case "nodes":
return json(await listNodes(client, p.space_id, p.parent_node_token));
return jsonToolResult(await listNodes(client, p.space_id, p.parent_node_token));
case "get":
return json(await getNode(client, p.token));
return jsonToolResult(await getNode(client, p.token));
case "search":
return json({
return jsonToolResult({
error:
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
});
case "create":
return json(
return jsonToolResult(
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
);
case "move":
return json(
return jsonToolResult(
await moveNode(
client,
p.space_id,
@@ -219,13 +215,13 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
),
);
case "rename":
return json(await renameNode(client, p.space_id, p.node_token, p.title));
return jsonToolResult(await renameNode(client, p.space_id, p.node_token, p.title));
default:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
return json({ error: `Unknown action: ${(p as any).action}` });
return unknownToolActionResult((p as { action?: unknown }).action);
}
} catch (err) {
return json({ error: err instanceof Error ? err.message : String(err) });
return toolExecutionErrorResult(err);
}
},
};

View File

@@ -25,6 +25,7 @@ function extractBearerToken(header: unknown): string {
type ParsedGoogleChatInboundPayload =
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
| { ok: false };
type ParsedGoogleChatInboundSuccess = Extract<ParsedGoogleChatInboundPayload, { ok: true }>;
function parseGoogleChatInboundPayload(
raw: unknown,
@@ -116,6 +117,23 @@ export function createGoogleChatWebhookRequestHandler(params: {
const headerBearer = extractBearerToken(req.headers.authorization);
let selectedTarget: WebhookTarget | null = null;
let parsedEvent: GoogleChatEvent | null = null;
const readAndParseEvent = async (
profile: "pre-auth" | "post-auth",
): Promise<ParsedGoogleChatInboundSuccess | null> => {
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile,
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return null;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
return parsed.ok ? parsed : null;
};
if (headerBearer) {
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
@@ -134,36 +152,14 @@ export function createGoogleChatWebhookRequestHandler(params: {
return true;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "post-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
const parsed = await readAndParseEvent("post-auth");
if (!parsed) {
return true;
}
parsedEvent = parsed.event;
} else {
const body = await readJsonWebhookBodyOrReject({
req,
res,
profile: "pre-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: "invalid payload",
});
if (!body.ok) {
return true;
}
const parsed = parseGoogleChatInboundPayload(body.value, res);
if (!parsed.ok) {
const parsed = await readAndParseEvent("pre-auth");
if (!parsed) {
return true;
}
parsedEvent = parsed.event;

View File

@@ -1,6 +1,7 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
@@ -266,21 +267,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
cliPath: null,
dbPath: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "imessage",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,

View File

@@ -1,8 +1,7 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
createNormalizedOutboundDeliverer,
createReplyPrefixOptions,
dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
logInboundDrop,
isDangerousNameMatchingEnabled,
@@ -332,44 +331,31 @@ export async function handleIrcInbound(params: {
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
await dispatchInboundReplyWithBase({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
route,
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
ctxPayload,
core,
deliver: async (payload) => {
await deliverIrcReply({
payload,
target: peerId,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
},
onRecordError: (err) => {
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
},
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: config as OpenClawConfig,
agentId: route.agentId,
channel: CHANNEL_ID,
accountId: account.accountId,
});
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
await deliverIrcReply({
payload,
target: peerId,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as OpenClawConfig,
dispatcherOptions: {
...prefixOptions,
deliver: deliverReply,
onError: (err, info) => {
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
},
onDispatchError: (err, info) => {
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
},
replyOptions: {
skillFilter: groupMatch.groupConfig?.skills,
onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming

View File

@@ -1,6 +1,8 @@
import {
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
LineConfigSchema,
processLineMessage,
@@ -27,6 +29,42 @@ const meta = {
systemImage: "message.fill",
};
function patchLineAccountConfig(
cfg: OpenClawConfig,
lineConfig: LineConfig,
accountId: string,
patch: Record<string, unknown>,
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
...patch,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
...patch,
},
},
},
},
};
}
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
@@ -67,34 +105,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
enabled,
},
},
},
},
};
return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled });
},
deleteAccount: ({ cfg, accountId }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
@@ -224,34 +235,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
getLineRuntime().channel.line.normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
name,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
accounts: {
...lineConfig.accounts,
[accountId]: {
...lineConfig.accounts?.[accountId],
name,
},
},
},
},
};
return patchLineAccountConfig(cfg, lineConfig, accountId, { name });
},
validateInput: ({ accountId, input }) => {
const typedInput = input as {
@@ -615,20 +599,18 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
const configured = Boolean(
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
);
return {
const base = buildComputedAccountStatusSnapshot({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: "webhook",
runtime,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
});
return {
...base,
tokenSource: account.tokenSource,
mode: "webhook",
};
},
},
@@ -699,39 +681,21 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
}
}
const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if (
"channelAccessToken" in nextEntry ||
"channelSecret" in nextEntry ||
"tokenFile" in nextEntry ||
"secretFile" in nextEntry
) {
cleared = true;
delete nextEntry.channelAccessToken;
delete nextEntry.channelSecret;
delete nextEntry.tokenFile;
delete nextEntry.secretFile;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
const accountCleanup = clearAccountEntryFields({
accounts: nextLine.accounts,
accountId,
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
markClearedOnFieldPresence: true,
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextLine.accounts;
changed = true;
if (accountCleanup.nextAccounts) {
nextLine.accounts = accountCleanup.nextAccounts;
} else {
nextLine.accounts = accounts;
delete nextLine.accounts;
}
}

View File

@@ -2,6 +2,7 @@ import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
@@ -380,21 +381,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) {
return [];
}
return [
{
channel: "matrix",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs, cfg }) => {

View File

@@ -4,9 +4,11 @@ import {
createScopedPairingAccess,
createReplyPrefixOptions,
createTypingCallbacks,
dispatchReplyFromConfigWithSettledDispatcher,
formatAllowlistMatchMeta,
logInboundDrop,
logTypingFailure,
resolveInboundSessionEnvelopeContext,
resolveControlCommandGate,
type PluginRuntime,
type RuntimeEnv,
@@ -484,14 +486,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const textWithId = threadRootId
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
: `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const { storePath, envelopeOptions, previousTimestamp } =
resolveInboundSessionEnvelopeContext({
cfg,
agentId: route.agentId,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatInboundEnvelope({
channel: "Matrix",
from: envelopeFrom,
@@ -655,22 +655,18 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
},
});
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
}),
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
onModelSelected,
},
});
if (!queuedFinal) {
return;

View File

@@ -1,7 +1,7 @@
import {
createLoggerBackedRuntime,
GROUP_POLICY_BLOCKED_LABEL,
mergeAllowlist,
resolveRuntimeEnv,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
summarizeMapping,
@@ -241,11 +241,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
const runtime: RuntimeEnv =
opts.runtime ??
createLoggerBackedRuntime({
logger,
});
const runtime: RuntimeEnv = resolveRuntimeEnv({
runtime: opts.runtime,
logger,
});
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
return;

View File

@@ -1,4 +1,5 @@
import {
buildOauthProviderAuthResult,
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderAuthContext,
@@ -60,22 +61,14 @@ function createOAuthHandler(region: MiniMaxRegion) {
await ctx.prompter.note(result.notification_message, "MiniMax OAuth");
}
const profileId = `${PROVIDER_ID}:default`;
const baseUrl = result.resourceUrl || defaultBaseUrl;
return {
profiles: [
{
profileId,
credential: {
type: "oauth" as const,
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
},
},
],
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: modelRef(DEFAULT_MODEL),
access: result.access,
refresh: result.refresh,
expires: result.expires,
configPatch: {
models: {
providers: {
@@ -119,13 +112,12 @@ function createOAuthHandler(region: MiniMaxRegion) {
},
},
},
defaultModel: modelRef(DEFAULT_MODEL),
notes: [
"MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
`Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
...(result.notification_message ? [result.notification_message] : []),
],
};
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
progress.stop(`MiniMax OAuth failed: ${errorMsg}`);

View File

@@ -4,7 +4,8 @@ import type {
OpenClawConfig,
} from "openclaw/plugin-sdk/msteams";
import {
buildBaseChannelStatusSummary,
buildProbeChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
buildChannelConfigSchema,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
@@ -250,11 +251,43 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
name: undefined as string | undefined,
note: undefined as string | undefined,
}));
type ResolveTargetResultEntry = (typeof results)[number];
type PendingTargetEntry = { input: string; query: string; index: number };
const stripPrefix = (value: string) => normalizeMSTeamsUserInput(value);
const markPendingLookupFailed = (pending: PendingTargetEntry[]) => {
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) {
entry.note = "lookup failed";
}
});
};
const resolvePending = async <T>(
pending: PendingTargetEntry[],
resolveEntries: (entries: string[]) => Promise<T[]>,
applyResolvedEntry: (target: ResolveTargetResultEntry, entry: T) => void,
) => {
if (pending.length === 0) {
return;
}
try {
const resolved = await resolveEntries(pending.map((entry) => entry.query));
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) {
return;
}
applyResolvedEntry(target, entry);
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
markPendingLookupFailed(pending);
}
};
if (kind === "user") {
const pending: Array<{ input: string; query: string; index: number }> = [];
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
@@ -270,37 +303,21 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
pending.push({ input: entry.input, query: cleaned, index });
});
if (pending.length > 0) {
try {
const resolved = await resolveMSTeamsUserAllowlist({
cfg,
entries: pending.map((entry) => entry.query),
});
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) {
return;
}
target.resolved = entry.resolved;
target.id = entry.id;
target.name = entry.name;
target.note = entry.note;
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) {
entry.note = "lookup failed";
}
});
}
}
await resolvePending(
pending,
(entries) => resolveMSTeamsUserAllowlist({ cfg, entries }),
(target, entry) => {
target.resolved = entry.resolved;
target.id = entry.id;
target.name = entry.name;
target.note = entry.note;
},
);
return results;
}
const pending: Array<{ input: string; query: string; index: number }> = [];
const pending: PendingTargetEntry[] = [];
results.forEach((entry, index) => {
const trimmed = entry.input.trim();
if (!trimmed) {
@@ -323,48 +340,32 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
pending.push({ input: entry.input, query, index });
});
if (pending.length > 0) {
try {
const resolved = await resolveMSTeamsChannelAllowlist({
cfg,
entries: pending.map((entry) => entry.query),
});
resolved.forEach((entry, idx) => {
const target = results[pending[idx]?.index ?? -1];
if (!target) {
return;
}
if (!entry.resolved || !entry.teamId) {
target.resolved = false;
target.note = entry.note;
return;
}
target.resolved = true;
if (entry.channelId) {
target.id = `${entry.teamId}/${entry.channelId}`;
target.name =
entry.channelName && entry.teamName
? `${entry.teamName}/${entry.channelName}`
: (entry.channelName ?? entry.teamName);
} else {
target.id = entry.teamId;
target.name = entry.teamName;
target.note = "team id";
}
if (entry.note) {
target.note = entry.note;
}
});
} catch (err) {
runtime.error?.(`msteams resolve failed: ${String(err)}`);
pending.forEach(({ index }) => {
const entry = results[index];
if (entry) {
entry.note = "lookup failed";
}
});
}
}
await resolvePending(
pending,
(entries) => resolveMSTeamsChannelAllowlist({ cfg, entries }),
(target, entry) => {
if (!entry.resolved || !entry.teamId) {
target.resolved = false;
target.note = entry.note;
return;
}
target.resolved = true;
if (entry.channelId) {
target.id = `${entry.teamId}/${entry.channelId}`;
target.name =
entry.channelName && entry.teamName
? `${entry.teamName}/${entry.channelName}`
: (entry.channelName ?? entry.teamName);
} else {
target.id = entry.teamId;
target.name = entry.teamName;
target.note = "team id";
}
if (entry.note) {
target.note = entry.note;
}
},
);
return results;
},
@@ -429,23 +430,17 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
outbound: msteamsOutbound,
status: {
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
buildChannelSummary: ({ snapshot }) => ({
...buildBaseChannelStatusSummary(snapshot),
port: snapshot.port ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
buildChannelSummary: ({ snapshot }) =>
buildProbeChannelStatusSummary(snapshot, {
port: snapshot.port ?? null,
}),
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
port: runtime?.port ?? null,
probe,
}),
},
gateway: {

View File

@@ -72,6 +72,17 @@ const createRecordedSendActivity = (
};
};
const REVOCATION_ERROR = "Cannot perform 'set' on a proxy that has been revoked";
const createFallbackAdapter = (proactiveSent: string[]): MSTeamsAdapter => ({
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
});
},
process: async () => {},
});
describe("msteams messenger", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
@@ -297,18 +308,11 @@ describe("msteams messenger", () => {
const ctx = {
sendActivity: async () => {
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
throw new TypeError(REVOCATION_ERROR);
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
});
},
process: async () => {},
};
const adapter = createFallbackAdapter(proactiveSent);
const ids = await sendMSTeamsMessages({
replyStyle: "thread",
@@ -338,18 +342,11 @@ describe("msteams messenger", () => {
threadSent.push(content);
return { id: `id:${content}` };
}
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
throw new TypeError(REVOCATION_ERROR);
},
};
const adapter: MSTeamsAdapter = {
continueConversation: async (_appId, _reference, logic) => {
await logic({
sendActivity: createRecordedSendActivity(proactiveSent),
});
},
process: async () => {},
};
const adapter = createFallbackAdapter(proactiveSent);
const ids = await sendMSTeamsMessages({
replyStyle: "thread",

View File

@@ -2,6 +2,7 @@ import {
DEFAULT_ACCOUNT_ID,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
dispatchReplyFromConfigWithSettledDispatcher,
DEFAULT_GROUP_HISTORY_LIMIT,
createScopedPairingAccess,
logInboundDrop,
@@ -11,6 +12,7 @@ import {
isDangerousNameMatchingEnabled,
readStoreAllowFromForDmPolicy,
resolveMentionGating,
resolveInboundSessionEnvelopeContext,
formatAllowlistMatchMeta,
resolveEffectiveAllowFromLists,
resolveDmGroupAccessWithLists,
@@ -451,12 +453,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
const envelopeFrom = isDirectMessage ? senderName : conversationType;
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
cfg,
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
@@ -559,18 +558,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
log.info("dispatching to agent", { sessionKey: route.sessionKey });
try {
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
cfg,
ctxPayload,
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions,
}),
replyOptions,
});
log.info("dispatch complete", { queuedFinal, counts });

View File

@@ -157,24 +157,13 @@ export async function sendMessageMSTeams(
log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length });
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
const messageId = await sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix: "msteams consent card send",
});
log.info("sent file consent card", { conversationId, messageId, uploadId });
@@ -245,14 +234,11 @@ export async function sendMessageMSTeams(
text: messageText || undefined,
attachments: [fileCardAttachment],
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
const messageId = await sendProactiveActivityRaw({
adapter,
appId,
ref,
activity,
});
log.info("sent native file card", {
@@ -288,14 +274,11 @@ export async function sendMessageMSTeams(
type: "message",
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
const proactiveRef = { ...baseRef, activityId: undefined };
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
const response = await turnCtx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
const messageId = await sendProactiveActivityRaw({
adapter,
appId,
ref,
activity,
});
log.info("sent message with OneDrive file link", {
@@ -382,13 +365,14 @@ type ProactiveActivityParams = {
errorPrefix: string;
};
async function sendProactiveActivity({
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
async function sendProactiveActivityRaw({
adapter,
appId,
ref,
activity,
errorPrefix,
}: ProactiveActivityParams): Promise<string> {
}: ProactiveActivityRawParams): Promise<string> {
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
@@ -396,12 +380,27 @@ async function sendProactiveActivity({
};
let messageId = "unknown";
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
}
async function sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix,
}: ProactiveActivityParams): Promise<string> {
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
return await sendProactiveActivityRaw({
adapter,
appId,
ref,
activity,
});
return messageId;
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);

View File

@@ -1,6 +1,9 @@
import {
applyAccountNameToChannelSection,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
buildRuntimeAccountStatusSnapshot,
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
@@ -288,17 +291,21 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
secretSource: snapshot.secretSource ?? "none",
running: snapshot.running ?? false,
mode: "webhook",
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
}),
buildChannelSummary: ({ snapshot }) => {
const base = buildBaseChannelStatusSummary(snapshot);
return {
configured: base.configured,
secretSource: snapshot.secretSource ?? "none",
running: base.running,
mode: "webhook",
lastStartAt: base.lastStartAt,
lastStopAt: base.lastStopAt,
lastError: base.lastError,
};
},
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
const runtimeSnapshot = buildRuntimeAccountStatusSnapshot({ runtime });
return {
accountId: account.accountId,
name: account.name,
@@ -306,10 +313,10 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
configured,
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
running: runtimeSnapshot.running,
lastStartAt: runtimeSnapshot.lastStartAt,
lastStopAt: runtimeSnapshot.lastStopAt,
lastError: runtimeSnapshot.lastError,
mode: "webhook",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
@@ -353,36 +360,20 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
cleared = true;
changed = true;
}
const accounts =
nextSection.accounts && typeof nextSection.accounts === "object"
? { ...nextSection.accounts }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if ("botSecret" in nextEntry) {
const secret = nextEntry.botSecret;
if (typeof secret === "string" ? secret.trim() : secret) {
cleared = true;
}
delete nextEntry.botSecret;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
const accountCleanup = clearAccountEntryFields({
accounts: nextSection.accounts,
accountId,
fields: ["botSecret"],
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextSection.accounts;
changed = true;
if (accountCleanup.nextAccounts) {
nextSection.accounts = accountCleanup.nextAccounts;
} else {
nextSection.accounts = accounts;
delete nextSection.accounts;
}
}
}

View File

@@ -1,8 +1,7 @@
import {
GROUP_POLICY_BLOCKED_LABEL,
createScopedPairingAccess,
createNormalizedOutboundDeliverer,
createReplyPrefixOptions,
dispatchInboundReplyWithBase,
formatTextWithAttachmentLinks,
logInboundDrop,
readStoreAllowFromForDmPolicy,
@@ -291,43 +290,30 @@ export async function handleNextcloudTalkInbound(params: {
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
await dispatchInboundReplyWithBase({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
route,
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
ctxPayload,
core,
deliver: async (payload) => {
await deliverNextcloudTalkReply({
payload,
roomToken,
accountId: account.accountId,
statusSink,
});
},
onRecordError: (err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
},
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: config as OpenClawConfig,
agentId: route.agentId,
channel: CHANNEL_ID,
accountId: account.accountId,
});
const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
await deliverNextcloudTalkReply({
payload,
roomToken,
accountId: account.accountId,
statusSink,
});
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as OpenClawConfig,
dispatcherOptions: {
...prefixOptions,
deliver: deliverReply,
onError: (err, info) => {
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
},
onDispatchError: (err, info) => {
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
},
replyOptions: {
skillFilter: roomConfig?.skills,
onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming

View File

@@ -43,6 +43,45 @@ function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConf
} as CoreConfig;
}
function setNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
updates: Record<string, unknown>,
): CoreConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
...updates,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...cfg.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
...updates,
},
},
},
},
};
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
@@ -105,40 +144,10 @@ async function promptNextcloudTalkAllowFrom(params: {
];
const unique = mergeAllowFromEntries(undefined, merged);
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...cfg.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...cfg.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
};
return setNextcloudTalkAccountConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: unique,
});
}
async function promptNextcloudTalkAllowFromForAccount(params: {
@@ -265,41 +274,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
}
if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled:
next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
baseUrl,
...(secret ? { botSecret: secret } : {}),
},
},
},
},
};
}
next = setNextcloudTalkAccountConfig(next, accountId, {
baseUrl,
...(secret ? { botSecret: secret } : {}),
});
}
const existingApiUser = resolvedAccount.config.apiUser?.trim();
@@ -333,41 +311,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
});
const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
apiUser,
...(apiPassword ? { apiPassword } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
"nextcloud-talk": {
...next.channels?.["nextcloud-talk"],
enabled: true,
accounts: {
...next.channels?.["nextcloud-talk"]?.accounts,
[accountId]: {
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
enabled:
next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
apiUser,
...(apiPassword ? { apiPassword } : {}),
},
},
},
},
};
}
next = setNextcloudTalkAccountConfig(next, accountId, {
apiUser,
...(apiPassword ? { apiPassword } : {}),
});
}
if (forceAllowFrom) {

View File

@@ -1,4 +1,5 @@
import {
buildOauthProviderAuthResult,
emptyPluginConfigSchema,
type OpenClawPluginApi,
type ProviderAuthContext,
@@ -63,22 +64,14 @@ const qwenPortalPlugin = {
progress.stop("Qwen OAuth complete");
const profileId = `${PROVIDER_ID}:default`;
const baseUrl = normalizeBaseUrl(result.resourceUrl);
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
},
},
],
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: DEFAULT_MODEL,
access: result.access,
refresh: result.refresh,
expires: result.expires,
configPatch: {
models: {
providers: {
@@ -110,12 +103,11 @@ const qwenPortalPlugin = {
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
`Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
],
};
});
} catch (err) {
progress.stop("Qwen OAuth failed");
await ctx.prompter.note(

View File

@@ -317,20 +317,11 @@ describe("createSynologyChatPlugin", () => {
});
describe("gateway", () => {
it("startAccount returns pending promise for disabled account", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: { "synology-chat": { enabled: false } },
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = plugin.gateway.startAccount(ctx);
async function expectPendingStartAccountPromise(
result: Promise<unknown>,
abortController: AbortController,
) {
expect(result).toBeInstanceOf(Promise);
// Promise should stay pending (never resolve) to prevent restart loop
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
@@ -338,29 +329,29 @@ describe("createSynologyChatPlugin", () => {
expect(resolved).toBe("pending");
abortController.abort();
await result;
}
async function expectPendingStartAccount(accountConfig: Record<string, unknown>) {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: { "synology-chat": accountConfig },
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = plugin.gateway.startAccount(ctx);
await expectPendingStartAccountPromise(result, abortController);
}
it("startAccount returns pending promise for disabled account", async () => {
await expectPendingStartAccount({ enabled: false });
});
it("startAccount returns pending promise for account without token", async () => {
const plugin = createSynologyChatPlugin();
const abortController = new AbortController();
const ctx = {
cfg: {
channels: { "synology-chat": { enabled: true } },
},
accountId: "default",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
abortSignal: abortController.signal,
};
const result = plugin.gateway.startAccount(ctx);
expect(result).toBeInstanceOf(Promise);
// Promise should stay pending (never resolve) to prevent restart loop
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
abortController.abort();
await result;
await expectPendingStartAccount({ enabled: true });
});
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
@@ -387,16 +378,9 @@ describe("createSynologyChatPlugin", () => {
};
const result = plugin.gateway.startAccount(ctx);
expect(result).toBeInstanceOf(Promise);
const resolved = await Promise.race([
result,
new Promise((r) => setTimeout(() => r("pending"), 50)),
]);
expect(resolved).toBe("pending");
await expectPendingStartAccountPromise(result, abortController);
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
expect(registerMock).not.toHaveBeenCalled();
abortController.abort();
await result;
});
it("deregisters stale route before re-registering same account/path", async () => {

View File

@@ -118,26 +118,21 @@ describe("sendFileUrl", () => {
function mockUserListResponse(
users: Array<{ user_id: number; username: string; nickname: string }>,
) {
const httpsGet = vi.mocked((https as any).get);
httpsGet.mockImplementation((_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
process.nextTick(() => {
callback(res);
res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
res.emit("end");
});
const req = new EventEmitter() as any;
req.destroy = vi.fn();
return req;
});
mockUserListResponseImpl(users, false);
}
function mockUserListResponseOnce(
users: Array<{ user_id: number; username: string; nickname: string }>,
) {
mockUserListResponseImpl(users, true);
}
function mockUserListResponseImpl(
users: Array<{ user_id: number; username: string; nickname: string }>,
once: boolean,
) {
const httpsGet = vi.mocked((https as any).get);
httpsGet.mockImplementationOnce((_url: any, _opts: any, callback: any) => {
const impl = (_url: any, _opts: any, callback: any) => {
const res = new EventEmitter() as any;
res.statusCode = 200;
process.nextTick(() => {
@@ -148,7 +143,12 @@ function mockUserListResponseOnce(
const req = new EventEmitter() as any;
req.destroy = vi.fn();
return req;
});
};
if (once) {
httpsGet.mockImplementationOnce(impl);
return;
}
httpsGet.mockImplementation(impl);
}
describe("resolveChatUserId", () => {

View File

@@ -52,6 +52,25 @@ function createStartAccountCtx(params: {
};
}
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () =>
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
);
setTelegramRuntime({
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
return { monitorTelegramProvider, probeTelegram };
}
describe("telegramPlugin duplicate token guard", () => {
it("marks secondary account as not configured when token is shared", async () => {
const cfg = createCfg();
@@ -84,20 +103,7 @@ describe("telegramPlugin duplicate token guard", () => {
});
it("blocks startup for duplicate token accounts before polling starts", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "bot" } }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true });
await expect(
telegramPlugin.gateway!.startAccount!(
@@ -114,20 +120,10 @@ describe("telegramPlugin duplicate token guard", () => {
});
it("passes webhookPort through to monitor startup options", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "opsbot" } }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const { monitorTelegramProvider } = installGatewayRuntime({
probeOk: true,
botUsername: "opsbot",
});
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {
@@ -192,20 +188,7 @@ describe("telegramPlugin duplicate token guard", () => {
});
it("does not crash startup when a resolved account token is undefined", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: false }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false });
const cfg = createCfg();
const ctx = createStartAccountCtx({

View File

@@ -2,6 +2,7 @@ import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
clearAccountEntryFields,
collectTelegramStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
@@ -519,36 +520,20 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
cleared = true;
changed = true;
}
const accounts =
nextTelegram.accounts && typeof nextTelegram.accounts === "object"
? { ...nextTelegram.accounts }
: undefined;
if (accounts && accountId in accounts) {
const entry = accounts[accountId];
if (entry && typeof entry === "object") {
const nextEntry = { ...entry } as Record<string, unknown>;
if ("botToken" in nextEntry) {
const token = nextEntry.botToken;
if (typeof token === "string" ? token.trim() : token) {
cleared = true;
}
delete nextEntry.botToken;
changed = true;
}
if (Object.keys(nextEntry).length === 0) {
delete accounts[accountId];
changed = true;
} else {
accounts[accountId] = nextEntry as typeof entry;
}
const accountCleanup = clearAccountEntryFields({
accounts: nextTelegram.accounts,
accountId,
fields: ["botToken"],
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
}
if (accounts) {
if (Object.keys(accounts).length === 0) {
delete nextTelegram.accounts;
changed = true;
if (accountCleanup.nextAccounts) {
nextTelegram.accounts = accountCleanup.nextAccounts;
} else {
nextTelegram.accounts = accounts;
delete nextTelegram.accounts;
}
}
}

View File

@@ -51,14 +51,10 @@ describe("checkTwitchAccessControl", () => {
describe("when no restrictions are configured", () => {
it("allows messages that mention the bot (default requireMention)", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
const result = runAccessCheck({
message: {
message: "@testbot hello",
},
});
expect(result.allowed).toBe(true);
});
@@ -66,30 +62,20 @@ describe("checkTwitchAccessControl", () => {
describe("requireMention default", () => {
it("defaults to true when undefined", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "hello bot",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
const result = runAccessCheck({
message: {
message: "hello bot",
},
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("allows mention when requireMention is undefined", () => {
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account: mockAccount,
botUsername: "testbot",
const result = runAccessCheck({
message: {
message: "@testbot hello",
},
});
expect(result.allowed).toBe(true);
});
@@ -97,52 +83,25 @@ describe("checkTwitchAccessControl", () => {
describe("requireMention", () => {
it("allows messages that mention the bot", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@testbot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = runAccessCheck({
account: { requireMention: true },
message: { message: "@testbot hello" },
});
expect(result.allowed).toBe(true);
});
it("blocks messages that don't mention the bot", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const result = checkTwitchAccessControl({
message: mockMessage,
account,
botUsername: "testbot",
const result = runAccessCheck({
account: { requireMention: true },
});
expect(result.allowed).toBe(false);
expect(result.reason).toContain("does not mention the bot");
});
it("is case-insensitive for bot username", () => {
const account: TwitchAccountConfig = {
...mockAccount,
requireMention: true,
};
const message: TwitchChatMessage = {
...mockMessage,
message: "@TestBot hello",
};
const result = checkTwitchAccessControl({
message,
account,
botUsername: "testbot",
const result = runAccessCheck({
account: { requireMention: true },
message: { message: "@TestBot hello" },
});
expect(result.allowed).toBe(true);
});

View File

@@ -14,17 +14,28 @@ import { describe, expect, it } from "vitest";
import { collectTwitchStatusIssues } from "./status.js";
import type { ChannelAccountSnapshot } from "./types.js";
function createSnapshot(overrides: Partial<ChannelAccountSnapshot> = {}): ChannelAccountSnapshot {
return {
accountId: "default",
configured: true,
enabled: true,
running: false,
...overrides,
};
}
function createSimpleTwitchConfig(overrides: Record<string, unknown>) {
return {
channels: {
twitch: overrides,
},
};
}
describe("status", () => {
describe("collectTwitchStatusIssues", () => {
it("should detect unconfigured accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: false,
enabled: true,
running: false,
},
];
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
const issues = collectTwitchStatusIssues(snapshots);
@@ -34,14 +45,7 @@ describe("status", () => {
});
it("should detect disabled accounts", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: false,
running: false,
},
];
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
const issues = collectTwitchStatusIssues(snapshots);
@@ -51,24 +55,12 @@ describe("status", () => {
});
it("should detect missing clientId when account configured (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
// clientId missing
},
},
};
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123",
// clientId missing
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
@@ -77,24 +69,12 @@ describe("status", () => {
});
it("should warn about oauth: prefix in token (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123", // has prefix
clientId: "test-id",
},
},
};
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123", // has prefix
clientId: "test-id",
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
@@ -104,26 +84,14 @@ describe("status", () => {
});
it("should detect clientSecret without refreshToken (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
},
},
};
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "oauth:test123",
clientId: "test-id",
clientSecret: "secret123",
// refreshToken missing
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
@@ -132,25 +100,13 @@ describe("status", () => {
});
it("should detect empty allowFrom array (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowFrom: [], // empty array
},
},
};
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowFrom: [], // empty array
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
@@ -159,26 +115,14 @@ describe("status", () => {
});
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
},
];
const mockCfg = {
channels: {
twitch: {
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
},
},
};
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
const mockCfg = createSimpleTwitchConfig({
username: "testbot",
accessToken: "test123",
clientId: "test-id",
allowedRoles: ["all"],
allowFrom: ["123456"], // conflict!
});
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
@@ -189,13 +133,7 @@ describe("status", () => {
it("should detect runtime errors", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
lastError: "Connection timeout",
},
createSnapshot({ lastError: "Connection timeout" }),
];
const issues = collectTwitchStatusIssues(snapshots);
@@ -207,15 +145,11 @@ describe("status", () => {
it("should detect accounts that never connected", () => {
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
running: false,
createSnapshot({
lastStartAt: undefined,
lastInboundAt: undefined,
lastOutboundAt: undefined,
},
}),
];
const issues = collectTwitchStatusIssues(snapshots);
@@ -230,13 +164,10 @@ describe("status", () => {
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const snapshots: ChannelAccountSnapshot[] = [
{
accountId: "default",
configured: true,
enabled: true,
createSnapshot({
running: true,
lastStartAt: oldDate,
},
}),
];
const issues = collectTwitchStatusIssues(snapshots);

View File

@@ -209,6 +209,23 @@ const voiceCallPlugin = {
const rt = await ensureRuntime();
return { rt, callId, message } as const;
};
const initiateCallAndRespond = async (params: {
rt: VoiceCallRuntime;
respond: GatewayRequestHandlerOptions["respond"];
to: string;
message?: string;
mode?: "notify" | "conversation";
}) => {
const result = await params.rt.manager.initiateCall(params.to, undefined, {
message: params.message,
mode: params.mode,
});
if (!result.success) {
params.respond(false, { error: result.error || "initiate failed" });
return;
}
params.respond(true, { callId: result.callId, initiated: true });
};
api.registerGatewayMethod(
"voicecall.initiate",
@@ -230,15 +247,13 @@ const voiceCallPlugin = {
}
const mode =
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
const result = await rt.manager.initiateCall(to, undefined, {
await initiateCallAndRespond({
rt,
respond,
to,
message,
mode,
});
if (!result.success) {
respond(false, { error: result.error || "initiate failed" });
return;
}
respond(true, { callId: result.callId, initiated: true });
} catch (err) {
sendError(respond, err);
}
@@ -347,14 +362,12 @@ const voiceCallPlugin = {
return;
}
const rt = await ensureRuntime();
const result = await rt.manager.initiateCall(to, undefined, {
await initiateCallAndRespond({
rt,
respond,
to,
message: message || undefined,
});
if (!result.success) {
respond(false, { error: result.error || "initiate failed" });
return;
}
respond(true, { callId: result.callId, initiated: true });
} catch (err) {
sendError(respond, err);
}

View File

@@ -1,49 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig {
return {
enabled: true,
provider,
fromNumber: "+15550001234",
inboundPolicy: "disabled",
allowFrom: [],
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,
silenceTimeoutMs: 800,
transcriptTimeoutMs: 180000,
ringTimeoutMs: 30000,
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
sttProvider: "openai-realtime",
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 800,
vadThreshold: 0.5,
streamPath: "/voice/stream",
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
},
skipSignatureVerification: false,
stt: { provider: "openai", model: "whisper-1" },
tts: {
provider: "openai",
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
},
responseModel: "openai/gpt-4o-mini",
responseTimeoutMs: 30000,
};
return createVoiceCallBaseConfig({ provider });
}
describe("validateProviderConfig", () => {

View File

@@ -1,3 +1,5 @@
import { pcmToMulaw } from "../telephony-audio.js";
/**
* OpenAI TTS Provider
*
@@ -179,55 +181,6 @@ function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
/**
* Convert 16-bit PCM to 8-bit mu-law.
* Standard G.711 mu-law encoding for telephony.
*/
function pcmToMulaw(pcm: Buffer): Buffer {
const samples = pcm.length / 2;
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
/**
* Convert a single 16-bit linear sample to 8-bit mu-law.
* Implements ITU-T G.711 mu-law encoding.
*/
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
// Get sign bit
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) {
sample = -sample;
}
// Clip to prevent overflow
if (sample > CLIP) {
sample = CLIP;
}
// Add bias and find segment
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--, expMask >>= 1) {
// Find the segment (exponent)
}
// Extract mantissa bits
const mantissa = (sample >> (exponent + 3)) & 0x0f;
// Combine into mu-law byte (inverted for transmission)
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}
/**
* Convert 8-bit mu-law to 16-bit linear PCM.
* Useful for decoding incoming audio.

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { VoiceCallConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
const mocks = vi.hoisted(() => ({
resolveVoiceCallConfig: vi.fn(),
@@ -45,48 +46,7 @@ vi.mock("./webhook/tailscale.js", () => ({
import { createVoiceCallRuntime } from "./runtime.js";
function createBaseConfig(): VoiceCallConfig {
return {
enabled: true,
provider: "mock",
fromNumber: "+15550001234",
inboundPolicy: "disabled",
allowFrom: [],
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,
silenceTimeoutMs: 800,
transcriptTimeoutMs: 180000,
ringTimeoutMs: 30000,
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false },
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
sttProvider: "openai-realtime",
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 800,
vadThreshold: 0.5,
streamPath: "/voice/stream",
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
},
skipSignatureVerification: false,
stt: { provider: "openai", model: "whisper-1" },
tts: {
provider: "openai",
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
},
responseModel: "openai/gpt-4o-mini",
responseTimeoutMs: 30000,
};
return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" });
}
describe("createVoiceCallRuntime lifecycle", () => {

View File

@@ -0,0 +1,52 @@
import type { VoiceCallConfig } from "./config.js";
export function createVoiceCallBaseConfig(params?: {
provider?: "telnyx" | "twilio" | "plivo" | "mock";
tunnelProvider?: "none" | "ngrok";
}): VoiceCallConfig {
return {
enabled: true,
provider: params?.provider ?? "mock",
fromNumber: "+15550001234",
inboundPolicy: "disabled",
allowFrom: [],
outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
maxDurationSeconds: 300,
staleCallReaperSeconds: 600,
silenceTimeoutMs: 800,
transcriptTimeoutMs: 180000,
ringTimeoutMs: 30000,
maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: {
provider: params?.tunnelProvider ?? "none",
allowNgrokFreeTierLoopbackBypass: false,
},
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
sttProvider: "openai-realtime",
sttModel: "gpt-4o-transcribe",
silenceDurationMs: 800,
vadThreshold: 0.5,
streamPath: "/voice/stream",
preStartTimeoutMs: 5000,
maxPendingConnections: 32,
maxPendingConnectionsPerIp: 4,
maxConnections: 128,
},
skipSignatureVerification: false,
stt: { provider: "openai", model: "whisper-1" },
tts: {
provider: "openai",
openai: { model: "gpt-4o-mini-tts", voice: "coral" },
},
responseModel: "openai/gpt-4o-mini",
responseTimeoutMs: 30000,
};
}

View File

@@ -6,8 +6,10 @@ import type {
} from "openclaw/plugin-sdk/zalo";
import {
applyAccountNameToChannelSection,
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
buildTokenChannelStatusSummary,
buildChannelSendResult,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
chunkTextForOutbound,
@@ -15,10 +17,13 @@ import {
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
isNumericTargetId,
PAIRING_APPROVED_MESSAGE,
resolveOutboundMediaUrls,
resolveDefaultGroupPolicy,
resolveOpenProviderRuntimeGroupPolicy,
resolveChannelAccountConfigBasePath,
sendPayloadWithChunkedTextAndMedia,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/zalo";
import {
@@ -182,13 +187,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return /^\d{3,}$/.test(trimmed);
},
looksLikeId: isNumericTargetId,
hint: "<chatId>",
},
},
@@ -303,51 +302,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalo", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zaloPlugin.outbound!.sendMedia!({
...ctx,
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await zaloPlugin.outbound!.sendMedia!({
...ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
const outbound = zaloPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
chunker: zaloPlugin.outbound!.chunker,
sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalo", messageId: "" },
}),
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
cfg: cfg,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
return buildChannelSendResult("zalo", result);
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
@@ -355,12 +324,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
mediaUrl,
cfg: cfg,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
return buildChannelSendResult("zalo", result);
},
},
status: {
@@ -377,19 +341,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.token?.trim());
const base = buildBaseAccountStatusSnapshot({
account: {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
},
runtime,
});
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
...base,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: account.config.webhookUrl ? "webhook" : "polling",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},

View File

@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
return { core, readAllowFromStore, upsertPairingRequest };
}
async function postUntilRateLimited(params: {
baseUrl: string;
path: string;
secret: string;
withNonceQuery?: boolean;
attempts?: number;
}): Promise<boolean> {
const attempts = params.attempts ?? 130;
for (let i = 0; i < attempts; i += 1) {
const url = params.withNonceQuery
? `${params.baseUrl}${params.path}?nonce=${i}`
: `${params.baseUrl}${params.path}`;
const response = await fetch(url, {
method: "POST",
headers: {
"x-bot-api-secret-token": params.secret,
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
return true;
}
}
return false;
}
describe("handleZaloWebhookRequest", () => {
afterEach(() => {
clearZaloWebhookSecurityStateForTest();
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-rate`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
}
const saw429 = await postUntilRateLimited({
baseUrl,
path: "/hook-rate",
secret: "secret",
});
expect(saw429).toBe(true);
});
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
try {
await withServer(webhookRequestHandler, async (baseUrl) => {
let saw429 = false;
for (let i = 0; i < 130; i += 1) {
const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
method: "POST",
headers: {
"x-bot-api-secret-token": "secret",
"content-type": "application/json",
},
body: "{}",
});
if (response.status === 429) {
saw429 = true;
break;
}
}
const saw429 = await postUntilRateLimited({
baseUrl,
path: "/hook-query-rate",
secret: "secret",
withNonceQuery: true,
});
expect(saw429).toBe(true);
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);

View File

@@ -40,37 +40,47 @@ function resolveSendContext(options: ZaloSendOptions): {
return { token, fetcher: resolveZaloProxyFetch(proxy) };
}
function resolveValidatedSendContext(
chatId: string,
options: ZaloSendOptions,
): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
const trimmedChatId = chatId?.trim();
if (!trimmedChatId) {
return { ok: false, error: "No chat_id provided" };
}
return { ok: true, chatId: trimmedChatId, token, fetcher };
}
export async function sendMessageZalo(
chatId: string,
text: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
const context = resolveValidatedSendContext(chatId, options);
if (!context.ok) {
return { ok: false, error: context.error };
}
if (options.mediaUrl) {
return sendPhotoZalo(chatId, options.mediaUrl, {
return sendPhotoZalo(context.chatId, options.mediaUrl, {
...options,
token,
token: context.token,
caption: text || options.caption,
});
}
try {
const response = await sendMessage(
token,
context.token,
{
chat_id: chatId.trim(),
chat_id: context.chatId,
text: text.slice(0, 2000),
},
fetcher,
context.fetcher,
);
if (response.ok && response.result) {
@@ -88,14 +98,9 @@ export async function sendPhotoZalo(
photoUrl: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
const context = resolveValidatedSendContext(chatId, options);
if (!context.ok) {
return { ok: false, error: context.error };
}
if (!photoUrl?.trim()) {
@@ -104,13 +109,13 @@ export async function sendPhotoZalo(
try {
const response = await sendPhoto(
token,
context.token,
{
chat_id: chatId.trim(),
chat_id: context.chatId,
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
},
fetcher,
context.fetcher,
);
if (response.ok && response.result) {

View File

@@ -8,6 +8,19 @@ export type ZaloTokenResolution = BaseTokenResolution & {
source: "env" | "config" | "configFile" | "none";
};
function readTokenFromFile(tokenFile: string | undefined): string {
const trimmedPath = tokenFile?.trim();
if (!trimmedPath) {
return "";
}
try {
return readFileSync(trimmedPath, "utf8").trim();
} catch {
// ignore read failures
return "";
}
}
export function resolveZaloToken(
config: ZaloConfig | undefined,
accountId?: string | null,
@@ -44,28 +57,16 @@ export function resolveZaloToken(
if (token) {
return { token, source: "config" };
}
const tokenFile = accountConfig.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
} catch {
// ignore read failures
}
const fileToken = readTokenFromFile(accountConfig.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}
const accountTokenFile = accountConfig?.tokenFile?.trim();
if (!accountHasBotToken && accountTokenFile) {
try {
const fileToken = readFileSync(accountTokenFile, "utf8").trim();
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
} catch {
// ignore read failures
if (!accountHasBotToken) {
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}
@@ -79,16 +80,9 @@ export function resolveZaloToken(
if (token) {
return { token, source: "config" };
}
const tokenFile = baseConfig?.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
} catch {
// ignore read failures
}
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
if (fileToken) {
return { token: fileToken, source: "configFile" };
}
}

View File

@@ -1,5 +1,3 @@
import fsp from "node:fs/promises";
import path from "node:path";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
@@ -12,16 +10,19 @@ import type {
} from "openclaw/plugin-sdk/zalouser";
import {
applyAccountNameToChannelSection,
buildChannelSendResult,
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
chunkTextForOutbound,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
formatPairingApproveHint,
isNumericTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolvePreferredOpenClawTmpDir,
resolveChannelAccountConfigBasePath,
sendPayloadWithChunkedTextAndMedia,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/zalouser";
import {
@@ -37,6 +38,7 @@ import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-po
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { probeZalouser } from "./probe.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import {
@@ -69,25 +71,6 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
return normalized;
}
async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}
function mapUser(params: {
id: string;
name?: string | null;
@@ -116,39 +99,30 @@ function mapGroup(params: {
};
}
function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
const account = resolveZalouserAccountSync({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
return findZalouserGroupEntry(
groups,
buildZalouserGroupCandidates({
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
}),
);
}
function resolveZalouserGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const account = resolveZalouserAccountSync({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
const entry = findZalouserGroupEntry(
groups,
buildZalouserGroupCandidates({
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
}),
);
return entry?.tools;
return resolveZalouserGroupPolicyEntry(params)?.tools;
}
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
const account = resolveZalouserAccountSync({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
const entry = findZalouserGroupEntry(
groups,
buildZalouserGroupCandidates({
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
}),
);
const entry = resolveZalouserGroupPolicyEntry(params);
if (typeof entry?.requireMention === "boolean") {
return entry.requireMention;
}
@@ -395,13 +369,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
return trimmed.replace(/^(zalouser|zlu):/i, "");
},
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return /^\d{3,}$/.test(trimmed);
},
looksLikeId: isNumericTargetId,
hint: "<threadId>",
},
},
@@ -560,49 +528,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
chunker: chunkTextForOutbound,
chunkerMode: "text",
textChunkLimit: 2000,
sendPayload: async (ctx) => {
const text = ctx.payload.text ?? "";
const urls = ctx.payload.mediaUrls?.length
? ctx.payload.mediaUrls
: ctx.payload.mediaUrl
? [ctx.payload.mediaUrl]
: [];
if (!text && urls.length === 0) {
return { channel: "zalouser", messageId: "" };
}
if (urls.length > 0) {
let lastResult = await zalouserPlugin.outbound!.sendMedia!({
...ctx,
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await zalouserPlugin.outbound!.sendMedia!({
...ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
const outbound = zalouserPlugin.outbound!;
const limit = outbound.textChunkLimit;
const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
for (const chunk of chunks) {
lastResult = await outbound.sendText!({ ...ctx, text: chunk });
}
return lastResult!;
},
sendPayload: async (ctx) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
chunker: zalouserPlugin.outbound!.chunker,
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
emptyResult: { channel: "zalouser", messageId: "" },
}),
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
return buildChannelSendResult("zalouser", result);
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
@@ -611,12 +549,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
mediaUrl,
mediaLocalRoots,
});
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
return buildChannelSendResult("zalouser", result);
},
},
status: {
@@ -641,17 +574,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
buildAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
const configError = "not authenticated";
const base = buildBaseAccountStatusSnapshot({
account: {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
},
runtime: configured
? runtime
: { ...runtime, lastError: runtime?.lastError ?? configError },
});
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: configured ? (runtime?.lastError ?? null) : (runtime?.lastError ?? configError),
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
...base,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},

View File

@@ -1,21 +1,10 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
import { describe, expect, it, vi } from "vitest";
import { __testing } from "./monitor.js";
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
sendDeliveredZalouser: sendDeliveredZalouserMock,
sendSeenZalouser: sendSeenZalouserMock,
}));
describe("zalouser monitor pairing account scoping", () => {
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
const readAllowFromStore = vi.fn(

View File

@@ -1,21 +1,15 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { __testing } from "./monitor.js";
import {
sendDeliveredZalouserMock,
sendMessageZalouserMock,
sendSeenZalouserMock,
sendTypingZalouserMock,
} from "./monitor.send-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
sendDeliveredZalouser: sendDeliveredZalouserMock,
sendSeenZalouser: sendSeenZalouserMock,
}));
function createAccount(): ResolvedZalouserAccount {
return {
accountId: "default",

View File

@@ -0,0 +1,20 @@
import { vi } from "vitest";
const sendMocks = vi.hoisted(() => ({
sendMessageZalouserMock: vi.fn(async () => {}),
sendTypingZalouserMock: vi.fn(async () => {}),
sendDeliveredZalouserMock: vi.fn(async () => {}),
sendSeenZalouserMock: vi.fn(async () => {}),
}));
export const sendMessageZalouserMock = sendMocks.sendMessageZalouserMock;
export const sendTypingZalouserMock = sendMocks.sendTypingZalouserMock;
export const sendDeliveredZalouserMock = sendMocks.sendDeliveredZalouserMock;
export const sendSeenZalouserMock = sendMocks.sendSeenZalouserMock;
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
sendDeliveredZalouser: sendDeliveredZalouserMock,
sendSeenZalouser: sendSeenZalouserMock,
}));

View File

@@ -0,0 +1,22 @@
import fsp from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/zalouser";
export async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}

View File

@@ -126,6 +126,20 @@ export type Listener = {
stop(): void;
};
type DeliveryEventMessage = {
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
};
type DeliveryEventMessages = DeliveryEventMessage | DeliveryEventMessage[];
export type API = {
listener: Listener;
getContext(): {
@@ -185,57 +199,10 @@ export type API = {
): Promise<unknown>;
sendDeliveredEvent(
isSeen: boolean,
messages:
| {
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
}
| Array<{
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
}>,
type?: number,
): Promise<unknown>;
sendSeenEvent(
messages:
| {
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
}
| Array<{
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
}>,
messages: DeliveryEventMessages,
type?: number,
): Promise<unknown>;
sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise<unknown>;
};
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {