mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:08:37 +00: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 type { BlueBubblesAttachment } from "./types.js";
|
||||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
|
import { installBlueBubblesFetchTestHooks } from "./test-harness.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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
installBlueBubblesFetchTestHooks({
|
||||||
|
mockFetch,
|
||||||
|
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||||
|
});
|
||||||
|
|
||||||
describe("downloadBlueBubblesAttachment", () => {
|
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 () => {
|
it("throws when guid is missing", async () => {
|
||||||
const attachment: BlueBubblesAttachment = {};
|
const attachment: BlueBubblesAttachment = {};
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import { postMultipartFormData } from "./multipart.js";
|
import { postMultipartFormData } from "./multipart.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||||
@@ -54,19 +54,7 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
return resolveBlueBubblesServerAccount(params);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadBlueBubblesAttachment(
|
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 { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
|
import { installBlueBubblesFetchTestHooks } from "./test-harness.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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
installBlueBubblesFetchTestHooks({
|
||||||
|
mockFetch,
|
||||||
|
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||||
|
});
|
||||||
|
|
||||||
describe("chat", () => {
|
describe("chat", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("fetch", mockFetch);
|
|
||||||
mockFetch.mockReset();
|
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("markBlueBubblesChatRead", () => {
|
describe("markBlueBubblesChatRead", () => {
|
||||||
it("does nothing when chatGuid is empty", async () => {
|
it("does nothing when chatGuid is empty", async () => {
|
||||||
await markBlueBubblesChatRead("", {
|
await markBlueBubblesChatRead("", {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import { postMultipartFormData } from "./multipart.js";
|
import { postMultipartFormData } from "./multipart.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
@@ -15,19 +15,7 @@ export type BlueBubblesChatOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesChatOpts) {
|
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
return resolveBlueBubblesServerAccount(params);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
|
import {
|
||||||
|
registerWebhookTarget,
|
||||||
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveWebhookTargets,
|
||||||
|
} from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
normalizeWebhookMessage,
|
normalizeWebhookMessage,
|
||||||
normalizeWebhookReaction,
|
normalizeWebhookReaction,
|
||||||
@@ -226,20 +231,11 @@ function removeDebouncer(target: WebhookTarget): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
||||||
const key = normalizeWebhookPath(target.path);
|
const registered = registerWebhookTarget(webhookTargets, target);
|
||||||
const normalizedTarget = { ...target, path: key };
|
|
||||||
const existing = webhookTargets.get(key) ?? [];
|
|
||||||
const next = [...existing, normalizedTarget];
|
|
||||||
webhookTargets.set(key, next);
|
|
||||||
return () => {
|
return () => {
|
||||||
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
registered.unregister();
|
||||||
if (updated.length > 0) {
|
|
||||||
webhookTargets.set(key, updated);
|
|
||||||
} else {
|
|
||||||
webhookTargets.delete(key);
|
|
||||||
}
|
|
||||||
// Clean up debouncer when target is unregistered
|
// Clean up debouncer when target is unregistered
|
||||||
removeDebouncer(normalizedTarget);
|
removeDebouncer(registered.target);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,17 +383,14 @@ export async function handleBlueBubblesWebhookRequest(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||||
const path = normalizeWebhookPath(url.pathname);
|
if (!resolved) {
|
||||||
const targets = webhookTargets.get(path);
|
|
||||||
if (!targets || targets.length === 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { path, targets } = resolved;
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (rejectNonPostWebhookRequest(req, res)) {
|
||||||
res.statusCode = 405;
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
res.end("Method Not Allowed");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||||
|
|
||||||
@@ -112,19 +112,7 @@ const REACTION_EMOJIS = new Map<string, string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
return resolveBlueBubblesServerAccount(params);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
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 type { BlueBubblesSendTarget } from "./types.js";
|
||||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
||||||
|
import { installBlueBubblesFetchTestHooks } from "./test-harness.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),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockFetch = vi.fn();
|
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", () => {
|
describe("send", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("fetch", mockFetch);
|
|
||||||
mockFetch.mockReset();
|
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveChatGuidForTarget", () => {
|
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 () => {
|
it("returns chatGuid directly for chat_guid target", async () => {
|
||||||
const target: BlueBubblesSendTarget = {
|
const target: BlueBubblesSendTarget = {
|
||||||
kind: "chat_guid",
|
kind: "chat_guid",
|
||||||
@@ -130,65 +153,31 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves handle target by matching participant", async () => {
|
it("resolves handle target by matching participant", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
const result = await resolveHandleTargetGuid([
|
||||||
ok: true,
|
{
|
||||||
json: () =>
|
guid: "iMessage;-;+15559999999",
|
||||||
Promise.resolve({
|
participants: [{ address: "+15559999999" }],
|
||||||
data: [
|
},
|
||||||
{
|
{
|
||||||
guid: "iMessage;-;+15559999999",
|
guid: "iMessage;-;+15551234567",
|
||||||
participants: [{ address: "+15559999999" }],
|
participants: [{ address: "+15551234567" }],
|
||||||
},
|
},
|
||||||
{
|
]);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("iMessage;-;+15551234567");
|
expect(result).toBe("iMessage;-;+15551234567");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
const result = await resolveHandleTargetGuid([
|
||||||
ok: true,
|
{
|
||||||
json: () =>
|
guid: "iMessage;+;group-123",
|
||||||
Promise.resolve({
|
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
||||||
data: [
|
},
|
||||||
{
|
{
|
||||||
guid: "iMessage;+;group-123",
|
guid: "iMessage;-;+15551234567",
|
||||||
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
participants: [{ address: "+15551234567" }],
|
||||||
},
|
},
|
||||||
{
|
]);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe("iMessage;-;+15551234567");
|
expect(result).toBe("iMessage;-;+15551234567");
|
||||||
});
|
});
|
||||||
@@ -416,28 +405,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sends message successfully", async () => {
|
it("sends message successfully", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-uuid-123" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -456,28 +425,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("strips markdown formatting from outbound messages", async () => {
|
it("strips markdown formatting from outbound messages", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(
|
const result = await sendMessageBlueBubbles(
|
||||||
"+15551234567",
|
"+15551234567",
|
||||||
@@ -578,28 +527,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses private-api when reply metadata is present", async () => {
|
it("uses private-api when reply metadata is present", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-uuid-124" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -620,28 +549,8 @@ describe("send", () => {
|
|||||||
|
|
||||||
it("downgrades threaded reply to plain send when private API is disabled", async () => {
|
it("downgrades threaded reply to plain send when private API is disabled", async () => {
|
||||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -659,28 +568,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes effect names and uses private-api for effects", async () => {
|
it("normalizes effect names and uses private-api for effects", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-uuid-125" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -722,24 +611,12 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles send failure", async () => {
|
it("handles send failure", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: false,
|
||||||
json: () =>
|
status: 500,
|
||||||
Promise.resolve({
|
text: () => Promise.resolve("Internal server error"),
|
||||||
data: [
|
});
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 500,
|
|
||||||
text: () => Promise.resolve("Internal server error"),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15551234567", "Hello", {
|
sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
@@ -750,23 +627,11 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty response body", async () => {
|
it("handles empty response body", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
text: () => Promise.resolve(""),
|
||||||
Promise.resolve({
|
});
|
||||||
data: [
|
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -777,23 +642,11 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles invalid JSON response body", async () => {
|
it("handles invalid JSON response body", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
text: () => Promise.resolve("not valid json"),
|
||||||
Promise.resolve({
|
});
|
||||||
data: [
|
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve("not valid json"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -804,28 +657,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("extracts messageId from various response formats", async () => {
|
it("extracts messageId from various response formats", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ id: "numeric-id-456" });
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () =>
|
|
||||||
Promise.resolve(
|
|
||||||
JSON.stringify({
|
|
||||||
id: "numeric-id-456",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -836,28 +669,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("extracts messageGuid from response payload", async () => {
|
it("extracts messageGuid from response payload", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
|
||||||
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" },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
@@ -868,23 +681,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("resolves credentials from config", async () => {
|
it("resolves credentials from config", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg-123" } });
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
cfg: {
|
cfg: {
|
||||||
@@ -903,23 +701,8 @@ describe("send", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("includes tempGuid in request payload", async () => {
|
it("includes tempGuid in request payload", async () => {
|
||||||
mockFetch
|
mockResolvedHandleTarget();
|
||||||
.mockResolvedValueOnce({
|
mockSendResponse({ data: { guid: "msg" } });
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
guid: "iMessage;-;+15551234567",
|
|
||||||
participants: [{ address: "+15551234567" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
isAllowedParsedChatSender,
|
||||||
parseChatAllowTargetPrefixes,
|
parseChatAllowTargetPrefixes,
|
||||||
parseChatTargetPrefixesOrThrow,
|
parseChatTargetPrefixesOrThrow,
|
||||||
resolveServicePrefixedAllowTarget,
|
resolveServicePrefixedAllowTarget,
|
||||||
@@ -329,43 +330,15 @@ export function isAllowedBlueBubblesSender(params: {
|
|||||||
chatGuid?: string | null;
|
chatGuid?: string | null;
|
||||||
chatIdentifier?: string | null;
|
chatIdentifier?: string | null;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
return isAllowedParsedChatSender({
|
||||||
if (allowFrom.length === 0) {
|
allowFrom: params.allowFrom,
|
||||||
return true;
|
sender: params.sender,
|
||||||
}
|
chatId: params.chatId,
|
||||||
if (allowFrom.includes("*")) {
|
chatGuid: params.chatGuid,
|
||||||
return true;
|
chatIdentifier: params.chatIdentifier,
|
||||||
}
|
normalizeSender: normalizeBlueBubblesHandle,
|
||||||
|
parseAllowTarget: parseBlueBubblesAllowTarget,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatBlueBubblesChatTarget(params: {
|
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;
|
return a === 100 && b >= 64 && b <= 127;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickLanIPv4(): string | null {
|
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
|
||||||
const nets = os.networkInterfaces();
|
const nets = os.networkInterfaces();
|
||||||
for (const entries of Object.values(nets)) {
|
for (const entries of Object.values(nets)) {
|
||||||
if (!entries) {
|
if (!entries) {
|
||||||
@@ -137,7 +137,7 @@ function pickLanIPv4(): string | null {
|
|||||||
if (!address) {
|
if (!address) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isPrivateIPv4(address)) {
|
if (predicate(address)) {
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,29 +145,12 @@ function pickLanIPv4(): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickLanIPv4(): string | null {
|
||||||
|
return pickMatchingIPv4(isPrivateIPv4);
|
||||||
|
}
|
||||||
|
|
||||||
function pickTailnetIPv4(): string | null {
|
function pickTailnetIPv4(): string | null {
|
||||||
const nets = os.networkInterfaces();
|
return pickMatchingIPv4(isTailnetIPv4);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
|
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Readable } from "stream";
|
|||||||
import { resolveFeishuAccount } from "./accounts.js";
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
||||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||||
|
|
||||||
export type DownloadImageResult = {
|
export type DownloadImageResult = {
|
||||||
@@ -283,15 +284,8 @@ export async function sendImageFeishu(params: {
|
|||||||
msg_type: "image",
|
msg_type: "image",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu image reply failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.im.message.create({
|
const response = await client.im.message.create({
|
||||||
@@ -302,15 +296,8 @@ export async function sendImageFeishu(params: {
|
|||||||
msg_type: "image",
|
msg_type: "image",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu image send failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -349,15 +336,8 @@ export async function sendFileFeishu(params: {
|
|||||||
msg_type: msgType,
|
msg_type: msgType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu file reply failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.im.message.create({
|
const response = await client.im.message.create({
|
||||||
@@ -368,15 +348,8 @@ export async function sendFileFeishu(params: {
|
|||||||
msg_type: msgType,
|
msg_type: msgType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu file send failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: 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 { createFeishuClient } from "./client.js";
|
||||||
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
||||||
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
||||||
|
|
||||||
export type FeishuMessageInfo = {
|
export type FeishuMessageInfo = {
|
||||||
@@ -161,15 +162,8 @@ export async function sendMessageFeishu(
|
|||||||
msg_type: msgType,
|
msg_type: msgType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu reply failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.im.message.create({
|
const response = await client.im.message.create({
|
||||||
@@ -180,15 +174,8 @@ export async function sendMessageFeishu(
|
|||||||
msg_type: msgType,
|
msg_type: msgType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu send failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu send failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SendFeishuCardParams = {
|
export type SendFeishuCardParams = {
|
||||||
@@ -223,15 +210,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
|||||||
msg_type: "interactive",
|
msg_type: "interactive",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu card reply failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await client.im.message.create({
|
const response = await client.im.message.create({
|
||||||
@@ -242,15 +222,8 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
|||||||
msg_type: "interactive",
|
msg_type: "interactive",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertFeishuMessageApiSuccess(response, "Feishu card send failed");
|
||||||
if (response.code !== 0) {
|
return toFeishuSendResult(response, receiveId);
|
||||||
throw new Error(`Feishu card send failed: ${response.msg || `code ${response.code}`}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: response.data?.message_id ?? "unknown",
|
|
||||||
chatId: receiveId,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCardFeishu(params: {
|
export async function updateCardFeishu(params: {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
createActionGate,
|
createActionGate,
|
||||||
|
extractToolSend,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
@@ -64,16 +65,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
|
|||||||
return Array.from(actions);
|
return Array.from(actions);
|
||||||
},
|
},
|
||||||
extractToolSend: ({ args }) => {
|
extractToolSend: ({ args }) => {
|
||||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
return extractToolSend(args, "sendMessage");
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||||
const account = resolveGoogleChatAccount({
|
const account = resolveGoogleChatAccount({
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
createReplyPrefixOptions,
|
createReplyPrefixOptions,
|
||||||
normalizeWebhookPath,
|
|
||||||
readJsonBodyWithLimit,
|
readJsonBodyWithLimit,
|
||||||
|
registerWebhookTarget,
|
||||||
|
rejectNonPostWebhookRequest,
|
||||||
resolveWebhookPath,
|
resolveWebhookPath,
|
||||||
|
resolveWebhookTargets,
|
||||||
requestBodyErrorToText,
|
requestBodyErrorToText,
|
||||||
resolveMentionGatingWithBypass,
|
resolveMentionGatingWithBypass,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
@@ -89,19 +91,7 @@ function warnDeprecatedUsersEmailEntries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||||
const key = normalizeWebhookPath(target.path);
|
return registerWebhookTarget(webhookTargets, target).unregister;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||||
@@ -123,17 +113,13 @@ export async function handleGoogleChatWebhookRequest(
|
|||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||||
const path = normalizeWebhookPath(url.pathname);
|
if (!resolved) {
|
||||||
const targets = webhookTargets.get(path);
|
|
||||||
if (!targets || targets.length === 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { targets } = resolved;
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (rejectNonPostWebhookRequest(req, res)) {
|
||||||
res.statusCode = 405;
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
res.end("Method Not Allowed");
|
|
||||||
return true;
|
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 type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||||
|
import { createMockServerResponse } from "../../../src/test-utils/mock-http-response.js";
|
||||||
import { verifyGoogleChatRequest } from "./auth.js";
|
import { verifyGoogleChatRequest } from "./auth.js";
|
||||||
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
|
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
|
||||||
|
|
||||||
@@ -37,24 +38,6 @@ function createWebhookRequest(params: {
|
|||||||
return req;
|
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) =>
|
const baseAccount = (accountId: string) =>
|
||||||
({
|
({
|
||||||
accountId,
|
accountId,
|
||||||
@@ -105,7 +88,7 @@ describe("Google Chat webhook routing", () => {
|
|||||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = createWebhookResponse();
|
const res = createMockServerResponse();
|
||||||
const handled = await handleGoogleChatWebhookRequest(
|
const handled = await handleGoogleChatWebhookRequest(
|
||||||
createWebhookRequest({
|
createWebhookRequest({
|
||||||
authorization: "Bearer test-token",
|
authorization: "Bearer test-token",
|
||||||
@@ -131,7 +114,7 @@ describe("Google Chat webhook routing", () => {
|
|||||||
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
const { sinkA, sinkB, unregister } = registerTwoTargets();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = createWebhookResponse();
|
const res = createMockServerResponse();
|
||||||
const handled = await handleGoogleChatWebhookRequest(
|
const handled = await handleGoogleChatWebhookRequest(
|
||||||
createWebhookRequest({
|
createWebhookRequest({
|
||||||
authorization: "Bearer test-token",
|
authorization: "Bearer test-token",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk", () => ({
|
vi.mock("openclaw/plugin-sdk", () => ({
|
||||||
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
||||||
@@ -92,47 +93,8 @@ describe("googlechat resolveTarget", () => {
|
|||||||
expect(result.to).toBe("users/user@example.com");
|
expect(result.to).toBe("users/user@example.com");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should error on normalization failure with allowlist (implicit mode)", () => {
|
installCommonResolveTargetErrorCases({
|
||||||
const result = resolveTarget({
|
resolveTarget,
|
||||||
to: "invalid-target",
|
implicitAllowFrom: ["spaces/BBB"],
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||||
import { resolveIrcAccount } from "./accounts.js";
|
import { resolveIrcAccount } from "./accounts.js";
|
||||||
import { connectIrcClient, type IrcClient } from "./client.js";
|
import { connectIrcClient, type IrcClient } from "./client.js";
|
||||||
|
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||||
import { handleIrcInbound } from "./inbound.js";
|
import { handleIrcInbound } from "./inbound.js";
|
||||||
import { isChannelTarget } from "./normalize.js";
|
import { isChannelTarget } from "./normalize.js";
|
||||||
import { makeIrcMessageId } from "./protocol.js";
|
import { makeIrcMessageId } from "./protocol.js";
|
||||||
@@ -59,91 +60,79 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto
|
|||||||
|
|
||||||
let client: IrcClient | null = null;
|
let client: IrcClient | null = null;
|
||||||
|
|
||||||
client = await connectIrcClient({
|
client = await connectIrcClient(
|
||||||
host: account.host,
|
buildIrcConnectOptions(account, {
|
||||||
port: account.port,
|
channels: account.config.channels,
|
||||||
tls: account.tls,
|
abortSignal: opts.abortSignal,
|
||||||
nick: account.nick,
|
onLine: (line) => {
|
||||||
username: account.username,
|
if (core.logging.shouldLogVerbose()) {
|
||||||
realname: account.realname,
|
logger.debug?.(`[${account.accountId}] << ${line}`);
|
||||||
password: account.password,
|
}
|
||||||
nickserv: {
|
},
|
||||||
enabled: account.config.nickserv?.enabled,
|
onNotice: (text, target) => {
|
||||||
service: account.config.nickserv?.service,
|
if (core.logging.shouldLogVerbose()) {
|
||||||
password: account.config.nickserv?.password,
|
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
|
||||||
register: account.config.nickserv?.register,
|
}
|
||||||
registerEmail: account.config.nickserv?.registerEmail,
|
},
|
||||||
},
|
onError: (error) => {
|
||||||
channels: account.config.channels,
|
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
|
||||||
abortSignal: opts.abortSignal,
|
},
|
||||||
onLine: (line) => {
|
onPrivmsg: async (event) => {
|
||||||
if (core.logging.shouldLogVerbose()) {
|
if (!client) {
|
||||||
logger.debug?.(`[${account.accountId}] << ${line}`);
|
return;
|
||||||
}
|
}
|
||||||
},
|
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
|
||||||
onNotice: (text, target) => {
|
return;
|
||||||
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({
|
const inboundTarget = resolveIrcInboundTarget({
|
||||||
target: event.target,
|
target: event.target,
|
||||||
senderNick: event.senderNick,
|
senderNick: event.senderNick,
|
||||||
});
|
});
|
||||||
const message: IrcInboundMessage = {
|
const message: IrcInboundMessage = {
|
||||||
messageId: makeIrcMessageId(),
|
messageId: makeIrcMessageId(),
|
||||||
target: inboundTarget.target,
|
target: inboundTarget.target,
|
||||||
rawTarget: inboundTarget.rawTarget,
|
rawTarget: inboundTarget.rawTarget,
|
||||||
senderNick: event.senderNick,
|
senderNick: event.senderNick,
|
||||||
senderUser: event.senderUser,
|
senderUser: event.senderUser,
|
||||||
senderHost: event.senderHost,
|
senderHost: event.senderHost,
|
||||||
text: event.text,
|
text: event.text,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
isGroup: inboundTarget.isGroup,
|
isGroup: inboundTarget.isGroup,
|
||||||
};
|
};
|
||||||
|
|
||||||
core.channel.activity.record({
|
core.channel.activity.record({
|
||||||
channel: "irc",
|
channel: "irc",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
at: message.timestamp,
|
at: message.timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts.onMessage) {
|
if (opts.onMessage) {
|
||||||
await opts.onMessage(message, client);
|
await opts.onMessage(message, client);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleIrcInbound({
|
await handleIrcInbound({
|
||||||
message,
|
message,
|
||||||
account,
|
account,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
runtime,
|
runtime,
|
||||||
connectedNick: client.nick,
|
connectedNick: client.nick,
|
||||||
sendReply: async (target, text) => {
|
sendReply: async (target, text) => {
|
||||||
client?.sendPrivmsg(target, text);
|
client?.sendPrivmsg(target, text);
|
||||||
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
core.channel.activity.record({
|
core.channel.activity.record({
|
||||||
channel: "irc",
|
channel: "irc",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
direction: "outbound",
|
direction: "outbound",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
statusSink: opts.statusSink,
|
statusSink: opts.statusSink,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
|
`[${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 type { CoreConfig, IrcProbe } from "./types.js";
|
||||||
import { resolveIrcAccount } from "./accounts.js";
|
import { resolveIrcAccount } from "./accounts.js";
|
||||||
import { connectIrcClient } from "./client.js";
|
import { connectIrcClient } from "./client.js";
|
||||||
|
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||||
|
|
||||||
function formatError(err: unknown): string {
|
function formatError(err: unknown): string {
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
@@ -31,23 +32,11 @@ export async function probeIrc(
|
|||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
const client = await connectIrcClient({
|
const client = await connectIrcClient(
|
||||||
host: account.host,
|
buildIrcConnectOptions(account, {
|
||||||
port: account.port,
|
connectTimeoutMs: opts?.timeoutMs ?? 8000,
|
||||||
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 elapsed = Date.now() - started;
|
const elapsed = Date.now() - started;
|
||||||
client.quit("probe");
|
client.quit("probe");
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { IrcClient } from "./client.js";
|
|||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
import { resolveIrcAccount } from "./accounts.js";
|
import { resolveIrcAccount } from "./accounts.js";
|
||||||
import { connectIrcClient } from "./client.js";
|
import { connectIrcClient } from "./client.js";
|
||||||
|
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||||
import { normalizeIrcMessagingTarget } from "./normalize.js";
|
import { normalizeIrcMessagingTarget } from "./normalize.js";
|
||||||
import { makeIrcMessageId } from "./protocol.js";
|
import { makeIrcMessageId } from "./protocol.js";
|
||||||
import { getIrcRuntime } from "./runtime.js";
|
import { getIrcRuntime } from "./runtime.js";
|
||||||
@@ -65,23 +66,11 @@ export async function sendMessageIrc(
|
|||||||
if (client?.isReady()) {
|
if (client?.isReady()) {
|
||||||
client.sendPrivmsg(target, payload);
|
client.sendPrivmsg(target, payload);
|
||||||
} else {
|
} else {
|
||||||
const transient = await connectIrcClient({
|
const transient = await connectIrcClient(
|
||||||
host: account.host,
|
buildIrcConnectOptions(account, {
|
||||||
port: account.port,
|
connectTimeoutMs: 12000,
|
||||||
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,
|
|
||||||
});
|
|
||||||
transient.sendPrivmsg(target, payload);
|
transient.sendPrivmsg(target, payload);
|
||||||
transient.quit("sent");
|
transient.quit("sent");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ import type { CoreConfig } from "../../types.js";
|
|||||||
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
import type { MatrixActionClient, MatrixActionClientOpts } from "./types.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { getActiveMatrixClient } from "../active-client.js";
|
import { getActiveMatrixClient } from "../active-client.js";
|
||||||
import {
|
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||||
createMatrixClient,
|
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
||||||
isBunRuntime,
|
|
||||||
resolveMatrixAuth,
|
|
||||||
resolveSharedMatrixClient,
|
|
||||||
} from "../client.js";
|
|
||||||
|
|
||||||
export function ensureNodeRuntime() {
|
export function ensureNodeRuntime() {
|
||||||
if (isBunRuntime()) {
|
if (isBunRuntime()) {
|
||||||
@@ -42,24 +38,10 @@ export async function resolveActionClient(
|
|||||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
const client = await createMatrixClient({
|
const client = await createPreparedMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
auth,
|
||||||
userId: auth.userId,
|
timeoutMs: opts.timeoutMs,
|
||||||
accessToken: auth.accessToken,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
localTimeoutMs: opts.timeoutMs,
|
|
||||||
accountId,
|
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 };
|
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);
|
setMatrixRuntime(runtimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decrypts encrypted media when file payloads are present", async () => {
|
function makeEncryptedMediaFixture() {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
crypto: { decryptMedia },
|
crypto: { decryptMedia },
|
||||||
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
|
||||||
|
|
||||||
const file = {
|
const file = {
|
||||||
url: "mxc://example/file",
|
url: "mxc://example/file",
|
||||||
key: {
|
key: {
|
||||||
@@ -43,6 +41,11 @@ describe("downloadMatrixMedia", () => {
|
|||||||
hashes: { sha256: "hash" },
|
hashes: { sha256: "hash" },
|
||||||
v: "v2",
|
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({
|
const result = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
@@ -64,26 +67,7 @@ describe("downloadMatrixMedia", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
it("rejects encrypted media that exceeds maxBytes before decrypting", async () => {
|
||||||
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
const { decryptMedia, client, file } = makeEncryptedMediaFixture();
|
||||||
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
downloadMatrixMedia({
|
downloadMatrixMedia({
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
|
|||||||
import type { CoreConfig } from "../../types.js";
|
import type { CoreConfig } from "../../types.js";
|
||||||
import { getMatrixRuntime } from "../../runtime.js";
|
import { getMatrixRuntime } from "../../runtime.js";
|
||||||
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
|
import { getActiveMatrixClient, getAnyActiveMatrixClient } from "../active-client.js";
|
||||||
import {
|
import { createPreparedMatrixClient } from "../client-bootstrap.js";
|
||||||
createMatrixClient,
|
import { isBunRuntime, resolveMatrixAuth, resolveSharedMatrixClient } from "../client.js";
|
||||||
isBunRuntime,
|
|
||||||
resolveMatrixAuth,
|
|
||||||
resolveSharedMatrixClient,
|
|
||||||
} from "../client.js";
|
|
||||||
|
|
||||||
const getCore = () => getMatrixRuntime();
|
const getCore = () => getMatrixRuntime();
|
||||||
|
|
||||||
@@ -92,25 +88,10 @@ export async function resolveMatrixClient(opts: {
|
|||||||
return { client, stopOnDone: false };
|
return { client, stopOnDone: false };
|
||||||
}
|
}
|
||||||
const auth = await resolveMatrixAuth({ accountId });
|
const auth = await resolveMatrixAuth({ accountId });
|
||||||
const client = await createMatrixClient({
|
const client = await createPreparedMatrixClient({
|
||||||
homeserver: auth.homeserver,
|
auth,
|
||||||
userId: auth.userId,
|
timeoutMs: opts.timeoutMs,
|
||||||
accessToken: auth.accessToken,
|
|
||||||
encryption: auth.encryption,
|
|
||||||
localTimeoutMs: opts.timeoutMs,
|
|
||||||
accountId,
|
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 };
|
return { client, stopOnDone: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||||
import type WebSocket from "ws";
|
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
|
||||||
import { Buffer } from "node:buffer";
|
|
||||||
|
|
||||||
export { createDedupeCache } from "openclaw/plugin-sdk";
|
|
||||||
|
|
||||||
export type ResponsePrefixContext = {
|
export type ResponsePrefixContext = {
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -40,25 +37,6 @@ export function formatInboundFromLabel(params: {
|
|||||||
return `${directLabel} id:${directId}`;
|
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 {
|
function normalizeAgentId(value: string | undefined | null): string {
|
||||||
const trimmed = (value ?? "").trim();
|
const trimmed = (value ?? "").trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -6,23 +5,11 @@ import { beforeEach, describe, expect, it } from "vitest";
|
|||||||
import type { StoredConversationReference } from "./conversation-store.js";
|
import type { StoredConversationReference } from "./conversation-store.js";
|
||||||
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
import { createMSTeamsConversationStoreFs } from "./conversation-store-fs.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.js";
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
import { msteamsRuntimeStub } from "./test-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;
|
|
||||||
|
|
||||||
describe("msteams conversation store (fs)", () => {
|
describe("msteams conversation store (fs)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMSTeamsRuntime(runtimeStub);
|
setMSTeamsRuntime(msteamsRuntimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters and prunes expired entries (but keeps legacy ones)", async () => {
|
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 fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
|
import { buildMSTeamsPollCard, createMSTeamsPollStoreFs, extractMSTeamsPollVote } from "./polls.js";
|
||||||
import { setMSTeamsRuntime } from "./runtime.js";
|
import { setMSTeamsRuntime } from "./runtime.js";
|
||||||
|
import { msteamsRuntimeStub } from "./test-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;
|
|
||||||
|
|
||||||
describe("msteams polls", () => {
|
describe("msteams polls", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMSTeamsRuntime(runtimeStub);
|
setMSTeamsRuntime(msteamsRuntimeStub);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("builds poll cards with fallback text", () => {
|
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.
|
* 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)
|
// Send poll via proactive conversation (Adaptive Cards require direct activity send)
|
||||||
const baseRef = buildConversationReference(ref);
|
const messageId = await sendProactiveActivity({
|
||||||
const proactiveRef = {
|
adapter,
|
||||||
...baseRef,
|
appId,
|
||||||
activityId: undefined,
|
ref,
|
||||||
};
|
activity,
|
||||||
|
errorPrefix: "msteams poll send",
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
|
log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId });
|
||||||
|
|
||||||
@@ -469,27 +494,13 @@ export async function sendAdaptiveCardMSTeams(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Send card via proactive conversation
|
// Send card via proactive conversation
|
||||||
const baseRef = buildConversationReference(ref);
|
const messageId = await sendProactiveActivity({
|
||||||
const proactiveRef = {
|
adapter,
|
||||||
...baseRef,
|
appId,
|
||||||
activityId: undefined,
|
ref,
|
||||||
};
|
activity,
|
||||||
|
errorPrefix: "msteams card send",
|
||||||
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 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("sent adaptive card", { conversationId, messageId });
|
log.info("sent adaptive card", { conversationId, messageId });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk";
|
||||||
import { safeParseJson } from "openclaw/plugin-sdk";
|
|
||||||
import { withFileLock as withPathLock } from "./file-lock.js";
|
import { withFileLock as withPathLock } from "./file-lock.js";
|
||||||
|
|
||||||
const STORE_LOCK_OPTIONS = {
|
const STORE_LOCK_OPTIONS = {
|
||||||
@@ -19,31 +17,11 @@ export async function readJsonFile<T>(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
): Promise<{ value: T; exists: boolean }> {
|
): Promise<{ value: T; exists: boolean }> {
|
||||||
try {
|
return await readJsonFileWithFallback(filePath, fallback);
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||||
const dir = path.dirname(filePath);
|
await writeJsonFileAtomically(filePath, value);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureJsonFile(filePath: string, fallback: unknown) {
|
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 { describe, expect, it } from "vitest";
|
||||||
|
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||||
import { readNextcloudTalkWebhookBody } from "./monitor.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", () => {
|
describe("readNextcloudTalkWebhookBody", () => {
|
||||||
it("reads valid body within max bytes", async () => {
|
it("reads valid body within max bytes", async () => {
|
||||||
const req = createMockRequest(['{"type":"Create"}']);
|
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
||||||
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
||||||
expect(body).toBe('{"type":"Create"}');
|
expect(body).toBe('{"type":"Create"}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects when payload exceeds max bytes", async () => {
|
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");
|
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,6 +50,24 @@ export type MetricName =
|
|||||||
| DecryptMetricName
|
| DecryptMetricName
|
||||||
| MemoryMetricName;
|
| 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
|
// Metric Event
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -93,26 +111,7 @@ export interface MetricsSnapshot {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Relay stats by URL */
|
/** Relay stats by URL */
|
||||||
relays: Record<
|
relays: Record<string, RelayMetrics>;
|
||||||
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;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
/** Rate limiting stats */
|
/** Rate limiting stats */
|
||||||
rateLimiting: {
|
rateLimiting: {
|
||||||
@@ -174,26 +173,7 @@ export function createMetrics(onMetric?: OnMetricCallback): NostrMetrics {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Per-relay stats
|
// Per-relay stats
|
||||||
const relays = new Map<
|
const relays = new Map<string, RelayMetrics>();
|
||||||
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;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Rate limiting stats
|
// Rate limiting stats
|
||||||
const rateLimiting = {
|
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
|
// Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -342,20 +359,7 @@ describe("nostr-profile-http", () => {
|
|||||||
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
|
const req = createMockRequest("POST", "/api/channels/nostr/default/profile/import", {});
|
||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
mockSuccessfulProfileImport();
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
@@ -406,20 +410,7 @@ describe("nostr-profile-http", () => {
|
|||||||
});
|
});
|
||||||
const res = createMockResponse();
|
const res = createMockResponse();
|
||||||
|
|
||||||
vi.mocked(importProfileFromRelays).mockResolvedValue({
|
mockSuccessfulProfileImport();
|
||||||
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",
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler(req, res);
|
await handler(req, res);
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,27 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
|
|||||||
entries.delete(idToEvict);
|
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
|
// Prune expired entries
|
||||||
function pruneExpired(): void {
|
function pruneExpired(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -180,25 +201,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
|
|||||||
evictLRU();
|
evictLRU();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new entry at front
|
insertAtFront(id, now);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function has(id: string): boolean {
|
function has(id: string): boolean {
|
||||||
@@ -268,24 +271,7 @@ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker {
|
|||||||
for (let i = ids.length - 1; i >= 0; i--) {
|
for (let i = ids.length - 1; i >= 0; i--) {
|
||||||
const id = ids[i];
|
const id = ids[i];
|
||||||
if (!entries.has(id) && entries.size < maxEntries) {
|
if (!entries.has(id) && entries.size < maxEntries) {
|
||||||
const newEntry: Entry = {
|
insertAtFront(id, now);
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
extractSlackToolSend,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
getChatChannelMeta,
|
getChatChannelMeta,
|
||||||
|
handleSlackMessageAction,
|
||||||
listSlackMessageActions,
|
listSlackMessageActions,
|
||||||
listSlackAccountIds,
|
listSlackAccountIds,
|
||||||
listSlackDirectoryGroupsFromConfig,
|
listSlackDirectoryGroupsFromConfig,
|
||||||
@@ -15,8 +16,6 @@ import {
|
|||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
normalizeSlackMessagingTarget,
|
normalizeSlackMessagingTarget,
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
readNumberParam,
|
|
||||||
readStringParam,
|
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
resolveSlackAccount,
|
resolveSlackAccount,
|
||||||
resolveSlackReplyToMode,
|
resolveSlackReplyToMode,
|
||||||
@@ -234,151 +233,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
actions: {
|
actions: {
|
||||||
listActions: ({ cfg }) => listSlackMessageActions(cfg),
|
listActions: ({ cfg }) => listSlackMessageActions(cfg),
|
||||||
extractToolSend: ({ args }) => extractSlackToolSend(args),
|
extractToolSend: ({ args }) => extractSlackToolSend(args),
|
||||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
handleAction: async (ctx) =>
|
||||||
const resolveChannelId = () =>
|
await handleSlackMessageAction({
|
||||||
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true });
|
providerId: meta.id,
|
||||||
|
ctx,
|
||||||
if (action === "send") {
|
invoke: async (action, cfg, toolContext) =>
|
||||||
const to = readStringParam(params, "to", { required: true });
|
await getSlackRuntime().channel.slack.handleSlackAction(action, cfg, toolContext),
|
||||||
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}.`);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
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,
|
DEFAULT_ACCOUNT_ID,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
|
import { buildTlonAccountFields } from "./account-fields.js";
|
||||||
import { tlonChannelConfigSchema } from "./config-schema.js";
|
import { tlonChannelConfigSchema } from "./config-schema.js";
|
||||||
import { monitorTlonProvider } from "./monitor/index.js";
|
import { monitorTlonProvider } from "./monitor/index.js";
|
||||||
import { tlonOnboardingAdapter } from "./onboarding.js";
|
import { tlonOnboardingAdapter } from "./onboarding.js";
|
||||||
@@ -47,19 +48,7 @@ function applyTlonSetupConfig(params: {
|
|||||||
});
|
});
|
||||||
const base = namedConfig.channels?.tlon ?? {};
|
const base = namedConfig.channels?.tlon ?? {};
|
||||||
|
|
||||||
const payload = {
|
const payload = buildTlonAccountFields(input);
|
||||||
...(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 }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (useDefault) {
|
if (useDefault) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type WizardPrompter,
|
type WizardPrompter,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { TlonResolvedAccount } from "./types.js";
|
import type { TlonResolvedAccount } from "./types.js";
|
||||||
|
import { buildTlonAccountFields } from "./account-fields.js";
|
||||||
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
|
import { listTlonAccountIds, resolveTlonAccount } from "./types.js";
|
||||||
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
|
import { isBlockedUrbitHostname, validateUrbitBaseUrl } from "./urbit/base-url.js";
|
||||||
|
|
||||||
@@ -34,6 +35,11 @@ function applyAccountConfig(params: {
|
|||||||
const { cfg, accountId, input } = params;
|
const { cfg, accountId, input } = params;
|
||||||
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
const useDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
const base = cfg.channels?.tlon ?? {};
|
const base = cfg.channels?.tlon ?? {};
|
||||||
|
const nextValues = {
|
||||||
|
enabled: true,
|
||||||
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
...buildTlonAccountFields(input),
|
||||||
|
};
|
||||||
|
|
||||||
if (useDefault) {
|
if (useDefault) {
|
||||||
return {
|
return {
|
||||||
@@ -42,19 +48,7 @@ function applyAccountConfig(params: {
|
|||||||
...cfg.channels,
|
...cfg.channels,
|
||||||
tlon: {
|
tlon: {
|
||||||
...base,
|
...base,
|
||||||
enabled: true,
|
...nextValues,
|
||||||
...(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 }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -73,19 +67,7 @@ function applyAccountConfig(params: {
|
|||||||
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
...(base as { accounts?: Record<string, Record<string, unknown>> }).accounts?.[
|
||||||
accountId
|
accountId
|
||||||
],
|
],
|
||||||
enabled: true,
|
...nextValues,
|
||||||
...(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 }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
* - Abort signal handling
|
* - Abort signal handling
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { twitchOutbound } from "./outbound.js";
|
import { twitchOutbound } from "./outbound.js";
|
||||||
|
import {
|
||||||
|
BASE_TWITCH_TEST_ACCOUNT,
|
||||||
|
installTwitchTestHooks,
|
||||||
|
makeTwitchTestConfig,
|
||||||
|
} from "./test-fixtures.js";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("./config.js", () => ({
|
vi.mock("./config.js", () => ({
|
||||||
@@ -35,29 +39,12 @@ vi.mock("./utils/twitch.js", () => ({
|
|||||||
|
|
||||||
describe("outbound", () => {
|
describe("outbound", () => {
|
||||||
const mockAccount = {
|
const mockAccount = {
|
||||||
username: "testbot",
|
...BASE_TWITCH_TEST_ACCOUNT,
|
||||||
accessToken: "oauth:test123",
|
accessToken: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
|
||||||
channel: "#testchannel",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = makeTwitchTestConfig(mockAccount);
|
||||||
channels: {
|
installTwitchTestHooks();
|
||||||
twitch: {
|
|
||||||
accounts: {
|
|
||||||
default: mockAccount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpenClawConfig;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("metadata", () => {
|
describe("metadata", () => {
|
||||||
it("should have direct delivery mode", () => {
|
it("should have direct delivery mode", () => {
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
* - Registry integration
|
* - Registry integration
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { sendMessageTwitchInternal } from "./send.js";
|
import { sendMessageTwitchInternal } from "./send.js";
|
||||||
|
import {
|
||||||
|
BASE_TWITCH_TEST_ACCOUNT,
|
||||||
|
installTwitchTestHooks,
|
||||||
|
makeTwitchTestConfig,
|
||||||
|
} from "./test-fixtures.js";
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
vi.mock("./config.js", () => ({
|
vi.mock("./config.js", () => ({
|
||||||
@@ -43,29 +47,12 @@ describe("send", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const mockAccount = {
|
const mockAccount = {
|
||||||
username: "testbot",
|
...BASE_TWITCH_TEST_ACCOUNT,
|
||||||
token: "oauth:test123",
|
token: "oauth:test123",
|
||||||
clientId: "test-client-id",
|
|
||||||
channel: "#testchannel",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = makeTwitchTestConfig(mockAccount);
|
||||||
channels: {
|
installTwitchTestHooks();
|
||||||
twitch: {
|
|
||||||
accounts: {
|
|
||||||
default: mockAccount,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as OpenClawConfig;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("sendMessageTwitchInternal", () => {
|
describe("sendMessageTwitchInternal", () => {
|
||||||
it("should send a message successfully", async () => {
|
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");
|
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", () => {
|
describe("TelnyxProvider.verifyWebhook", () => {
|
||||||
it("fails closed when public key is missing and skipVerification is false", () => {
|
it("fails closed when public key is missing and skipVerification is false", () => {
|
||||||
const provider = new TelnyxProvider(
|
const provider = new TelnyxProvider(
|
||||||
@@ -63,59 +94,13 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
|||||||
|
|
||||||
const rawPublicKey = decodeBase64Url(jwk.x as string);
|
const rawPublicKey = decodeBase64Url(jwk.x as string);
|
||||||
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
|
const rawPublicKeyBase64 = rawPublicKey.toString("base64");
|
||||||
|
expectWebhookVerificationSucceeds({ publicKey: rawPublicKeyBase64, privateKey });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
|
it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
|
||||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||||
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
||||||
const spkiDerBase64 = spkiDer.toString("base64");
|
const spkiDerBase64 = spkiDer.toString("base64");
|
||||||
|
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { installCommonResolveTargetErrorCases } from "../../shared/resolve-target-test-helpers.js";
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk", () => ({
|
vi.mock("openclaw/plugin-sdk", () => ({
|
||||||
getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }),
|
getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }),
|
||||||
@@ -147,47 +148,8 @@ describe("whatsapp resolveTarget", () => {
|
|||||||
expect(result.error).toBeDefined();
|
expect(result.error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should error on normalization failure with allowlist (implicit mode)", () => {
|
installCommonResolveTargetErrorCases({
|
||||||
const result = resolveTarget({
|
resolveTarget,
|
||||||
to: "invalid-target",
|
implicitAllowFrom: ["5511999999999"],
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
createReplyPrefixOptions,
|
createReplyPrefixOptions,
|
||||||
normalizeWebhookPath,
|
|
||||||
readJsonBodyWithLimit,
|
readJsonBodyWithLimit,
|
||||||
|
registerWebhookTarget,
|
||||||
|
rejectNonPostWebhookRequest,
|
||||||
|
resolveSenderCommandAuthorization,
|
||||||
resolveWebhookPath,
|
resolveWebhookPath,
|
||||||
|
resolveWebhookTargets,
|
||||||
requestBodyErrorToText,
|
requestBodyErrorToText,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||||
@@ -83,36 +86,20 @@ type WebhookTarget = {
|
|||||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||||
|
|
||||||
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
||||||
const key = normalizeWebhookPath(target.path);
|
return registerWebhookTarget(webhookTargets, target).unregister;
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleZaloWebhookRequest(
|
export async function handleZaloWebhookRequest(
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||||
const path = normalizeWebhookPath(url.pathname);
|
if (!resolved) {
|
||||||
const targets = webhookTargets.get(path);
|
|
||||||
if (!targets || targets.length === 0) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const { targets } = resolved;
|
||||||
|
|
||||||
if (req.method !== "POST") {
|
if (rejectNonPostWebhookRequest(req, res)) {
|
||||||
res.statusCode = 405;
|
|
||||||
res.setHeader("Allow", "POST");
|
|
||||||
res.end("Method Not Allowed");
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,22 +389,20 @@ async function processMessageWithPipeline(params: {
|
|||||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
|
||||||
const storeAllowFrom =
|
cfg: config,
|
||||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
rawBody,
|
||||||
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
|
isGroup,
|
||||||
: [];
|
dmPolicy,
|
||||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
configuredAllowFrom: configAllowFrom,
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
senderId,
|
||||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
isSenderAllowed,
|
||||||
const commandAuthorized = shouldComputeAuth
|
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
|
||||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
shouldComputeCommandAuthorized: (body, cfg) =>
|
||||||
useAccessGroups,
|
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
||||||
authorizers: [
|
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
|
||||||
],
|
});
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import type { OpenClawConfig, MarkdownTableMode, RuntimeEnv } from "openclaw/plugin-sdk";
|
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 type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js";
|
||||||
import { getZalouserRuntime } from "./runtime.js";
|
import { getZalouserRuntime } from "./runtime.js";
|
||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
@@ -192,22 +197,20 @@ async function processMessage(
|
|||||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||||
const rawBody = content.trim();
|
const rawBody = content.trim();
|
||||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
|
const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
|
||||||
const storeAllowFrom =
|
cfg: config,
|
||||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
rawBody,
|
||||||
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
|
isGroup,
|
||||||
: [];
|
dmPolicy,
|
||||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
configuredAllowFrom: configAllowFrom,
|
||||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
senderId,
|
||||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
isSenderAllowed,
|
||||||
const commandAuthorized = shouldComputeAuth
|
readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalouser"),
|
||||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
shouldComputeCommandAuthorized: (body, cfg) =>
|
||||||
useAccessGroups,
|
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
||||||
authorizers: [
|
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
||||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
|
||||||
],
|
});
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!isGroup) {
|
if (!isGroup) {
|
||||||
if (dmPolicy === "disabled") {
|
if (dmPolicy === "disabled") {
|
||||||
|
|||||||
Reference in New Issue
Block a user