mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor(extensions): dedupe connector helper usage
This commit is contained in:
29
extensions/bluebubbles/src/account-resolve.ts
Normal file
29
extensions/bluebubbles/src/account-resolve.ts
Normal 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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
45
extensions/bluebubbles/src/test-harness.ts
Normal file
45
extensions/bluebubbles/src/test-harness.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
11
extensions/bluebubbles/src/test-mocks.ts
Normal file
11
extensions/bluebubbles/src/test-mocks.ts
Normal 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();
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
29
extensions/feishu/src/send-result.ts
Normal file
29
extensions/feishu/src/send-result.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
30
extensions/irc/src/connect-options.ts
Normal file
30
extensions/irc/src/connect-options.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
39
extensions/matrix/src/matrix/client-bootstrap.ts
Normal file
39
extensions/matrix/src/matrix/client-bootstrap.ts
Normal 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;
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
16
extensions/msteams/src/test-runtime.ts
Normal file
16
extensions/msteams/src/test-runtime.ts
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
extensions/shared/resolve-target-test-helpers.ts
Normal file
66
extensions/shared/resolve-target-test-helpers.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
25
extensions/tlon/src/account-fields.ts
Normal file
25
extensions/tlon/src/account-fields.ts
Normal 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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
30
extensions/twitch/src/test-fixtures.ts
Normal file
30
extensions/twitch/src/test-fixtures.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user