mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
perf: optimize bundled extension tests
This commit is contained in:
@@ -1527,7 +1527,9 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
|
||||
}
|
||||
const rawText = extractTextContent(typed.content);
|
||||
const text =
|
||||
role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText);
|
||||
role === "assistant"
|
||||
? stripRecalledContextNoise(rawText)
|
||||
: stripInjectedActiveMemoryPrefixOnly(rawText);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { z } from "zod";
|
||||
import type { GoogleChatAccountConfig } from "./types.config.js";
|
||||
|
||||
@@ -28,6 +27,14 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
||||
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
|
||||
@@ -9,7 +10,6 @@ const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
|
||||
const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
|
||||
const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
|
||||
const loadOutboundMediaFromUrl = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listEnabledGoogleChatAccounts,
|
||||
@@ -32,15 +32,6 @@ vi.mock("./targets.js", () => ({
|
||||
resolveGoogleChatOutboundSpace,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
|
||||
return {
|
||||
...actual,
|
||||
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>
|
||||
(loadOutboundMediaFromUrl as unknown as typeof actual.loadOutboundMediaFromUrl)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
|
||||
|
||||
describe("googlechat message actions", () => {
|
||||
@@ -161,11 +152,9 @@ describe("googlechat message actions", () => {
|
||||
config: { mediaMaxMb: 5 },
|
||||
});
|
||||
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
|
||||
loadOutboundMediaFromUrl.mockResolvedValue({
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
fileName: "local.txt",
|
||||
contentType: "text/plain",
|
||||
});
|
||||
const localRoot = "/tmp/googlechat-action-test";
|
||||
const localPath = path.join(localRoot, "local.md");
|
||||
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
|
||||
getGoogleChatRuntime.mockReturnValue({
|
||||
channel: {
|
||||
media: {
|
||||
@@ -187,23 +176,22 @@ describe("googlechat message actions", () => {
|
||||
action: "upload-file",
|
||||
params: {
|
||||
to: "spaces/BBB",
|
||||
path: "/tmp/local.txt",
|
||||
path: localPath,
|
||||
message: "notes",
|
||||
filename: "renamed.txt",
|
||||
},
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
mediaLocalRoots: [localRoot],
|
||||
mediaReadFile: readFile,
|
||||
} as never);
|
||||
|
||||
expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"/tmp/local.txt",
|
||||
expect.objectContaining({ mediaLocalRoots: ["/tmp"] }),
|
||||
);
|
||||
expect(readFile).toHaveBeenCalledWith(localPath);
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/BBB",
|
||||
filename: "renamed.txt",
|
||||
buffer: Buffer.from("local-bytes"),
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
createActionGate,
|
||||
extractToolSend,
|
||||
jsonResult,
|
||||
loadOutboundMediaFromUrl,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
createGoogleChatReaction,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { getGoogleChatAccessToken } from "./auth.js";
|
||||
import type { GoogleChatReaction } from "./types.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GoogleAuth, OAuth2Client } from "google-auth-library";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
|
||||
@@ -16,6 +15,10 @@ const verifyClient = new OAuth2Client();
|
||||
|
||||
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
|
||||
if (account.credentialsFile) {
|
||||
return `file:${account.credentialsFile}`;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
createAccountStatusSink,
|
||||
runPassiveAccountLifecycle,
|
||||
type OpenClawConfig,
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./channel.deps.runtime.js";
|
||||
} from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
|
||||
const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage } from "./api.js";
|
||||
import type { GoogleChatCoreRuntime } from "./monitor-types.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js";
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
@@ -28,42 +29,7 @@ function normalizeUserId(raw?: string | null): string {
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
function isEmailLike(value: string): boolean {
|
||||
// Keep this intentionally loose; allowlists are user-provided config.
|
||||
return value.includes("@");
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSenderId = normalizeUserId(senderId);
|
||||
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(entry);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
|
||||
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
if (withoutPrefix.startsWith("users/")) {
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
export { isSenderAllowed } from "./sender-allow.js";
|
||||
|
||||
type GoogleChatGroupEntry = {
|
||||
requireMention?: boolean;
|
||||
|
||||
55
extensions/googlechat/src/monitor-routing.ts
Normal file
55
extensions/googlechat/src/monitor-routing.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
|
||||
import type { GoogleChatEvent } from "./types.js";
|
||||
|
||||
type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise<void>;
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
|
||||
let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {};
|
||||
|
||||
export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void {
|
||||
processGoogleChatEvent = processEvent;
|
||||
}
|
||||
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
},
|
||||
});
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
return registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: {
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "googlechat",
|
||||
source: "googlechat-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
},
|
||||
}).unregister;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
return await googleChatWebhookRequestHandler(req, res);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import type { GoogleChatAudienceType } from "./auth.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { getGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
export type GoogleChatRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
|
||||
@@ -8,8 +8,11 @@ const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn());
|
||||
const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn());
|
||||
const verifyGoogleChatRequest = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({
|
||||
readJsonWebhookBodyOrReject,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
}));
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
readJsonWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrReject,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
type WebhookInFlightLimiter,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/webhook-targets";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import type { WebhookTarget } from "./monitor-types.js";
|
||||
import type {
|
||||
@@ -15,6 +14,10 @@ import type {
|
||||
GoogleChatUser,
|
||||
} from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function extractBearerToken(header: unknown): string {
|
||||
const authHeader = Array.isArray(header)
|
||||
? typeof header[0] === "string"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
@@ -7,8 +6,6 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
createChannelReplyPipeline,
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveWebhookPath,
|
||||
} from "../runtime-api.js";
|
||||
@@ -21,27 +18,27 @@ import {
|
||||
} from "./api.js";
|
||||
import { type GoogleChatAudienceType } from "./auth.js";
|
||||
import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
setGoogleChatWebhookEventProcessor,
|
||||
} from "./monitor-routing.js";
|
||||
import type {
|
||||
GoogleChatCoreRuntime,
|
||||
GoogleChatMonitorOptions,
|
||||
GoogleChatRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-types.js";
|
||||
import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js";
|
||||
import { getGoogleChatRuntime } from "./runtime.js";
|
||||
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
|
||||
export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js";
|
||||
export {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
export { isSenderAllowed };
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
const webhookInFlightLimiter = createWebhookInFlightLimiter();
|
||||
const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({
|
||||
webhookTargets,
|
||||
webhookInFlightLimiter,
|
||||
processEvent: async (event, target) => {
|
||||
await processGoogleChatEvent(event, target);
|
||||
},
|
||||
});
|
||||
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
|
||||
|
||||
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
@@ -49,29 +46,6 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv,
|
||||
}
|
||||
}
|
||||
|
||||
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
|
||||
return registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: {
|
||||
auth: "plugin",
|
||||
match: "exact",
|
||||
pluginId: "googlechat",
|
||||
source: "googlechat-webhook",
|
||||
accountId: target.account.accountId,
|
||||
log: target.runtime.log,
|
||||
handler: async (req, res) => {
|
||||
const handled = await handleGoogleChatWebhookRequest(req, res);
|
||||
if (!handled && !res.headersSent) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("Not Found");
|
||||
}
|
||||
},
|
||||
},
|
||||
}).unregister;
|
||||
}
|
||||
|
||||
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(value);
|
||||
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
|
||||
@@ -87,13 +61,6 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function handleGoogleChatWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
return await googleChatWebhookRequestHandler(req, res);
|
||||
}
|
||||
|
||||
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
|
||||
const eventType = event.type ?? (event as { eventType?: string }).eventType;
|
||||
if (eventType !== "MESSAGE") {
|
||||
|
||||
@@ -7,7 +7,10 @@ import { createMockServerResponse } from "../../../test/helpers/plugins/mock-htt
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { verifyGoogleChatRequest } from "./auth.js";
|
||||
import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js";
|
||||
import {
|
||||
handleGoogleChatWebhookRequest,
|
||||
registerGoogleChatWebhookTarget,
|
||||
} from "./monitor-routing.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
verifyGoogleChatRequest: vi.fn(),
|
||||
|
||||
48
extensions/googlechat/src/sender-allow.ts
Normal file
48
extensions/googlechat/src/sender-allow.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeUserId(raw?: string | null): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, ""));
|
||||
}
|
||||
|
||||
function isEmailLike(value: string): boolean {
|
||||
// Keep this intentionally loose; allowlists are user-provided config.
|
||||
return value.includes("@");
|
||||
}
|
||||
|
||||
export function isSenderAllowed(
|
||||
senderId: string,
|
||||
senderEmail: string | undefined,
|
||||
allowFrom: string[],
|
||||
allowNameMatching = false,
|
||||
) {
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
const normalizedSenderId = normalizeUserId(senderId);
|
||||
const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? "");
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(entry);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accept `googlechat:<id>` but treat `users/...` as an *ID* only (deprecated `users/<email>`).
|
||||
const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
if (withoutPrefix.startsWith("users/")) {
|
||||
return normalizeUserId(withoutPrefix) === normalizedSenderId;
|
||||
}
|
||||
|
||||
// Raw email allowlist entries are a break-glass override.
|
||||
if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) {
|
||||
return withoutPrefix === normalizedEmail;
|
||||
}
|
||||
|
||||
return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const channel = "googlechat" as const;
|
||||
@@ -23,6 +19,24 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const USE_ENV_FLAG = "__googlechatUseEnv";
|
||||
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeStringifiedOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return normalizeOptionalString(String(value));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const promptAllowFrom = createPromptParsedAllowFromForAccount({
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
|
||||
|
||||
@@ -11,14 +11,14 @@ import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import {
|
||||
listGoogleChatAccountIds,
|
||||
resolveGoogleChatAccount,
|
||||
resolveDefaultGoogleChatAccountId,
|
||||
} from "./channel.deps.runtime.js";
|
||||
type ResolvedGoogleChatAccount,
|
||||
} from "./accounts.js";
|
||||
import { startGoogleChatGatewayAccount } from "./gateway.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
import { googlechatSetupWizard } from "./setup-surface.js";
|
||||
@@ -27,13 +27,16 @@ const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
googleChatChannelRuntime: {
|
||||
resolveGoogleChatWebhookPath: ({
|
||||
account,
|
||||
}: {
|
||||
account: { config: { webhookPath?: string } };
|
||||
}) => account.config.webhookPath ?? "/googlechat",
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const googlechatSetupPlugin = {
|
||||
id: "googlechat",
|
||||
@@ -65,6 +68,16 @@ function buildAccount(): ResolvedGoogleChatAccount {
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForGoogleChatMonitorStarted() {
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
if (hoisted.startGoogleChatMonitor.mock.calls.length === 1) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
describe("googlechat setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -356,7 +369,7 @@ describe("googlechat setup", () => {
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
waitForStarted: waitForGoogleChatMonitorStarted,
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
import { isSenderAllowed } from "./sender-allow.js";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
@@ -18,10 +18,8 @@ const mocks = vi.hoisted(() => ({
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => {
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { findGoogleChatDirectMessage } from "./api.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetLmstudioPreloadCooldownForTest,
|
||||
wrapLmstudioInferencePreload,
|
||||
} from "./stream.js";
|
||||
import { __resetLmstudioPreloadCooldownForTest, wrapLmstudioInferencePreload } from "./stream.js";
|
||||
|
||||
const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn());
|
||||
const resolveLmstudioProviderHeadersMock = vi.hoisted(() =>
|
||||
|
||||
@@ -241,10 +241,7 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
|
||||
};
|
||||
const cause = annotated.cause ?? error;
|
||||
const failures = annotated.consecutiveFailures ?? 1;
|
||||
const cooldownSec = Math.max(
|
||||
0,
|
||||
Math.round((annotated.cooldownMs ?? 0) / 1000),
|
||||
);
|
||||
const cooldownSec = Math.max(0, Math.round((annotated.cooldownMs ?? 0) / 1000));
|
||||
log.warn(
|
||||
`LM Studio inference preload failed for "${modelKey}" (${failures} consecutive failure${
|
||||
failures === 1 ? "" : "s"
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveAccountWithDefaultFallback,
|
||||
} from "../runtime-api.js";
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function isTruthyEnvValue(value?: string): boolean {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(value);
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-core";
|
||||
export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
listNextcloudTalkAccountIds,
|
||||
resolveDefaultNextcloudTalkAccountId,
|
||||
@@ -14,6 +13,10 @@ import {
|
||||
} from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter<
|
||||
ResolvedNextcloudTalkAccount,
|
||||
ResolvedNextcloudTalkAccount,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
@@ -16,39 +16,9 @@ import {
|
||||
verifyNextcloudTalkSignature,
|
||||
} from "./signature.js";
|
||||
|
||||
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
|
||||
const readFileSync = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => {
|
||||
return vi
|
||||
.importActual<typeof import("../runtime-api.js")>("../runtime-api.js")
|
||||
.then((actual) => ({
|
||||
...actual,
|
||||
fetchWithSsrFGuard,
|
||||
}));
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => {
|
||||
return vi.importActual<typeof import("node:fs")>("node:fs").then((actual) => ({
|
||||
...actual,
|
||||
readFileSync,
|
||||
}));
|
||||
});
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
let resolveNextcloudTalkRoomKind: typeof import("./room-info.js").resolveNextcloudTalkRoomKind;
|
||||
let resetNextcloudTalkRoomCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const roomInfo = await import("./room-info.js");
|
||||
resolveNextcloudTalkRoomKind = roomInfo.resolveNextcloudTalkRoomKind;
|
||||
resetNextcloudTalkRoomCache = roomInfo.__testing.resetRoomCache;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
fetchWithSsrFGuard.mockReset();
|
||||
readFileSync.mockReset();
|
||||
resetNextcloudTalkRoomCache();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
@@ -161,7 +131,7 @@ describe("nextcloud talk core", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("persists replay decisions across guard instances", async () => {
|
||||
it("persists replay decisions across guard instances and scopes account namespaces", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
|
||||
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
@@ -182,34 +152,20 @@ describe("nextcloud talk core", () => {
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
const otherAccountFirstAttempt = await secondGuard.shouldProcessMessage({
|
||||
accountId: "account-b",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(firstAttempt).toBe(true);
|
||||
expect(replayAttempt).toBe(false);
|
||||
expect(restartReplayAttempt).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes replay state by account namespace", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
|
||||
const accountAFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
const accountBFirst = await guard.shouldProcessMessage({
|
||||
accountId: "account-b",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-9",
|
||||
});
|
||||
|
||||
expect(accountAFirst).toBe(true);
|
||||
expect(accountBFirst).toBe(true);
|
||||
expect(otherAccountFirstAttempt).toBe(true);
|
||||
});
|
||||
|
||||
it("releases in-flight replay claims when processing fails", async () => {
|
||||
const stateDir = await makeTempDir();
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
const guard = createNextcloudTalkReplayGuard({});
|
||||
|
||||
const firstClaim = await guard.claimMessage({
|
||||
accountId: "account-a",
|
||||
@@ -345,91 +301,4 @@ describe("nextcloud talk core", () => {
|
||||
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves direct rooms from the room info endpoint", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ocs: {
|
||||
data: {
|
||||
type: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const kind = await resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-direct",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-direct",
|
||||
});
|
||||
|
||||
expect(kind).toBe("direct");
|
||||
expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct",
|
||||
auditContext: "nextcloud-talk.room-info",
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads the api password from a file and logs non-ok room info responses", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn();
|
||||
readFileSync.mockReturnValue("file-secret\n");
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: {
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const kind = await resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-group",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPasswordFile: "/tmp/nextcloud-secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-group",
|
||||
runtime: { log, error, exit },
|
||||
});
|
||||
|
||||
expect(kind).toBeUndefined();
|
||||
expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8");
|
||||
expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns undefined from room info without credentials or base url", async () => {
|
||||
await expect(
|
||||
resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-missing",
|
||||
baseUrl: "",
|
||||
config: {},
|
||||
} as never,
|
||||
roomToken: "room-missing",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import {
|
||||
NextcloudTalkRetryableWebhookError,
|
||||
@@ -14,17 +11,6 @@ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("readNextcloudTalkWebhookBody", () => {
|
||||
it("reads valid body within max bytes", async () => {
|
||||
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
||||
@@ -89,22 +75,35 @@ describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
||||
});
|
||||
|
||||
describe("createNextcloudTalkWebhookServer replay handling", () => {
|
||||
function createReplayAwareProcessMessage(params: {
|
||||
stateDir: string;
|
||||
function createReplayGuardedProcess(params: {
|
||||
stateDir?: string;
|
||||
accountId?: string;
|
||||
handleMessage: (message: NextcloudTalkInboundMessage) => Promise<void>;
|
||||
handleMessage: () => Promise<void>;
|
||||
}) {
|
||||
const replayGuard = createNextcloudTalkReplayGuard({
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
const replayGuard = createNextcloudTalkReplayGuard(
|
||||
params.stateDir ? { stateDir: params.stateDir } : {},
|
||||
);
|
||||
|
||||
return async (message: NextcloudTalkInboundMessage): Promise<void> => {
|
||||
await processNextcloudTalkReplayGuardedMessage({
|
||||
return (message: NextcloudTalkInboundMessage) =>
|
||||
processNextcloudTalkReplayGuardedMessage({
|
||||
replayGuard,
|
||||
accountId: params.accountId ?? "acct",
|
||||
message,
|
||||
handleMessage: () => params.handleMessage(message),
|
||||
handleMessage: params.handleMessage,
|
||||
});
|
||||
}
|
||||
|
||||
function buildInboundMessage(): NextcloudTalkInboundMessage {
|
||||
return {
|
||||
messageId: "msg-1",
|
||||
roomToken: "room-token",
|
||||
roomName: "Room 1",
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "hello",
|
||||
mediaType: "text/plain",
|
||||
timestamp: 1_700_000_000_000,
|
||||
isGroupChat: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,91 +142,41 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
|
||||
expect(onMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a retry after processMessage fails before replay commit", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
||||
tempDirs.push(stateDir);
|
||||
it("allows a retry after replay-guarded processing fails before commit", async () => {
|
||||
let attempts = 0;
|
||||
const onError = vi.fn();
|
||||
const handleMessage = vi.fn(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure");
|
||||
}
|
||||
});
|
||||
const processMessage = vi.fn(
|
||||
createReplayAwareProcessMessage({
|
||||
stateDir,
|
||||
handleMessage,
|
||||
}),
|
||||
);
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-replay-process",
|
||||
processMessage,
|
||||
onMessage: vi.fn(),
|
||||
onError,
|
||||
const processMessage = createReplayGuardedProcess({
|
||||
handleMessage,
|
||||
});
|
||||
const message = buildInboundMessage();
|
||||
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure");
|
||||
await expect(processMessage(message)).resolves.toBe("processed");
|
||||
|
||||
const first = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
|
||||
const second = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
await vi.waitFor(() => expect(handleMessage).toHaveBeenCalledTimes(2));
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(handleMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps replay committed after a non-retryable processMessage failure", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
||||
tempDirs.push(stateDir);
|
||||
const onError = vi.fn();
|
||||
it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => {
|
||||
const visibleSideEffect = vi.fn();
|
||||
const handleMessage = vi.fn(async () => {
|
||||
visibleSideEffect();
|
||||
throw new Error("post-send failure");
|
||||
});
|
||||
const processMessage = vi.fn(
|
||||
createReplayAwareProcessMessage({
|
||||
stateDir,
|
||||
handleMessage,
|
||||
}),
|
||||
);
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-replay-post-send",
|
||||
processMessage,
|
||||
onMessage: vi.fn(),
|
||||
onError,
|
||||
const processMessage = createReplayGuardedProcess({
|
||||
handleMessage,
|
||||
});
|
||||
const message = buildInboundMessage();
|
||||
|
||||
const { body, headers } = createSignedCreateMessageRequest();
|
||||
await expect(processMessage(message)).rejects.toThrow("post-send failure");
|
||||
await expect(processMessage(message)).resolves.toBe("duplicate");
|
||||
|
||||
const first = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1));
|
||||
const second = await fetch(harness.webhookUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(handleMessage).toHaveBeenCalledTimes(1);
|
||||
expect(visibleSideEffect).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,7 +222,7 @@ describe("createNextcloudTalkWebhookServer payload validation", () => {
|
||||
|
||||
describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
|
||||
it("rate limits repeated invalid signature attempts from the same source", async () => {
|
||||
const maxRequests = 2;
|
||||
const maxRequests = 1;
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-rate-limit",
|
||||
authRateLimit: { maxRequests },
|
||||
@@ -307,7 +256,7 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
|
||||
});
|
||||
|
||||
it("does not rate limit valid signed webhook bursts from the same source", async () => {
|
||||
const maxRequests = 2;
|
||||
const maxRequests = 1;
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-auth-rate-limit-valid",
|
||||
authRateLimit: { maxRequests },
|
||||
|
||||
@@ -2,12 +2,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import {
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
createAuthRateLimiter,
|
||||
isRequestBodyLimitError,
|
||||
readRequestBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { z } from "zod";
|
||||
import { createAuthRateLimiter } from "./api.js";
|
||||
import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
||||
import type {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
@@ -29,7 +27,7 @@ export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined
|
||||
|
||||
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
||||
const normalized = stripNextcloudTalkTargetPrefix(raw);
|
||||
return normalized ? normalizeLowercaseStringOrEmpty(`nextcloud-talk:${normalized}`) : undefined;
|
||||
return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
evaluateMatchedGroupAccessForPolicy,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "../runtime-api.js";
|
||||
import type { NextcloudTalkRoomConfig } from "./types.js";
|
||||
|
||||
function normalizeAllowEntry(raw: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""));
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeNextcloudTalkAllowlist(
|
||||
@@ -165,19 +166,15 @@ export function resolveNextcloudTalkMentionGate(params: {
|
||||
hasControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
|
||||
const result = resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: true,
|
||||
wasMentioned: params.wasMentioned,
|
||||
implicitMentionKinds: [],
|
||||
},
|
||||
policy: {
|
||||
isGroup: params.isGroup,
|
||||
requireMention: params.requireMention,
|
||||
allowTextCommands: params.allowTextCommands,
|
||||
hasControlCommand: params.hasControlCommand,
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
},
|
||||
});
|
||||
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
|
||||
const shouldBypassMention =
|
||||
params.isGroup &&
|
||||
params.requireMention &&
|
||||
!params.wasMentioned &&
|
||||
params.allowTextCommands &&
|
||||
params.commandAuthorized &&
|
||||
params.hasControlCommand;
|
||||
return {
|
||||
shouldBypassMention,
|
||||
shouldSkip: params.requireMention && !params.wasMentioned && !shouldBypassMention,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function buildReplayKey(params: { roomToken: string; messageId: string }): strin
|
||||
}
|
||||
|
||||
export type NextcloudTalkReplayGuardOptions = {
|
||||
stateDir: string;
|
||||
stateDir?: string;
|
||||
ttlMs?: number;
|
||||
memoryMaxSize?: number;
|
||||
fileMaxEntries?: number;
|
||||
@@ -57,15 +57,27 @@ export type NextcloudTalkReplayGuard = {
|
||||
export function createNextcloudTalkReplayGuard(
|
||||
options: NextcloudTalkReplayGuardOptions,
|
||||
): NextcloudTalkReplayGuard {
|
||||
const stateDir = options.stateDir.trim();
|
||||
const dedupe = createClaimableDedupe({
|
||||
const stateDir = options.stateDir?.trim();
|
||||
const baseOptions = {
|
||||
ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
|
||||
memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
|
||||
fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
|
||||
resolveFilePath: (namespace) =>
|
||||
path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
|
||||
onDiskError: options.onDiskError,
|
||||
});
|
||||
};
|
||||
const dedupe = createClaimableDedupe(
|
||||
stateDir
|
||||
? {
|
||||
...baseOptions,
|
||||
fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
|
||||
resolveFilePath: (namespace) =>
|
||||
path.join(
|
||||
stateDir,
|
||||
"nextcloud-talk",
|
||||
"replay-dedupe",
|
||||
`${sanitizeSegment(namespace)}.json`,
|
||||
),
|
||||
onDiskError: options.onDiskError,
|
||||
}
|
||||
: baseOptions,
|
||||
);
|
||||
|
||||
return {
|
||||
claimMessage: async ({ accountId, roomToken, messageId }) => {
|
||||
|
||||
116
extensions/nextcloud-talk/src/room-info.test.ts
Normal file
116
extensions/nextcloud-talk/src/room-info.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveNextcloudTalkRoomKind, __testing } from "./room-info.js";
|
||||
|
||||
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
|
||||
const readFileSync = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../runtime-api.js", () => {
|
||||
return vi
|
||||
.importActual<typeof import("../runtime-api.js")>("../runtime-api.js")
|
||||
.then((actual) => ({
|
||||
...actual,
|
||||
fetchWithSsrFGuard,
|
||||
}));
|
||||
});
|
||||
|
||||
vi.mock("node:fs", () => {
|
||||
return vi.importActual<typeof import("node:fs")>("node:fs").then((actual) => ({
|
||||
...actual,
|
||||
readFileSync,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuard.mockReset();
|
||||
readFileSync.mockReset();
|
||||
__testing.resetRoomCache();
|
||||
});
|
||||
|
||||
describe("nextcloud talk room info", () => {
|
||||
it("resolves direct rooms from the room info endpoint", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
ocs: {
|
||||
data: {
|
||||
type: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const kind = await resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-direct",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-direct",
|
||||
});
|
||||
|
||||
expect(kind).toBe("direct");
|
||||
expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct",
|
||||
auditContext: "nextcloud-talk.room-info",
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reads the api password from a file and logs non-ok room info responses", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn();
|
||||
readFileSync.mockReturnValue("file-secret\n");
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: {
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({}),
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
const kind = await resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-group",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPasswordFile: "/tmp/nextcloud-secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-group",
|
||||
runtime: { log, error, exit },
|
||||
});
|
||||
|
||||
expect(kind).toBeUndefined();
|
||||
expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8");
|
||||
expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns undefined from room info without credentials or base url", async () => {
|
||||
await expect(
|
||||
resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-missing",
|
||||
baseUrl: "",
|
||||
config: {},
|
||||
} as never,
|
||||
roomToken: "room-missing",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,22 @@
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
||||
|
||||
type NextcloudTalkOutboundSessionRouteParams = {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export function resolveNextcloudTalkOutboundSessionRoute(
|
||||
params: ChannelOutboundSessionRouteParams,
|
||||
params: NextcloudTalkOutboundSessionRouteParams,
|
||||
) {
|
||||
const roomId = stripNextcloudTalkTargetPrefix(params.target);
|
||||
if (!roomId) {
|
||||
return null;
|
||||
}
|
||||
return buildChannelOutboundSessionRoute({
|
||||
const baseSessionKey = buildOutboundBaseSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nextcloud-talk",
|
||||
@@ -20,8 +25,16 @@ export function resolveNextcloudTalkOutboundSessionRoute(
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
},
|
||||
chatType: "group",
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer: {
|
||||
kind: "group" as const,
|
||||
id: roomId,
|
||||
},
|
||||
chatType: "group" as const,
|
||||
from: `nextcloud-talk:room:${roomId}`,
|
||||
to: `nextcloud-talk:${roomId}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
patchScopedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createSetupInputPresenceValidator,
|
||||
mergeAllowFromEntries,
|
||||
@@ -10,8 +14,6 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { applyAccountNameToChannelSection, patchScopedAccountConfig } from "../runtime-api.js";
|
||||
import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
@@ -24,6 +26,10 @@ type NextcloudSetupInput = ChannelSetupInput & {
|
||||
};
|
||||
type NextcloudTalkSection = NonNullable<CoreConfig["channels"]>["nextcloud-talk"];
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function addWildcardAllowFrom(allowFrom?: Array<string | number> | null): string[] {
|
||||
return mergeAllowFromEntries(allowFrom, ["*"]);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
setSetupChannelEnabled,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import {
|
||||
clearNextcloudTalkAccountFields,
|
||||
@@ -21,6 +20,14 @@ import type { CoreConfig } from "./types.js";
|
||||
const channel = "nextcloud-talk" as const;
|
||||
const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
stepOrder: "text-first",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { NextcloudTalkWebhookHeaders } from "./types.js";
|
||||
|
||||
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
||||
const RANDOM_HEADER = "x-nextcloud-talk-random";
|
||||
const BACKEND_HEADER = "x-nextcloud-talk-backend";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the HMAC-SHA256 signature of an incoming webhook request.
|
||||
* Signature is calculated as: HMAC-SHA256(random + body, secret)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
export { buildChannelConfigSchema, formatPairingApproveHint } from "openclaw/plugin-sdk/core";
|
||||
export type { ChannelOutboundAdapter, ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
export {
|
||||
collectStatusIssuesFromLastError,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export {
|
||||
createPreCryptoDirectDmAuthorizer,
|
||||
dispatchInboundDirectDmWithRuntime,
|
||||
resolveInboundDirectDmAccessWithRuntime,
|
||||
} from "openclaw/plugin-sdk/direct-dm";
|
||||
} from "openclaw/plugin-sdk/direct-dm-access";
|
||||
|
||||
@@ -17,9 +17,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-key-utils.js", () => ({
|
||||
getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"),
|
||||
normalizePubkey: mocks.normalizePubkey,
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
function createMockBus() {
|
||||
|
||||
@@ -13,9 +13,12 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("./nostr-bus.js", () => ({
|
||||
DEFAULT_RELAYS: ["wss://relay.example.com"],
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
vi.mock("./nostr-key-utils.js", () => ({
|
||||
getPublicKeyFromPrivate: vi.fn(() => "pubkey"),
|
||||
normalizePubkey: mocks.normalizePubkey,
|
||||
startNostrBus: mocks.startNostrBus,
|
||||
}));
|
||||
|
||||
function createCfg() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { nostrSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
TEST_HEX_PRIVATE_KEY,
|
||||
TEST_SETUP_RELAY_URLS,
|
||||
buildResolvedNostrAccount,
|
||||
createConfiguredNostrCfg,
|
||||
} from "./test-fixtures.js";
|
||||
import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
|
||||
@@ -226,7 +227,9 @@ describe("nostrPlugin", () => {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [` nostr:${TEST_HEX_PRIVATE_KEY} `],
|
||||
});
|
||||
const account = nostrTestPlugin.config.resolveAccount(cfg, "default");
|
||||
const account = buildResolvedNostrAccount({
|
||||
config: cfg.channels.nostr,
|
||||
});
|
||||
|
||||
const result = resolveDmPolicy({ cfg, account });
|
||||
if (!result) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
nostrPairingTextAdapter,
|
||||
startNostrGatewayAccount,
|
||||
} from "./gateway.js";
|
||||
import { normalizePubkey } from "./nostr-bus.js";
|
||||
import { normalizePubkey } from "./nostr-key-utils.js";
|
||||
import type { ProfilePublishResult } from "./nostr-profile.js";
|
||||
import { resolveNostrOutboundSessionRoute } from "./session-route.js";
|
||||
import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js";
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createPreCryptoDirectDmAuthorizer,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
dispatchInboundDirectDmWithRuntime,
|
||||
type ChannelOutboundAdapter,
|
||||
resolveInboundDirectDmAccessWithRuntime,
|
||||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import type { MetricEvent, MetricsSnapshot } from "./metrics.js";
|
||||
import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
||||
import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js";
|
||||
import { normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { getNostrRuntime } from "./runtime.js";
|
||||
import { resolveDefaultNostrAccountId, type ResolvedNostrAccount } from "./types.js";
|
||||
|
||||
@@ -148,6 +148,7 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dispatchInboundDirectDmWithRuntime } = await import("./inbound-direct-dm-runtime.js");
|
||||
await dispatchInboundDirectDmWithRuntime({
|
||||
cfg: ctx.cfg,
|
||||
runtime,
|
||||
|
||||
1
extensions/nostr/src/inbound-direct-dm-runtime.ts
Normal file
1
extensions/nostr/src/inbound-direct-dm-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMetrics, type MetricName } from "./metrics.js";
|
||||
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js";
|
||||
import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { createSeenTracker } from "./seen-tracker.js";
|
||||
import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js";
|
||||
|
||||
@@ -26,90 +26,31 @@ function createCollectingMetrics() {
|
||||
|
||||
describe("validatePrivateKey fuzz", () => {
|
||||
describe("type confusion", () => {
|
||||
it("rejects null input", () => {
|
||||
expect(() => validatePrivateKey(null as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects undefined input", () => {
|
||||
expect(() => validatePrivateKey(undefined as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects number input", () => {
|
||||
expect(() => validatePrivateKey(123 as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects boolean input", () => {
|
||||
expect(() => validatePrivateKey(true as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects object input", () => {
|
||||
expect(() => validatePrivateKey({} as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects array input", () => {
|
||||
expect(() => validatePrivateKey([] as unknown as string)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects function input", () => {
|
||||
expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow();
|
||||
it("rejects non-string input", () => {
|
||||
for (const value of [null, undefined, 123, true, {}, [], () => {}]) {
|
||||
expect(() => validatePrivateKey(value as unknown as string)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("unicode attacks", () => {
|
||||
it("rejects unicode lookalike characters", () => {
|
||||
// Using zero-width characters
|
||||
const withZeroWidth =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf";
|
||||
expect(() => validatePrivateKey(withZeroWidth)).toThrow();
|
||||
});
|
||||
it("rejects unicode and control-character attacks", () => {
|
||||
const invalidKeys = [
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf",
|
||||
`\u202E${TEST_HEX_PRIVATE_KEY}`,
|
||||
"0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf",
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff",
|
||||
];
|
||||
|
||||
it("rejects RTL override", () => {
|
||||
const withRtl = `\u202E${TEST_HEX_PRIVATE_KEY}`;
|
||||
expect(() => validatePrivateKey(withRtl)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects homoglyph 'a' (Cyrillic а)", () => {
|
||||
// Using Cyrillic 'а' (U+0430) instead of Latin 'a'
|
||||
const withCyrillicA = "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
expect(() => validatePrivateKey(withCyrillicA)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects emoji", () => {
|
||||
const withEmoji = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀";
|
||||
expect(() => validatePrivateKey(withEmoji)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects combining characters", () => {
|
||||
// 'a' followed by combining acute accent
|
||||
const withCombining = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301";
|
||||
expect(() => validatePrivateKey(withCombining)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("injection attempts", () => {
|
||||
it("rejects null byte injection", () => {
|
||||
const withNullByte = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f";
|
||||
expect(() => validatePrivateKey(withNullByte)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects newline injection", () => {
|
||||
const withNewline = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf";
|
||||
expect(() => validatePrivateKey(withNewline)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects carriage return injection", () => {
|
||||
const withCR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf";
|
||||
expect(() => validatePrivateKey(withCR)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects tab injection", () => {
|
||||
const withTab = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf";
|
||||
expect(() => validatePrivateKey(withTab)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects form feed injection", () => {
|
||||
const withFormFeed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff";
|
||||
expect(() => validatePrivateKey(withFormFeed)).toThrow();
|
||||
for (const key of invalidKeys) {
|
||||
expect(() => validatePrivateKey(key)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,34 +95,18 @@ describe("validatePrivateKey fuzz", () => {
|
||||
|
||||
describe("isValidPubkey fuzz", () => {
|
||||
describe("type confusion", () => {
|
||||
it("handles null gracefully", () => {
|
||||
expect(isValidPubkey(null as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles undefined gracefully", () => {
|
||||
expect(isValidPubkey(undefined as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles number gracefully", () => {
|
||||
expect(isValidPubkey(123 as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles object gracefully", () => {
|
||||
expect(isValidPubkey({} as unknown as string)).toBe(false);
|
||||
it("handles non-string input gracefully", () => {
|
||||
for (const value of [null, undefined, 123, {}]) {
|
||||
expect(isValidPubkey(value as unknown as string)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("malicious inputs", () => {
|
||||
it("rejects __proto__ key", () => {
|
||||
expect(isValidPubkey("__proto__")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects constructor key", () => {
|
||||
expect(isValidPubkey("constructor")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects toString key", () => {
|
||||
expect(isValidPubkey("toString")).toBe(false);
|
||||
it("rejects prototype property names", () => {
|
||||
for (const value of ["__proto__", "constructor", "toString"]) {
|
||||
expect(isValidPubkey(value)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -192,16 +117,10 @@ describe("isValidPubkey fuzz", () => {
|
||||
|
||||
describe("normalizePubkey fuzz", () => {
|
||||
describe("prototype pollution attempts", () => {
|
||||
it("throws for __proto__", () => {
|
||||
expect(() => normalizePubkey("__proto__")).toThrow();
|
||||
});
|
||||
|
||||
it("throws for constructor", () => {
|
||||
expect(() => normalizePubkey("constructor")).toThrow();
|
||||
});
|
||||
|
||||
it("throws for prototype", () => {
|
||||
expect(() => normalizePubkey("prototype")).toThrow();
|
||||
it("throws for prototype property names", () => {
|
||||
for (const value of ["__proto__", "constructor", "prototype"]) {
|
||||
expect(() => normalizePubkey(value)).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,109 +358,3 @@ describe("Metrics fuzz", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Event Shape Validation (simulating malformed events)
|
||||
// ============================================================================
|
||||
|
||||
describe("Event shape validation", () => {
|
||||
describe("malformed event structures", () => {
|
||||
// These test what happens if malformed data somehow gets through
|
||||
|
||||
it("identifies missing required fields", () => {
|
||||
const malformedEvents = [
|
||||
{}, // empty
|
||||
{ id: "abc" }, // missing pubkey, created_at, etc.
|
||||
{ id: null, pubkey: null }, // null values
|
||||
{ id: 123, pubkey: 456 }, // wrong types
|
||||
{ tags: "not-an-array" }, // wrong type for tags
|
||||
{ tags: [[1, 2, 3]] }, // wrong type for tag elements
|
||||
];
|
||||
|
||||
for (const event of malformedEvents) {
|
||||
// These should be caught by shape validation before processing
|
||||
const hasId = typeof event?.id === "string";
|
||||
const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string";
|
||||
const hasTags = Array.isArray((event as { tags?: unknown })?.tags);
|
||||
|
||||
// At least one should be invalid
|
||||
expect(hasId && hasPubkey && hasTags).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestamp edge cases", () => {
|
||||
const testTimestamps = [
|
||||
{ value: NaN, desc: "NaN" },
|
||||
{ value: Infinity, desc: "Infinity" },
|
||||
{ value: -Infinity, desc: "-Infinity" },
|
||||
{ value: -1, desc: "negative" },
|
||||
{ value: 0, desc: "zero" },
|
||||
{ value: 253402300800, desc: "year 10000" }, // Far future
|
||||
{ value: -62135596800, desc: "year 0001" }, // Far past
|
||||
{ value: 1.5, desc: "float" },
|
||||
];
|
||||
|
||||
for (const { value, desc } of testTimestamps) {
|
||||
it(`handles ${desc} timestamp`, () => {
|
||||
const isValidTimestamp =
|
||||
typeof value === "number" &&
|
||||
!isNaN(value) &&
|
||||
isFinite(value) &&
|
||||
value >= 0 &&
|
||||
Number.isInteger(value);
|
||||
|
||||
// Timestamps should be validated as positive integers
|
||||
if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) {
|
||||
expect(isValidTimestamp).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// JSON parsing edge cases (simulating relay responses)
|
||||
// ============================================================================
|
||||
|
||||
describe("JSON parsing edge cases", () => {
|
||||
const malformedJsonCases = [
|
||||
{ input: "", desc: "empty string" },
|
||||
{ input: "null", desc: "null literal" },
|
||||
{ input: "undefined", desc: "undefined literal" },
|
||||
{ input: "{", desc: "incomplete object" },
|
||||
{ input: "[", desc: "incomplete array" },
|
||||
{ input: '{"key": undefined}', desc: "undefined value" },
|
||||
{ input: "{'key': 'value'}", desc: "single quotes" },
|
||||
{ input: '{"key": NaN}', desc: "NaN value" },
|
||||
{ input: '{"key": Infinity}', desc: "Infinity value" },
|
||||
{ input: "\x00", desc: "null byte" },
|
||||
{ input: "abc", desc: "plain string" },
|
||||
{ input: "123", desc: "plain number" },
|
||||
];
|
||||
|
||||
for (const { input, desc } of malformedJsonCases) {
|
||||
it(`handles malformed JSON: ${desc}`, () => {
|
||||
let parsed: unknown;
|
||||
let parseError = false;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(input);
|
||||
} catch {
|
||||
parseError = true;
|
||||
}
|
||||
|
||||
// Either it throws or produces something that needs validation
|
||||
if (!parseError) {
|
||||
// If it parsed, we need to validate the structure
|
||||
const isValidRelayMessage =
|
||||
Array.isArray(parsed) && parsed.length >= 2 && typeof parsed[0] === "string";
|
||||
|
||||
// Most malformed cases won't produce valid relay messages
|
||||
if (["null literal", "plain number", "plain string"].includes(desc)) {
|
||||
expect(isValidRelayMessage).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isValidPubkey,
|
||||
normalizePubkey,
|
||||
pubkeyToNpub,
|
||||
} from "./nostr-bus.js";
|
||||
} from "./nostr-key-utils.js";
|
||||
import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js";
|
||||
|
||||
describe("validatePrivateKey", () => {
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import {
|
||||
SimplePool,
|
||||
finalizeEvent,
|
||||
getPublicKey,
|
||||
verifyEvent,
|
||||
nip19,
|
||||
type Event,
|
||||
} from "nostr-tools";
|
||||
import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools";
|
||||
import { decrypt, encrypt } from "nostr-tools/nip04";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createDirectDmPreCryptoGuardPolicy,
|
||||
type DirectDmPreCryptoGuardPolicyOverrides,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/direct-dm-guard-policy";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import {
|
||||
@@ -21,6 +13,7 @@ import {
|
||||
type MetricsSnapshot,
|
||||
type MetricEvent,
|
||||
} from "./metrics.js";
|
||||
import { validatePrivateKey } from "./nostr-key-utils.js";
|
||||
import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
|
||||
import {
|
||||
readNostrBusState,
|
||||
@@ -31,6 +24,14 @@ import {
|
||||
} from "./nostr-state-store.js";
|
||||
import { createSeenTracker, type SeenTracker } from "./seen-tracker.js";
|
||||
|
||||
export {
|
||||
validatePrivateKey,
|
||||
getPublicKeyFromPrivate,
|
||||
isValidPubkey,
|
||||
normalizePubkey,
|
||||
pubkeyToNpub,
|
||||
} from "./nostr-key-utils.js";
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
@@ -340,46 +341,6 @@ function createRelayHealthTracker(): RelayHealthTracker {
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Key Validation
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate and normalize a private key (accepts hex or nsec format)
|
||||
*/
|
||||
export function validatePrivateKey(key: string): Uint8Array {
|
||||
const trimmed = key.trim();
|
||||
|
||||
// Handle nsec (bech32) format
|
||||
if (trimmed.startsWith("nsec1")) {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type !== "nsec") {
|
||||
throw new Error("Invalid nsec key: wrong type");
|
||||
}
|
||||
return decoded.data;
|
||||
}
|
||||
|
||||
// Handle hex format
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
throw new Error("Private key must be 64 hex characters or nsec bech32 format");
|
||||
}
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key from private key (hex or nsec format)
|
||||
*/
|
||||
export function getPublicKeyFromPrivate(privateKey: string): string {
|
||||
const sk = validatePrivateKey(privateKey);
|
||||
return getPublicKey(sk);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Bus
|
||||
// ============================================================================
|
||||
@@ -834,64 +795,3 @@ async function sendEncryptedDm(
|
||||
|
||||
throw new Error(`Failed to publish to any relay: ${lastError?.message}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pubkey Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if a string looks like a valid Nostr pubkey (hex or npub)
|
||||
*/
|
||||
export function isValidPubkey(input: string): boolean {
|
||||
if (typeof input !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
|
||||
// npub format
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
return decoded.type === "npub";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hex format
|
||||
return /^[0-9a-fA-F]{64}$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pubkey to hex format (accepts npub or hex)
|
||||
*/
|
||||
export function normalizePubkey(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// npub format - decode to hex
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type !== "npub") {
|
||||
throw new Error("Invalid npub key");
|
||||
}
|
||||
// Convert Uint8Array to hex string
|
||||
return Array.from(decoded.data as unknown as Uint8Array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Already hex - validate and return lowercase
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
throw new Error("Pubkey must be 64 hex characters or npub format");
|
||||
}
|
||||
return normalizeLowercaseStringOrEmpty(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex pubkey to npub format
|
||||
*/
|
||||
export function pubkeyToNpub(hexPubkey: string): string {
|
||||
const normalized = normalizePubkey(hexPubkey);
|
||||
// npubEncode expects a hex string, not Uint8Array
|
||||
return nip19.npubEncode(normalized);
|
||||
}
|
||||
|
||||
94
extensions/nostr/src/nostr-key-utils.ts
Normal file
94
extensions/nostr/src/nostr-key-utils.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getPublicKey, nip19 } from "nostr-tools";
|
||||
|
||||
/**
|
||||
* Validate and normalize a private key (accepts hex or nsec format)
|
||||
*/
|
||||
export function validatePrivateKey(key: string): Uint8Array {
|
||||
const trimmed = key.trim();
|
||||
|
||||
// Handle nsec (bech32) format
|
||||
if (trimmed.startsWith("nsec1")) {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type !== "nsec") {
|
||||
throw new Error("Invalid nsec key: wrong type");
|
||||
}
|
||||
return decoded.data;
|
||||
}
|
||||
|
||||
// Handle hex format
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
throw new Error("Private key must be 64 hex characters or nsec bech32 format");
|
||||
}
|
||||
|
||||
// Convert hex string to Uint8Array
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key from private key (hex or nsec format)
|
||||
*/
|
||||
export function getPublicKeyFromPrivate(privateKey: string): string {
|
||||
const sk = validatePrivateKey(privateKey);
|
||||
return getPublicKey(sk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string looks like a valid Nostr pubkey (hex or npub)
|
||||
*/
|
||||
export function isValidPubkey(input: string): boolean {
|
||||
if (typeof input !== "string") {
|
||||
return false;
|
||||
}
|
||||
const trimmed = input.trim();
|
||||
|
||||
// npub format
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
return decoded.type === "npub";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Hex format
|
||||
return /^[0-9a-fA-F]{64}$/.test(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a pubkey to hex format (accepts npub or hex)
|
||||
*/
|
||||
export function normalizePubkey(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// npub format - decode to hex
|
||||
if (trimmed.startsWith("npub1")) {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type !== "npub") {
|
||||
throw new Error("Invalid npub key");
|
||||
}
|
||||
// Convert Uint8Array to hex string
|
||||
return Array.from(decoded.data as unknown as Uint8Array)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Already hex - validate and return lowercase
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
||||
throw new Error("Pubkey must be 64 hex characters or npub format");
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hex pubkey to npub format
|
||||
*/
|
||||
export function pubkeyToNpub(hexPubkey: string): string {
|
||||
const normalized = normalizePubkey(hexPubkey);
|
||||
// npubEncode expects a hex string, not Uint8Array
|
||||
return nip19.npubEncode(normalized);
|
||||
}
|
||||
134
extensions/nostr/src/nostr-profile-core.ts
Normal file
134
extensions/nostr/src/nostr-profile-core.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
||||
|
||||
/** NIP-01 profile content (JSON inside kind:0 event). */
|
||||
export interface ProfileContent {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
website?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert our config profile schema to NIP-01 content format.
|
||||
* Strips undefined fields and validates URLs.
|
||||
*/
|
||||
export function profileToContent(profile: NostrProfile): ProfileContent {
|
||||
const validated = NostrProfileSchema.parse(profile);
|
||||
|
||||
const content: ProfileContent = {};
|
||||
|
||||
if (validated.name !== undefined) {
|
||||
content.name = validated.name;
|
||||
}
|
||||
if (validated.displayName !== undefined) {
|
||||
content.display_name = validated.displayName;
|
||||
}
|
||||
if (validated.about !== undefined) {
|
||||
content.about = validated.about;
|
||||
}
|
||||
if (validated.picture !== undefined) {
|
||||
content.picture = validated.picture;
|
||||
}
|
||||
if (validated.banner !== undefined) {
|
||||
content.banner = validated.banner;
|
||||
}
|
||||
if (validated.website !== undefined) {
|
||||
content.website = validated.website;
|
||||
}
|
||||
if (validated.nip05 !== undefined) {
|
||||
content.nip05 = validated.nip05;
|
||||
}
|
||||
if (validated.lud16 !== undefined) {
|
||||
content.lud16 = validated.lud16;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NIP-01 content format back to our config profile schema.
|
||||
* Useful for importing existing profiles from relays.
|
||||
*/
|
||||
export function contentToProfile(content: ProfileContent): NostrProfile {
|
||||
const profile: NostrProfile = {};
|
||||
|
||||
if (content.name !== undefined) {
|
||||
profile.name = content.name;
|
||||
}
|
||||
if (content.display_name !== undefined) {
|
||||
profile.displayName = content.display_name;
|
||||
}
|
||||
if (content.about !== undefined) {
|
||||
profile.about = content.about;
|
||||
}
|
||||
if (content.picture !== undefined) {
|
||||
profile.picture = content.picture;
|
||||
}
|
||||
if (content.banner !== undefined) {
|
||||
profile.banner = content.banner;
|
||||
}
|
||||
if (content.website !== undefined) {
|
||||
profile.website = content.website;
|
||||
}
|
||||
if (content.nip05 !== undefined) {
|
||||
profile.nip05 = content.nip05;
|
||||
}
|
||||
if (content.lud16 !== undefined) {
|
||||
profile.lud16 = content.lud16;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a profile without throwing (returns result object).
|
||||
*/
|
||||
export function validateProfile(profile: unknown): {
|
||||
valid: boolean;
|
||||
profile?: NostrProfile;
|
||||
errors?: string[];
|
||||
} {
|
||||
const result = NostrProfileSchema.safeParse(profile);
|
||||
|
||||
if (result.success) {
|
||||
return { valid: true, profile: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize profile text fields to prevent XSS when displaying in UI.
|
||||
* Escapes HTML special characters.
|
||||
*/
|
||||
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
|
||||
const escapeHtml = (str: string | undefined): string | undefined => {
|
||||
if (str === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
return {
|
||||
name: escapeHtml(profile.name),
|
||||
displayName: escapeHtml(profile.displayName),
|
||||
about: escapeHtml(profile.about),
|
||||
picture: profile.picture,
|
||||
banner: profile.banner,
|
||||
website: profile.website,
|
||||
nip05: escapeHtml(profile.nip05),
|
||||
lud16: escapeHtml(profile.lud16),
|
||||
};
|
||||
}
|
||||
6
extensions/nostr/src/nostr-profile-http-runtime.ts
Normal file
6
extensions/nostr/src/nostr-profile-http-runtime.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
readJsonBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
export { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { getPluginRuntimeGatewayRequestScope } from "../runtime-api.js";
|
||||
@@ -5,7 +5,6 @@
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as runtimeApi from "../runtime-api.js";
|
||||
import {
|
||||
clearNostrProfileRateLimitStateForTest,
|
||||
createNostrProfileHttpHandler,
|
||||
@@ -14,6 +13,19 @@ import {
|
||||
type NostrProfileHttpContext,
|
||||
} from "./nostr-profile-http.js";
|
||||
|
||||
const runtimeScopeMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./nostr-profile-http-runtime.js", async () => {
|
||||
const webhookIngress = await import("openclaw/plugin-sdk/webhook-ingress");
|
||||
const requestGuards = await import("openclaw/plugin-sdk/webhook-request-guards");
|
||||
return {
|
||||
createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter,
|
||||
readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit,
|
||||
requestBodyErrorToText: requestGuards.requestBodyErrorToText,
|
||||
getPluginRuntimeGatewayRequestScope: runtimeScopeMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the channel exports
|
||||
vi.mock("./channel.js", () => ({
|
||||
publishNostrProfile: vi.fn(),
|
||||
@@ -35,24 +47,23 @@ import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js";
|
||||
// ============================================================================
|
||||
|
||||
const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0];
|
||||
const runtimeScopeSpy = vi.spyOn(runtimeApi, "getPluginRuntimeGatewayRequestScope");
|
||||
|
||||
afterAll(() => {
|
||||
runtimeScopeSpy.mockRestore();
|
||||
runtimeScopeMock.mockReset();
|
||||
});
|
||||
|
||||
function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void {
|
||||
if (!scopes) {
|
||||
runtimeScopeSpy.mockReturnValue(undefined);
|
||||
runtimeScopeMock.mockReturnValue(undefined);
|
||||
return;
|
||||
}
|
||||
runtimeScopeSpy.mockReturnValue({
|
||||
runtimeScopeMock.mockReturnValue({
|
||||
client: {
|
||||
connect: {
|
||||
scopes: [...scopes],
|
||||
},
|
||||
},
|
||||
} as unknown as ReturnType<typeof runtimeApi.getPluginRuntimeGatewayRequestScope>);
|
||||
});
|
||||
}
|
||||
|
||||
function responseChunkText(chunk: unknown): string {
|
||||
|
||||
@@ -8,20 +8,15 @@
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
readStringValue,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
getPluginRuntimeGatewayRequestScope,
|
||||
readJsonBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
} from "../runtime-api.js";
|
||||
import { publishNostrProfile, getNostrProfileState } from "./channel.js";
|
||||
import { NostrProfileSchema, type NostrProfile } from "./config-schema.js";
|
||||
} from "./nostr-profile-http-runtime.js";
|
||||
import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js";
|
||||
import { validateUrlSafety } from "./nostr-profile-url-safety.js";
|
||||
|
||||
@@ -29,6 +24,22 @@ import { validateUrlSafety } from "./nostr-profile-url-safety.js";
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
function readStringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalLowercaseString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalLowercaseString(value) ?? "";
|
||||
}
|
||||
|
||||
export interface NostrProfileHttpContext {
|
||||
/** Get current profile from config */
|
||||
getConfigProfile: (accountId: string) => NostrProfile | undefined;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { isBlockedHostnameOrIp } from "../runtime-api.js";
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } {
|
||||
try {
|
||||
@@ -9,7 +8,7 @@ export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; e
|
||||
return { ok: false, error: "URL must use https:// protocol" };
|
||||
}
|
||||
|
||||
const hostname = normalizeLowercaseStringOrEmpty(url.hostname);
|
||||
const hostname = url.hostname.trim().toLowerCase();
|
||||
|
||||
if (isBlockedHostnameOrIp(hostname)) {
|
||||
return { ok: false, error: "URL must not point to private/internal addresses" };
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import {
|
||||
createProfileEvent,
|
||||
profileToContent,
|
||||
validateProfile,
|
||||
sanitizeProfileForDisplay,
|
||||
} from "./nostr-profile.js";
|
||||
import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js";
|
||||
|
||||
function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) {
|
||||
return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt);
|
||||
}
|
||||
validateProfile,
|
||||
} from "./nostr-profile-core.js";
|
||||
|
||||
// ============================================================================
|
||||
// Unicode Attack Vectors
|
||||
@@ -434,52 +428,3 @@ describe("profile type confusion", () => {
|
||||
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Event Creation Edge Cases
|
||||
// ============================================================================
|
||||
|
||||
describe("event creation edge cases", () => {
|
||||
it("handles profile with all fields at max length", () => {
|
||||
const profile: NostrProfile = {
|
||||
name: "a".repeat(256),
|
||||
displayName: "b".repeat(256),
|
||||
about: "c".repeat(2000),
|
||||
nip05: "d".repeat(200) + "@example.com",
|
||||
lud16: "e".repeat(200) + "@example.com",
|
||||
};
|
||||
|
||||
const event = createTestProfileEvent(profile);
|
||||
expect(event.kind).toBe(0);
|
||||
|
||||
// Content should be parseable JSON
|
||||
expect(() => JSON.parse(event.content)).not.toThrow();
|
||||
});
|
||||
|
||||
it("handles rapid sequential events with monotonic timestamps", () => {
|
||||
const profile: NostrProfile = { name: "rapid" };
|
||||
|
||||
// Create events in quick succession
|
||||
let lastTimestamp = 0;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const event = createTestProfileEvent(profile, lastTimestamp);
|
||||
expect(event.created_at).toBeGreaterThan(lastTimestamp);
|
||||
lastTimestamp = event.created_at;
|
||||
}
|
||||
});
|
||||
|
||||
it("handles JSON special characters in content", () => {
|
||||
const profile: NostrProfile = {
|
||||
name: 'test"user',
|
||||
about: "line1\nline2\ttab\\backslash",
|
||||
};
|
||||
|
||||
const event = createTestProfileEvent(profile);
|
||||
const parsed = JSON.parse(event.content) as { name: string; about: string };
|
||||
|
||||
expect(parsed.name).toBe('test"user');
|
||||
expect(parsed.about).toContain("\n");
|
||||
expect(parsed.about).toContain("\t");
|
||||
expect(parsed.about).toContain("\\");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
|
||||
import { finalizeEvent, SimplePool, type Event } from "nostr-tools";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { type NostrProfile, NostrProfileSchema } from "./config-schema.js";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { profileToContent } from "./nostr-profile-core.js";
|
||||
export {
|
||||
contentToProfile,
|
||||
profileToContent,
|
||||
sanitizeProfileForDisplay,
|
||||
validateProfile,
|
||||
type ProfileContent,
|
||||
} from "./nostr-profile-core.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -25,94 +33,6 @@ export interface ProfilePublishResult {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** NIP-01 profile content (JSON inside kind:0 event) */
|
||||
export interface ProfileContent {
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
about?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
website?: string;
|
||||
nip05?: string;
|
||||
lud16?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Content Conversion
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert our config profile schema to NIP-01 content format.
|
||||
* Strips undefined fields and validates URLs.
|
||||
*/
|
||||
export function profileToContent(profile: NostrProfile): ProfileContent {
|
||||
const validated = NostrProfileSchema.parse(profile);
|
||||
|
||||
const content: ProfileContent = {};
|
||||
|
||||
if (validated.name !== undefined) {
|
||||
content.name = validated.name;
|
||||
}
|
||||
if (validated.displayName !== undefined) {
|
||||
content.display_name = validated.displayName;
|
||||
}
|
||||
if (validated.about !== undefined) {
|
||||
content.about = validated.about;
|
||||
}
|
||||
if (validated.picture !== undefined) {
|
||||
content.picture = validated.picture;
|
||||
}
|
||||
if (validated.banner !== undefined) {
|
||||
content.banner = validated.banner;
|
||||
}
|
||||
if (validated.website !== undefined) {
|
||||
content.website = validated.website;
|
||||
}
|
||||
if (validated.nip05 !== undefined) {
|
||||
content.nip05 = validated.nip05;
|
||||
}
|
||||
if (validated.lud16 !== undefined) {
|
||||
content.lud16 = validated.lud16;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert NIP-01 content format back to our config profile schema.
|
||||
* Useful for importing existing profiles from relays.
|
||||
*/
|
||||
export function contentToProfile(content: ProfileContent): NostrProfile {
|
||||
const profile: NostrProfile = {};
|
||||
|
||||
if (content.name !== undefined) {
|
||||
profile.name = content.name;
|
||||
}
|
||||
if (content.display_name !== undefined) {
|
||||
profile.displayName = content.display_name;
|
||||
}
|
||||
if (content.about !== undefined) {
|
||||
profile.about = content.about;
|
||||
}
|
||||
if (content.picture !== undefined) {
|
||||
profile.picture = content.picture;
|
||||
}
|
||||
if (content.banner !== undefined) {
|
||||
profile.banner = content.banner;
|
||||
}
|
||||
if (content.website !== undefined) {
|
||||
profile.website = content.website;
|
||||
}
|
||||
if (content.nip05 !== undefined) {
|
||||
profile.nip05 = content.nip05;
|
||||
}
|
||||
if (content.lud16 !== undefined) {
|
||||
profile.lud16 = content.lud16;
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Creation
|
||||
// ============================================================================
|
||||
@@ -222,56 +142,3 @@ export async function publishProfile(
|
||||
const event = createProfileEvent(sk, profile, lastPublishedAt);
|
||||
return publishProfileEvent(pool, relays, event);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Profile Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate a profile without throwing (returns result object).
|
||||
*/
|
||||
export function validateProfile(profile: unknown): {
|
||||
valid: boolean;
|
||||
profile?: NostrProfile;
|
||||
errors?: string[];
|
||||
} {
|
||||
const result = NostrProfileSchema.safeParse(profile);
|
||||
|
||||
if (result.success) {
|
||||
return { valid: true, profile: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize profile text fields to prevent XSS when displaying in UI.
|
||||
* Escapes HTML special characters.
|
||||
*/
|
||||
export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile {
|
||||
const escapeHtml = (str: string | undefined): string | undefined => {
|
||||
if (str === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
return {
|
||||
name: escapeHtml(profile.name),
|
||||
displayName: escapeHtml(profile.displayName),
|
||||
about: escapeHtml(profile.about),
|
||||
picture: profile.picture, // URLs already validated by schema
|
||||
banner: profile.banner,
|
||||
website: profile.website,
|
||||
nip05: escapeHtml(profile.nip05),
|
||||
lud16: escapeHtml(profile.lud16),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
splitSetupEntries,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js";
|
||||
import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-key-utils.js";
|
||||
import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js";
|
||||
|
||||
const channel = "nostr" as const;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sd
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { NostrProfile } from "./config-schema.js";
|
||||
import { DEFAULT_RELAYS } from "./default-relays.js";
|
||||
import { getPublicKeyFromPrivate } from "./nostr-bus.js";
|
||||
import { getPublicKeyFromPrivate } from "./nostr-key-utils.js";
|
||||
|
||||
export interface NostrAccountConfig {
|
||||
enabled?: boolean;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/setup";
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
applyQQBotAccountConfig,
|
||||
@@ -19,6 +15,20 @@ import {
|
||||
} from "./config.js";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeStringifiedOptionalString(
|
||||
value: string | number | null | undefined,
|
||||
): string | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = String(value).trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export const qqbotMeta = {
|
||||
id: "qqbot",
|
||||
label: "QQ Bot",
|
||||
|
||||
@@ -1,67 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
|
||||
import { qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { qqbotSetupPlugin } from "./channel.setup.js";
|
||||
import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js";
|
||||
import { QQBotConfigSchema } from "./config-schema.js";
|
||||
import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js";
|
||||
|
||||
describe("qqbot config", () => {
|
||||
it("accepts top-level speech overrides in the manifest schema", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "qqbot.manifest.speech-overrides",
|
||||
value: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "tts-key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
authStyle: "api-key",
|
||||
queryParams: {
|
||||
format: "wav",
|
||||
},
|
||||
speed: 1.1,
|
||||
},
|
||||
stt: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "stt-key",
|
||||
model: "whisper-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts defaultAccount in the manifest schema", () => {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: "qqbot.manifest.default-account",
|
||||
value: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("honors configured defaultAccount when resolving the default QQ Bot account id", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -222,8 +165,8 @@ describe("qqbot config", () => {
|
||||
|
||||
expect(resolved.clientSecret).toBe("");
|
||||
expect(resolved.secretSource).toBe("config");
|
||||
expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true);
|
||||
expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true);
|
||||
expect(qqbotConfigAdapter.isConfigured(resolved)).toBe(true);
|
||||
expect(qqbotConfigAdapter.describeAccount(resolved).configured).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -238,10 +181,7 @@ describe("qqbot config", () => {
|
||||
expectedPath: ["channels", "qqbot", "accounts", "bot2"],
|
||||
},
|
||||
])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => {
|
||||
const setup = qqbotSetupPlugin.setup;
|
||||
expect(setup).toBeDefined();
|
||||
|
||||
const next = setup!.applyAccountConfig?.({
|
||||
const next = qqbotSetupAdapterShared.applyAccountConfig({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: inputAccountId,
|
||||
input: {
|
||||
@@ -263,11 +203,9 @@ describe("qqbot config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed --token consistently across setup paths", () => {
|
||||
it("rejects malformed --token in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = qqbotSetupPlugin.setup;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
expect(lightweightSetup).toBeDefined();
|
||||
|
||||
const input = { token: "broken", name: "Bad" };
|
||||
|
||||
@@ -278,13 +216,6 @@ describe("qqbot config", () => {
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --token must be in appId:clientSecret format");
|
||||
expect(
|
||||
lightweightSetup!.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --token must be in appId:clientSecret format");
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -292,20 +223,11 @@ describe("qqbot config", () => {
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
expect(
|
||||
lightweightSetup!.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves the --use-env add flow across setup paths", () => {
|
||||
it("preserves the --use-env add flow in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = qqbotSetupPlugin.setup;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
expect(lightweightSetup).toBeDefined();
|
||||
|
||||
const input = { useEnv: true, name: "Env Bot" };
|
||||
|
||||
@@ -324,21 +246,6 @@ describe("qqbot config", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
lightweightSetup!.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input,
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
allowFrom: ["*"],
|
||||
name: "Env Bot",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when runtime setup accountId is omitted", () => {
|
||||
@@ -362,11 +269,9 @@ describe("qqbot config", () => {
|
||||
).toBe("bot2");
|
||||
});
|
||||
|
||||
it("rejects --use-env for named accounts across setup paths", () => {
|
||||
it("rejects --use-env for named accounts in shared setup config", () => {
|
||||
const runtimeSetup = qqbotSetupAdapterShared;
|
||||
const lightweightSetup = qqbotSetupPlugin.setup;
|
||||
expect(runtimeSetup).toBeDefined();
|
||||
expect(lightweightSetup).toBeDefined();
|
||||
|
||||
const input = { useEnv: true, name: "Env Bot" };
|
||||
|
||||
@@ -377,13 +282,6 @@ describe("qqbot config", () => {
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --use-env only supports the default account");
|
||||
expect(
|
||||
lightweightSetup!.validateInput?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toBe("QQBot --use-env only supports the default account");
|
||||
expect(
|
||||
runtimeSetup.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -391,12 +289,5 @@ describe("qqbot config", () => {
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
expect(
|
||||
lightweightSetup!.applyAccountConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "bot2",
|
||||
input,
|
||||
} as never),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js";
|
||||
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
@@ -16,7 +15,11 @@ interface QQBotChannelConfig extends QQBotAccountConfig {
|
||||
}
|
||||
|
||||
function normalizeConfiguredDefaultAccountId(raw: unknown): string | null {
|
||||
return normalizeOptionalLowercaseString(raw) ?? null;
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeQQBotAccountConfig(account: QQBotAccountConfig | undefined): QQBotAccountConfig {
|
||||
|
||||
56
extensions/qqbot/src/manifest-schema.test.ts
Normal file
56
extensions/qqbot/src/manifest-schema.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),
|
||||
) as { configSchema: Record<string, unknown> };
|
||||
const manifestConfigSchemaCacheKey = "qqbot.manifest.config-schema";
|
||||
|
||||
describe("qqbot manifest schema", () => {
|
||||
it("accepts top-level speech overrides", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: manifestConfigSchemaCacheKey,
|
||||
value: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "tts-key",
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
authStyle: "api-key",
|
||||
queryParams: {
|
||||
format: "wav",
|
||||
},
|
||||
speed: 1.1,
|
||||
},
|
||||
stt: {
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.com/v1",
|
||||
apiKey: "stt-key",
|
||||
model: "whisper-1",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts defaultAccount", () => {
|
||||
const result = validateJsonSchemaValue({
|
||||
schema: manifest.configSchema,
|
||||
cacheKey: manifestConfigSchemaCacheKey,
|
||||
value: {
|
||||
defaultAccount: "bot2",
|
||||
accounts: {
|
||||
bot2: {
|
||||
appId: "654321",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,6 @@
|
||||
* 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media.
|
||||
*/
|
||||
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
sendC2CMessage,
|
||||
sendDmMessage,
|
||||
@@ -36,6 +32,18 @@ import { filterInternalMarkers } from "./utils/text-parsing.js";
|
||||
|
||||
// Type definitions.
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
export interface DeliverEventContext {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getFrameworkCommands,
|
||||
matchSlashCommand,
|
||||
@@ -28,6 +29,18 @@ function buildCtx(overrides: Partial<SlashCommandContext> = {}): SlashCommandCon
|
||||
};
|
||||
}
|
||||
|
||||
function stubEmptyLogFilesystem() {
|
||||
vi.spyOn(fs, "existsSync").mockReturnValue(false);
|
||||
vi.spyOn(fs, "readdirSync").mockReturnValue([] as never);
|
||||
vi.spyOn(fs, "statSync").mockImplementation(() => {
|
||||
throw new Error("missing");
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("slash command authorization", () => {
|
||||
// ---- /bot-logs (moved to framework registerCommand) ----
|
||||
// /bot-logs is registered with the framework via registerCommand() so that
|
||||
@@ -151,21 +164,17 @@ describe("/bot-logs framework command hardening", () => {
|
||||
});
|
||||
|
||||
it("allows /bot-logs when allowFrom contains numeric sender ids", async () => {
|
||||
stubEmptyLogFilesystem();
|
||||
const handler = getBotLogsHandler();
|
||||
const accountConfig = { allowFrom: [12345] } as unknown as SlashCommandContext["accountConfig"];
|
||||
const result = await handler(buildCtx({ accountConfig }));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).not.toBe(
|
||||
"⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。",
|
||||
);
|
||||
expect(result).toContain("未找到日志文件");
|
||||
});
|
||||
|
||||
it("allows /bot-logs execution when allowFrom is explicit", async () => {
|
||||
stubEmptyLogFilesystem();
|
||||
const handler = getBotLogsHandler();
|
||||
const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:user-1"] } }));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).not.toBe(
|
||||
"⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。",
|
||||
);
|
||||
expect(result).toContain("未找到日志文件");
|
||||
});
|
||||
});
|
||||
|
||||
1
extensions/qqbot/src/utils/file-utils-runtime.ts
Normal file
1
extensions/qqbot/src/utils/file-utils-runtime.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -7,7 +7,7 @@ const mediaRuntimeMocks = vi.hoisted(() => ({
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
vi.mock("./file-utils-runtime.js", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,12 +2,20 @@ import crypto from "node:crypto";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { fetchRemoteMedia } from "./file-utils-runtime.js";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return normalizeOptionalString(value)?.toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
/** Maximum file size accepted by the QQ Bot API. */
|
||||
export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { expandTilde } from "./platform.js";
|
||||
|
||||
// Canonical media tags. `qqmedia` is the generic auto-routing tag.
|
||||
@@ -93,7 +92,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp(
|
||||
|
||||
/** Normalize a raw tag name into the canonical tag set. */
|
||||
function resolveTagName(raw: string): (typeof VALID_TAGS)[number] {
|
||||
const lower = normalizeLowercaseStringOrEmpty(raw);
|
||||
const lower = raw.trim().toLowerCase();
|
||||
if ((VALID_TAGS as readonly string[]).includes(lower)) {
|
||||
return lower as (typeof VALID_TAGS)[number];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import { estimateBase64DecodedBytes } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { RefAttachmentSummary } from "../ref-index-store.js";
|
||||
|
||||
const MAX_FACE_EXT_BYTES = 64 * 1024;
|
||||
|
||||
function estimateBase64DecodedBytes(base64: string): number {
|
||||
let effectiveLen = 0;
|
||||
for (let i = 0; i < base64.length; i += 1) {
|
||||
if (base64.charCodeAt(i) > 0x20) {
|
||||
effectiveLen += 1;
|
||||
}
|
||||
}
|
||||
if (effectiveLen === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let padding = 0;
|
||||
let end = base64.length - 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 1;
|
||||
end -= 1;
|
||||
while (end >= 0 && base64.charCodeAt(end) <= 0x20) {
|
||||
end -= 1;
|
||||
}
|
||||
if (end >= 0 && base64[end] === "=") {
|
||||
padding = 2;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(0, Math.floor((effectiveLen * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Replace QQ face tags with readable text labels. */
|
||||
export function parseFaceTags(text: string | undefined | null): string {
|
||||
if (!text) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
resolveMergedAccountConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import type {
|
||||
SynologyChatChannelConfig,
|
||||
ResolvedSynologyChatAccount,
|
||||
|
||||
@@ -35,9 +35,7 @@ vi.mock("./webhook-handler.js", () => ({
|
||||
createWebhookHandler: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
const freshChannelModulePath = "./channel.js?channel-test";
|
||||
const { createSynologyChatPlugin } = await import(freshChannelModulePath);
|
||||
const { synologyChatPlugin } = await import("./channel.js");
|
||||
const { createSynologyChatPlugin, synologyChatPlugin } = await import("./channel.js");
|
||||
const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlugin);
|
||||
|
||||
describe("createSynologyChatPlugin", () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listAccountIds, resolveAccount } from "./accounts.js";
|
||||
import { synologyChatApprovalAuth } from "./approval-auth.js";
|
||||
import { sendMessage, sendFileUrl } from "./client.js";
|
||||
@@ -36,6 +35,10 @@ import type { ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "synology-chat";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
|
||||
channelKey: CHANNEL_ID,
|
||||
resolvePolicy: (account) => account.dmPolicy,
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
import * as http from "node:http";
|
||||
import * as https from "node:https";
|
||||
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
const MIN_SEND_INTERVAL_MS = 500;
|
||||
let lastSendTime = 0;
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
// --- Chat user_id resolution ---
|
||||
// Synology Chat uses two different user_id spaces:
|
||||
// - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { sendMessage } from "./client.js";
|
||||
import { buildSynologyChatInboundContext, type SynologyInboundMessage } from "./inbound-context.js";
|
||||
import { getSynologyRuntime } from "./runtime.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
|
||||
const CHANNEL_ID = "synology-chat";
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ChannelSetupWizard,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { listAccountIds, resolveAccount } from "./accounts.js";
|
||||
import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js";
|
||||
|
||||
@@ -35,6 +34,14 @@ const SYNOLOGY_ALLOW_FROM_HELP_LINES = [
|
||||
`Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`,
|
||||
];
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig {
|
||||
return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import * as querystring from "node:querystring";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
beginWebhookRequestPipelineOrReject,
|
||||
createWebhookInFlightLimiter,
|
||||
@@ -17,6 +16,10 @@ import * as synologyClient from "./client.js";
|
||||
import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
|
||||
import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
// One rate limiter per account, created lazily
|
||||
const rateLimiters = new Map<string, RateLimiter>();
|
||||
const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
|
||||
// Extensions cannot import core internals directly, so use node:crypto here.
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { PendingApproval } from "../settings.js";
|
||||
|
||||
export type { PendingApproval };
|
||||
|
||||
export type ApprovalType = "dm" | "channel" | "group";
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
export type CreateApprovalParams = {
|
||||
type: ApprovalType;
|
||||
requestingShip: string;
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
fetchRemoteMedia,
|
||||
MAX_IMAGE_BYTES,
|
||||
saveMediaBuffer,
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadMedia, extractImageBlocks } from "./media.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
|
||||
"openclaw/plugin-sdk/media-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
MAX_IMAGE_BYTES: 6 * 1024 * 1024,
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
const fetchRemoteMediaMock = vi.mocked(fetchRemoteMedia);
|
||||
const saveMediaBufferMock = vi.mocked(saveMediaBuffer);
|
||||
|
||||
describe("tlon monitor media", () => {
|
||||
async function loadMediaModule() {
|
||||
const mediaRuntime = await import("openclaw/plugin-sdk/media-runtime");
|
||||
const mediaModule = await import("./media.js");
|
||||
return {
|
||||
fetchRemoteMedia: vi.mocked(mediaRuntime.fetchRemoteMedia),
|
||||
saveMediaBuffer: vi.mocked(mediaRuntime.saveMediaBuffer),
|
||||
...mediaModule,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
@@ -34,7 +27,6 @@ describe("tlon monitor media", () => {
|
||||
});
|
||||
|
||||
it("caps extracted images at eight per message", async () => {
|
||||
const { extractImageBlocks } = await loadMediaModule();
|
||||
const content = Array.from({ length: 10 }, (_, index) => ({
|
||||
block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } },
|
||||
}));
|
||||
@@ -48,14 +40,12 @@ describe("tlon monitor media", () => {
|
||||
});
|
||||
|
||||
it("stores fetched media through the shared inbound media store with the image cap", async () => {
|
||||
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
|
||||
|
||||
fetchRemoteMedia.mockResolvedValue({
|
||||
fetchRemoteMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("image-data"),
|
||||
contentType: "image/png",
|
||||
fileName: "photo.png",
|
||||
});
|
||||
saveMediaBuffer.mockResolvedValue({
|
||||
saveMediaBufferMock.mockResolvedValue({
|
||||
id: "photo---uuid.png",
|
||||
path: "/tmp/openclaw/media/inbound/photo---uuid.png",
|
||||
size: "image-data".length,
|
||||
@@ -64,7 +54,7 @@ describe("tlon monitor media", () => {
|
||||
|
||||
const result = await downloadMedia("https://example.com/photo.png");
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect(fetchRemoteMediaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/photo.png",
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
@@ -72,7 +62,7 @@ describe("tlon monitor media", () => {
|
||||
requestInit: { method: "GET" },
|
||||
}),
|
||||
);
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||
expect(saveMediaBufferMock).toHaveBeenCalledWith(
|
||||
Buffer.from("image-data"),
|
||||
"image/png",
|
||||
"inbound",
|
||||
@@ -87,9 +77,7 @@ describe("tlon monitor media", () => {
|
||||
});
|
||||
|
||||
it("returns null when the fetch exceeds the image cap", async () => {
|
||||
const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule();
|
||||
|
||||
fetchRemoteMedia.mockRejectedValue(
|
||||
fetchRemoteMediaMock.mockRejectedValue(
|
||||
new Error(
|
||||
`Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`,
|
||||
),
|
||||
@@ -98,6 +86,6 @@ describe("tlon monitor media", () => {
|
||||
const result = await downloadMedia("https://example.com/photo.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(saveMediaBuffer).not.toHaveBeenCalled();
|
||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { formatErrorMessage as sharedFormatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { asNullableObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeShip } from "../targets.js";
|
||||
|
||||
// Cite types for message references
|
||||
@@ -187,6 +186,20 @@ export const asRecord = asNullableObjectRecord;
|
||||
export const formatErrorMessage = sharedFormatErrorMessage;
|
||||
export const readString = readStringField;
|
||||
|
||||
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readStringField(
|
||||
record: Record<string, unknown> | null | undefined,
|
||||
field: string,
|
||||
): string | undefined {
|
||||
const value = record?.[field];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
// Helper to recursively extract text from inline content
|
||||
function renderInlineItem(
|
||||
item: unknown,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SsrFBlockedError } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../../api.js";
|
||||
import { SsrFBlockedError } from "../../api.js";
|
||||
import { authenticate } from "./auth.js";
|
||||
|
||||
describe("tlon urbit auth ssrf", () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export type UrbitBaseUrlValidation =
|
||||
| { ok: true; baseUrl: string; hostname: string }
|
||||
@@ -10,7 +9,7 @@ function hasScheme(value: string): boolean {
|
||||
}
|
||||
|
||||
export function normalizeUrbitHostname(hostname: string | undefined): string {
|
||||
return normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, "");
|
||||
return (hostname ?? "").trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { fetchWithSsrFGuard, type LookupFn, type SsrFPolicy } from "../../runtime-api.js";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { validateUrbitBaseUrl } from "./base-url.js";
|
||||
import { UrbitUrlError } from "./errors.js";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fetchWithSsrFGuard } from "../../runtime-api.js";
|
||||
import { uploadFile } from "../tlon-api.js";
|
||||
import { uploadImageFromUrl } from "./upload.js";
|
||||
|
||||
vi.mock("../../runtime-api.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Upload an image from a URL to Tlon storage.
|
||||
*/
|
||||
import { fetchWithSsrFGuard } from "../../runtime-api.js";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { uploadFile } from "../tlon-api.js";
|
||||
import { getDefaultSsrFPolicy } from "./context.js";
|
||||
|
||||
|
||||
@@ -83,12 +83,22 @@ export async function probeTwitch(
|
||||
});
|
||||
});
|
||||
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
timeoutHandle = setTimeout(
|
||||
() => reject(new Error(`timeout after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
client.connect();
|
||||
await Promise.race([connectionPromise, timeout]);
|
||||
try {
|
||||
await Promise.race([connectionPromise, timeout]);
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
|
||||
client.quit();
|
||||
client = undefined;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/core";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
|
||||
export type TwitchTokenSource = "env" | "config" | "none";
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
/**
|
||||
* Twitch-specific utility functions
|
||||
*/
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Twitch channel names.
|
||||
*
|
||||
|
||||
@@ -44,10 +44,7 @@ async function loadQrTerminal() {
|
||||
return mod.default ?? mod;
|
||||
}
|
||||
|
||||
export async function writeCredsJsonAtomically(
|
||||
authDir: string,
|
||||
creds: unknown,
|
||||
): Promise<void> {
|
||||
export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise<void> {
|
||||
const credsPath = resolveWebCredsPath(authDir);
|
||||
const tempPath = path.join(authDir, `.creds.${process.pid}.${Date.now()}.tmp`);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user