refactor(extensions): dedupe connector helper usage

This commit is contained in:
Peter Steinberger
2026-02-16 14:51:55 +00:00
parent bc55ffb160
commit 544ffbcf7b
49 changed files with 854 additions and 1478 deletions

View File

@@ -0,0 +1,29 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
export type BlueBubblesAccountResolveOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
cfg?: OpenClawConfig;
};
export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
baseUrl: string;
password: string;
accountId: string;
} {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
}

View File

@@ -1,38 +1,18 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesAttachment } from "./types.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("downloadBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("throws when guid is missing", async () => {
const attachment: BlueBubblesAttachment = {};
await expect(

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
@@ -54,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
}
function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export async function downloadBlueBubblesAttachment(

View File

@@ -1,37 +1,17 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
describe("chat", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("markBlueBubblesChatRead", () => {
it("does nothing when chatGuid is empty", async () => {
await markBlueBubblesChatRead("", {

View File

@@ -1,7 +1,7 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { postMultipartFormData } from "./multipart.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -15,19 +15,7 @@ export type BlueBubblesChatOpts = {
};
function resolveAccount(params: BlueBubblesChatOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
function assertPrivateApiEnabled(accountId: string, feature: string): void {

View File

@@ -1,6 +1,11 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { timingSafeEqual } from "node:crypto";
import {
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
import {
normalizeWebhookMessage,
normalizeWebhookReaction,
@@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void {
}
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
const registered = registerWebhookTarget(webhookTargets, target);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
registered.unregister();
// Clean up debouncer when target is unregistered
removeDebouncer(normalizedTarget);
removeDebouncer(registered.target);
};
}
@@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}

View File

@@ -1,5 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map<string, string>([
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) {
throw new Error("BlueBubbles serverUrl is required");
}
if (!password) {
throw new Error("BlueBubbles password is required");
}
return { baseUrl, password, accountId: account.accountId };
return resolveBlueBubblesServerAccount(params);
}
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {

View File

@@ -1,39 +1,62 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import type { BlueBubblesSendTarget } from "./types.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
vi.mock("./accounts.js", () => ({
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
const config = cfg?.channels?.bluebubbles ?? {};
return {
accountId: accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}),
}));
vi.mock("./probe.js", () => ({
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
}));
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
const mockFetch = vi.fn();
installBlueBubblesFetchTestHooks({
mockFetch,
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
});
function mockResolvedHandleTarget(
guid: string = "iMessage;-;+15551234567",
address: string = "+15551234567",
) {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid,
participants: [{ address }],
},
],
}),
});
}
function mockSendResponse(body: unknown) {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify(body)),
});
}
describe("send", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("resolveChatGuidForTarget", () => {
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
return await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
};
it("returns chatGuid directly for chat_guid target", async () => {
const target: BlueBubblesSendTarget = {
kind: "chat_guid",
@@ -130,65 +153,31 @@ describe("send", () => {
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;-;+15559999999",
participants: [{ address: "+15559999999" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
const result = await resolveHandleTargetGuid([
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
]);
expect(result).toBe("iMessage;-;+15551234567");
});
@@ -416,28 +405,8 @@ describe("send", () => {
});
it("sends message successfully", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-123" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
serverUrl: "http://localhost:1234",
@@ -456,28 +425,8 @@ describe("send", () => {
});
it("strips markdown formatting from outbound messages", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-stripped" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
const result = await sendMessageBlueBubbles(
"+15551234567",
@@ -578,28 +527,8 @@ describe("send", () => {
});
it("uses private-api when reply metadata is present", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-124" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-124" } });
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
serverUrl: "http://localhost:1234",
@@ -620,28 +549,8 @@ describe("send", () => {
it("downgrades threaded reply to plain send when private API is disabled", async () => {
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-plain" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
serverUrl: "http://localhost:1234",
@@ -659,28 +568,8 @@ describe("send", () => {
});
it("normalizes effect names and uses private-api for effects", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-125" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-uuid-125" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -722,24 +611,12 @@ describe("send", () => {
});
it("handles send failure", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve("Internal server error"),
});
await expect(
sendMessageBlueBubbles("+15551234567", "Hello", {
@@ -750,23 +627,11 @@ describe("send", () => {
});
it("handles empty response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(""),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -777,23 +642,11 @@ describe("send", () => {
});
it("handles invalid JSON response body", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
mockResolvedHandleTarget();
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve("not valid json"),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -804,28 +657,8 @@ describe("send", () => {
});
it("extracts messageId from various response formats", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
id: "numeric-id-456",
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ id: "numeric-id-456" });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -836,28 +669,8 @@ describe("send", () => {
});
it("extracts messageGuid from response payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { messageGuid: "msg-guid-789" },
}),
),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
@@ -868,23 +681,8 @@ describe("send", () => {
});
it("resolves credentials from config", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg-123" } });
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
cfg: {
@@ -903,23 +701,8 @@ describe("send", () => {
});
it("includes tempGuid in request payload", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
});
mockResolvedHandleTarget();
mockSendResponse({ data: { guid: "msg" } });
await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",

View File

@@ -1,4 +1,5 @@
import {
isAllowedParsedChatSender,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
@@ -329,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: {
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) {
return true;
}
if (allowFrom.includes("*")) {
return true;
}
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) {
continue;
}
const parsed = parseBlueBubblesAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) {
return true;
}
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) {
return true;
}
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) {
return true;
}
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) {
return true;
}
}
}
return false;
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeBlueBubblesHandle,
parseAllowTarget: parseBlueBubblesAllowTarget,
});
}
export function formatBlueBubblesChatTarget(params: {

View File

@@ -0,0 +1,45 @@
import { afterEach, beforeEach, vi } from "vitest";
export function resolveBlueBubblesAccountFromConfig(params: {
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
accountId?: string;
}) {
const config = params.cfg?.channels?.bluebubbles ?? {};
return {
accountId: params.accountId ?? "default",
enabled: config.enabled !== false,
configured: Boolean(config.serverUrl && config.password),
config,
};
}
export function createBlueBubblesAccountsMockModule() {
return {
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
};
}
export function createBlueBubblesProbeMockModule() {
return {
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
};
}
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType<typeof vi.fn>;
privateApiStatusMock: {
mockReset: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
params.privateApiStatusMock.mockReset();
params.privateApiStatusMock.mockReturnValue(null);
});
afterEach(() => {
vi.unstubAllGlobals();
});
}

View File

@@ -0,0 +1,11 @@
import { vi } from "vitest";
vi.mock("./accounts.js", async () => {
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
return createBlueBubblesAccountsMockModule();
});
vi.mock("./probe.js", async () => {
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
return createBlueBubblesProbeMockModule();
});

View File

@@ -120,7 +120,7 @@ function isTailnetIPv4(address: string): boolean {
return a === 100 && b >= 64 && b <= 127;
}
function pickLanIPv4(): string | null {
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
@@ -137,7 +137,7 @@ function pickLanIPv4(): string | null {
if (!address) {
continue;
}
if (isPrivateIPv4(address)) {
if (predicate(address)) {
return address;
}
}
@@ -145,29 +145,12 @@ function pickLanIPv4(): string | null {
return null;
}
function pickLanIPv4(): string | null {
return pickMatchingIPv4(isPrivateIPv4);
}
function pickTailnetIPv4(): string | null {
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
// Check for IPv4 (string "IPv4" on Node 18+, number 4 on older)
const isIpv4 = family === "IPv4" || String(family) === "4";
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (isTailnetIPv4(address)) {
return address;
}
}
}
return null;
return pickMatchingIPv4(isTailnetIPv4);
}
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {

View File

@@ -6,6 +6,7 @@ import { Readable } from "stream";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type DownloadImageResult = {
@@ -283,15 +284,8 @@ export async function sendImageFeishu(params: {
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -302,15 +296,8 @@ export async function sendImageFeishu(params: {
msg_type: "image",
},
});
if (response.code !== 0) {
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
return toFeishuSendResult(response, receiveId);
}
/**
@@ -349,15 +336,8 @@ export async function sendFileFeishu(params: {
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -368,15 +348,8 @@ export async function sendFileFeishu(params: {
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
return toFeishuSendResult(response, receiveId);
}
/**

View File

@@ -0,0 +1,29 @@
export type FeishuMessageApiResponse = {
code?: number;
msg?: string;
data?: {
message_id?: string;
};
};
export function assertFeishuMessageApiSuccess(
response: FeishuMessageApiResponse,
errorPrefix: string,
) {
if (response.code !== 0) {
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
}
}
export function toFeishuSendResult(
response: FeishuMessageApiResponse,
chatId: string,
): {
messageId: string;
chatId: string;
} {
return {
messageId: response.data?.message_id ?? "unknown",
chatId,
};
}

View File

@@ -5,6 +5,7 @@ import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export type FeishuMessageInfo = {
@@ -161,15 +162,8 @@ export async function sendMessageFeishu(
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -180,15 +174,8 @@ export async function sendMessageFeishu(
msg_type: msgType,
},
});
if (response.code !== 0) {
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu send failed");
return toFeishuSendResult(response, receiveId);
}
export type SendFeishuCardParams = {
@@ -223,15 +210,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
return toFeishuSendResult(response, receiveId);
}
const response = await client.im.message.create({
@@ -242,15 +222,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
msg_type: "interactive",
},
});
if (response.code !== 0) {
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
}
return {
messageId: response.data?.message_id ?? "unknown",
chatId: receiveId,
};
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
return toFeishuSendResult(response, receiveId);
}
export async function updateCardFeishu(params: {

View File

@@ -5,6 +5,7 @@ import type {
} from "openclaw/plugin-sdk";
import {
createActionGate,
extractToolSend,
jsonResult,
readNumberParam,
readReactionParams,
@@ -64,16 +65,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") {
return null;
}
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) {
return null;
}
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId }) => {
const account = resolveGoogleChatAccount({

View File

@@ -2,9 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
normalizeWebhookPath,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveWebhookPath,
resolveWebhookTargets,
requestBodyErrorToText,
resolveMentionGatingWithBypass,
} from "openclaw/plugin-sdk";
@@ -89,19 +91,7 @@ function warnDeprecatedUsersEmailEntries(
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
return registerWebhookTarget(webhookTargets, target).unregister;
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
@@ -123,17 +113,13 @@ export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}

View File

@@ -1,8 +1,9 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { IncomingMessage } from "node:http";
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
import { verifyGoogleChatRequest } from "./auth.js";
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
@@ -37,24 +38,6 @@ function createWebhookRequest(params: {
return req;
}
function createWebhookResponse(): ServerResponse & { body?: string } {
const headers: Record<string, string> = {};
const res = {
headersSent: false,
statusCode: 200,
setHeader: (key: string, value: string) => {
headers[key.toLowerCase()] = value;
return res;
},
end: (body?: string) => {
res.headersSent = true;
res.body = body;
return res;
},
} as unknown as ServerResponse & { body?: string };
return res;
}
const baseAccount = (accountId: string) =>
({
accountId,
@@ -105,7 +88,7 @@ describe("Google Chat webhook routing", () => {
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createWebhookResponse();
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",
@@ -131,7 +114,7 @@ describe("Google Chat webhook routing", () => {
const { sinkA, sinkB, unregister } = registerTwoTargets();
try {
const res = createWebhookResponse();
const res = createMockServerResponse();
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest({
authorization: "Bearer test-token",

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
@@ -92,47 +93,8 @@ describe("googlechat resolveTarget", () => {
expect(result.to).toBe("users/user@example.com");
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
installCommonResolveTargetErrorCases({
resolveTarget,
implicitAllowFrom: ["spaces/BBB"],
});
});

View File

@@ -0,0 +1,30 @@
import type { ResolvedIrcAccount } from "./accounts.js";
import type { IrcClientOptions } from "./client.js";
type IrcConnectOverrides = Omit<
Partial<IrcClientOptions>,
"host" | "port" | "tls" | "nick" | "username" | "realname" | "password" | "nickserv"
>;
export function buildIrcConnectOptions(
account: ResolvedIrcAccount,
overrides: IrcConnectOverrides = {},
): IrcClientOptions {
return {
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
...overrides,
};
}

View File

@@ -2,6 +2,7 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient, type IrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { handleIrcInbound } from "./inbound.js";
import { isChannelTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
@@ -59,91 +60,79 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto
let client: IrcClient | null = null;
client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
client = await connectIrcClient(
buildIrcConnectOptions(account, {
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
});
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
}),
);
logger.info(
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,

View File

@@ -1,6 +1,7 @@
import type { CoreConfig, IrcProbe } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
function formatError(err: unknown): string {
if (err instanceof Error) {
@@ -31,23 +32,11 @@ export async function probeIrc(
const started = Date.now();
try {
const client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: opts?.timeoutMs ?? 8000,
});
const client = await connectIrcClient(
buildIrcConnectOptions(account, {
connectTimeoutMs: opts?.timeoutMs ?? 8000,
}),
);
const elapsed = Date.now() - started;
client.quit("probe");
return {

View File

@@ -2,6 +2,7 @@ import type { IrcClient } from "./client.js";
import type { CoreConfig } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
@@ -65,23 +66,11 @@ export async function sendMessageIrc(
if (client?.isReady()) {
client.sendPrivmsg(target, payload);
} else {
const transient = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: 12000,
});
const transient = await connectIrcClient(
buildIrcConnectOptions(account, {
connectTimeoutMs: 12000,
}),
);
transient.sendPrivmsg(target, payload);
transient.quit("sent");
}

View File

@@ -3,12 +3,8 @@ import type { CoreConfig } from "../../types.js";
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createPreparedMatrixClient } from "../client-bootstrap.js";
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
export function ensureNodeRuntime() {
if (isBunRuntime()) {
@@ -42,24 +38,10 @@ export async function resolveActionClient(
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
accountId,
});
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
const client = await createPreparedMatrixClient({
auth,
timeoutMs: opts.timeoutMs,
accountId,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
joinedRooms,
);
} catch {
// Ignore crypto prep failures for one-off actions.
}
}
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -0,0 +1,39 @@
import { createMatrixClient } from "./client.js";
type MatrixClientBootstrapAuth = {
homeserver: string;
userId: string;
accessToken: string;
encryption?: boolean;
};
type MatrixCryptoPrepare = {
prepare: (rooms?: string[]) => Promise<void>;
};
type MatrixBootstrapClient = Awaited<ReturnType<typeof createMatrixClient>>;
export async function createPreparedMatrixClient(opts: {
auth: MatrixClientBootstrapAuth;
timeoutMs?: number;
accountId?: string;
}): Promise<MatrixBootstrapClient> {
const client = await createMatrixClient({
homeserver: opts.auth.homeserver,
userId: opts.auth.userId,
accessToken: opts.auth.accessToken,
encryption: opts.auth.encryption,
localTimeoutMs: opts.timeoutMs,
accountId: opts.accountId,
});
if (opts.auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as MatrixCryptoPrepare).prepare(joinedRooms);
} catch {
// Ignore crypto prep failures for one-off requests.
}
}
await client.start();
return client;
}

View File

@@ -22,14 +22,12 @@ describe("downloadMatrixMedia", () => {
setMatrixRuntime(runtimeStub);
});
it("decrypts encrypted media when file payloads are present", async () => {
function makeEncryptedMediaFixture() {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
@@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => {
hashes: { sha256: "hash" },
v: "v2",
};
return { decryptMedia, client, file };
}
it("decrypts encrypted media when file payloads are present", async () => {
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
const result = await downloadMatrixMedia({
client,
@@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => {
});
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
key: {
kty: "oct",
key_ops: ["encrypt", "decrypt"],
alg: "A256CTR",
k: "secret",
ext: true,
},
iv: "iv",
hashes: { sha256: "hash" },
v: "v2",
};
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
await expect(
downloadMatrixMedia({

View File

@@ -3,12 +3,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
import type { CoreConfig } from "../../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
import {
createMatrixClient,
isBunRuntime,
resolveMatrixAuth,
resolveSharedMatrixClient,
} from "../client.js";
import { createPreparedMatrixClient } from "../client-bootstrap.js";
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
const getCore = () => getMatrixRuntime();
@@ -92,25 +88,10 @@ export async function resolveMatrixClient(opts: {
return { client, stopOnDone: false };
}
const auth = await resolveMatrixAuth({ accountId });
const client = await createMatrixClient({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
encryption: auth.encryption,
localTimeoutMs: opts.timeoutMs,
const client = await createPreparedMatrixClient({
auth,
timeoutMs: opts.timeoutMs,
accountId,
});
if (auth.encryption && client.crypto) {
try {
const joinedRooms = await client.getJoinedRooms();
await (client.crypto as { prepare: (rooms?: string[]) => Promise<void> }).prepare(
joinedRooms,
);
} catch {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -1,8 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import type WebSocket from "ws";
import { Buffer } from "node:buffer";
export { createDedupeCache } from "openclaw/plugin-sdk";
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
export type ResponsePrefixContext = {
model?: string;
@@ -40,25 +37,6 @@ export function formatInboundFromLabel(params: {
return `${directLabel} id:${directId}`;
}
export function rawDataToString(
data: WebSocket.RawData,
encoding: BufferEncoding = "utf8",
): string {
if (typeof data === "string") {
return data;
}
if (Buffer.isBuffer(data)) {
return data.toString(encoding);
}
if (Array.isArray(data)) {
return Buffer.concat(data).toString(encoding);
}
if (data instanceof ArrayBuffer) {
return Buffer.from(data).toString(encoding);
}
return Buffer.from(String(data)).toString(encoding);
}
function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {

View File

@@ -1,4 +1,3 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -6,23 +5,11 @@ import { beforeEach, describe, expect, it } from "vitest";
import type { StoredConversationReference } from "./conversation-store.js";
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;
import { msteamsRuntimeStub } from "./test-runtime.js";
describe("msteams conversation store (fs)", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("filters and prunes expired entries (but keeps legacy ones)", async () => {

View File

@@ -1,27 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
import { setMSTeamsRuntime } from "./runtime.js";
const runtimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;
import { msteamsRuntimeStub } from "./test-runtime.js";
describe("msteams polls", () => {
beforeEach(() => {
setMSTeamsRuntime(runtimeStub);
setMSTeamsRuntime(msteamsRuntimeStub);
});
it("builds poll cards with fallback text", () => {

View File

@@ -374,6 +374,45 @@ async function sendTextWithMedia(
};
}
type ProactiveActivityParams = {
adapter: MSTeamsProactiveContext["adapter"];
appId: string;
ref: MSTeamsProactiveContext["ref"];
activity: Record<string, unknown>;
errorPrefix: string;
};
async function sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix,
}: ProactiveActivityParams): Promise<string> {
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.sendActivity(activity);
messageId = extractMessageId(response) ?? "unknown";
});
return messageId;
} catch (err) {
const classification = classifyMSTeamsSendError(err);
const hint = formatMSTeamsSendErrorHint(classification);
const status = classification.statusCode ? ` (HTTP ${classification.statusCode})` : "";
throw new Error(
`${errorPrefix} failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
}
/**
* Send a poll (Adaptive Card) to a Teams conversation or user.
*/
@@ -409,27 +448,13 @@ export async function sendPollMSTeams(
};
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.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 poll send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
const messageId = await sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix: "msteams poll send",
});
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
@@ -469,27 +494,13 @@ export async function sendAdaptiveCardMSTeams(
};
// Send card via proactive conversation
const baseRef = buildConversationReference(ref);
const proactiveRef = {
...baseRef,
activityId: undefined,
};
let messageId = "unknown";
try {
await adapter.continueConversation(appId, proactiveRef, async (ctx) => {
const response = await ctx.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 card send failed${status}: ${formatUnknownError(err)}${hint ? ` (${hint})` : ""}`,
{ cause: err },
);
}
const messageId = await sendProactiveActivity({
adapter,
appId,
ref,
activity,
errorPrefix: "msteams card send",
});
log.info("sent adaptive card", { conversationId, messageId });

View File

@@ -1,7 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { safeParseJson } from "openclaw/plugin-sdk";
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
import { withFileLock as withPathLock } from "./file-lock.js";
const STORE_LOCK_OPTIONS = {
@@ -19,31 +17,11 @@ export async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) {
return { value: fallback, exists: true };
}
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") {
return { value: fallback, exists: false };
}
return { value: fallback, exists: false };
}
return await readJsonFileWithFallback(filePath, fallback);
}
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`);
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
await writeJsonFileAtomically(filePath, value);
}
async function ensureJsonFile(filePath: string, fallback: unknown) {

View File

@@ -0,0 +1,16 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import os from "node:os";
import path from "node:path";
export const msteamsRuntimeStub = {
state: {
resolveStateDir: (env: NodeJS.ProcessEnv = process.env, homedir?: () => string) => {
const override = env.OPENCLAW_STATE_DIR?.trim() || env.OPENCLAW_STATE_DIR?.trim();
if (override) {
return override;
}
const resolvedHome = homedir ? homedir() : os.homedir();
return path.join(resolvedHome, ".openclaw");
},
},
} as unknown as PluginRuntime;

View File

@@ -1,38 +1,16 @@
import type { IncomingMessage } from "node:http";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import { readNextcloudTalkWebhookBody } from "./monitor.js";
function createMockRequest(chunks: string[]): IncomingMessage {
const req = new EventEmitter() as IncomingMessage & { destroyed?: boolean; destroy: () => void };
req.destroyed = false;
req.headers = {};
req.destroy = () => {
req.destroyed = true;
};
void Promise.resolve().then(() => {
for (const chunk of chunks) {
req.emit("data", Buffer.from(chunk, "utf-8"));
if (req.destroyed) {
return;
}
}
req.emit("end");
});
return req;
}
describe("readNextcloudTalkWebhookBody", () => {
it("reads valid body within max bytes", async () => {
const req = createMockRequest(['{"type":"Create"}']);
const req = createMockIncomingRequest(['{"type":"Create"}']);
const body = await readNextcloudTalkWebhookBody(req, 1024);
expect(body).toBe('{"type":"Create"}');
});
it("rejects when payload exceeds max bytes", async () => {
const req = createMockRequest(["x".repeat(300)]);
const req = createMockIncomingRequest(["x".repeat(300)]);
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});

View File

@@ -50,6 +50,24 @@ export type MetricName =
| DecryptMetricName
| MemoryMetricName;
type RelayMetrics = {
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
};
// ============================================================================
// Metric Event
// ============================================================================
@@ -93,26 +111,7 @@ export interface MetricsSnapshot {
};
/** Relay stats by URL */
relays: Record<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>;
relays: Record<string, RelayMetrics>;
/** Rate limiting stats */
rateLimiting: {
@@ -174,26 +173,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
};
// Per-relay stats
const relays = new Map<
string,
{
connects: number;
disconnects: number;
reconnects: number;
errors: number;
messagesReceived: {
event: number;
eose: number;
closed: number;
notice: number;
ok: number;
auth: number;
};
circuitBreakerState: "closed" | "open" | "half_open";
circuitBreakerOpens: number;
circuitBreakerCloses: number;
}
>();
const relays = new Map<string, RelayMetrics>();
// Rate limiting stats
const rateLimiting = {

View File

@@ -112,6 +112,23 @@ function createMockContext(overrides?: Partial<NostrProfileHttpContext>): NostrP
};
}
function mockSuccessfulProfileImport() {
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
}
// ============================================================================
// Tests
// ============================================================================
@@ -342,20 +359,7 @@ describe("nostr-profile-http", () => {
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
mockSuccessfulProfileImport();
await handler(req, res);
@@ -406,20 +410,7 @@ describe("nostr-profile-http", () => {
});
const res = createMockResponse();
vi.mocked(importProfileFromRelays).mockResolvedValue({
ok: true,
profile: {
name: "imported",
displayName: "Imported User",
},
event: {
id: "evt123",
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
created_at: 1234567890,
},
relaysQueried: ["wss://relay.damus.io"],
sourceRelay: "wss://relay.damus.io",
});
mockSuccessfulProfileImport();
await handler(req, res);

View File

@@ -137,6 +137,27 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
entries.delete(idToEvict);
}
function insertAtFront(id: string, seenAt: number): void {
const newEntry: Entry = {
seenAt,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
}
// Prune expired entries
function pruneExpired(): void {
const now = Date.now();
@@ -180,25 +201,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
evictLRU();
}
// Add new entry at front
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
insertAtFront(id, now);
}
function has(id: string): boolean {
@@ -268,24 +271,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
for (let i = ids.length - 1; i >= 0; i--) {
const id = ids[i];
if (!entries.has(id) && entries.size < maxEntries) {
const newEntry: Entry = {
seenAt: now,
prev: null,
next: head,
};
if (head) {
const headEntry = entries.get(head);
if (headEntry) {
headEntry.prev = id;
}
}
entries.set(id, newEntry);
head = id;
if (!tail) {
tail = id;
}
insertAtFront(id, now);
}
}
}

View File

@@ -0,0 +1,66 @@
import { expect, it } from "vitest";
type ResolveTargetMode = "explicit" | "implicit" | "heartbeat";
type ResolveTargetResult = {
ok: boolean;
to?: string;
error?: unknown;
};
type ResolveTargetFn = (params: {
to?: string;
mode: ResolveTargetMode;
allowFrom: string[];
}) => ResolveTargetResult;
export function installCommonResolveTargetErrorCases(params: {
resolveTarget: ResolveTargetFn;
implicitAllowFrom: string[];
}) {
const { resolveTarget, implicitAllowFrom } = params;
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: implicitAllowFrom,
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: implicitAllowFrom,
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
}

View File

@@ -6,6 +6,7 @@ import {
extractSlackToolSend,
formatPairingApproveHint,
getChatChannelMeta,
handleSlackMessageAction,
listSlackMessageActions,
listSlackAccountIds,
listSlackDirectoryGroupsFromConfig,
@@ -15,8 +16,6 @@ import {
normalizeAccountId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
readNumberParam,
readStringParam,
resolveDefaultSlackAccountId,
resolveSlackAccount,
resolveSlackReplyToMode,
@@ -234,151 +233,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
actions: {
listActions: ({ cfg }) => listSlackMessageActions(cfg),
extractToolSend: ({ args }) => extractSlackToolSend(args),
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () =>
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId");
const replyTo = readStringParam(params, "replyTo");
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "sendMessage",
to,
content,
mediaUrl: mediaUrl ?? undefined,
accountId: accountId ?? undefined,
threadTs: threadId ?? replyTo ?? undefined,
},
cfg,
toolContext,
);
}
if (action === "react") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = typeof params.remove === "boolean" ? params.remove : undefined;
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "react",
channelId: resolveChannelId(),
messageId,
emoji,
remove,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "reactions",
channelId: resolveChannelId(),
messageId,
limit,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "readMessages",
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
const content = readStringParam(params, "message", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "editMessage",
channelId: resolveChannelId(),
messageId,
content,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", {
required: true,
});
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action: "deleteMessage",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins"
? undefined
: readStringParam(params, "messageId", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{
action:
action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
channelId: resolveChannelId(),
messageId,
accountId: accountId ?? undefined,
},
cfg,
);
}
if (action === "member-info") {
const userId = readStringParam(params, "userId", { required: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "memberInfo", userId, accountId: accountId ?? undefined },
cfg,
);
}
if (action === "emoji-list") {
const limit = readNumberParam(params, "limit", { integer: true });
return await getSlackRuntime().channel.slack.handleSlackAction(
{ action: "emojiList", limit, accountId: accountId ?? undefined },
cfg,
);
}
throw new Error(`Action ${action} is not supported for provider ${meta.id}.`);
},
handleAction: async (ctx) =>
await handleSlackMessageAction({
providerId: meta.id,
ctx,
invoke: async (action, cfg, toolContext) =>
await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext),
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@@ -0,0 +1,25 @@
export type TlonAccountFieldsInput = {
ship?: string;
url?: string;
code?: string;
allowPrivateNetwork?: boolean;
groupChannels?: string[];
dmAllowlist?: string[];
autoDiscoverChannels?: boolean;
};
export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
return {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
};
}

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
} from "openclaw/plugin-sdk";
import { buildTlonAccountFields } from "./account-fields.js";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { monitorTlonProvider } from "./monitor/index.js";
import { tlonOnboardingAdapter } from "./onboarding.js";
@@ -47,19 +48,7 @@ function applyTlonSetupConfig(params: {
});
const base = namedConfig.channels?.tlon ?? {};
const payload = {
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
};
const payload = buildTlonAccountFields(input);
if (useDefault) {
return {

View File

@@ -8,6 +8,7 @@ import {
type WizardPrompter,
} from "openclaw/plugin-sdk";
import type { TlonResolvedAccount } from "./types.js";
import { buildTlonAccountFields } from "./account-fields.js";
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
@@ -34,6 +35,11 @@ function applyAccountConfig(params: {
const { cfg, accountId, input } = params;
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
const base = cfg.channels?.tlon ?? {};
const nextValues = {
enabled: true,
...(input.name ? { name: input.name } : {}),
...buildTlonAccountFields(input),
};
if (useDefault) {
return {
@@ -42,19 +48,7 @@ function applyAccountConfig(params: {
...cfg.channels,
tlon: {
...base,
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
...nextValues,
},
},
};
@@ -73,19 +67,7 @@ function applyAccountConfig(params: {
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
accountId
],
enabled: true,
...(input.name ? { name: input.name } : {}),
...(input.ship ? { ship: input.ship } : {}),
...(input.url ? { url: input.url } : {}),
...(input.code ? { code: input.code } : {}),
...(typeof input.allowPrivateNetwork === "boolean"
? { allowPrivateNetwork: input.allowPrivateNetwork }
: {}),
...(input.groupChannels ? { groupChannels: input.groupChannels } : {}),
...(input.dmAllowlist ? { dmAllowlist: input.dmAllowlist } : {}),
...(typeof input.autoDiscoverChannels === "boolean"
? { autoDiscoverChannels: input.autoDiscoverChannels }
: {}),
...nextValues,
},
},
},

View File

@@ -9,9 +9,13 @@
* - Abort signal handling
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { twitchOutbound } from "./outbound.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
// Mock dependencies
vi.mock("./config.js", () => ({
@@ -35,29 +39,12 @@ vi.mock("./utils/twitch.js", () => ({
describe("outbound", () => {
const mockAccount = {
username: "testbot",
...BASE_TWITCH_TEST_ACCOUNT,
accessToken: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
describe("metadata", () => {
it("should have direct delivery mode", () => {

View File

@@ -10,9 +10,13 @@
* - Registry integration
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { sendMessageTwitchInternal } from "./send.js";
import {
BASE_TWITCH_TEST_ACCOUNT,
installTwitchTestHooks,
makeTwitchTestConfig,
} from "./test-fixtures.js";
// Mock dependencies
vi.mock("./config.js", () => ({
@@ -43,29 +47,12 @@ describe("send", () => {
};
const mockAccount = {
username: "testbot",
...BASE_TWITCH_TEST_ACCOUNT,
token: "oauth:test123",
clientId: "test-client-id",
channel: "#testchannel",
};
const mockConfig = {
channels: {
twitch: {
accounts: {
default: mockAccount,
},
},
},
} as unknown as OpenClawConfig;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const mockConfig = makeTwitchTestConfig(mockAccount);
installTwitchTestHooks();
describe("sendMessageTwitchInternal", () => {
it("should send a message successfully", async () => {

View File

@@ -0,0 +1,30 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, vi } from "vitest";
export const BASE_TWITCH_TEST_ACCOUNT = {
username: "testbot",
clientId: "test-client-id",
channel: "#testchannel",
};
export function makeTwitchTestConfig(account: Record<string, unknown>): OpenClawConfig {
return {
channels: {
twitch: {
accounts: {
default: account,
},
},
},
} as unknown as OpenClawConfig;
}
export function installTwitchTestHooks() {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
}

View File

@@ -22,6 +22,37 @@ function decodeBase64Url(input: string): Buffer {
return Buffer.from(padded, "base64");
}
function expectWebhookVerificationSucceeds(params: {
publicKey: string;
privateKey: crypto.KeyObject;
}) {
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: params.publicKey },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto
.sign(null, Buffer.from(signedPayload), params.privateKey)
.toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
}
describe("TelnyxProvider.verifyWebhook", () => {
it("fails closed when public key is missing and skipVerification is false", () => {
const provider = new TelnyxProvider(
@@ -63,59 +94,13 @@ describe("TelnyxProvider.verifyWebhook", () => {
const rawPublicKey = decodeBase64Url(jwk.x as string);
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
});
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
const spkiDerBase64 = spkiDer.toString("base64");
const provider = new TelnyxProvider(
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
{ skipVerification: false },
);
const rawBody = JSON.stringify({
event_type: "call.initiated",
payload: { call_control_id: "x" },
});
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
const result = provider.verifyWebhook(
createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
);
expect(result.ok).toBe(true);
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
});
});

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }),
@@ -147,47 +148,8 @@ describe("whatsapp resolveTarget", () => {
expect(result.error).toBeDefined();
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
installCommonResolveTargetErrorCases({
resolveTarget,
implicitAllowFrom: ["5511999999999"],
});
});

View File

@@ -2,9 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
normalizeWebhookPath,
readJsonBodyWithLimit,
registerWebhookTarget,
rejectNonPostWebhookRequest,
resolveSenderCommandAuthorization,
resolveWebhookPath,
resolveWebhookTargets,
requestBodyErrorToText,
} from "openclaw/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
@@ -83,36 +86,20 @@ type WebhookTarget = {
const webhookTargets = new Map<string, WebhookTarget[]>();
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
return registerWebhookTarget(webhookTargets, target).unregister;
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) {
const resolved = resolveWebhookTargets(req, webhookTargets);
if (!resolved) {
return false;
}
const { targets } = resolved;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
if (rejectNonPostWebhookRequest(req, res)) {
return true;
}
@@ -402,22 +389,20 @@ async function processMessageWithPipeline(params: {
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
senderId,
isSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (!isGroup) {
if (dmPolicy === "disabled") {

View File

@@ -1,6 +1,11 @@
import type { ChildProcess } from "node:child_process";
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
import { createReplyPrefixOptions, mergeAllowlist, summarizeMapping } from "openclaw/plugin-sdk";
import {
createReplyPrefixOptions,
mergeAllowlist,
resolveSenderCommandAuthorization,
summarizeMapping,
} from "openclaw/plugin-sdk";
import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser } from "./send.js";
@@ -192,22 +197,20 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const rawBody = content.trim();
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
cfg: config,
rawBody,
isGroup,
dmPolicy,
configuredAllowFrom: configAllowFrom,
senderId,
isSenderAllowed,
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"),
shouldComputeCommandAuthorized: (body, cfg) =>
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
resolveCommandAuthorizedFromAuthorizers: (params) =>
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
});
if (!isGroup) {
if (dmPolicy === "disabled") {