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