perf: optimize bundled extension tests

This commit is contained in:
Peter Steinberger
2026-04-17 16:04:32 +01:00
parent 605cb60586
commit af954a81d1
87 changed files with 1099 additions and 1210 deletions

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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";

View File

@@ -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}`;

View File

@@ -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(

View File

@@ -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;

View 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);
}

View File

@@ -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;

View File

@@ -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,
}));

View File

@@ -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"

View File

@@ -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") {

View File

@@ -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(),

View 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;
});
}

View File

@@ -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>)",

View File

@@ -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,

View File

@@ -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,
};
});

View File

@@ -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) {

View File

@@ -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(() =>

View File

@@ -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"

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 },

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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 }) => {

View 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();
});
});

View File

@@ -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}`,
});
};
}

View File

@@ -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, ["*"]);
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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,

View File

@@ -0,0 +1 @@
export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm";

View File

@@ -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);
}
}
});
}
});

View File

@@ -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", () => {

View File

@@ -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);
}

View 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);
}

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
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),
};
}

View 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";

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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" };

View File

@@ -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("\\");
});
});

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
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),
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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",

View File

@@ -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({});
});
});

View File

@@ -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 {

View 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);
});
});

View File

@@ -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;

View File

@@ -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("未找到日志文件");
});
});

View File

@@ -0,0 +1 @@
export { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";

View File

@@ -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),
}));

View File

@@ -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;

View File

@@ -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];
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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";

View File

@@ -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>({

View File

@@ -1,4 +1,4 @@
import { buildAgentSessionKey } from "openclaw/plugin-sdk/core";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
const CHANNEL_ID = "synology-chat";

View File

@@ -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) ?? {};
}

View File

@@ -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>();

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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(),
}));

View File

@@ -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";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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.
*

View File

@@ -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 {