mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:50:43 +00:00
refactor: dedupe qqbot helpers
This commit is contained in:
30
extensions/qqbot/src/channel-base.ts
Normal file
30
extensions/qqbot/src/channel-base.ts
Normal 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";
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
extensions/qqbot/src/qqbot-test-support.ts
Normal file
29
extensions/qqbot/src/qqbot-test-support.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
6
extensions/qqbot/src/tools/result.ts
Normal file
6
extensions/qqbot/src/tools/result.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function jsonToolResult(data: unknown) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user