refactor: dedupe qqbot helpers

This commit is contained in:
Peter Steinberger
2026-04-21 00:03:19 +01:00
parent 8e681123d8
commit 594337698f
16 changed files with 256 additions and 268 deletions

View File

@@ -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<ChannelPlugin<ResolvedQQBotAccount>> & {
id: "qqbot";
};

View File

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

View File

@@ -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<QQBotOutboundModule> {
}
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
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 => {

View File

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

View File

@@ -42,6 +42,23 @@ function normalizeAppId(raw: unknown): string {
return "";
}
function buildQQBotAccountConfigPatch(input: {
appId?: string;
clientSecret?: string;
clientSecretFile?: string;
name?: string;
}): Partial<QQBotAccountConfig> {
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<string>();
@@ -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<string, unknown> | 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,
},
},
},

View File

@@ -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 = <T>(sendFn: (token: string) => Promise<T>) => 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<void> {
const { account, log } = actx;
const prefix = `[qqbot:${account.accountId}]`;
async function sendMarkdownReply(params: ReplyModeParams): Promise<void> {
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<void> {
const { account, log } = actx;
const prefix = `[qqbot:${account.accountId}]`;
async function sendPlainTextReply(params: ReplyModeParams): Promise<void> {
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,

View File

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

View File

@@ -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<StructuredPayloadMediaType, "image">,
): 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<void> {
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<void> {
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");
}
}
},

View File

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

View File

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

View File

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

View File

@@ -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, string>): string {
let url = `${API_BASE}${path}`;
if (query && Object.keys(query).length > 0) {

View File

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

View File

@@ -0,0 +1,6 @@
export function jsonToolResult(data: unknown) {
return {
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
details: data,
};
}

View File

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

View File

@@ -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}</${tag}>`;
});
};
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}</${tag}>`;
});
return cleaned.replace(FUZZY_MEDIA_TAG_REGEX, normalizeWrappedTag);
}