From 594337698f3548a0501be0d92d45b41b8f993c1c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 00:03:19 +0100 Subject: [PATCH] refactor: dedupe qqbot helpers --- extensions/qqbot/src/channel-base.ts | 30 +++++++ extensions/qqbot/src/channel.setup.ts | 25 +----- extensions/qqbot/src/channel.ts | 30 +------ extensions/qqbot/src/config.test.ts | 38 +-------- extensions/qqbot/src/config.ts | 34 ++++---- extensions/qqbot/src/outbound-deliver.ts | 91 ++++++++++---------- extensions/qqbot/src/qqbot-test-support.ts | 29 +++++++ extensions/qqbot/src/reply-dispatcher.ts | 97 +++++++++++++--------- extensions/qqbot/src/session-store.ts | 21 +++-- extensions/qqbot/src/setup-surface.ts | 50 +++++++---- extensions/qqbot/src/setup.test.ts | 25 +----- extensions/qqbot/src/tools/channel.ts | 8 +- extensions/qqbot/src/tools/remind.ts | 8 +- extensions/qqbot/src/tools/result.ts | 6 ++ extensions/qqbot/src/utils/file-utils.ts | 16 +--- extensions/qqbot/src/utils/media-tags.ts | 16 ++-- 16 files changed, 256 insertions(+), 268 deletions(-) create mode 100644 extensions/qqbot/src/channel-base.ts create mode 100644 extensions/qqbot/src/qqbot-test-support.ts create mode 100644 extensions/qqbot/src/tools/result.ts diff --git a/extensions/qqbot/src/channel-base.ts b/extensions/qqbot/src/channel-base.ts new file mode 100644 index 00000000000..940624c8602 --- /dev/null +++ b/extensions/qqbot/src/channel-base.ts @@ -0,0 +1,30 @@ +import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; +import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js"; +import { qqbotChannelConfigSchema } from "./config-schema.js"; +import { qqbotSetupWizard } from "./setup-surface.js"; +import type { ResolvedQQBotAccount } from "./types.js"; + +export const qqbotBasePluginFields = { + id: "qqbot", + setupWizard: qqbotSetupWizard, + meta: { + ...qqbotMeta, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + reactions: false, + threads: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.qqbot"] }, + configSchema: qqbotChannelConfigSchema, + config: { + ...qqbotConfigAdapter, + }, + setup: { + ...qqbotSetupAdapterShared, + }, +} satisfies Partial> & { + id: "qqbot"; +}; diff --git a/extensions/qqbot/src/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts index f02933ba67e..e90641627d5 100644 --- a/extensions/qqbot/src/channel.setup.ts +++ b/extensions/qqbot/src/channel.setup.ts @@ -1,7 +1,5 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js"; -import { qqbotChannelConfigSchema } from "./config-schema.js"; -import { qqbotSetupWizard } from "./setup-surface.js"; +import { qqbotBasePluginFields } from "./channel-base.js"; import type { ResolvedQQBotAccount } from "./types.js"; /** @@ -9,24 +7,5 @@ import type { ResolvedQQBotAccount } from "./types.js"; * and `openclaw configure` without pulling the full runtime dependencies. */ export const qqbotSetupPlugin: ChannelPlugin = { - id: "qqbot", - setupWizard: qqbotSetupWizard, - meta: { - ...qqbotMeta, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: false, - threads: false, - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.qqbot"] }, - configSchema: qqbotChannelConfigSchema, - config: { - ...qqbotConfigAdapter, - }, - setup: { - ...qqbotSetupAdapterShared, - }, + ...qqbotBasePluginFields, }; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 55087809f4f..0eeed94ee09 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -1,11 +1,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import { initApiConfig } from "./api.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js"; -import { qqbotChannelConfigSchema } from "./config-schema.js"; +import { qqbotBasePluginFields } from "./channel-base.js"; import { DEFAULT_ACCOUNT_ID, resolveQQBotAccount } from "./config.js"; import { getQQBotRuntime } from "./runtime.js"; -import { qqbotSetupWizard } from "./setup-surface.js"; // Re-export text helpers so existing consumers of channel.ts are unaffected. // The canonical definition lives in text-utils.ts to avoid a circular // dependency: channel.ts → (dynamic) gateway.ts → outbound-deliver.ts → channel.ts. @@ -30,31 +28,7 @@ function loadOutboundModule(): Promise { } export const qqbotPlugin: ChannelPlugin = { - id: "qqbot", - setupWizard: qqbotSetupWizard, - meta: { - ...qqbotMeta, - }, - capabilities: { - chatTypes: ["direct", "group"], - media: true, - reactions: false, - threads: false, - /** - * blockStreaming=true means the channel supports block streaming. - * The framework collects streamed blocks and sends them through deliver(). - */ - blockStreaming: true, - }, - reload: { configPrefixes: ["channels.qqbot"] }, - configSchema: qqbotChannelConfigSchema, - - config: { - ...qqbotConfigAdapter, - }, - setup: { - ...qqbotSetupAdapterShared, - }, + ...qqbotBasePluginFields, messaging: { /** Normalize common QQ Bot target formats into the canonical qqbot:... form. */ normalizeTarget: (target: string): string | undefined => { diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index 3a33482679b..f6859b852ce 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js"; import { QQBotConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js"; +import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js"; describe("qqbot config", () => { it("honors configured defaultAccount when resolving the default QQ Bot account id", () => { @@ -127,18 +128,7 @@ describe("qqbot config", () => { }); it("rejects unresolved SecretRefs on runtime resolution", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - }, - }, - } as OpenClawConfig; + const cfg = makeQqbotSecretRefConfig(); expect(() => resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID)).toThrow( 'channels.qqbot.clientSecret: unresolved SecretRef "env:default:QQBOT_CLIENT_SECRET"', @@ -146,18 +136,7 @@ describe("qqbot config", () => { }); it("allows unresolved SecretRefs for setup/status flows", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - }, - }, - } as OpenClawConfig; + const cfg = makeQqbotSecretRefConfig(); const resolved = resolveQQBotAccount(cfg, DEFAULT_ACCOUNT_ID, { allowUnresolvedSecretRef: true, @@ -254,16 +233,7 @@ describe("qqbot config", () => { expect( runtimeSetup.resolveAccountId?.({ - cfg: { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { appId: "123456" }, - }, - }, - }, - } as OpenClawConfig, + cfg: makeQqbotDefaultAccountConfig(), accountId: undefined, } as never), ).toBe("bot2"); diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts index 9a9052eabb9..7e18728aa6b 100644 --- a/extensions/qqbot/src/config.ts +++ b/extensions/qqbot/src/config.ts @@ -42,6 +42,23 @@ function normalizeAppId(raw: unknown): string { return ""; } +function buildQQBotAccountConfigPatch(input: { + appId?: string; + clientSecret?: string; + clientSecretFile?: string; + name?: string; +}): Partial { + return { + ...(input.appId ? { appId: input.appId } : {}), + ...(input.clientSecret + ? { clientSecret: input.clientSecret, clientSecretFile: undefined } + : input.clientSecretFile + ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } + : {}), + ...(input.name ? { name: input.name } : {}), + }; +} + /** List all configured QQBot account IDs. */ export function listQQBotAccountIds(cfg: OpenClawConfig): string[] { const ids = new Set(); @@ -166,6 +183,7 @@ export function applyQQBotAccountConfig( }, ): OpenClawConfig { const next = { ...cfg }; + const accountConfigPatch = buildQQBotAccountConfigPatch(input); if (accountId === DEFAULT_ACCOUNT_ID) { // Default allowFrom to ["*"] when not yet configured. @@ -178,13 +196,7 @@ export function applyQQBotAccountConfig( ...(next.channels?.qqbot as Record | undefined), enabled: true, allowFrom, - ...(input.appId ? { appId: input.appId } : {}), - ...(input.clientSecret - ? { clientSecret: input.clientSecret, clientSecretFile: undefined } - : input.clientSecretFile - ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } - : {}), - ...(input.name ? { name: input.name } : {}), + ...accountConfigPatch, }, }; } else { @@ -204,13 +216,7 @@ export function applyQQBotAccountConfig( ...(next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId], enabled: true, allowFrom, - ...(input.appId ? { appId: input.appId } : {}), - ...(input.clientSecret - ? { clientSecret: input.clientSecret, clientSecretFile: undefined } - : input.clientSecretFile - ? { clientSecretFile: input.clientSecretFile, clientSecret: undefined } - : {}), - ...(input.name ? { name: input.name } : {}), + ...accountConfigPatch, }, }, }, diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/outbound-deliver.ts index ff102c02b88..ed2a43e7d63 100644 --- a/extensions/qqbot/src/outbound-deliver.ts +++ b/extensions/qqbot/src/outbound-deliver.ts @@ -6,6 +6,10 @@ * 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media. */ +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; import { sendC2CMessage, sendDmMessage, @@ -32,18 +36,6 @@ 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; @@ -70,6 +62,30 @@ export type SendWithRetryFn = (sendFn: (token: string) => Promise) => Prom /** Consume a quote ref exactly once. */ export type ConsumeQuoteRefFn = () => string | undefined; +type ReplyModeParams = { + textWithoutImages: string; + imageUrls: string[]; + mdMatches: RegExpMatchArray[]; + bareUrlMatches: RegExpMatchArray[]; + event: DeliverEventContext; + actx: DeliverAccountContext; + sendWithRetry: SendWithRetryFn; + consumeQuoteRef: ConsumeQuoteRefFn; +}; + +function resolveReplyModeRuntime(params: ReplyModeParams) { + const { event, actx, sendWithRetry, consumeQuoteRef } = params; + const { account, log } = actx; + return { + event, + account, + log, + sendWithRetry, + consumeQuoteRef, + prefix: `[qqbot:${account.accountId}]`, + }; +} + function resolveQQBotMediaTargetContext( event: DeliverEventContext, account: ResolvedQQBotAccount, @@ -377,27 +393,27 @@ export async function sendPlainReply( } if (useMarkdown) { - await sendMarkdownReply( + await sendMarkdownReply({ textWithoutImages, - collectedImageUrls, + imageUrls: collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - ); + }); } else { - await sendPlainTextReply( + await sendPlainTextReply({ textWithoutImages, - collectedImageUrls, + imageUrls: collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, - ); + }); } // Send local media collected from payload.mediaUrl or markdown local paths. @@ -645,18 +661,10 @@ async function sendVoiceWithTimeout( } /** Send in markdown mode. */ -async function sendMarkdownReply( - textWithoutImages: string, - imageUrls: string[], - mdMatches: RegExpMatchArray[], - bareUrlMatches: RegExpMatchArray[], - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, -): Promise { - const { account, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; +async function sendMarkdownReply(params: ReplyModeParams): Promise { + const { textWithoutImages, imageUrls, mdMatches, bareUrlMatches } = params; + const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = + resolveReplyModeRuntime(params); // Split images into public URLs vs. Base64 payloads. const httpImageUrls: string[] = []; @@ -780,26 +788,17 @@ async function sendMarkdownReply( } /** Send in plain-text mode. */ -async function sendPlainTextReply( - textWithoutImages: string, - imageUrls: string[], - mdMatches: RegExpMatchArray[], - bareUrlMatches: RegExpMatchArray[], - event: DeliverEventContext, - actx: DeliverAccountContext, - sendWithRetry: SendWithRetryFn, - consumeQuoteRef: ConsumeQuoteRefFn, -): Promise { - const { account, log } = actx; - const prefix = `[qqbot:${account.accountId}]`; +async function sendPlainTextReply(params: ReplyModeParams): Promise { + const { event, account, log, sendWithRetry, consumeQuoteRef, prefix } = + resolveReplyModeRuntime(params); const imgMediaTarget = resolveQQBotMediaTargetContext(event, account, prefix); - let result = textWithoutImages; - for (const m of mdMatches) { + let result = params.textWithoutImages; + for (const m of params.mdMatches) { result = result.replace(m[0], "").trim(); } - for (const m of bareUrlMatches) { + for (const m of params.bareUrlMatches) { result = result.replace(m[0], "").trim(); } @@ -809,7 +808,7 @@ async function sendPlainTextReply( } try { - for (const imageUrl of imageUrls) { + for (const imageUrl of params.imageUrls) { await sendQQBotPhotoWithLogging({ target: imgMediaTarget, imageUrl, diff --git a/extensions/qqbot/src/qqbot-test-support.ts b/extensions/qqbot/src/qqbot-test-support.ts new file mode 100644 index 00000000000..2c3a0e87354 --- /dev/null +++ b/extensions/qqbot/src/qqbot-test-support.ts @@ -0,0 +1,29 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +export function makeQqbotSecretRefConfig(): OpenClawConfig { + return { + channels: { + qqbot: { + appId: "123456", + clientSecret: { + source: "env", + provider: "default", + id: "QQBOT_CLIENT_SECRET", + }, + }, + }, + } as OpenClawConfig; +} + +export function makeQqbotDefaultAccountConfig(): OpenClawConfig { + return { + channels: { + qqbot: { + defaultAccount: "bot2", + accounts: { + bot2: { appId: "123456" }, + }, + }, + }, + } as OpenClawConfig; +} diff --git a/extensions/qqbot/src/reply-dispatcher.ts b/extensions/qqbot/src/reply-dispatcher.ts index 9bd02f7ba46..2c879cf2b55 100644 --- a/extensions/qqbot/src/reply-dispatcher.ts +++ b/extensions/qqbot/src/reply-dispatcher.ts @@ -210,10 +210,16 @@ export async function handleStructuredPayload( // Media payload handlers. +type StructuredPayloadMediaType = "image" | "video" | "file"; + +function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string { + return mediaType[0].toUpperCase() + mediaType.slice(1); +} + function validateStructuredPayloadLocalPath( ctx: ReplyContext, payloadPath: string, - mediaType: "image" | "video" | "file", + mediaType: StructuredPayloadMediaType, ): string | null { const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath); if (allowedPath) { @@ -234,6 +240,41 @@ function isInlineImageDataUrl(p: string): boolean { return /^data:image\/[^;]+;base64,/i.test(p); } +function resolveStructuredPayloadPath( + ctx: ReplyContext, + payload: MediaPayload, + mediaType: StructuredPayloadMediaType, +): { path: string; isHttpUrl: boolean } | null { + const originalPath = payload.path ?? ""; + const normalizedPath = normalizePath(originalPath); + const isHttpUrl = isRemoteHttpUrl(normalizedPath); + const resolvedPath = isHttpUrl + ? normalizedPath + : validateStructuredPayloadLocalPath(ctx, originalPath, mediaType); + if (!resolvedPath) { + return null; + } + if (!resolvedPath.trim()) { + ctx.log?.error( + `[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`, + ); + return null; + } + return { path: resolvedPath, isHttpUrl }; +} + +function logUnsupportedStructuredMediaTarget( + ctx: ReplyContext, + mediaType: Exclude, +): void { + const label = formatMediaTypeLabel(mediaType); + if (ctx.target.type === "dm") { + ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in DM`); + } else if (ctx.target.channelId) { + ctx.log?.error(`[qqbot:${ctx.account.accountId}] ${label} not supported in channel`); + } +} + function sanitizeForLog(value: string, maxLen = 200): string { return value .replace(/[\r\n\t]/g, " ") @@ -505,19 +546,12 @@ async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Pro async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise { const { target, account, log } = ctx; try { - const originalPath = payload.path ?? ""; - const normalizedPath = normalizePath(originalPath); - const isHttpUrl = isRemoteHttpUrl(normalizedPath); - const videoPath = isHttpUrl - ? normalizedPath - : validateStructuredPayloadLocalPath(ctx, originalPath, "video"); - if (!videoPath) { - return; - } - if (!videoPath.trim()) { - log?.error(`[qqbot:${account.accountId}] Video missing path`); + const resolved = resolveStructuredPayloadPath(ctx, payload, "video"); + if (!resolved) { return; } + const videoPath = resolved.path; + const isHttpUrl = resolved.isHttpUrl; log?.info( `[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`, @@ -546,10 +580,8 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro undefined, target.messageId, ); - } else if (target.type === "dm") { - log?.error(`[qqbot:${account.accountId}] Video not supported in DM`); - } else if (target.channelId) { - log?.error(`[qqbot:${account.accountId}] Video not supported in channel`); + } else { + logUnsupportedStructuredMediaTarget(ctx, "video"); } } else { const fileBuffer = await readStructuredPayloadLocalFile(videoPath); @@ -578,10 +610,8 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro videoBase64, target.messageId, ); - } else if (target.type === "dm") { - log?.error(`[qqbot:${account.accountId}] Video not supported in DM`); - } else if (target.channelId) { - log?.error(`[qqbot:${account.accountId}] Video not supported in channel`); + } else { + logUnsupportedStructuredMediaTarget(ctx, "video"); } } }, @@ -603,19 +633,12 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise { const { target, account, log } = ctx; try { - const originalPath = payload.path ?? ""; - const normalizedPath = normalizePath(originalPath); - const isHttpUrl = isRemoteHttpUrl(normalizedPath); - const filePath = isHttpUrl - ? normalizedPath - : validateStructuredPayloadLocalPath(ctx, originalPath, "file"); - if (!filePath) { - return; - } - if (!filePath.trim()) { - log?.error(`[qqbot:${account.accountId}] File missing path`); + const resolved = resolveStructuredPayloadPath(ctx, payload, "file"); + if (!resolved) { return; } + const filePath = resolved.path; + const isHttpUrl = resolved.isHttpUrl; const fileName = sanitizeFileName(path.basename(filePath)); log?.info( @@ -647,10 +670,8 @@ async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Prom target.messageId, fileName, ); - } else if (target.type === "dm") { - log?.error(`[qqbot:${account.accountId}] File not supported in DM`); - } else if (target.channelId) { - log?.error(`[qqbot:${account.accountId}] File not supported in channel`); + } else { + logUnsupportedStructuredMediaTarget(ctx, "file"); } } else { const fileBuffer = await readStructuredPayloadLocalFile(filePath); @@ -676,10 +697,8 @@ async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Prom target.messageId, fileName, ); - } else if (target.type === "dm") { - log?.error(`[qqbot:${account.accountId}] File not supported in DM`); - } else if (target.channelId) { - log?.error(`[qqbot:${account.accountId}] File not supported in channel`); + } else { + logUnsupportedStructuredMediaTarget(ctx, "file"); } } }, diff --git a/extensions/qqbot/src/session-store.ts b/extensions/qqbot/src/session-store.ts index 1139f66690e..c5bc9a98c91 100644 --- a/extensions/qqbot/src/session-store.ts +++ b/extensions/qqbot/src/session-store.ts @@ -56,6 +56,16 @@ function getCandidateSessionPaths(accountId: string): string[] { return primaryPath === legacyPath ? [primaryPath] : [primaryPath, legacyPath]; } +function isSessionFileName(file: string): boolean { + return file.startsWith("session-") && file.endsWith(".json"); +} + +function readSessionStateFile(file: string): { filePath: string; state: SessionState } { + const filePath = path.join(SESSION_DIR, file); + const data = fs.readFileSync(filePath, "utf-8"); + return { filePath, state: JSON.parse(data) as SessionState }; +} + /** Load a saved session, rejecting expired or mismatched appId entries. */ export function loadSession(accountId: string, expectedAppId?: string): SessionState | null { try { @@ -227,11 +237,9 @@ export function getAllSessions(): SessionState[] { const files = fs.readdirSync(SESSION_DIR); for (const file of files) { - if (file.startsWith("session-") && file.endsWith(".json")) { - const filePath = path.join(SESSION_DIR, file); + if (isSessionFileName(file)) { try { - const data = fs.readFileSync(filePath, "utf-8"); - const state = JSON.parse(data) as SessionState; + const { state } = readSessionStateFile(file); if (typeof state.accountId !== "string" || !state.accountId) { continue; } @@ -263,11 +271,10 @@ export function cleanupExpiredSessions(): number { const now = Date.now(); for (const file of files) { - if (file.startsWith("session-") && file.endsWith(".json")) { + if (isSessionFileName(file)) { const filePath = path.join(SESSION_DIR, file); try { - const data = fs.readFileSync(filePath, "utf-8"); - const state = JSON.parse(data) as SessionState; + const { state } = readSessionStateFile(file); if (now - state.savedAt > SESSION_EXPIRE_TIME) { fs.unlinkSync(filePath); diff --git a/extensions/qqbot/src/setup-surface.ts b/extensions/qqbot/src/setup-surface.ts index 05ff48de9f5..18889e51355 100644 --- a/extensions/qqbot/src/setup-surface.ts +++ b/extensions/qqbot/src/setup-surface.ts @@ -17,6 +17,30 @@ import { const channel = "qqbot" as const; type QQBotEnvCredentialField = "appId" | "clientSecret"; +type QQBotSetupCredentialState = { + accountConfigured: boolean; + hasConfiguredSecretValue: boolean; + resolvedAppId?: string; + resolvedClientSecret?: string; +}; + +function resolveQQBotSetupCredentialState( + cfg: OpenClawConfig, + accountId: string, +): QQBotSetupCredentialState { + const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); + const hasConfiguredSecretValue = Boolean( + hasConfiguredSecretInput(resolved.config.clientSecret) || + normalizeOptionalString(resolved.config.clientSecretFile) || + resolved.clientSecret, + ); + return { + accountConfigured: Boolean(resolved.appId && hasConfiguredSecretValue), + hasConfiguredSecretValue, + resolvedAppId: resolved.appId || undefined, + resolvedClientSecret: resolved.clientSecret || undefined, + }; +} /** * Clear only the credential fields owned by the setup prompt that switched to @@ -100,16 +124,11 @@ export const qqbotSetupWizard: ChannelSetupWizard = { inputPrompt: "Enter QQ Bot AppID", allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, inspect: ({ cfg, accountId }) => { - const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); - const hasConfiguredValue = Boolean( - hasConfiguredSecretInput(resolved.config.clientSecret) || - normalizeOptionalString(resolved.config.clientSecretFile) || - resolved.clientSecret, - ); + const state = resolveQQBotSetupCredentialState(cfg, accountId); return { - accountConfigured: Boolean(resolved.appId && hasConfiguredValue), - hasConfiguredValue: Boolean(resolved.appId), - resolvedValue: resolved.appId || undefined, + accountConfigured: state.accountConfigured, + hasConfiguredValue: Boolean(state.resolvedAppId), + resolvedValue: state.resolvedAppId, envValue: accountId === DEFAULT_ACCOUNT_ID ? normalizeOptionalString(process.env.QQBOT_APP_ID) @@ -133,16 +152,11 @@ export const qqbotSetupWizard: ChannelSetupWizard = { inputPrompt: "Enter QQ Bot AppSecret", allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, inspect: ({ cfg, accountId }) => { - const resolved = resolveQQBotAccount(cfg, accountId, { allowUnresolvedSecretRef: true }); - const hasConfiguredValue = Boolean( - hasConfiguredSecretInput(resolved.config.clientSecret) || - normalizeOptionalString(resolved.config.clientSecretFile) || - resolved.clientSecret, - ); + const state = resolveQQBotSetupCredentialState(cfg, accountId); return { - accountConfigured: Boolean(resolved.appId && hasConfiguredValue), - hasConfiguredValue, - resolvedValue: resolved.clientSecret || undefined, + accountConfigured: state.accountConfigured, + hasConfiguredValue: state.hasConfiguredSecretValue, + resolvedValue: state.resolvedClientSecret, envValue: accountId === DEFAULT_ACCOUNT_ID ? normalizeOptionalString(process.env.QQBOT_CLIENT_SECRET) diff --git a/extensions/qqbot/src/setup.test.ts b/extensions/qqbot/src/setup.test.ts index a4a85c7a69e..4b0572bee1c 100644 --- a/extensions/qqbot/src/setup.test.ts +++ b/extensions/qqbot/src/setup.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js"; import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./channel-config-shared.js"; import { DEFAULT_ACCOUNT_ID } from "./config.js"; +import { makeQqbotDefaultAccountConfig, makeQqbotSecretRefConfig } from "./qqbot-test-support.js"; import { qqbotSetupWizard } from "./setup-surface.js"; const qqbotSetupPlugin = { @@ -89,18 +90,7 @@ describe("qqbot setup", () => { }); it("marks unresolved SecretRef accounts as configured in setup-only plugin status", () => { - const cfg = { - channels: { - qqbot: { - appId: "123456", - clientSecret: { - source: "env", - provider: "default", - id: "QQBOT_CLIENT_SECRET", - }, - }, - }, - } as OpenClawConfig; + const cfg = makeQqbotSecretRefConfig(); const account = qqbotSetupPlugin.config.resolveAccount?.(cfg, DEFAULT_ACCOUNT_ID); @@ -148,16 +138,7 @@ describe("qqbot setup", () => { expect( setup.resolveAccountId?.({ - cfg: { - channels: { - qqbot: { - defaultAccount: "bot2", - accounts: { - bot2: { appId: "123456" }, - }, - }, - }, - } as OpenClawConfig, + cfg: makeQqbotDefaultAccountConfig(), accountId: undefined, } as never), ).toBe("bot2"); diff --git a/extensions/qqbot/src/tools/channel.ts b/extensions/qqbot/src/tools/channel.ts index 32342c42e1a..d9ff881f3ba 100644 --- a/extensions/qqbot/src/tools/channel.ts +++ b/extensions/qqbot/src/tools/channel.ts @@ -3,6 +3,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { getAccessToken } from "../api.js"; import { listQQBotAccountIds, resolveQQBotAccount } from "../config.js"; import { debugError, debugLog } from "../utils/debug-log.js"; +import { jsonToolResult as json } from "./result.js"; const API_BASE = "https://api.sgroup.qq.com"; const DEFAULT_TIMEOUT_MS = 30000; @@ -44,13 +45,6 @@ const ChannelApiSchema = { required: ["method", "path"], } as const; -function json(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - details: data, - }; -} - function buildUrl(path: string, query?: Record): string { let url = `${API_BASE}${path}`; if (query && Object.keys(query).length > 0) { diff --git a/extensions/qqbot/src/tools/remind.ts b/extensions/qqbot/src/tools/remind.ts index d9e175b47b0..5130ed6a82c 100644 --- a/extensions/qqbot/src/tools/remind.ts +++ b/extensions/qqbot/src/tools/remind.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { jsonToolResult as json } from "./result.js"; interface RemindParams { action: "add" | "list" | "remove"; @@ -56,13 +57,6 @@ const RemindSchema = { required: ["action"], } as const; -function json(data: unknown) { - return { - content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], - details: data, - }; -} - function parseRelativeTime(timeStr: string): number | null { const s = normalizeLowercaseStringOrEmpty(timeStr); if (/^\d+$/.test(s)) { diff --git a/extensions/qqbot/src/tools/result.ts b/extensions/qqbot/src/tools/result.ts new file mode 100644 index 00000000000..28b188f7d5b --- /dev/null +++ b/extensions/qqbot/src/tools/result.ts @@ -0,0 +1,6 @@ +export function jsonToolResult(data: unknown) { + return { + content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }], + details: data, + }; +} diff --git a/extensions/qqbot/src/utils/file-utils.ts b/extensions/qqbot/src/utils/file-utils.ts index 27410ba710e..63020a6bc8c 100644 --- a/extensions/qqbot/src/utils/file-utils.ts +++ b/extensions/qqbot/src/utils/file-utils.ts @@ -3,20 +3,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-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; diff --git a/extensions/qqbot/src/utils/media-tags.ts b/extensions/qqbot/src/utils/media-tags.ts index 329ac0f4f2d..e57c2a93caf 100644 --- a/extensions/qqbot/src/utils/media-tags.ts +++ b/extensions/qqbot/src/utils/media-tags.ts @@ -121,7 +121,7 @@ const MULTILINE_TAG_CLEANUP = new RegExp( /** Normalize malformed media-tag output into canonical wrapped tags. */ export function normalizeMediaTags(text: string): string { - let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, (_match, rawTag: string, content: string) => { + const normalizeWrappedTag = (_match: string, rawTag: string, content: string): string => { const tag = resolveTagName(rawTag); const trimmed = content.trim(); if (!trimmed) { @@ -129,7 +129,9 @@ export function normalizeMediaTags(text: string): string { } const expanded = expandTilde(trimmed); return `<${tag}>${expanded}`; - }); + }; + + let cleaned = text.replace(SELF_CLOSING_TAG_REGEX, normalizeWrappedTag); cleaned = cleaned.replace( MULTILINE_TAG_CLEANUP, @@ -139,13 +141,5 @@ export function normalizeMediaTags(text: string): string { }, ); - return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, (_match, rawTag: string, content: string) => { - const tag = resolveTagName(rawTag); - const trimmed = content.trim(); - if (!trimmed) { - return _match; - } - const expanded = expandTilde(trimmed); - return `<${tag}>${expanded}`; - }); + return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, normalizeWrappedTag); }