mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(extensions): reuse shared helper primitives
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
|||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
buildComputedAccountStatusSnapshot,
|
||||||
buildProbeChannelStatusSummary,
|
buildProbeChannelStatusSummary,
|
||||||
collectBlueBubblesStatusIssues,
|
collectBlueBubblesStatusIssues,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
|
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
@@ -255,40 +257,27 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
})
|
})
|
||||||
: namedConfig;
|
: namedConfig;
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
return {
|
return applyBlueBubblesConnectionConfig({
|
||||||
...next,
|
cfg: next,
|
||||||
channels: {
|
accountId,
|
||||||
...next.channels,
|
patch: {
|
||||||
bluebubbles: {
|
serverUrl: input.httpUrl,
|
||||||
...next.channels?.bluebubbles,
|
password: input.password,
|
||||||
enabled: true,
|
webhookPath: input.webhookPath,
|
||||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
|
||||||
...(input.password ? { password: input.password } : {}),
|
|
||||||
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
onlyDefinedFields: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return applyBlueBubblesConnectionConfig({
|
||||||
...next,
|
cfg: next,
|
||||||
channels: {
|
accountId,
|
||||||
...next.channels,
|
patch: {
|
||||||
bluebubbles: {
|
serverUrl: input.httpUrl,
|
||||||
...next.channels?.bluebubbles,
|
password: input.password,
|
||||||
enabled: true,
|
webhookPath: input.webhookPath,
|
||||||
accounts: {
|
|
||||||
...next.channels?.bluebubbles?.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...next.channels?.bluebubbles?.accounts?.[accountId],
|
|
||||||
enabled: true,
|
|
||||||
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
|
|
||||||
...(input.password ? { password: input.password } : {}),
|
|
||||||
...(input.webhookPath ? { webhookPath: input.webhookPath } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
onlyDefinedFields: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
pairing: {
|
pairing: {
|
||||||
@@ -372,20 +361,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||||
const running = runtime?.running ?? false;
|
const running = runtime?.running ?? false;
|
||||||
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
|
||||||
return {
|
const base = buildComputedAccountStatusSnapshot({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
baseUrl: account.baseUrl,
|
runtime,
|
||||||
running,
|
|
||||||
connected: probeOk ?? running,
|
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
|
||||||
lastError: runtime?.lastError ?? null,
|
|
||||||
probe,
|
probe,
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
});
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
return {
|
||||||
|
...base,
|
||||||
|
baseUrl: account.baseUrl,
|
||||||
|
connected: probeOk ?? running,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,39 @@ function resolvePartIndex(partIndex: number | undefined): number {
|
|||||||
return typeof partIndex === "number" ? partIndex : 0;
|
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: {
|
async function sendPrivateApiJsonRequest(params: {
|
||||||
opts: BlueBubblesChatOpts;
|
opts: BlueBubblesChatOpts;
|
||||||
feature: string;
|
feature: string;
|
||||||
@@ -65,24 +98,13 @@ export async function markBlueBubblesChatRead(
|
|||||||
chatGuid: string,
|
chatGuid: string,
|
||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmed = chatGuid.trim();
|
await sendBlueBubblesChatEndpointRequest({
|
||||||
if (!trimmed) {
|
chatGuid,
|
||||||
return;
|
opts,
|
||||||
}
|
endpoint: "read",
|
||||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
method: "POST",
|
||||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
action: "read",
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = buildBlueBubblesApiUrl({
|
|
||||||
baseUrl,
|
|
||||||
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
|
||||||
password,
|
|
||||||
});
|
});
|
||||||
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text().catch(() => "");
|
|
||||||
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendBlueBubblesTyping(
|
export async function sendBlueBubblesTyping(
|
||||||
@@ -90,28 +112,13 @@ export async function sendBlueBubblesTyping(
|
|||||||
typing: boolean,
|
typing: boolean,
|
||||||
opts: BlueBubblesChatOpts = {},
|
opts: BlueBubblesChatOpts = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trimmed = chatGuid.trim();
|
await sendBlueBubblesChatEndpointRequest({
|
||||||
if (!trimmed) {
|
chatGuid,
|
||||||
return;
|
opts,
|
||||||
}
|
endpoint: "typing",
|
||||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
method: typing ? "POST" : "DELETE",
|
||||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
action: "typing",
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = buildBlueBubblesApiUrl({
|
|
||||||
baseUrl,
|
|
||||||
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
|
||||||
password,
|
|
||||||
});
|
});
|
||||||
const res = await blueBubblesFetchWithTimeout(
|
|
||||||
url,
|
|
||||||
{ method: typing ? "POST" : "DELETE" },
|
|
||||||
opts.timeoutMs,
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
const errorText = await res.text().catch(() => "");
|
|
||||||
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
77
extensions/bluebubbles/src/config-apply.ts
Normal file
77
extensions/bluebubbles/src/config-apply.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
|
||||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||||
import type { BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
@@ -35,17 +36,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const value = record[key];
|
return parseFiniteNumber(record[key]);
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const parsed = Number.parseFloat(value);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
||||||
|
|||||||
@@ -240,6 +240,15 @@ function getFirstDispatchCall(): DispatchReplyParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("BlueBubbles webhook monitor", () => {
|
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;
|
let unregister: () => void;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -261,122 +270,144 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
unregister?.();
|
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", () => {
|
describe("webhook parsing + auth handling", () => {
|
||||||
it("rejects non-POST requests", async () => {
|
it("rejects non-POST requests", async () => {
|
||||||
const account = createMockAccount();
|
registerWebhookTarget();
|
||||||
const config: OpenClawConfig = {};
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const core = createMockRuntime();
|
method: "GET",
|
||||||
setBlueBubblesRuntime(core);
|
body: {},
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(405);
|
expect(res.statusCode).toBe(405);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts POST requests with valid JSON payload", async () => {
|
it("accepts POST requests with valid JSON payload", async () => {
|
||||||
const account = createMockAccount();
|
registerWebhookTarget();
|
||||||
const config: OpenClawConfig = {};
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const core = createMockRuntime();
|
body: createWebhookPayload({ date: Date.now() }),
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
date: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body).toBe("ok");
|
expect(res.body).toBe("ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects requests with invalid JSON", async () => {
|
it("rejects requests with invalid JSON", async () => {
|
||||||
const account = createMockAccount();
|
registerWebhookTarget();
|
||||||
const config: OpenClawConfig = {};
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const core = createMockRuntime();
|
body: "invalid json {{",
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts URL-encoded payload wrappers", async () => {
|
it("accepts URL-encoded payload wrappers", async () => {
|
||||||
const account = createMockAccount();
|
registerWebhookTarget();
|
||||||
const config: OpenClawConfig = {};
|
const payload = createWebhookPayload({ date: Date.now() });
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
date: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const encodedBody = new URLSearchParams({
|
const encodedBody = new URLSearchParams({
|
||||||
payload: JSON.stringify(payload),
|
payload: JSON.stringify(payload),
|
||||||
}).toString();
|
}).toString();
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
const { handled, res } = await sendWebhookRequest({ body: encodedBody });
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(200);
|
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 () => {
|
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const account = createMockAccount();
|
registerWebhookTarget();
|
||||||
const config: OpenClawConfig = {};
|
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a request that never sends data or ends (simulates slow-loris)
|
// Create a request that never sends data or ends (simulates slow-loris)
|
||||||
const req = new EventEmitter() as IncomingMessage;
|
const req = new EventEmitter() as IncomingMessage;
|
||||||
req.method = "POST";
|
req.method = "POST";
|
||||||
req.url = "/bluebubbles-webhook?password=test-password";
|
req.url = `${WEBHOOK_PATH}?password=test-password`;
|
||||||
req.headers = {};
|
req.headers = {};
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||||
remoteAddress: "127.0.0.1",
|
remoteAddress: "127.0.0.1",
|
||||||
@@ -426,22 +446,13 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unauthorized requests before reading the body", async () => {
|
it("rejects unauthorized requests before reading the body", async () => {
|
||||||
const account = createMockAccount({ password: "secret-token" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ password: "secret-token" }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const req = new EventEmitter() as IncomingMessage;
|
const req = new EventEmitter() as IncomingMessage;
|
||||||
req.method = "POST";
|
req.method = "POST";
|
||||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
req.url = `${WEBHOOK_PATH}?password=wrong-token`;
|
||||||
req.headers = {};
|
req.headers = {};
|
||||||
const onSpy = vi.spyOn(req, "on");
|
const onSpy = vi.spyOn(req, "on");
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||||
@@ -457,112 +468,43 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("authenticates via password query parameter", async () => {
|
it("authenticates via password query parameter", async () => {
|
||||||
const account = createMockAccount({ password: "secret-token" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ password: "secret-token" }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
// Mock non-localhost request
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
const { handled, res } = await sendWebhookRequest({
|
||||||
|
url: `${WEBHOOK_PATH}?password=secret-token`,
|
||||||
|
body: createWebhookPayload(),
|
||||||
remoteAddress: "192.168.1.100",
|
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(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("authenticates via x-password header", async () => {
|
it("authenticates via x-password header", async () => {
|
||||||
const account = createMockAccount({ password: "secret-token" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ password: "secret-token" }),
|
||||||
const core = createMockRuntime();
|
});
|
||||||
setBlueBubblesRuntime(core);
|
const { handled, res } = await sendWebhookRequest({
|
||||||
|
body: createWebhookPayload(),
|
||||||
const req = createMockRequest(
|
headers: { "x-password": "secret-token" },
|
||||||
"POST",
|
remoteAddress: "192.168.1.100",
|
||||||
"/bluebubbles-webhook",
|
|
||||||
{
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ "x-password": "secret-token" },
|
|
||||||
);
|
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
||||||
remoteAddress: "192.168.1.100",
|
|
||||||
};
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = createMockResponse();
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unauthorized requests with wrong password", async () => {
|
it("rejects unauthorized requests with wrong password", async () => {
|
||||||
const account = createMockAccount({ password: "secret-token" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ password: "secret-token" }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
const { handled, res } = await sendWebhookRequest({
|
||||||
|
url: `${WEBHOOK_PATH}?password=wrong-token`,
|
||||||
|
body: createWebhookPayload(),
|
||||||
remoteAddress: "192.168.1.100",
|
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(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(401);
|
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 () => {
|
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
||||||
const accountA = createMockAccount({ password: "secret-token" });
|
const accountA = createMockAccount({ password: "secret-token" });
|
||||||
const accountB = createMockAccount({ password: "secret-token" });
|
const accountB = createMockAccount({ password: "secret-token" });
|
||||||
const config: OpenClawConfig = {};
|
const { config, core, runtime } = createWebhookTargetDeps();
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
const sinkA = vi.fn();
|
const sinkA = vi.fn();
|
||||||
const sinkB = vi.fn();
|
const sinkB = vi.fn();
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
const unregisterA = registerWebhookTarget({
|
||||||
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({
|
|
||||||
account: accountA,
|
account: accountA,
|
||||||
config,
|
config,
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
runtime,
|
||||||
core,
|
core,
|
||||||
path: "/bluebubbles-webhook",
|
trackForCleanup: false,
|
||||||
statusSink: sinkA,
|
statusSink: sinkA,
|
||||||
});
|
}).stop;
|
||||||
const unregisterB = registerBlueBubblesWebhookTarget({
|
const unregisterB = registerWebhookTarget({
|
||||||
account: accountB,
|
account: accountB,
|
||||||
config,
|
config,
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
runtime,
|
||||||
core,
|
core,
|
||||||
path: "/bluebubbles-webhook",
|
trackForCleanup: false,
|
||||||
statusSink: sinkB,
|
statusSink: sinkB,
|
||||||
});
|
}).stop;
|
||||||
unregister = () => {
|
unregister = () => {
|
||||||
unregisterA();
|
unregisterA();
|
||||||
unregisterB();
|
unregisterB();
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = createMockResponse();
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
url: `${WEBHOOK_PATH}?password=secret-token`,
|
||||||
|
body: createWebhookPayload(),
|
||||||
|
remoteAddress: "192.168.1.100",
|
||||||
|
});
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(401);
|
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 () => {
|
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
||||||
const accountStrict = createMockAccount({ password: "secret-token" });
|
const accountStrict = createMockAccount({ password: "secret-token" });
|
||||||
const accountWithoutPassword = createMockAccount({ password: undefined });
|
const accountWithoutPassword = createMockAccount({ password: undefined });
|
||||||
const config: OpenClawConfig = {};
|
const { config, core, runtime } = createWebhookTargetDeps();
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
const sinkStrict = vi.fn();
|
const sinkStrict = vi.fn();
|
||||||
const sinkWithoutPassword = vi.fn();
|
const sinkWithoutPassword = vi.fn();
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
const unregisterStrict = registerWebhookTarget({
|
||||||
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({
|
|
||||||
account: accountStrict,
|
account: accountStrict,
|
||||||
config,
|
config,
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
runtime,
|
||||||
core,
|
core,
|
||||||
path: "/bluebubbles-webhook",
|
trackForCleanup: false,
|
||||||
statusSink: sinkStrict,
|
statusSink: sinkStrict,
|
||||||
});
|
}).stop;
|
||||||
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
const unregisterNoPassword = registerWebhookTarget({
|
||||||
account: accountWithoutPassword,
|
account: accountWithoutPassword,
|
||||||
config,
|
config,
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
runtime,
|
||||||
core,
|
core,
|
||||||
path: "/bluebubbles-webhook",
|
trackForCleanup: false,
|
||||||
statusSink: sinkWithoutPassword,
|
statusSink: sinkWithoutPassword,
|
||||||
});
|
}).stop;
|
||||||
unregister = () => {
|
unregister = () => {
|
||||||
unregisterStrict();
|
unregisterStrict();
|
||||||
unregisterNoPassword();
|
unregisterNoPassword();
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = createMockResponse();
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
url: `${WEBHOOK_PATH}?password=secret-token`,
|
||||||
|
body: createWebhookPayload(),
|
||||||
|
remoteAddress: "192.168.1.100",
|
||||||
|
});
|
||||||
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
@@ -677,34 +593,20 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
it("requires authentication for loopback requests when password is configured", async () => {
|
it("requires authentication for loopback requests when password is configured", async () => {
|
||||||
const account = createMockAccount({ password: "secret-token" });
|
const account = createMockAccount({ password: "secret-token" });
|
||||||
const config: OpenClawConfig = {};
|
const { config, core, runtime } = createWebhookTargetDeps();
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
const loopbackUnregister = registerWebhookTarget({
|
||||||
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({
|
|
||||||
account,
|
account,
|
||||||
config,
|
config,
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
runtime,
|
||||||
core,
|
core,
|
||||||
path: "/bluebubbles-webhook",
|
trackForCleanup: false,
|
||||||
});
|
}).stop;
|
||||||
|
|
||||||
const res = createMockResponse();
|
const { handled, res } = await sendWebhookRequest({
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
body: createWebhookPayload(),
|
||||||
|
remoteAddress,
|
||||||
|
});
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(401);
|
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 () => {
|
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
||||||
const account = createMockAccount({ password: undefined });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ password: undefined }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerVariants: Record<string, string>[] = [
|
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" },
|
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
||||||
];
|
];
|
||||||
for (const headers of headerVariants) {
|
for (const headers of headerVariants) {
|
||||||
const req = createMockRequest(
|
const { handled, res } = await sendWebhookRequest({
|
||||||
"POST",
|
body: createWebhookPayload(),
|
||||||
"/bluebubbles-webhook",
|
|
||||||
{
|
|
||||||
type: "new-message",
|
|
||||||
data: {
|
|
||||||
text: "hello",
|
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: false,
|
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers,
|
headers,
|
||||||
);
|
|
||||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
|
||||||
remoteAddress: "127.0.0.1",
|
remoteAddress: "127.0.0.1",
|
||||||
};
|
});
|
||||||
const res = createMockResponse();
|
|
||||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
expect(handled).toBe(true);
|
expect(handled).toBe(true);
|
||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
}
|
}
|
||||||
@@ -770,36 +648,18 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||||
|
|
||||||
const account = createMockAccount({ groupPolicy: "open" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ groupPolicy: "open" }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
await sendWebhookRequest({
|
||||||
type: "new-message",
|
body: createWebhookPayload({
|
||||||
data: {
|
|
||||||
text: "hello from group",
|
text: "hello from group",
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
chatId: "123",
|
chatId: "123",
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
}),
|
||||||
};
|
});
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||||
@@ -819,36 +679,18 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
return EMPTY_DISPATCH_RESULT;
|
return EMPTY_DISPATCH_RESULT;
|
||||||
});
|
});
|
||||||
|
|
||||||
const account = createMockAccount({ groupPolicy: "open" });
|
registerWebhookTarget({
|
||||||
const config: OpenClawConfig = {};
|
account: createMockAccount({ groupPolicy: "open" }),
|
||||||
const core = createMockRuntime();
|
|
||||||
setBlueBubblesRuntime(core);
|
|
||||||
|
|
||||||
unregister = registerBlueBubblesWebhookTarget({
|
|
||||||
account,
|
|
||||||
config,
|
|
||||||
runtime: { log: vi.fn(), error: vi.fn() },
|
|
||||||
core,
|
|
||||||
path: "/bluebubbles-webhook",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = {
|
await sendWebhookRequest({
|
||||||
type: "new-message",
|
body: createWebhookPayload({
|
||||||
data: {
|
|
||||||
text: "hello from group",
|
text: "hello from group",
|
||||||
handle: { address: "+15551234567" },
|
|
||||||
isGroup: true,
|
isGroup: true,
|
||||||
isFromMe: false,
|
|
||||||
guid: "msg-1",
|
|
||||||
chat: { chatGuid: "iMessage;+;chat123456" },
|
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
}),
|
||||||
};
|
});
|
||||||
|
|
||||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
|
||||||
const res = createMockResponse();
|
|
||||||
|
|
||||||
await handleBlueBubblesWebhookRequest(req, res);
|
|
||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
resolveBlueBubblesAccount,
|
resolveBlueBubblesAccount,
|
||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
|
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||||
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||||
@@ -283,42 +284,16 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply config
|
// Apply config
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
next = applyBlueBubblesConnectionConfig({
|
||||||
next = {
|
cfg: next,
|
||||||
...next,
|
accountId,
|
||||||
channels: {
|
patch: {
|
||||||
...next.channels,
|
serverUrl,
|
||||||
bluebubbles: {
|
password,
|
||||||
...next.channels?.bluebubbles,
|
webhookPath,
|
||||||
enabled: true,
|
},
|
||||||
serverUrl,
|
accountEnabled: "preserve-or-true",
|
||||||
password,
|
});
|
||||||
webhookPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
channels: {
|
|
||||||
...next.channels,
|
|
||||||
bluebubbles: {
|
|
||||||
...next.channels?.bluebubbles,
|
|
||||||
enabled: true,
|
|
||||||
accounts: {
|
|
||||||
...next.channels?.bluebubbles?.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...next.channels?.bluebubbles?.accounts?.[accountId],
|
|
||||||
enabled: next.channels?.bluebubbles?.accounts?.[accountId]?.enabled ?? true,
|
|
||||||
serverUrl,
|
|
||||||
password,
|
|
||||||
webhookPath,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,12 +1 @@
|
|||||||
export function resolveRequestUrl(input: RequestInfo | URL): string {
|
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>;
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||||
|
|
||||||
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
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"}`);
|
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
}
|
}
|
||||||
const body = await res.text();
|
return parseBlueBubblesMessageResponse(res);
|
||||||
if (!body) {
|
|
||||||
return { messageId: "ok" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as unknown;
|
|
||||||
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
||||||
} catch {
|
|
||||||
return { messageId: "ok" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendMessageBlueBubbles(
|
export async function sendMessageBlueBubbles(
|
||||||
@@ -464,14 +468,5 @@ export async function sendMessageBlueBubbles(
|
|||||||
const errorText = await res.text();
|
const errorText = await res.text();
|
||||||
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||||
}
|
}
|
||||||
const body = await res.text();
|
return parseBlueBubblesMessageResponse(res);
|
||||||
if (!body) {
|
|
||||||
return { messageId: "ok" };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as unknown;
|
|
||||||
return { messageId: extractBlueBubblesMessageId(parsed) };
|
|
||||||
} catch {
|
|
||||||
return { messageId: "ok" };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ import {
|
|||||||
resolveDiffsPluginSecurity,
|
resolveDiffsPluginSecurity,
|
||||||
} from "./config.js";
|
} 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", () => {
|
describe("resolveDiffsPluginDefaults", () => {
|
||||||
it("returns built-in defaults when config is missing", () => {
|
it("returns built-in defaults when config is missing", () => {
|
||||||
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
expect(resolveDiffsPluginDefaults(undefined)).toEqual(DEFAULT_DIFFS_TOOL_DEFAULTS);
|
||||||
@@ -15,39 +32,9 @@ describe("resolveDiffsPluginDefaults", () => {
|
|||||||
it("applies configured defaults from plugin config", () => {
|
it("applies configured defaults from plugin config", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveDiffsPluginDefaults({
|
resolveDiffsPluginDefaults({
|
||||||
defaults: {
|
defaults: 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",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual(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",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps and falls back for invalid line spacing and indicators", () => {
|
it("clamps and falls back for invalid line spacing and indicators", () => {
|
||||||
|
|||||||
@@ -95,23 +95,11 @@ describe("diffs tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders PDF output when fileFormat is pdf", async () => {
|
it("renders PDF output when fileFormat is pdf", async () => {
|
||||||
const screenshotter = {
|
const screenshotter = createPdfScreenshotter({
|
||||||
screenshotHtml: vi.fn(
|
assertOutputPath: (outputPath) => {
|
||||||
async ({
|
expect(outputPath).toMatch(/preview\.pdf$/);
|
||||||
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 tool = createDiffsTool({
|
const tool = createDiffsTool({
|
||||||
api: createApi(),
|
api: createApi(),
|
||||||
@@ -208,22 +196,7 @@ describe("diffs tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts deprecated format alias for fileFormat", async () => {
|
it("accepts deprecated format alias for fileFormat", async () => {
|
||||||
const screenshotter = {
|
const screenshotter = createPdfScreenshotter();
|
||||||
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 tool = createDiffsTool({
|
const tool = createDiffsTool({
|
||||||
api: createApi(),
|
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 {
|
function readTextContent(result: unknown, index: number): string {
|
||||||
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
|
const content = (result as { content?: Array<{ type?: string; text?: string }> } | undefined)
|
||||||
?.content;
|
?.content;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||||
import {
|
import {
|
||||||
buildBaseChannelStatusSummary,
|
buildProbeChannelStatusSummary,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
@@ -54,6 +55,30 @@ const secretInputJsonSchema = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} 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> = {
|
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||||
id: "feishu",
|
id: "feishu",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -178,23 +203,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For named accounts, set enabled in accounts[accountId]
|
// For named accounts, set enabled in accounts[accountId]
|
||||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
return setFeishuNamedAccountEnabled(cfg, accountId, enabled);
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
feishu: {
|
|
||||||
...feishuCfg,
|
|
||||||
accounts: {
|
|
||||||
...feishuCfg?.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...feishuCfg?.accounts?.[accountId],
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
deleteAccount: ({ cfg, accountId }) => {
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
@@ -281,23 +290,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
return setFeishuNamedAccountEnabled(cfg, accountId, true);
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
feishu: {
|
|
||||||
...feishuCfg,
|
|
||||||
accounts: {
|
|
||||||
...feishuCfg?.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...feishuCfg?.accounts?.[accountId],
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onboarding: feishuOnboardingAdapter,
|
onboarding: feishuOnboardingAdapter,
|
||||||
@@ -342,12 +335,10 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
outbound: feishuOutbound,
|
outbound: feishuOutbound,
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
...buildBaseChannelStatusSummary(snapshot),
|
buildProbeChannelStatusSummary(snapshot, {
|
||||||
port: snapshot.port ?? null,
|
port: snapshot.port ?? null,
|
||||||
probe: snapshot.probe,
|
}),
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
||||||
}),
|
|
||||||
probeAccount: async ({ account }) => await probeFeishu(account),
|
probeAccount: async ({ account }) => await probeFeishu(account),
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@@ -356,12 +347,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
appId: account.appId,
|
appId: account.appId,
|
||||||
domain: account.domain,
|
domain: account.domain,
|
||||||
running: runtime?.running ?? false,
|
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
|
||||||
lastError: runtime?.lastError ?? null,
|
|
||||||
port: runtime?.port ?? null,
|
port: runtime?.port ?? null,
|
||||||
probe,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
|||||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||||
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
|
||||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||||
|
import {
|
||||||
// ============ Helpers ============
|
jsonToolResult,
|
||||||
|
toolExecutionErrorResult,
|
||||||
function json(data: unknown) {
|
unknownToolActionResult,
|
||||||
return {
|
} from "./tool-result.js";
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
||||||
details: data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Actions ============
|
// ============ Actions ============
|
||||||
|
|
||||||
@@ -206,21 +202,21 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
|||||||
});
|
});
|
||||||
switch (p.action) {
|
switch (p.action) {
|
||||||
case "list":
|
case "list":
|
||||||
return json(await listFolder(client, p.folder_token));
|
return jsonToolResult(await listFolder(client, p.folder_token));
|
||||||
case "info":
|
case "info":
|
||||||
return json(await getFileInfo(client, p.file_token));
|
return jsonToolResult(await getFileInfo(client, p.file_token));
|
||||||
case "create_folder":
|
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":
|
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":
|
case "delete":
|
||||||
return json(await deleteFile(client, p.file_token, p.type));
|
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
|
||||||
default:
|
default:
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
// 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) {
|
} catch (err) {
|
||||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
return toolExecutionErrorResult(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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];
|
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
||||||
|
|
||||||
function buildDebounceConfig(): ClawdbotConfig {
|
function buildDebounceConfig(): ClawdbotConfig {
|
||||||
@@ -152,6 +176,30 @@ function getFirstDispatchedEvent(): FeishuMessageEvent {
|
|||||||
return firstParams.event;
|
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", () => {
|
describe("resolveReactionSyntheticEvent", () => {
|
||||||
it("filters app self-reactions", async () => {
|
it("filters app self-reactions", async () => {
|
||||||
const event = makeReactionEvent({ operator_type: "app" });
|
const event = makeReactionEvent({ operator_type: "app" });
|
||||||
@@ -272,23 +320,12 @@ describe("resolveReactionSyntheticEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses event chat context when provided", async () => {
|
it("uses event chat context when provided", async () => {
|
||||||
const event = makeReactionEvent({
|
const result = await resolveReactionWithLookup({
|
||||||
chat_id: "oc_group_from_event",
|
event: makeReactionEvent({
|
||||||
chat_type: "group",
|
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",
|
|
||||||
}),
|
}),
|
||||||
uuid: () => "fixed-uuid",
|
lookupChatId: "oc_group_from_lookup",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -309,20 +346,8 @@ describe("resolveReactionSyntheticEvent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
|
it("falls back to reacted message chat_id when event chat_id is absent", async () => {
|
||||||
const event = makeReactionEvent();
|
const result = await resolveReactionWithLookup({
|
||||||
const result = await resolveReactionSyntheticEvent({
|
lookupChatId: "oc_group_from_lookup",
|
||||||
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",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.message.chat_id).toBe("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 () => {
|
it("falls back to sender p2p chat when lookup returns empty chat_id", async () => {
|
||||||
const event = makeReactionEvent();
|
const result = await resolveReactionWithLookup({
|
||||||
const result = await resolveReactionSyntheticEvent({
|
lookupChatId: "",
|
||||||
cfg,
|
|
||||||
accountId: "default",
|
|
||||||
event,
|
|
||||||
botOpenId: "ou_bot",
|
|
||||||
fetchMessage: async () => ({
|
|
||||||
messageId: "om_msg1",
|
|
||||||
chatId: "",
|
|
||||||
senderOpenId: "ou_bot",
|
|
||||||
content: "hello",
|
|
||||||
contentType: "text",
|
|
||||||
}),
|
|
||||||
uuid: () => "fixed-uuid",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result?.message.chat_id).toBe("p2p:ou_user1");
|
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 () => {
|
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
|
||||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
setDedupPassThroughMocks();
|
||||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
||||||
const onMessage = await setupDebounceMonitor();
|
const onMessage = await setupDebounceMonitor();
|
||||||
|
|
||||||
await onMessage(
|
await enqueueDebouncedMessage(
|
||||||
|
onMessage,
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_1",
|
messageId: "om_1",
|
||||||
text: "first",
|
text: "first",
|
||||||
mentions: [
|
mentions: [createMention({ openId: "ou_user_a", name: "user-a" })],
|
||||||
{
|
|
||||||
key: "@_user_1",
|
|
||||||
id: { open_id: "ou_user_a" },
|
|
||||||
name: "user-a",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await enqueueDebouncedMessage(
|
||||||
await Promise.resolve();
|
onMessage,
|
||||||
await onMessage(
|
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_2",
|
messageId: "om_2",
|
||||||
text: "@bot second",
|
text: "@bot second",
|
||||||
mentions: [
|
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
||||||
{
|
|
||||||
key: "@_user_1",
|
|
||||||
id: { open_id: "ou_bot" },
|
|
||||||
name: "bot",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await vi.advanceTimersByTimeAsync(25);
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
|
||||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -473,42 +469,25 @@ describe("Feishu inbound debounce regressions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not synthesize mention-forward intent across separate messages", async () => {
|
it("does not synthesize mention-forward intent across separate messages", async () => {
|
||||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
setDedupPassThroughMocks();
|
||||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
||||||
const onMessage = await setupDebounceMonitor();
|
const onMessage = await setupDebounceMonitor();
|
||||||
|
|
||||||
await onMessage(
|
await enqueueDebouncedMessage(
|
||||||
|
onMessage,
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_user_mention",
|
messageId: "om_user_mention",
|
||||||
text: "@alice first",
|
text: "@alice first",
|
||||||
mentions: [
|
mentions: [createMention({ openId: "ou_alice", name: "alice" })],
|
||||||
{
|
|
||||||
key: "@_user_1",
|
|
||||||
id: { open_id: "ou_alice" },
|
|
||||||
name: "alice",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await enqueueDebouncedMessage(
|
||||||
await Promise.resolve();
|
onMessage,
|
||||||
await onMessage(
|
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_bot_mention",
|
messageId: "om_bot_mention",
|
||||||
text: "@bot second",
|
text: "@bot second",
|
||||||
mentions: [
|
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
||||||
{
|
|
||||||
key: "@_user_1",
|
|
||||||
id: { open_id: "ou_bot" },
|
|
||||||
name: "bot",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await vi.advanceTimersByTimeAsync(25);
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
|
||||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
|
||||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
setDedupPassThroughMocks();
|
||||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
|
||||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
|
||||||
const onMessage = await setupDebounceMonitor();
|
const onMessage = await setupDebounceMonitor();
|
||||||
|
|
||||||
await onMessage(
|
await enqueueDebouncedMessage(
|
||||||
|
onMessage,
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_bot_first",
|
messageId: "om_bot_first",
|
||||||
text: "@bot first",
|
text: "@bot first",
|
||||||
mentions: [
|
mentions: [createMention({ openId: "ou_bot", name: "bot" })],
|
||||||
{
|
|
||||||
key: "@_user_1",
|
|
||||||
id: { open_id: "ou_bot" },
|
|
||||||
name: "bot",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await enqueueDebouncedMessage(
|
||||||
await Promise.resolve();
|
onMessage,
|
||||||
await onMessage(
|
|
||||||
createTextEvent({
|
createTextEvent({
|
||||||
messageId: "om_plain_second",
|
messageId: "om_plain_second",
|
||||||
text: "plain follow-up",
|
text: "plain follow-up",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
await vi.advanceTimersByTimeAsync(25);
|
await vi.advanceTimersByTimeAsync(25);
|
||||||
|
|
||||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
||||||
|
import {
|
||||||
|
createFeishuClientMockModule,
|
||||||
|
createFeishuRuntimeMockModule,
|
||||||
|
} from "./monitor.test-mocks.js";
|
||||||
|
|
||||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -8,27 +12,8 @@ vi.mock("./probe.js", () => ({
|
|||||||
probeFeishu: probeFeishuMock,
|
probeFeishu: probeFeishuMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./client.js", () => ({
|
vi.mock("./client.js", () => createFeishuClientMockModule());
|
||||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
|
||||||
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./runtime.js", () => ({
|
|
||||||
getFeishuRuntime: () => ({
|
|
||||||
channel: {
|
|
||||||
debounce: {
|
|
||||||
resolveInboundDebounceMs: () => 0,
|
|
||||||
createInboundDebouncer: () => ({
|
|
||||||
enqueue: async () => {},
|
|
||||||
flushKey: async () => {},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
hasControlCommand: () => false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
import { vi } from "vitest";
|
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", () => ({
|
export function createFeishuRuntimeMockModule() {
|
||||||
probeFeishu: probeFeishuMock,
|
return {
|
||||||
}));
|
getFeishuRuntime: () => ({
|
||||||
|
channel: {
|
||||||
vi.mock("./client.js", () => ({
|
debounce: {
|
||||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
resolveInboundDebounceMs: () => 0,
|
||||||
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
createInboundDebouncer: () => ({
|
||||||
}));
|
enqueue: async () => {},
|
||||||
|
flushKey: async () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
hasControlCommand: () => false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { createServer } from "node:http";
|
|||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createFeishuClientMockModule,
|
||||||
|
createFeishuRuntimeMockModule,
|
||||||
|
} from "./monitor.test-mocks.js";
|
||||||
|
|
||||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
@@ -9,27 +13,8 @@ vi.mock("./probe.js", () => ({
|
|||||||
probeFeishu: probeFeishuMock,
|
probeFeishu: probeFeishuMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./client.js", () => ({
|
vi.mock("./client.js", () => createFeishuClientMockModule());
|
||||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
vi.mock("./runtime.js", () => createFeishuRuntimeMockModule());
|
||||||
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("@larksuiteoapi/node-sdk", () => ({
|
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||||
adaptDefault: vi.fn(
|
adaptDefault: vi.fn(
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
|||||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||||
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
||||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||||
|
import {
|
||||||
// ============ Helpers ============
|
jsonToolResult,
|
||||||
|
toolExecutionErrorResult,
|
||||||
function json(data: unknown) {
|
unknownToolActionResult,
|
||||||
return {
|
} from "./tool-result.js";
|
||||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
||||||
details: data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListTokenType =
|
type ListTokenType =
|
||||||
| "doc"
|
| "doc"
|
||||||
@@ -154,21 +150,21 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
|||||||
});
|
});
|
||||||
switch (p.action) {
|
switch (p.action) {
|
||||||
case "list":
|
case "list":
|
||||||
return json(await listMembers(client, p.token, p.type));
|
return jsonToolResult(await listMembers(client, p.token, p.type));
|
||||||
case "add":
|
case "add":
|
||||||
return json(
|
return jsonToolResult(
|
||||||
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
||||||
);
|
);
|
||||||
case "remove":
|
case "remove":
|
||||||
return json(
|
return jsonToolResult(
|
||||||
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
|
await removeMember(client, p.token, p.type, p.member_type, p.member_id),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
// 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) {
|
} catch (err) {
|
||||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
return toolExecutionErrorResult(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
71
extensions/feishu/src/send-message.ts
Normal file
71
extensions/feishu/src/send-message.ts
Normal 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);
|
||||||
|
}
|
||||||
32
extensions/feishu/src/tool-result.test.ts
Normal file
32
extensions/feishu/src/tool-result.test.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
14
extensions/feishu/src/tool-result.ts
Normal file
14
extensions/feishu/src/tool-result.ts
Normal 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) });
|
||||||
|
}
|
||||||
@@ -2,17 +2,13 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
|
||||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||||
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
||||||
|
import {
|
||||||
|
jsonToolResult,
|
||||||
|
toolExecutionErrorResult,
|
||||||
|
unknownToolActionResult,
|
||||||
|
} from "./tool-result.js";
|
||||||
import { FeishuWikiSchema, type FeishuWikiParams } from "./wiki-schema.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";
|
type ObjType = "doc" | "sheet" | "mindnote" | "bitable" | "file" | "docx" | "slides";
|
||||||
|
|
||||||
// ============ Actions ============
|
// ============ Actions ============
|
||||||
@@ -194,22 +190,22 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
|||||||
});
|
});
|
||||||
switch (p.action) {
|
switch (p.action) {
|
||||||
case "spaces":
|
case "spaces":
|
||||||
return json(await listSpaces(client));
|
return jsonToolResult(await listSpaces(client));
|
||||||
case "nodes":
|
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":
|
case "get":
|
||||||
return json(await getNode(client, p.token));
|
return jsonToolResult(await getNode(client, p.token));
|
||||||
case "search":
|
case "search":
|
||||||
return json({
|
return jsonToolResult({
|
||||||
error:
|
error:
|
||||||
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
|
"Search is not available. Use feishu_wiki with action: 'nodes' to browse or action: 'get' to lookup by token.",
|
||||||
});
|
});
|
||||||
case "create":
|
case "create":
|
||||||
return json(
|
return jsonToolResult(
|
||||||
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
|
await createNode(client, p.space_id, p.title, p.obj_type, p.parent_node_token),
|
||||||
);
|
);
|
||||||
case "move":
|
case "move":
|
||||||
return json(
|
return jsonToolResult(
|
||||||
await moveNode(
|
await moveNode(
|
||||||
client,
|
client,
|
||||||
p.space_id,
|
p.space_id,
|
||||||
@@ -219,13 +215,13 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
case "rename":
|
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:
|
default:
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
|
// 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) {
|
} catch (err) {
|
||||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
return toolExecutionErrorResult(err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ function extractBearerToken(header: unknown): string {
|
|||||||
type ParsedGoogleChatInboundPayload =
|
type ParsedGoogleChatInboundPayload =
|
||||||
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
| { ok: true; event: GoogleChatEvent; addOnBearerToken: string }
|
||||||
| { ok: false };
|
| { ok: false };
|
||||||
|
type ParsedGoogleChatInboundSuccess = Extract<ParsedGoogleChatInboundPayload, { ok: true }>;
|
||||||
|
|
||||||
function parseGoogleChatInboundPayload(
|
function parseGoogleChatInboundPayload(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
@@ -116,6 +117,23 @@ export function createGoogleChatWebhookRequestHandler(params: {
|
|||||||
const headerBearer = extractBearerToken(req.headers.authorization);
|
const headerBearer = extractBearerToken(req.headers.authorization);
|
||||||
let selectedTarget: WebhookTarget | null = null;
|
let selectedTarget: WebhookTarget | null = null;
|
||||||
let parsedEvent: GoogleChatEvent | 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) {
|
if (headerBearer) {
|
||||||
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
selectedTarget = await resolveWebhookTargetWithAuthOrReject({
|
||||||
@@ -134,36 +152,14 @@ export function createGoogleChatWebhookRequestHandler(params: {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await readJsonWebhookBodyOrReject({
|
const parsed = await readAndParseEvent("post-auth");
|
||||||
req,
|
if (!parsed) {
|
||||||
res,
|
|
||||||
profile: "post-auth",
|
|
||||||
emptyObjectOnEmpty: false,
|
|
||||||
invalidJsonMessage: "invalid payload",
|
|
||||||
});
|
|
||||||
if (!body.ok) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
parsedEvent = parsed.event;
|
parsedEvent = parsed.event;
|
||||||
} else {
|
} else {
|
||||||
const body = await readJsonWebhookBodyOrReject({
|
const parsed = await readAndParseEvent("pre-auth");
|
||||||
req,
|
if (!parsed) {
|
||||||
res,
|
|
||||||
profile: "pre-auth",
|
|
||||||
emptyObjectOnEmpty: false,
|
|
||||||
invalidJsonMessage: "invalid payload",
|
|
||||||
});
|
|
||||||
if (!body.ok) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = parseGoogleChatInboundPayload(body.value, res);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
parsedEvent = parsed.event;
|
parsedEvent = parsed.event;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
collectStatusIssuesFromLastError,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
@@ -266,21 +267,7 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
cliPath: null,
|
cliPath: null,
|
||||||
dbPath: null,
|
dbPath: null,
|
||||||
},
|
},
|
||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("imessage", accounts),
|
||||||
accounts.flatMap((account) => {
|
|
||||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
|
||||||
if (!lastError) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
channel: "imessage",
|
|
||||||
accountId: account.accountId,
|
|
||||||
kind: "runtime",
|
|
||||||
message: `Channel error: ${lastError}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
configured: snapshot.configured ?? false,
|
configured: snapshot.configured ?? false,
|
||||||
running: snapshot.running ?? false,
|
running: snapshot.running ?? false,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GROUP_POLICY_BLOCKED_LABEL,
|
GROUP_POLICY_BLOCKED_LABEL,
|
||||||
createScopedPairingAccess,
|
createScopedPairingAccess,
|
||||||
createNormalizedOutboundDeliverer,
|
dispatchInboundReplyWithBase,
|
||||||
createReplyPrefixOptions,
|
|
||||||
formatTextWithAttachmentLinks,
|
formatTextWithAttachmentLinks,
|
||||||
logInboundDrop,
|
logInboundDrop,
|
||||||
isDangerousNameMatchingEnabled,
|
isDangerousNameMatchingEnabled,
|
||||||
@@ -332,44 +331,31 @@ export async function handleIrcInbound(params: {
|
|||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
await dispatchInboundReplyWithBase({
|
||||||
|
cfg: config as OpenClawConfig,
|
||||||
|
channel: CHANNEL_ID,
|
||||||
|
accountId: account.accountId,
|
||||||
|
route,
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
ctxPayload,
|
||||||
ctx: ctxPayload,
|
core,
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverIrcReply({
|
||||||
|
payload,
|
||||||
|
target: peerId,
|
||||||
|
accountId: account.accountId,
|
||||||
|
sendReply: params.sendReply,
|
||||||
|
statusSink,
|
||||||
|
});
|
||||||
|
},
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
onDispatchError: (err, info) => {
|
||||||
|
runtime.error?.(`irc ${info.kind} reply failed: ${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)}`);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
replyOptions: {
|
replyOptions: {
|
||||||
skillFilter: groupMatch.groupConfig?.skills,
|
skillFilter: groupMatch.groupConfig?.skills,
|
||||||
onModelSelected,
|
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof account.config.blockStreaming === "boolean"
|
typeof account.config.blockStreaming === "boolean"
|
||||||
? !account.config.blockStreaming
|
? !account.config.blockStreaming
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
buildComputedAccountStatusSnapshot,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
|
clearAccountEntryFields,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
LineConfigSchema,
|
LineConfigSchema,
|
||||||
processLineMessage,
|
processLineMessage,
|
||||||
@@ -27,6 +29,42 @@ const meta = {
|
|||||||
systemImage: "message.fill",
|
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> = {
|
export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||||
id: "line",
|
id: "line",
|
||||||
meta: {
|
meta: {
|
||||||
@@ -67,34 +105,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
return patchLineAccountConfig(cfg, lineConfig, accountId, { enabled });
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
line: {
|
|
||||||
...lineConfig,
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
line: {
|
|
||||||
...lineConfig,
|
|
||||||
accounts: {
|
|
||||||
...lineConfig.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...lineConfig.accounts?.[accountId],
|
|
||||||
enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
deleteAccount: ({ cfg, accountId }) => {
|
deleteAccount: ({ cfg, accountId }) => {
|
||||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
@@ -224,34 +235,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
getLineRuntime().channel.line.normalizeAccountId(accountId),
|
||||||
applyAccountName: ({ cfg, accountId, name }) => {
|
applyAccountName: ({ cfg, accountId, name }) => {
|
||||||
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
return patchLineAccountConfig(cfg, lineConfig, accountId, { name });
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
line: {
|
|
||||||
...lineConfig,
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
line: {
|
|
||||||
...lineConfig,
|
|
||||||
accounts: {
|
|
||||||
...lineConfig.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...lineConfig.accounts?.[accountId],
|
|
||||||
name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
validateInput: ({ accountId, input }) => {
|
validateInput: ({ accountId, input }) => {
|
||||||
const typedInput = input as {
|
const typedInput = input as {
|
||||||
@@ -615,20 +599,18 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
const configured = Boolean(
|
const configured = Boolean(
|
||||||
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
|
account.channelAccessToken?.trim() && account.channelSecret?.trim(),
|
||||||
);
|
);
|
||||||
return {
|
const base = buildComputedAccountStatusSnapshot({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured,
|
configured,
|
||||||
tokenSource: account.tokenSource,
|
runtime,
|
||||||
running: runtime?.running ?? false,
|
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
|
||||||
lastError: runtime?.lastError ?? null,
|
|
||||||
mode: "webhook",
|
|
||||||
probe,
|
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;
|
const accountCleanup = clearAccountEntryFields({
|
||||||
if (accounts && accountId in accounts) {
|
accounts: nextLine.accounts,
|
||||||
const entry = accounts[accountId];
|
accountId,
|
||||||
if (entry && typeof entry === "object") {
|
fields: ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"],
|
||||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
markClearedOnFieldPresence: true,
|
||||||
if (
|
});
|
||||||
"channelAccessToken" in nextEntry ||
|
if (accountCleanup.changed) {
|
||||||
"channelSecret" in nextEntry ||
|
changed = true;
|
||||||
"tokenFile" in nextEntry ||
|
if (accountCleanup.cleared) {
|
||||||
"secretFile" in nextEntry
|
cleared = true;
|
||||||
) {
|
|
||||||
cleared = true;
|
|
||||||
delete nextEntry.channelAccessToken;
|
|
||||||
delete nextEntry.channelSecret;
|
|
||||||
delete nextEntry.tokenFile;
|
|
||||||
delete nextEntry.secretFile;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (Object.keys(nextEntry).length === 0) {
|
|
||||||
delete accounts[accountId];
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
accounts[accountId] = nextEntry as typeof entry;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (accountCleanup.nextAccounts) {
|
||||||
|
nextLine.accounts = accountCleanup.nextAccounts;
|
||||||
if (accounts) {
|
|
||||||
if (Object.keys(accounts).length === 0) {
|
|
||||||
delete nextLine.accounts;
|
|
||||||
changed = true;
|
|
||||||
} else {
|
} else {
|
||||||
nextLine.accounts = accounts;
|
delete nextLine.accounts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
buildProbeChannelStatusSummary,
|
buildProbeChannelStatusSummary,
|
||||||
|
collectStatusIssuesFromLastError,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
@@ -380,21 +381,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
collectStatusIssues: (accounts) =>
|
collectStatusIssues: (accounts) => collectStatusIssuesFromLastError("matrix", accounts),
|
||||||
accounts.flatMap((account) => {
|
|
||||||
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
|
|
||||||
if (!lastError) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
channel: "matrix",
|
|
||||||
accountId: account.accountId,
|
|
||||||
kind: "runtime",
|
|
||||||
message: `Channel error: ${lastError}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}),
|
|
||||||
buildChannelSummary: ({ snapshot }) =>
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
||||||
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
probeAccount: async ({ account, timeoutMs, cfg }) => {
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import {
|
|||||||
createScopedPairingAccess,
|
createScopedPairingAccess,
|
||||||
createReplyPrefixOptions,
|
createReplyPrefixOptions,
|
||||||
createTypingCallbacks,
|
createTypingCallbacks,
|
||||||
|
dispatchReplyFromConfigWithSettledDispatcher,
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
logInboundDrop,
|
logInboundDrop,
|
||||||
logTypingFailure,
|
logTypingFailure,
|
||||||
|
resolveInboundSessionEnvelopeContext,
|
||||||
resolveControlCommandGate,
|
resolveControlCommandGate,
|
||||||
type PluginRuntime,
|
type PluginRuntime,
|
||||||
type RuntimeEnv,
|
type RuntimeEnv,
|
||||||
@@ -484,14 +486,12 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|||||||
const textWithId = threadRootId
|
const textWithId = threadRootId
|
||||||
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
|
? `${bodyText}\n[matrix event id: ${messageId} room: ${roomId} thread: ${threadRootId}]`
|
||||||
: `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
: `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const { storePath, envelopeOptions, previousTimestamp } =
|
||||||
agentId: route.agentId,
|
resolveInboundSessionEnvelopeContext({
|
||||||
});
|
cfg,
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
agentId: route.agentId,
|
||||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
sessionKey: route.sessionKey,
|
||||||
storePath,
|
});
|
||||||
sessionKey: route.sessionKey,
|
|
||||||
});
|
|
||||||
const body = core.channel.reply.formatInboundEnvelope({
|
const body = core.channel.reply.formatInboundEnvelope({
|
||||||
channel: "Matrix",
|
channel: "Matrix",
|
||||||
from: envelopeFrom,
|
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,
|
dispatcher,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
},
|
},
|
||||||
run: () =>
|
replyOptions: {
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
...replyOptions,
|
||||||
ctx: ctxPayload,
|
skillFilter: roomConfig?.skills,
|
||||||
cfg,
|
onModelSelected,
|
||||||
dispatcher,
|
},
|
||||||
replyOptions: {
|
|
||||||
...replyOptions,
|
|
||||||
skillFilter: roomConfig?.skills,
|
|
||||||
onModelSelected,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createLoggerBackedRuntime,
|
|
||||||
GROUP_POLICY_BLOCKED_LABEL,
|
GROUP_POLICY_BLOCKED_LABEL,
|
||||||
mergeAllowlist,
|
mergeAllowlist,
|
||||||
|
resolveRuntimeEnv,
|
||||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
summarizeMapping,
|
summarizeMapping,
|
||||||
@@ -241,11 +241,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||||
const runtime: RuntimeEnv =
|
const runtime: RuntimeEnv = resolveRuntimeEnv({
|
||||||
opts.runtime ??
|
runtime: opts.runtime,
|
||||||
createLoggerBackedRuntime({
|
logger,
|
||||||
logger,
|
});
|
||||||
});
|
|
||||||
const logVerboseMessage = (message: string) => {
|
const logVerboseMessage = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) {
|
if (!core.logging.shouldLogVerbose()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
buildOauthProviderAuthResult,
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
type ProviderAuthContext,
|
type ProviderAuthContext,
|
||||||
@@ -60,22 +61,14 @@ function createOAuthHandler(region: MiniMaxRegion) {
|
|||||||
await ctx.prompter.note(result.notification_message, "MiniMax OAuth");
|
await ctx.prompter.note(result.notification_message, "MiniMax OAuth");
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileId = `${PROVIDER_ID}:default`;
|
|
||||||
const baseUrl = result.resourceUrl || defaultBaseUrl;
|
const baseUrl = result.resourceUrl || defaultBaseUrl;
|
||||||
|
|
||||||
return {
|
return buildOauthProviderAuthResult({
|
||||||
profiles: [
|
providerId: PROVIDER_ID,
|
||||||
{
|
defaultModel: modelRef(DEFAULT_MODEL),
|
||||||
profileId,
|
access: result.access,
|
||||||
credential: {
|
refresh: result.refresh,
|
||||||
type: "oauth" as const,
|
expires: result.expires,
|
||||||
provider: PROVIDER_ID,
|
|
||||||
access: result.access,
|
|
||||||
refresh: result.refresh,
|
|
||||||
expires: result.expires,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
configPatch: {
|
configPatch: {
|
||||||
models: {
|
models: {
|
||||||
providers: {
|
providers: {
|
||||||
@@ -119,13 +112,12 @@ function createOAuthHandler(region: MiniMaxRegion) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultModel: modelRef(DEFAULT_MODEL),
|
|
||||||
notes: [
|
notes: [
|
||||||
"MiniMax OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
|
"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.`,
|
`Base URL defaults to ${defaultBaseUrl}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
|
||||||
...(result.notification_message ? [result.notification_message] : []),
|
...(result.notification_message ? [result.notification_message] : []),
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
progress.stop(`MiniMax OAuth failed: ${errorMsg}`);
|
progress.stop(`MiniMax OAuth failed: ${errorMsg}`);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type {
|
|||||||
OpenClawConfig,
|
OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/msteams";
|
} from "openclaw/plugin-sdk/msteams";
|
||||||
import {
|
import {
|
||||||
buildBaseChannelStatusSummary,
|
buildProbeChannelStatusSummary,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
@@ -250,11 +251,43 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
name: undefined as string | undefined,
|
name: undefined as string | undefined,
|
||||||
note: 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 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") {
|
if (kind === "user") {
|
||||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
const pending: PendingTargetEntry[] = [];
|
||||||
results.forEach((entry, index) => {
|
results.forEach((entry, index) => {
|
||||||
const trimmed = entry.input.trim();
|
const trimmed = entry.input.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -270,37 +303,21 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
pending.push({ input: entry.input, query: cleaned, index });
|
pending.push({ input: entry.input, query: cleaned, index });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pending.length > 0) {
|
await resolvePending(
|
||||||
try {
|
pending,
|
||||||
const resolved = await resolveMSTeamsUserAllowlist({
|
(entries) => resolveMSTeamsUserAllowlist({ cfg, entries }),
|
||||||
cfg,
|
(target, entry) => {
|
||||||
entries: pending.map((entry) => entry.query),
|
target.resolved = entry.resolved;
|
||||||
});
|
target.id = entry.id;
|
||||||
resolved.forEach((entry, idx) => {
|
target.name = entry.name;
|
||||||
const target = results[pending[idx]?.index ?? -1];
|
target.note = entry.note;
|
||||||
if (!target) {
|
},
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
target.resolved = entry.resolved;
|
|
||||||
target.id = entry.id;
|
|
||||||
target.name = entry.name;
|
|
||||||
target.note = entry.note;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
|
||||||
pending.forEach(({ index }) => {
|
|
||||||
const entry = results[index];
|
|
||||||
if (entry) {
|
|
||||||
entry.note = "lookup failed";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending: Array<{ input: string; query: string; index: number }> = [];
|
const pending: PendingTargetEntry[] = [];
|
||||||
results.forEach((entry, index) => {
|
results.forEach((entry, index) => {
|
||||||
const trimmed = entry.input.trim();
|
const trimmed = entry.input.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -323,48 +340,32 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
pending.push({ input: entry.input, query, index });
|
pending.push({ input: entry.input, query, index });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pending.length > 0) {
|
await resolvePending(
|
||||||
try {
|
pending,
|
||||||
const resolved = await resolveMSTeamsChannelAllowlist({
|
(entries) => resolveMSTeamsChannelAllowlist({ cfg, entries }),
|
||||||
cfg,
|
(target, entry) => {
|
||||||
entries: pending.map((entry) => entry.query),
|
if (!entry.resolved || !entry.teamId) {
|
||||||
});
|
target.resolved = false;
|
||||||
resolved.forEach((entry, idx) => {
|
target.note = entry.note;
|
||||||
const target = results[pending[idx]?.index ?? -1];
|
return;
|
||||||
if (!target) {
|
}
|
||||||
return;
|
target.resolved = true;
|
||||||
}
|
if (entry.channelId) {
|
||||||
if (!entry.resolved || !entry.teamId) {
|
target.id = `${entry.teamId}/${entry.channelId}`;
|
||||||
target.resolved = false;
|
target.name =
|
||||||
target.note = entry.note;
|
entry.channelName && entry.teamName
|
||||||
return;
|
? `${entry.teamName}/${entry.channelName}`
|
||||||
}
|
: (entry.channelName ?? entry.teamName);
|
||||||
target.resolved = true;
|
} else {
|
||||||
if (entry.channelId) {
|
target.id = entry.teamId;
|
||||||
target.id = `${entry.teamId}/${entry.channelId}`;
|
target.name = entry.teamName;
|
||||||
target.name =
|
target.note = "team id";
|
||||||
entry.channelName && entry.teamName
|
}
|
||||||
? `${entry.teamName}/${entry.channelName}`
|
if (entry.note) {
|
||||||
: (entry.channelName ?? entry.teamName);
|
target.note = entry.note;
|
||||||
} else {
|
}
|
||||||
target.id = entry.teamId;
|
},
|
||||||
target.name = entry.teamName;
|
);
|
||||||
target.note = "team id";
|
|
||||||
}
|
|
||||||
if (entry.note) {
|
|
||||||
target.note = entry.note;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
runtime.error?.(`msteams resolve failed: ${String(err)}`);
|
|
||||||
pending.forEach(({ index }) => {
|
|
||||||
const entry = results[index];
|
|
||||||
if (entry) {
|
|
||||||
entry.note = "lookup failed";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
},
|
},
|
||||||
@@ -429,23 +430,17 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
outbound: msteamsOutbound,
|
outbound: msteamsOutbound,
|
||||||
status: {
|
status: {
|
||||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
...buildBaseChannelStatusSummary(snapshot),
|
buildProbeChannelStatusSummary(snapshot, {
|
||||||
port: snapshot.port ?? null,
|
port: snapshot.port ?? null,
|
||||||
probe: snapshot.probe,
|
}),
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
|
||||||
}),
|
|
||||||
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
probeAccount: async ({ cfg }) => await probeMSTeams(cfg.channels?.msteams),
|
||||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
running: runtime?.running ?? false,
|
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
|
||||||
lastError: runtime?.lastError ?? null,
|
|
||||||
port: runtime?.port ?? null,
|
port: runtime?.port ?? null,
|
||||||
probe,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("msteams messenger", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMSTeamsRuntime(runtimeStub);
|
setMSTeamsRuntime(runtimeStub);
|
||||||
@@ -297,18 +308,11 @@ describe("msteams messenger", () => {
|
|||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
sendActivity: async () => {
|
sendActivity: async () => {
|
||||||
throw new TypeError("Cannot perform 'set' on a proxy that has been revoked");
|
throw new TypeError(REVOCATION_ERROR);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const adapter: MSTeamsAdapter = {
|
const adapter = createFallbackAdapter(proactiveSent);
|
||||||
continueConversation: async (_appId, _reference, logic) => {
|
|
||||||
await logic({
|
|
||||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
process: async () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ids = await sendMSTeamsMessages({
|
const ids = await sendMSTeamsMessages({
|
||||||
replyStyle: "thread",
|
replyStyle: "thread",
|
||||||
@@ -338,18 +342,11 @@ describe("msteams messenger", () => {
|
|||||||
threadSent.push(content);
|
threadSent.push(content);
|
||||||
return { id: `id:${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 = {
|
const adapter = createFallbackAdapter(proactiveSent);
|
||||||
continueConversation: async (_appId, _reference, logic) => {
|
|
||||||
await logic({
|
|
||||||
sendActivity: createRecordedSendActivity(proactiveSent),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
process: async () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ids = await sendMSTeamsMessages({
|
const ids = await sendMSTeamsMessages({
|
||||||
replyStyle: "thread",
|
replyStyle: "thread",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
buildPendingHistoryContextFromMap,
|
buildPendingHistoryContextFromMap,
|
||||||
clearHistoryEntriesIfEnabled,
|
clearHistoryEntriesIfEnabled,
|
||||||
|
dispatchReplyFromConfigWithSettledDispatcher,
|
||||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||||
createScopedPairingAccess,
|
createScopedPairingAccess,
|
||||||
logInboundDrop,
|
logInboundDrop,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
isDangerousNameMatchingEnabled,
|
isDangerousNameMatchingEnabled,
|
||||||
readStoreAllowFromForDmPolicy,
|
readStoreAllowFromForDmPolicy,
|
||||||
resolveMentionGating,
|
resolveMentionGating,
|
||||||
|
resolveInboundSessionEnvelopeContext,
|
||||||
formatAllowlistMatchMeta,
|
formatAllowlistMatchMeta,
|
||||||
resolveEffectiveAllowFromLists,
|
resolveEffectiveAllowFromLists,
|
||||||
resolveDmGroupAccessWithLists,
|
resolveDmGroupAccessWithLists,
|
||||||
@@ -451,12 +453,9 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
const { storePath, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({
|
||||||
|
cfg,
|
||||||
agentId: route.agentId,
|
agentId: route.agentId,
|
||||||
});
|
|
||||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
||||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
||||||
storePath,
|
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
});
|
});
|
||||||
const body = core.channel.reply.formatAgentEnvelope({
|
const body = core.channel.reply.formatAgentEnvelope({
|
||||||
@@ -559,18 +558,14 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
|
|
||||||
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
log.info("dispatching to agent", { sessionKey: route.sessionKey });
|
||||||
try {
|
try {
|
||||||
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
const { queuedFinal, counts } = await dispatchReplyFromConfigWithSettledDispatcher({
|
||||||
|
cfg,
|
||||||
|
ctxPayload,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
markDispatchIdle();
|
markDispatchIdle();
|
||||||
},
|
},
|
||||||
run: () =>
|
replyOptions,
|
||||||
core.channel.reply.dispatchReplyFromConfig({
|
|
||||||
ctx: ctxPayload,
|
|
||||||
cfg,
|
|
||||||
dispatcher,
|
|
||||||
replyOptions,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("dispatch complete", { queuedFinal, counts });
|
log.info("dispatch complete", { queuedFinal, counts });
|
||||||
|
|||||||
@@ -157,24 +157,13 @@ export async function sendMessageMSTeams(
|
|||||||
|
|
||||||
log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length });
|
||||||
|
|
||||||
const baseRef = buildConversationReference(ref);
|
const messageId = await sendProactiveActivity({
|
||||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
adapter,
|
||||||
|
appId,
|
||||||
let messageId = "unknown";
|
ref,
|
||||||
try {
|
activity,
|
||||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
errorPrefix: "msteams consent card send",
|
||||||
const response = await turnCtx.sendActivity(activity);
|
});
|
||||||
messageId = extractMessageId(response) ?? "unknown";
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const classification = classifyMSTeamsSendError(err);
|
|
||||||
const hint = formatMSTeamsSendErrorHint(classification);
|
|
||||||
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
|
|
||||||
throw new Error(
|
|
||||||
`msteams consent card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
|
|
||||||
{ cause: err },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("sent file consent card", { conversationId, messageId, uploadId });
|
log.info("sent file consent card", { conversationId, messageId, uploadId });
|
||||||
|
|
||||||
@@ -245,14 +234,11 @@ export async function sendMessageMSTeams(
|
|||||||
text: messageText || undefined,
|
text: messageText || undefined,
|
||||||
attachments: [fileCardAttachment],
|
attachments: [fileCardAttachment],
|
||||||
};
|
};
|
||||||
|
const messageId = await sendProactiveActivityRaw({
|
||||||
const baseRef = buildConversationReference(ref);
|
adapter,
|
||||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
appId,
|
||||||
|
ref,
|
||||||
let messageId = "unknown";
|
activity,
|
||||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
|
||||||
const response = await turnCtx.sendActivity(activity);
|
|
||||||
messageId = extractMessageId(response) ?? "unknown";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("sent native file card", {
|
log.info("sent native file card", {
|
||||||
@@ -288,14 +274,11 @@ export async function sendMessageMSTeams(
|
|||||||
type: "message",
|
type: "message",
|
||||||
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
|
||||||
};
|
};
|
||||||
|
const messageId = await sendProactiveActivityRaw({
|
||||||
const baseRef = buildConversationReference(ref);
|
adapter,
|
||||||
const proactiveRef = { ...baseRef, activityId: undefined };
|
appId,
|
||||||
|
ref,
|
||||||
let messageId = "unknown";
|
activity,
|
||||||
await adapter.continueConversation(appId, proactiveRef, async (turnCtx) => {
|
|
||||||
const response = await turnCtx.sendActivity(activity);
|
|
||||||
messageId = extractMessageId(response) ?? "unknown";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("sent message with OneDrive file link", {
|
log.info("sent message with OneDrive file link", {
|
||||||
@@ -382,13 +365,14 @@ type ProactiveActivityParams = {
|
|||||||
errorPrefix: string;
|
errorPrefix: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sendProactiveActivity({
|
type ProactiveActivityRawParams = Omit<ProactiveActivityParams, "errorPrefix">;
|
||||||
|
|
||||||
|
async function sendProactiveActivityRaw({
|
||||||
adapter,
|
adapter,
|
||||||
appId,
|
appId,
|
||||||
ref,
|
ref,
|
||||||
activity,
|
activity,
|
||||||
errorPrefix,
|
}: ProactiveActivityRawParams): Promise<string> {
|
||||||
}: ProactiveActivityParams): Promise<string> {
|
|
||||||
const baseRef = buildConversationReference(ref);
|
const baseRef = buildConversationReference(ref);
|
||||||
const proactiveRef = {
|
const proactiveRef = {
|
||||||
...baseRef,
|
...baseRef,
|
||||||
@@ -396,12 +380,27 @@ async function sendProactiveActivity({
|
|||||||
};
|
};
|
||||||
|
|
||||||
let messageId = "unknown";
|
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 {
|
try {
|
||||||
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
|
return await sendProactiveActivityRaw({
|
||||||
const response = await ctx.sendActivity(activity);
|
adapter,
|
||||||
messageId = extractMessageId(response) ?? "unknown";
|
appId,
|
||||||
|
ref,
|
||||||
|
activity,
|
||||||
});
|
});
|
||||||
return messageId;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const classification = classifyMSTeamsSendError(err);
|
const classification = classifyMSTeamsSendError(err);
|
||||||
const hint = formatMSTeamsSendErrorHint(classification);
|
const hint = formatMSTeamsSendErrorHint(classification);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
buildBaseChannelStatusSummary,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
|
clearAccountEntryFields,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
@@ -288,17 +291,21 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) => {
|
||||||
configured: snapshot.configured ?? false,
|
const base = buildBaseChannelStatusSummary(snapshot);
|
||||||
secretSource: snapshot.secretSource ?? "none",
|
return {
|
||||||
running: snapshot.running ?? false,
|
configured: base.configured,
|
||||||
mode: "webhook",
|
secretSource: snapshot.secretSource ?? "none",
|
||||||
lastStartAt: snapshot.lastStartAt ?? null,
|
running: base.running,
|
||||||
lastStopAt: snapshot.lastStopAt ?? null,
|
mode: "webhook",
|
||||||
lastError: snapshot.lastError ?? null,
|
lastStartAt: base.lastStartAt,
|
||||||
}),
|
lastStopAt: base.lastStopAt,
|
||||||
|
lastError: base.lastError,
|
||||||
|
};
|
||||||
|
},
|
||||||
buildAccountSnapshot: ({ account, runtime }) => {
|
buildAccountSnapshot: ({ account, runtime }) => {
|
||||||
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
|
const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
|
||||||
|
const runtimeSnapshot = buildRuntimeAccountStatusSnapshot({ runtime });
|
||||||
return {
|
return {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -306,10 +313,10 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
configured,
|
configured,
|
||||||
secretSource: account.secretSource,
|
secretSource: account.secretSource,
|
||||||
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
||||||
running: runtime?.running ?? false,
|
running: runtimeSnapshot.running,
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
lastStartAt: runtimeSnapshot.lastStartAt,
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
lastStopAt: runtimeSnapshot.lastStopAt,
|
||||||
lastError: runtime?.lastError ?? null,
|
lastError: runtimeSnapshot.lastError,
|
||||||
mode: "webhook",
|
mode: "webhook",
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
@@ -353,36 +360,20 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
cleared = true;
|
cleared = true;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
const accounts =
|
const accountCleanup = clearAccountEntryFields({
|
||||||
nextSection.accounts && typeof nextSection.accounts === "object"
|
accounts: nextSection.accounts,
|
||||||
? { ...nextSection.accounts }
|
accountId,
|
||||||
: undefined;
|
fields: ["botSecret"],
|
||||||
if (accounts && accountId in accounts) {
|
});
|
||||||
const entry = accounts[accountId];
|
if (accountCleanup.changed) {
|
||||||
if (entry && typeof entry === "object") {
|
changed = true;
|
||||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
if (accountCleanup.cleared) {
|
||||||
if ("botSecret" in nextEntry) {
|
cleared = true;
|
||||||
const secret = nextEntry.botSecret;
|
|
||||||
if (typeof secret === "string" ? secret.trim() : secret) {
|
|
||||||
cleared = true;
|
|
||||||
}
|
|
||||||
delete nextEntry.botSecret;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (Object.keys(nextEntry).length === 0) {
|
|
||||||
delete accounts[accountId];
|
|
||||||
changed = true;
|
|
||||||
} else {
|
|
||||||
accounts[accountId] = nextEntry as typeof entry;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (accountCleanup.nextAccounts) {
|
||||||
if (accounts) {
|
nextSection.accounts = accountCleanup.nextAccounts;
|
||||||
if (Object.keys(accounts).length === 0) {
|
|
||||||
delete nextSection.accounts;
|
|
||||||
changed = true;
|
|
||||||
} else {
|
} else {
|
||||||
nextSection.accounts = accounts;
|
delete nextSection.accounts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
GROUP_POLICY_BLOCKED_LABEL,
|
GROUP_POLICY_BLOCKED_LABEL,
|
||||||
createScopedPairingAccess,
|
createScopedPairingAccess,
|
||||||
createNormalizedOutboundDeliverer,
|
dispatchInboundReplyWithBase,
|
||||||
createReplyPrefixOptions,
|
|
||||||
formatTextWithAttachmentLinks,
|
formatTextWithAttachmentLinks,
|
||||||
logInboundDrop,
|
logInboundDrop,
|
||||||
readStoreAllowFromForDmPolicy,
|
readStoreAllowFromForDmPolicy,
|
||||||
@@ -291,43 +290,30 @@ export async function handleNextcloudTalkInbound(params: {
|
|||||||
CommandAuthorized: commandAuthorized,
|
CommandAuthorized: commandAuthorized,
|
||||||
});
|
});
|
||||||
|
|
||||||
await core.channel.session.recordInboundSession({
|
await dispatchInboundReplyWithBase({
|
||||||
|
cfg: config as OpenClawConfig,
|
||||||
|
channel: CHANNEL_ID,
|
||||||
|
accountId: account.accountId,
|
||||||
|
route,
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
ctxPayload,
|
||||||
ctx: ctxPayload,
|
core,
|
||||||
|
deliver: async (payload) => {
|
||||||
|
await deliverNextcloudTalkReply({
|
||||||
|
payload,
|
||||||
|
roomToken,
|
||||||
|
accountId: account.accountId,
|
||||||
|
statusSink,
|
||||||
|
});
|
||||||
|
},
|
||||||
onRecordError: (err) => {
|
onRecordError: (err) => {
|
||||||
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
||||||
},
|
},
|
||||||
});
|
onDispatchError: (err, info) => {
|
||||||
|
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${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)}`);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
replyOptions: {
|
replyOptions: {
|
||||||
skillFilter: roomConfig?.skills,
|
skillFilter: roomConfig?.skills,
|
||||||
onModelSelected,
|
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof account.config.blockStreaming === "boolean"
|
typeof account.config.blockStreaming === "boolean"
|
||||||
? !account.config.blockStreaming
|
? !account.config.blockStreaming
|
||||||
|
|||||||
@@ -43,6 +43,45 @@ function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConf
|
|||||||
} as CoreConfig;
|
} 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> {
|
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
await prompter.note(
|
await prompter.note(
|
||||||
[
|
[
|
||||||
@@ -105,40 +144,10 @@ async function promptNextcloudTalkAllowFrom(params: {
|
|||||||
];
|
];
|
||||||
const unique = mergeAllowFromEntries(undefined, merged);
|
const unique = mergeAllowFromEntries(undefined, merged);
|
||||||
|
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
return setNextcloudTalkAccountConfig(cfg, accountId, {
|
||||||
return {
|
dmPolicy: "allowlist",
|
||||||
...cfg,
|
allowFrom: unique,
|
||||||
channels: {
|
});
|
||||||
...cfg.channels,
|
|
||||||
"nextcloud-talk": {
|
|
||||||
...cfg.channels?.["nextcloud-talk"],
|
|
||||||
enabled: true,
|
|
||||||
dmPolicy: "allowlist",
|
|
||||||
allowFrom: unique,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...cfg,
|
|
||||||
channels: {
|
|
||||||
...cfg.channels,
|
|
||||||
"nextcloud-talk": {
|
|
||||||
...cfg.channels?.["nextcloud-talk"],
|
|
||||||
enabled: true,
|
|
||||||
accounts: {
|
|
||||||
...cfg.channels?.["nextcloud-talk"]?.accounts,
|
|
||||||
[accountId]: {
|
|
||||||
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
|
||||||
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
|
||||||
dmPolicy: "allowlist",
|
|
||||||
allowFrom: unique,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function promptNextcloudTalkAllowFromForAccount(params: {
|
async function promptNextcloudTalkAllowFromForAccount(params: {
|
||||||
@@ -265,41 +274,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
|
if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
next = setNextcloudTalkAccountConfig(next, accountId, {
|
||||||
next = {
|
baseUrl,
|
||||||
...next,
|
...(secret ? { botSecret: secret } : {}),
|
||||||
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 } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingApiUser = resolvedAccount.config.apiUser?.trim();
|
const existingApiUser = resolvedAccount.config.apiUser?.trim();
|
||||||
@@ -333,41 +311,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|||||||
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
||||||
});
|
});
|
||||||
const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
|
const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
|
||||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
next = setNextcloudTalkAccountConfig(next, accountId, {
|
||||||
next = {
|
apiUser,
|
||||||
...next,
|
...(apiPassword ? { apiPassword } : {}),
|
||||||
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 } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forceAllowFrom) {
|
if (forceAllowFrom) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
buildOauthProviderAuthResult,
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
type ProviderAuthContext,
|
type ProviderAuthContext,
|
||||||
@@ -63,22 +64,14 @@ const qwenPortalPlugin = {
|
|||||||
|
|
||||||
progress.stop("Qwen OAuth complete");
|
progress.stop("Qwen OAuth complete");
|
||||||
|
|
||||||
const profileId = `${PROVIDER_ID}:default`;
|
|
||||||
const baseUrl = normalizeBaseUrl(result.resourceUrl);
|
const baseUrl = normalizeBaseUrl(result.resourceUrl);
|
||||||
|
|
||||||
return {
|
return buildOauthProviderAuthResult({
|
||||||
profiles: [
|
providerId: PROVIDER_ID,
|
||||||
{
|
defaultModel: DEFAULT_MODEL,
|
||||||
profileId,
|
access: result.access,
|
||||||
credential: {
|
refresh: result.refresh,
|
||||||
type: "oauth",
|
expires: result.expires,
|
||||||
provider: PROVIDER_ID,
|
|
||||||
access: result.access,
|
|
||||||
refresh: result.refresh,
|
|
||||||
expires: result.expires,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
configPatch: {
|
configPatch: {
|
||||||
models: {
|
models: {
|
||||||
providers: {
|
providers: {
|
||||||
@@ -110,12 +103,11 @@ const qwenPortalPlugin = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultModel: DEFAULT_MODEL,
|
|
||||||
notes: [
|
notes: [
|
||||||
"Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
|
"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.`,
|
`Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
|
||||||
],
|
],
|
||||||
};
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
progress.stop("Qwen OAuth failed");
|
progress.stop("Qwen OAuth failed");
|
||||||
await ctx.prompter.note(
|
await ctx.prompter.note(
|
||||||
|
|||||||
@@ -317,20 +317,11 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("gateway", () => {
|
describe("gateway", () => {
|
||||||
it("startAccount returns pending promise for disabled account", async () => {
|
async function expectPendingStartAccountPromise(
|
||||||
const plugin = createSynologyChatPlugin();
|
result: Promise<unknown>,
|
||||||
const abortController = new AbortController();
|
abortController: 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);
|
|
||||||
expect(result).toBeInstanceOf(Promise);
|
expect(result).toBeInstanceOf(Promise);
|
||||||
// Promise should stay pending (never resolve) to prevent restart loop
|
|
||||||
const resolved = await Promise.race([
|
const resolved = await Promise.race([
|
||||||
result,
|
result,
|
||||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
||||||
@@ -338,29 +329,29 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
expect(resolved).toBe("pending");
|
expect(resolved).toBe("pending");
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
await result;
|
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 () => {
|
it("startAccount returns pending promise for account without token", async () => {
|
||||||
const plugin = createSynologyChatPlugin();
|
await expectPendingStartAccount({ enabled: true });
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
||||||
@@ -387,16 +378,9 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = plugin.gateway.startAccount(ctx);
|
const result = plugin.gateway.startAccount(ctx);
|
||||||
expect(result).toBeInstanceOf(Promise);
|
await expectPendingStartAccountPromise(result, abortController);
|
||||||
const resolved = await Promise.race([
|
|
||||||
result,
|
|
||||||
new Promise((r) => setTimeout(() => r("pending"), 50)),
|
|
||||||
]);
|
|
||||||
expect(resolved).toBe("pending");
|
|
||||||
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
|
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
|
||||||
expect(registerMock).not.toHaveBeenCalled();
|
expect(registerMock).not.toHaveBeenCalled();
|
||||||
abortController.abort();
|
|
||||||
await result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deregisters stale route before re-registering same account/path", async () => {
|
it("deregisters stale route before re-registering same account/path", async () => {
|
||||||
|
|||||||
@@ -118,26 +118,21 @@ describe("sendFileUrl", () => {
|
|||||||
function mockUserListResponse(
|
function mockUserListResponse(
|
||||||
users: Array<{ user_id: number; username: string; nickname: string }>,
|
users: Array<{ user_id: number; username: string; nickname: string }>,
|
||||||
) {
|
) {
|
||||||
const httpsGet = vi.mocked((https as any).get);
|
mockUserListResponseImpl(users, false);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockUserListResponseOnce(
|
function mockUserListResponseOnce(
|
||||||
users: Array<{ user_id: number; username: string; nickname: string }>,
|
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);
|
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;
|
const res = new EventEmitter() as any;
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
@@ -148,7 +143,12 @@ function mockUserListResponseOnce(
|
|||||||
const req = new EventEmitter() as any;
|
const req = new EventEmitter() as any;
|
||||||
req.destroy = vi.fn();
|
req.destroy = vi.fn();
|
||||||
return req;
|
return req;
|
||||||
});
|
};
|
||||||
|
if (once) {
|
||||||
|
httpsGet.mockImplementationOnce(impl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
httpsGet.mockImplementation(impl);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveChatUserId", () => {
|
describe("resolveChatUserId", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
describe("telegramPlugin duplicate token guard", () => {
|
||||||
it("marks secondary account as not configured when token is shared", async () => {
|
it("marks secondary account as not configured when token is shared", async () => {
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
@@ -84,20 +103,7 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("blocks startup for duplicate token accounts before polling starts", async () => {
|
it("blocks startup for duplicate token accounts before polling starts", async () => {
|
||||||
const monitorTelegramProvider = vi.fn(async () => undefined);
|
const { monitorTelegramProvider, probeTelegram } = installGatewayRuntime({ probeOk: true });
|
||||||
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);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
telegramPlugin.gateway!.startAccount!(
|
telegramPlugin.gateway!.startAccount!(
|
||||||
@@ -114,20 +120,10 @@ describe("telegramPlugin duplicate token guard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("passes webhookPort through to monitor startup options", async () => {
|
it("passes webhookPort through to monitor startup options", async () => {
|
||||||
const monitorTelegramProvider = vi.fn(async () => undefined);
|
const { monitorTelegramProvider } = installGatewayRuntime({
|
||||||
const probeTelegram = vi.fn(async () => ({ ok: true, bot: { username: "opsbot" } }));
|
probeOk: true,
|
||||||
const runtime = {
|
botUsername: "opsbot",
|
||||||
channel: {
|
});
|
||||||
telegram: {
|
|
||||||
monitorTelegramProvider,
|
|
||||||
probeTelegram,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
shouldLogVerbose: () => false,
|
|
||||||
},
|
|
||||||
} as unknown as PluginRuntime;
|
|
||||||
setTelegramRuntime(runtime);
|
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
cfg.channels!.telegram!.accounts!.ops = {
|
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 () => {
|
it("does not crash startup when a resolved account token is undefined", async () => {
|
||||||
const monitorTelegramProvider = vi.fn(async () => undefined);
|
const { monitorTelegramProvider } = installGatewayRuntime({ probeOk: false });
|
||||||
const probeTelegram = vi.fn(async () => ({ ok: false }));
|
|
||||||
const runtime = {
|
|
||||||
channel: {
|
|
||||||
telegram: {
|
|
||||||
monitorTelegramProvider,
|
|
||||||
probeTelegram,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
|
||||||
shouldLogVerbose: () => false,
|
|
||||||
},
|
|
||||||
} as unknown as PluginRuntime;
|
|
||||||
setTelegramRuntime(runtime);
|
|
||||||
|
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
const ctx = createStartAccountCtx({
|
const ctx = createStartAccountCtx({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
|
clearAccountEntryFields,
|
||||||
collectTelegramStatusIssues,
|
collectTelegramStatusIssues,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
@@ -519,36 +520,20 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
cleared = true;
|
cleared = true;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
const accounts =
|
const accountCleanup = clearAccountEntryFields({
|
||||||
nextTelegram.accounts && typeof nextTelegram.accounts === "object"
|
accounts: nextTelegram.accounts,
|
||||||
? { ...nextTelegram.accounts }
|
accountId,
|
||||||
: undefined;
|
fields: ["botToken"],
|
||||||
if (accounts && accountId in accounts) {
|
});
|
||||||
const entry = accounts[accountId];
|
if (accountCleanup.changed) {
|
||||||
if (entry && typeof entry === "object") {
|
changed = true;
|
||||||
const nextEntry = { ...entry } as Record<string, unknown>;
|
if (accountCleanup.cleared) {
|
||||||
if ("botToken" in nextEntry) {
|
cleared = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
if (accountCleanup.nextAccounts) {
|
||||||
if (accounts) {
|
nextTelegram.accounts = accountCleanup.nextAccounts;
|
||||||
if (Object.keys(accounts).length === 0) {
|
|
||||||
delete nextTelegram.accounts;
|
|
||||||
changed = true;
|
|
||||||
} else {
|
} else {
|
||||||
nextTelegram.accounts = accounts;
|
delete nextTelegram.accounts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,14 +51,10 @@ describe("checkTwitchAccessControl", () => {
|
|||||||
|
|
||||||
describe("when no restrictions are configured", () => {
|
describe("when no restrictions are configured", () => {
|
||||||
it("allows messages that mention the bot (default requireMention)", () => {
|
it("allows messages that mention the bot (default requireMention)", () => {
|
||||||
const message: TwitchChatMessage = {
|
const result = runAccessCheck({
|
||||||
...mockMessage,
|
message: {
|
||||||
message: "@testbot hello",
|
message: "@testbot hello",
|
||||||
};
|
},
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message,
|
|
||||||
account: mockAccount,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -66,30 +62,20 @@ describe("checkTwitchAccessControl", () => {
|
|||||||
|
|
||||||
describe("requireMention default", () => {
|
describe("requireMention default", () => {
|
||||||
it("defaults to true when undefined", () => {
|
it("defaults to true when undefined", () => {
|
||||||
const message: TwitchChatMessage = {
|
const result = runAccessCheck({
|
||||||
...mockMessage,
|
message: {
|
||||||
message: "hello bot",
|
message: "hello bot",
|
||||||
};
|
},
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message,
|
|
||||||
account: mockAccount,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("does not mention the bot");
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows mention when requireMention is undefined", () => {
|
it("allows mention when requireMention is undefined", () => {
|
||||||
const message: TwitchChatMessage = {
|
const result = runAccessCheck({
|
||||||
...mockMessage,
|
message: {
|
||||||
message: "@testbot hello",
|
message: "@testbot hello",
|
||||||
};
|
},
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message,
|
|
||||||
account: mockAccount,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -97,52 +83,25 @@ describe("checkTwitchAccessControl", () => {
|
|||||||
|
|
||||||
describe("requireMention", () => {
|
describe("requireMention", () => {
|
||||||
it("allows messages that mention the bot", () => {
|
it("allows messages that mention the bot", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const result = runAccessCheck({
|
||||||
...mockAccount,
|
account: { requireMention: true },
|
||||||
requireMention: true,
|
message: { message: "@testbot hello" },
|
||||||
};
|
|
||||||
const message: TwitchChatMessage = {
|
|
||||||
...mockMessage,
|
|
||||||
message: "@testbot hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message,
|
|
||||||
account,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks messages that don't mention the bot", () => {
|
it("blocks messages that don't mention the bot", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const result = runAccessCheck({
|
||||||
...mockAccount,
|
account: { requireMention: true },
|
||||||
requireMention: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message: mockMessage,
|
|
||||||
account,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(false);
|
expect(result.allowed).toBe(false);
|
||||||
expect(result.reason).toContain("does not mention the bot");
|
expect(result.reason).toContain("does not mention the bot");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is case-insensitive for bot username", () => {
|
it("is case-insensitive for bot username", () => {
|
||||||
const account: TwitchAccountConfig = {
|
const result = runAccessCheck({
|
||||||
...mockAccount,
|
account: { requireMention: true },
|
||||||
requireMention: true,
|
message: { message: "@TestBot hello" },
|
||||||
};
|
|
||||||
const message: TwitchChatMessage = {
|
|
||||||
...mockMessage,
|
|
||||||
message: "@TestBot hello",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = checkTwitchAccessControl({
|
|
||||||
message,
|
|
||||||
account,
|
|
||||||
botUsername: "testbot",
|
|
||||||
});
|
});
|
||||||
expect(result.allowed).toBe(true);
|
expect(result.allowed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,17 +14,28 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { collectTwitchStatusIssues } from "./status.js";
|
import { collectTwitchStatusIssues } from "./status.js";
|
||||||
import type { ChannelAccountSnapshot } from "./types.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("status", () => {
|
||||||
describe("collectTwitchStatusIssues", () => {
|
describe("collectTwitchStatusIssues", () => {
|
||||||
it("should detect unconfigured accounts", () => {
|
it("should detect unconfigured accounts", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ configured: false })];
|
||||||
{
|
|
||||||
accountId: "default",
|
|
||||||
configured: false,
|
|
||||||
enabled: true,
|
|
||||||
running: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
@@ -34,14 +45,7 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should detect disabled accounts", () => {
|
it("should detect disabled accounts", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot({ enabled: false })];
|
||||||
{
|
|
||||||
accountId: "default",
|
|
||||||
configured: true,
|
|
||||||
enabled: false,
|
|
||||||
running: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|
||||||
@@ -51,24 +55,12 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should detect missing clientId when account configured (simplified config)", () => {
|
it("should detect missing clientId when account configured (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||||
{
|
const mockCfg = createSimpleTwitchConfig({
|
||||||
accountId: "default",
|
username: "testbot",
|
||||||
configured: true,
|
accessToken: "oauth:test123",
|
||||||
enabled: true,
|
// clientId missing
|
||||||
running: false,
|
});
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockCfg = {
|
|
||||||
channels: {
|
|
||||||
twitch: {
|
|
||||||
username: "testbot",
|
|
||||||
accessToken: "oauth:test123",
|
|
||||||
// clientId missing
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
@@ -77,24 +69,12 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should warn about oauth: prefix in token (simplified config)", () => {
|
it("should warn about oauth: prefix in token (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||||
{
|
const mockCfg = createSimpleTwitchConfig({
|
||||||
accountId: "default",
|
username: "testbot",
|
||||||
configured: true,
|
accessToken: "oauth:test123", // has prefix
|
||||||
enabled: true,
|
clientId: "test-id",
|
||||||
running: false,
|
});
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockCfg = {
|
|
||||||
channels: {
|
|
||||||
twitch: {
|
|
||||||
username: "testbot",
|
|
||||||
accessToken: "oauth:test123", // has prefix
|
|
||||||
clientId: "test-id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
@@ -104,26 +84,14 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should detect clientSecret without refreshToken (simplified config)", () => {
|
it("should detect clientSecret without refreshToken (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||||
{
|
const mockCfg = createSimpleTwitchConfig({
|
||||||
accountId: "default",
|
username: "testbot",
|
||||||
configured: true,
|
accessToken: "oauth:test123",
|
||||||
enabled: true,
|
clientId: "test-id",
|
||||||
running: false,
|
clientSecret: "secret123",
|
||||||
},
|
// refreshToken missing
|
||||||
];
|
});
|
||||||
|
|
||||||
const mockCfg = {
|
|
||||||
channels: {
|
|
||||||
twitch: {
|
|
||||||
username: "testbot",
|
|
||||||
accessToken: "oauth:test123",
|
|
||||||
clientId: "test-id",
|
|
||||||
clientSecret: "secret123",
|
|
||||||
// refreshToken missing
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
@@ -132,25 +100,13 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should detect empty allowFrom array (simplified config)", () => {
|
it("should detect empty allowFrom array (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||||
{
|
const mockCfg = createSimpleTwitchConfig({
|
||||||
accountId: "default",
|
username: "testbot",
|
||||||
configured: true,
|
accessToken: "test123",
|
||||||
enabled: true,
|
clientId: "test-id",
|
||||||
running: false,
|
allowFrom: [], // empty array
|
||||||
},
|
});
|
||||||
];
|
|
||||||
|
|
||||||
const mockCfg = {
|
|
||||||
channels: {
|
|
||||||
twitch: {
|
|
||||||
username: "testbot",
|
|
||||||
accessToken: "test123",
|
|
||||||
clientId: "test-id",
|
|
||||||
allowFrom: [], // empty array
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
@@ -159,26 +115,14 @@ describe("status", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
|
it("should detect allowedRoles 'all' with allowFrom conflict (simplified config)", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [createSnapshot()];
|
||||||
{
|
const mockCfg = createSimpleTwitchConfig({
|
||||||
accountId: "default",
|
username: "testbot",
|
||||||
configured: true,
|
accessToken: "test123",
|
||||||
enabled: true,
|
clientId: "test-id",
|
||||||
running: false,
|
allowedRoles: ["all"],
|
||||||
},
|
allowFrom: ["123456"], // conflict!
|
||||||
];
|
});
|
||||||
|
|
||||||
const mockCfg = {
|
|
||||||
channels: {
|
|
||||||
twitch: {
|
|
||||||
username: "testbot",
|
|
||||||
accessToken: "test123",
|
|
||||||
clientId: "test-id",
|
|
||||||
allowedRoles: ["all"],
|
|
||||||
allowFrom: ["123456"], // conflict!
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
const issues = collectTwitchStatusIssues(snapshots, () => mockCfg as never);
|
||||||
|
|
||||||
@@ -189,13 +133,7 @@ describe("status", () => {
|
|||||||
|
|
||||||
it("should detect runtime errors", () => {
|
it("should detect runtime errors", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
createSnapshot({ lastError: "Connection timeout" }),
|
||||||
accountId: "default",
|
|
||||||
configured: true,
|
|
||||||
enabled: true,
|
|
||||||
running: false,
|
|
||||||
lastError: "Connection timeout",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
@@ -207,15 +145,11 @@ describe("status", () => {
|
|||||||
|
|
||||||
it("should detect accounts that never connected", () => {
|
it("should detect accounts that never connected", () => {
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
createSnapshot({
|
||||||
accountId: "default",
|
|
||||||
configured: true,
|
|
||||||
enabled: true,
|
|
||||||
running: false,
|
|
||||||
lastStartAt: undefined,
|
lastStartAt: undefined,
|
||||||
lastInboundAt: undefined,
|
lastInboundAt: undefined,
|
||||||
lastOutboundAt: undefined,
|
lastOutboundAt: undefined,
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
@@ -230,13 +164,10 @@ describe("status", () => {
|
|||||||
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
const oldDate = Date.now() - 8 * 24 * 60 * 60 * 1000; // 8 days ago
|
||||||
|
|
||||||
const snapshots: ChannelAccountSnapshot[] = [
|
const snapshots: ChannelAccountSnapshot[] = [
|
||||||
{
|
createSnapshot({
|
||||||
accountId: "default",
|
|
||||||
configured: true,
|
|
||||||
enabled: true,
|
|
||||||
running: true,
|
running: true,
|
||||||
lastStartAt: oldDate,
|
lastStartAt: oldDate,
|
||||||
},
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const issues = collectTwitchStatusIssues(snapshots);
|
const issues = collectTwitchStatusIssues(snapshots);
|
||||||
|
|||||||
@@ -209,6 +209,23 @@ const voiceCallPlugin = {
|
|||||||
const rt = await ensureRuntime();
|
const rt = await ensureRuntime();
|
||||||
return { rt, callId, message } as const;
|
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(
|
api.registerGatewayMethod(
|
||||||
"voicecall.initiate",
|
"voicecall.initiate",
|
||||||
@@ -230,15 +247,13 @@ const voiceCallPlugin = {
|
|||||||
}
|
}
|
||||||
const mode =
|
const mode =
|
||||||
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
|
params?.mode === "notify" || params?.mode === "conversation" ? params.mode : undefined;
|
||||||
const result = await rt.manager.initiateCall(to, undefined, {
|
await initiateCallAndRespond({
|
||||||
|
rt,
|
||||||
|
respond,
|
||||||
|
to,
|
||||||
message,
|
message,
|
||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
|
||||||
respond(false, { error: result.error || "initiate failed" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
respond(true, { callId: result.callId, initiated: true });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(respond, err);
|
sendError(respond, err);
|
||||||
}
|
}
|
||||||
@@ -347,14 +362,12 @@ const voiceCallPlugin = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rt = await ensureRuntime();
|
const rt = await ensureRuntime();
|
||||||
const result = await rt.manager.initiateCall(to, undefined, {
|
await initiateCallAndRespond({
|
||||||
|
rt,
|
||||||
|
respond,
|
||||||
|
to,
|
||||||
message: message || undefined,
|
message: message || undefined,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
|
||||||
respond(false, { error: result.error || "initiate failed" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
respond(true, { callId: result.callId, initiated: true });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(respond, err);
|
sendError(respond, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js";
|
import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js";
|
||||||
|
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||||
|
|
||||||
function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig {
|
function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): VoiceCallConfig {
|
||||||
return {
|
return createVoiceCallBaseConfig({ provider });
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("validateProviderConfig", () => {
|
describe("validateProviderConfig", () => {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { pcmToMulaw } from "../telephony-audio.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenAI TTS Provider
|
* OpenAI TTS Provider
|
||||||
*
|
*
|
||||||
@@ -179,55 +181,6 @@ function clamp16(value: number): number {
|
|||||||
return Math.max(-32768, Math.min(32767, value));
|
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.
|
* Convert 8-bit mu-law to 16-bit linear PCM.
|
||||||
* Useful for decoding incoming audio.
|
* Useful for decoding incoming audio.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { VoiceCallConfig } from "./config.js";
|
import type { VoiceCallConfig } from "./config.js";
|
||||||
import type { CoreConfig } from "./core-bridge.js";
|
import type { CoreConfig } from "./core-bridge.js";
|
||||||
|
import { createVoiceCallBaseConfig } from "./test-fixtures.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
resolveVoiceCallConfig: vi.fn(),
|
resolveVoiceCallConfig: vi.fn(),
|
||||||
@@ -45,48 +46,7 @@ vi.mock("./webhook/tailscale.js", () => ({
|
|||||||
import { createVoiceCallRuntime } from "./runtime.js";
|
import { createVoiceCallRuntime } from "./runtime.js";
|
||||||
|
|
||||||
function createBaseConfig(): VoiceCallConfig {
|
function createBaseConfig(): VoiceCallConfig {
|
||||||
return {
|
return createVoiceCallBaseConfig({ tunnelProvider: "ngrok" });
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("createVoiceCallRuntime lifecycle", () => {
|
describe("createVoiceCallRuntime lifecycle", () => {
|
||||||
|
|||||||
52
extensions/voice-call/src/test-fixtures.ts
Normal file
52
extensions/voice-call/src/test-fixtures.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@ import type {
|
|||||||
} from "openclaw/plugin-sdk/zalo";
|
} from "openclaw/plugin-sdk/zalo";
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
buildBaseAccountStatusSnapshot,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
|
buildChannelSendResult,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
chunkTextForOutbound,
|
chunkTextForOutbound,
|
||||||
@@ -15,10 +17,13 @@ import {
|
|||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
|
isNumericTargetId,
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
|
resolveOutboundMediaUrls,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
resolveOpenProviderRuntimeGroupPolicy,
|
resolveOpenProviderRuntimeGroupPolicy,
|
||||||
resolveChannelAccountConfigBasePath,
|
resolveChannelAccountConfigBasePath,
|
||||||
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "openclaw/plugin-sdk/zalo";
|
} from "openclaw/plugin-sdk/zalo";
|
||||||
import {
|
import {
|
||||||
@@ -182,13 +187,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeZaloMessagingTarget,
|
normalizeTarget: normalizeZaloMessagingTarget,
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (raw) => {
|
looksLikeId: isNumericTargetId,
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return /^\d{3,}$/.test(trimmed);
|
|
||||||
},
|
|
||||||
hint: "<chatId>",
|
hint: "<chatId>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -303,51 +302,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
chunker: chunkTextForOutbound,
|
chunker: chunkTextForOutbound,
|
||||||
chunkerMode: "text",
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendPayload: async (ctx) => {
|
sendPayload: async (ctx) =>
|
||||||
const text = ctx.payload.text ?? "";
|
await sendPayloadWithChunkedTextAndMedia({
|
||||||
const urls = ctx.payload.mediaUrls?.length
|
ctx,
|
||||||
? ctx.payload.mediaUrls
|
textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
|
||||||
: ctx.payload.mediaUrl
|
chunker: zaloPlugin.outbound!.chunker,
|
||||||
? [ctx.payload.mediaUrl]
|
sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
|
||||||
: [];
|
sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
|
||||||
if (!text && urls.length === 0) {
|
emptyResult: { channel: "zalo", messageId: "" },
|
||||||
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!;
|
|
||||||
},
|
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
});
|
});
|
||||||
return {
|
return buildChannelSendResult("zalo", result);
|
||||||
channel: "zalo",
|
|
||||||
ok: result.ok,
|
|
||||||
messageId: result.messageId ?? "",
|
|
||||||
error: result.error ? new Error(result.error) : undefined,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||||
const result = await sendMessageZalo(to, text, {
|
const result = await sendMessageZalo(to, text, {
|
||||||
@@ -355,12 +324,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
});
|
});
|
||||||
return {
|
return buildChannelSendResult("zalo", result);
|
||||||
channel: "zalo",
|
|
||||||
ok: result.ok,
|
|
||||||
messageId: result.messageId ?? "",
|
|
||||||
error: result.error ? new Error(result.error) : undefined,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -377,19 +341,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
|||||||
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
||||||
buildAccountSnapshot: ({ account, runtime }) => {
|
buildAccountSnapshot: ({ account, runtime }) => {
|
||||||
const configured = Boolean(account.token?.trim());
|
const configured = Boolean(account.token?.trim());
|
||||||
|
const base = buildBaseAccountStatusSnapshot({
|
||||||
|
account: {
|
||||||
|
accountId: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
enabled: account.enabled,
|
||||||
|
configured,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
accountId: account.accountId,
|
...base,
|
||||||
name: account.name,
|
|
||||||
enabled: account.enabled,
|
|
||||||
configured,
|
|
||||||
tokenSource: account.tokenSource,
|
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",
|
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
||||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
|
|||||||
return { core, readAllowFromStore, upsertPairingRequest };
|
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", () => {
|
describe("handleZaloWebhookRequest", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearZaloWebhookSecurityStateForTest();
|
clearZaloWebhookSecurityStateForTest();
|
||||||
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||||
let saw429 = false;
|
const saw429 = await postUntilRateLimited({
|
||||||
for (let i = 0; i < 130; i += 1) {
|
baseUrl,
|
||||||
const response = await fetch(`${baseUrl}/hook-rate`, {
|
path: "/hook-rate",
|
||||||
method: "POST",
|
secret: "secret",
|
||||||
headers: {
|
});
|
||||||
"x-bot-api-secret-token": "secret",
|
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
if (response.status === 429) {
|
|
||||||
saw429 = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(saw429).toBe(true);
|
expect(saw429).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||||
let saw429 = false;
|
const saw429 = await postUntilRateLimited({
|
||||||
for (let i = 0; i < 130; i += 1) {
|
baseUrl,
|
||||||
const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
|
path: "/hook-query-rate",
|
||||||
method: "POST",
|
secret: "secret",
|
||||||
headers: {
|
withNonceQuery: true,
|
||||||
"x-bot-api-secret-token": "secret",
|
});
|
||||||
"content-type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{}",
|
|
||||||
});
|
|
||||||
if (response.status === 429) {
|
|
||||||
saw429 = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(saw429).toBe(true);
|
expect(saw429).toBe(true);
|
||||||
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
||||||
|
|||||||
@@ -40,37 +40,47 @@ function resolveSendContext(options: ZaloSendOptions): {
|
|||||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
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(
|
export async function sendMessageZalo(
|
||||||
chatId: string,
|
chatId: string,
|
||||||
text: string,
|
text: string,
|
||||||
options: ZaloSendOptions = {},
|
options: ZaloSendOptions = {},
|
||||||
): Promise<ZaloSendResult> {
|
): Promise<ZaloSendResult> {
|
||||||
const { token, fetcher } = resolveSendContext(options);
|
const context = resolveValidatedSendContext(chatId, options);
|
||||||
|
if (!context.ok) {
|
||||||
if (!token) {
|
return { ok: false, error: context.error };
|
||||||
return { ok: false, error: "No Zalo bot token configured" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chatId?.trim()) {
|
|
||||||
return { ok: false, error: "No chat_id provided" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.mediaUrl) {
|
if (options.mediaUrl) {
|
||||||
return sendPhotoZalo(chatId, options.mediaUrl, {
|
return sendPhotoZalo(context.chatId, options.mediaUrl, {
|
||||||
...options,
|
...options,
|
||||||
token,
|
token: context.token,
|
||||||
caption: text || options.caption,
|
caption: text || options.caption,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await sendMessage(
|
const response = await sendMessage(
|
||||||
token,
|
context.token,
|
||||||
{
|
{
|
||||||
chat_id: chatId.trim(),
|
chat_id: context.chatId,
|
||||||
text: text.slice(0, 2000),
|
text: text.slice(0, 2000),
|
||||||
},
|
},
|
||||||
fetcher,
|
context.fetcher,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok && response.result) {
|
if (response.ok && response.result) {
|
||||||
@@ -88,14 +98,9 @@ export async function sendPhotoZalo(
|
|||||||
photoUrl: string,
|
photoUrl: string,
|
||||||
options: ZaloSendOptions = {},
|
options: ZaloSendOptions = {},
|
||||||
): Promise<ZaloSendResult> {
|
): Promise<ZaloSendResult> {
|
||||||
const { token, fetcher } = resolveSendContext(options);
|
const context = resolveValidatedSendContext(chatId, options);
|
||||||
|
if (!context.ok) {
|
||||||
if (!token) {
|
return { ok: false, error: context.error };
|
||||||
return { ok: false, error: "No Zalo bot token configured" };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chatId?.trim()) {
|
|
||||||
return { ok: false, error: "No chat_id provided" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!photoUrl?.trim()) {
|
if (!photoUrl?.trim()) {
|
||||||
@@ -104,13 +109,13 @@ export async function sendPhotoZalo(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await sendPhoto(
|
const response = await sendPhoto(
|
||||||
token,
|
context.token,
|
||||||
{
|
{
|
||||||
chat_id: chatId.trim(),
|
chat_id: context.chatId,
|
||||||
photo: photoUrl.trim(),
|
photo: photoUrl.trim(),
|
||||||
caption: options.caption?.slice(0, 2000),
|
caption: options.caption?.slice(0, 2000),
|
||||||
},
|
},
|
||||||
fetcher,
|
context.fetcher,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.ok && response.result) {
|
if (response.ok && response.result) {
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ export type ZaloTokenResolution = BaseTokenResolution & {
|
|||||||
source: "env" | "config" | "configFile" | "none";
|
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(
|
export function resolveZaloToken(
|
||||||
config: ZaloConfig | undefined,
|
config: ZaloConfig | undefined,
|
||||||
accountId?: string | null,
|
accountId?: string | null,
|
||||||
@@ -44,28 +57,16 @@ export function resolveZaloToken(
|
|||||||
if (token) {
|
if (token) {
|
||||||
return { token, source: "config" };
|
return { token, source: "config" };
|
||||||
}
|
}
|
||||||
const tokenFile = accountConfig.tokenFile?.trim();
|
const fileToken = readTokenFromFile(accountConfig.tokenFile);
|
||||||
if (tokenFile) {
|
if (fileToken) {
|
||||||
try {
|
return { token: fileToken, source: "configFile" };
|
||||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
||||||
if (fileToken) {
|
|
||||||
return { token: fileToken, source: "configFile" };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore read failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountTokenFile = accountConfig?.tokenFile?.trim();
|
if (!accountHasBotToken) {
|
||||||
if (!accountHasBotToken && accountTokenFile) {
|
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
|
||||||
try {
|
if (fileToken) {
|
||||||
const fileToken = readFileSync(accountTokenFile, "utf8").trim();
|
return { token: fileToken, source: "configFile" };
|
||||||
if (fileToken) {
|
|
||||||
return { token: fileToken, source: "configFile" };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore read failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,16 +80,9 @@ export function resolveZaloToken(
|
|||||||
if (token) {
|
if (token) {
|
||||||
return { token, source: "config" };
|
return { token, source: "config" };
|
||||||
}
|
}
|
||||||
const tokenFile = baseConfig?.tokenFile?.trim();
|
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
|
||||||
if (tokenFile) {
|
if (fileToken) {
|
||||||
try {
|
return { token: fileToken, source: "configFile" };
|
||||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
|
||||||
if (fileToken) {
|
|
||||||
return { token: fileToken, source: "configFile" };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore read failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import fsp from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import type {
|
import type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelDirectoryEntry,
|
ChannelDirectoryEntry,
|
||||||
@@ -12,16 +10,19 @@ import type {
|
|||||||
} from "openclaw/plugin-sdk/zalouser";
|
} from "openclaw/plugin-sdk/zalouser";
|
||||||
import {
|
import {
|
||||||
applyAccountNameToChannelSection,
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelSendResult,
|
||||||
|
buildBaseAccountStatusSnapshot,
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
chunkTextForOutbound,
|
chunkTextForOutbound,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatAllowFromLowercase,
|
formatAllowFromLowercase,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
|
isNumericTargetId,
|
||||||
migrateBaseNameToDefaultAccount,
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
resolvePreferredOpenClawTmpDir,
|
|
||||||
resolveChannelAccountConfigBasePath,
|
resolveChannelAccountConfigBasePath,
|
||||||
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "openclaw/plugin-sdk/zalouser";
|
} from "openclaw/plugin-sdk/zalouser";
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,7 @@ import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-po
|
|||||||
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
|
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
|
||||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||||
import { probeZalouser } from "./probe.js";
|
import { probeZalouser } from "./probe.js";
|
||||||
|
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||||
import {
|
import {
|
||||||
@@ -69,25 +71,6 @@ function resolveZalouserQrProfile(accountId?: string | null): string {
|
|||||||
return normalized;
|
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: {
|
function mapUser(params: {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string | null;
|
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(
|
function resolveZalouserGroupToolPolicy(
|
||||||
params: ChannelGroupContext,
|
params: ChannelGroupContext,
|
||||||
): GroupToolPolicyConfig | undefined {
|
): GroupToolPolicyConfig | undefined {
|
||||||
const account = resolveZalouserAccountSync({
|
return resolveZalouserGroupPolicyEntry(params)?.tools;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||||
const account = resolveZalouserAccountSync({
|
const entry = resolveZalouserGroupPolicyEntry(params);
|
||||||
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,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (typeof entry?.requireMention === "boolean") {
|
if (typeof entry?.requireMention === "boolean") {
|
||||||
return entry.requireMention;
|
return entry.requireMention;
|
||||||
}
|
}
|
||||||
@@ -395,13 +369,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||||
},
|
},
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: (raw) => {
|
looksLikeId: isNumericTargetId,
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return /^\d{3,}$/.test(trimmed);
|
|
||||||
},
|
|
||||||
hint: "<threadId>",
|
hint: "<threadId>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -560,49 +528,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
chunker: chunkTextForOutbound,
|
chunker: chunkTextForOutbound,
|
||||||
chunkerMode: "text",
|
chunkerMode: "text",
|
||||||
textChunkLimit: 2000,
|
textChunkLimit: 2000,
|
||||||
sendPayload: async (ctx) => {
|
sendPayload: async (ctx) =>
|
||||||
const text = ctx.payload.text ?? "";
|
await sendPayloadWithChunkedTextAndMedia({
|
||||||
const urls = ctx.payload.mediaUrls?.length
|
ctx,
|
||||||
? ctx.payload.mediaUrls
|
textChunkLimit: zalouserPlugin.outbound!.textChunkLimit,
|
||||||
: ctx.payload.mediaUrl
|
chunker: zalouserPlugin.outbound!.chunker,
|
||||||
? [ctx.payload.mediaUrl]
|
sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx),
|
||||||
: [];
|
sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx),
|
||||||
if (!text && urls.length === 0) {
|
emptyResult: { channel: "zalouser", messageId: "" },
|
||||||
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!;
|
|
||||||
},
|
|
||||||
sendText: async ({ to, text, accountId, cfg }) => {
|
sendText: async ({ to, text, accountId, cfg }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||||
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
||||||
return {
|
return buildChannelSendResult("zalouser", result);
|
||||||
channel: "zalouser",
|
|
||||||
ok: result.ok,
|
|
||||||
messageId: result.messageId ?? "",
|
|
||||||
error: result.error ? new Error(result.error) : undefined,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
|
||||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||||
@@ -611,12 +549,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
mediaUrl,
|
mediaUrl,
|
||||||
mediaLocalRoots,
|
mediaLocalRoots,
|
||||||
});
|
});
|
||||||
return {
|
return buildChannelSendResult("zalouser", result);
|
||||||
channel: "zalouser",
|
|
||||||
ok: result.ok,
|
|
||||||
messageId: result.messageId ?? "",
|
|
||||||
error: result.error ? new Error(result.error) : undefined,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
@@ -641,17 +574,19 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
buildAccountSnapshot: async ({ account, runtime }) => {
|
||||||
const configured = await checkZcaAuthenticated(account.profile);
|
const configured = await checkZcaAuthenticated(account.profile);
|
||||||
const configError = "not authenticated";
|
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 {
|
return {
|
||||||
accountId: account.accountId,
|
...base,
|
||||||
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,
|
|
||||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { __testing } from "./monitor.js";
|
import { __testing } from "./monitor.js";
|
||||||
|
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
|
||||||
import { setZalouserRuntime } from "./runtime.js";
|
import { setZalouserRuntime } from "./runtime.js";
|
||||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.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", () => {
|
describe("zalouser monitor pairing account scoping", () => {
|
||||||
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
|
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
|
||||||
const readAllowFromStore = vi.fn(
|
const readAllowFromStore = vi.fn(
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { __testing } from "./monitor.js";
|
import { __testing } from "./monitor.js";
|
||||||
|
import {
|
||||||
|
sendDeliveredZalouserMock,
|
||||||
|
sendMessageZalouserMock,
|
||||||
|
sendSeenZalouserMock,
|
||||||
|
sendTypingZalouserMock,
|
||||||
|
} from "./monitor.send-mocks.js";
|
||||||
import { setZalouserRuntime } from "./runtime.js";
|
import { setZalouserRuntime } from "./runtime.js";
|
||||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.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 {
|
function createAccount(): ResolvedZalouserAccount {
|
||||||
return {
|
return {
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
|
|||||||
20
extensions/zalouser/src/monitor.send-mocks.ts
Normal file
20
extensions/zalouser/src/monitor.send-mocks.ts
Normal 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,
|
||||||
|
}));
|
||||||
22
extensions/zalouser/src/qr-temp-file.ts
Normal file
22
extensions/zalouser/src/qr-temp-file.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -126,6 +126,20 @@ export type Listener = {
|
|||||||
stop(): void;
|
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 = {
|
export type API = {
|
||||||
listener: Listener;
|
listener: Listener;
|
||||||
getContext(): {
|
getContext(): {
|
||||||
@@ -185,57 +199,10 @@ export type API = {
|
|||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
sendDeliveredEvent(
|
sendDeliveredEvent(
|
||||||
isSeen: boolean,
|
isSeen: boolean,
|
||||||
messages:
|
messages: DeliveryEventMessages,
|
||||||
| {
|
|
||||||
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;
|
|
||||||
}>,
|
|
||||||
type?: number,
|
type?: number,
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
|
sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
|
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user