Gate private QQBot group commands (#92154)

* fix: gate private qqbot group commands

* fix(qqbot): keep authorized stop urgent in groups

* fix(qqbot): preserve omitted group command level

* fix(qqbot): preserve ignore-other-mentions gate

* test(qqbot): avoid unbound mention gate mock

* fix(qqbot): close strict command visibility gaps

* fix(qqbot): gate private group commands and close strict command visibility gaps (#92154) (thanks @sliverp)
This commit is contained in:
Sliverp
2026-06-23 17:20:50 +08:00
committed by GitHub
parent 1ce8eb3993
commit 9535b102d3
25 changed files with 955 additions and 56 deletions
+1
View File
@@ -37,6 +37,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
#### Pull requests
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
+16
View File
@@ -151,6 +151,7 @@ to a group, then mention it or configure the group to run without a mention.
groups: {
"*": {
requireMention: true,
commandLevel: "all",
historyLimit: 50,
tools: { deny: ["exec", "read", "write"] },
},
@@ -158,6 +159,7 @@ to a group, then mention it or configure the group to run without a mention.
name: "Release room",
requireMention: false,
ignoreOtherMentions: true,
commandLevel: "safety",
historyLimit: 20,
prompt: "Keep replies short and operational.",
},
@@ -172,6 +174,9 @@ to a group, then mention it or configure the group to run without a mention.
settings include:
- `requireMention`: require an @mention before the bot replies. Default: `true`.
- `commandLevel`: control which built-in slash commands can run in groups.
Default: `all`, which preserves the pre-existing QQBot group behavior when the
setting is omitted.
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
- `tools`: allow/deny tools for the whole group.
@@ -179,6 +184,17 @@ settings include:
- `name`: friendly label used in logs and group context.
- `prompt`: per-group behavior prompt appended to the agent context.
`commandLevel` accepts:
- `all`: keep recognized built-in commands available as before. Some commands may
stay hidden from menus, but authorized users can still run them in the group.
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
`/bash` in private chat.
- `strict`: only allow the group-session controls needed for strict group
operation. `/stop` still stays urgent so an authorized sender can interrupt an
active run.
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
Activation modes are `mention` and `always`. `requireMention: true` maps to
+25 -2
View File
@@ -61,6 +61,27 @@
"secretInput": {
"anyOf": [{ "type": "string", "minLength": 1 }, { "$ref": "#/$defs/secretRef" }]
},
"group": {
"type": "object",
"additionalProperties": true,
"properties": {
"requireMention": { "type": "boolean" },
"commandLevel": {
"type": "string",
"enum": ["all", "safety", "strict"]
},
"ignoreOtherMentions": { "type": "boolean" },
"historyLimit": { "type": "number" },
"name": { "type": "string" },
"prompt": { "type": "string" }
}
},
"groups": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/group"
}
},
"account": {
"type": "object",
"additionalProperties": true,
@@ -108,7 +129,8 @@
}
}
]
}
},
"groups": { "$ref": "#/$defs/groups" }
}
}
},
@@ -164,7 +186,8 @@
"$ref": "#/$defs/account"
}
},
"defaultAccount": { "type": "string" }
"defaultAccount": { "type": "string" },
"groups": { "$ref": "#/$defs/groups" }
}
}
}
@@ -11,6 +11,7 @@
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import type { SlashCommandContext } from "../../engine/commands/slash-commands.js";
import type { QQBotGroupCommandLevel } from "../../engine/config/group.js";
import type { ResolvedQQBotAccount } from "../../types.js";
import type { QQBotFromParseResult } from "./from-parser.js";
@@ -32,6 +33,7 @@ interface BuildFrameworkSlashContextInput {
account: ResolvedQQBotAccount;
from: QQBotFromParseResult;
commandName: string;
groupCommandLevel?: QQBotGroupCommandLevel;
}
export function buildFrameworkSlashContext({
@@ -39,6 +41,7 @@ export function buildFrameworkSlashContext({
account,
from,
commandName,
groupCommandLevel,
}: BuildFrameworkSlashContextInput): SlashCommandContext {
const args = ctx.args ?? "";
const rawContent = args ? `/${commandName} ${args}` : `/${commandName}`;
@@ -55,6 +58,7 @@ export function buildFrameworkSlashContext({
appId: account.appId,
accountConfig: account.config as unknown as Record<string, unknown>,
commandAuthorized: ctx.isAuthorizedSender,
groupCommandLevel,
queueSnapshot: { ...DEFAULT_QUEUE_SNAPSHOT },
};
}
@@ -94,9 +94,25 @@ describe("registerQQBotFrameworkCommands", () => {
createCommandContext(config, "qqbot:group:GROUP_OPENID"),
);
expect(missingFromResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(nonQQBotResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(groupResult).toEqual({ text: "💡 请在私聊中使用此指令" });
expect(missingFromResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(nonQQBotResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(groupResult).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(writes).toHaveLength(0);
});
it("keeps private-only framework commands private when command level is all", async () => {
const config = createConfig();
const qqbot = config.channels?.qqbot as Record<string, unknown>;
qqbot.groups = {
GROUP_OPENID: { commandLevel: "all" },
};
const writes: OpenClawConfig[] = [];
installCommandRuntime(config, writes);
const command = findCommand(registerCommands(), "bot-streaming");
const result = await command.handler(createCommandContext(config, "qqbot:group:GROUP_OPENID"));
expect(result).toEqual({ text: "该命令仅限私聊使用,请在私聊中发送。" });
expect(writes).toHaveLength(0);
});
@@ -12,14 +12,14 @@
*/
import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { PRIVATE_CHAT_ONLY_TEXT } from "../../engine/commands/command-visibility.js";
import { getFrameworkCommands } from "../../engine/commands/slash-commands-impl.js";
import { resolveGroupCommandLevelFromAccountConfig } from "../../engine/config/group.js";
import { resolveQQBotAccount } from "../config.js";
import { buildFrameworkSlashContext } from "./framework-context-adapter.js";
import { parseQQBotFrom } from "./from-parser.js";
import { dispatchFrameworkSlashResult } from "./result-dispatcher.js";
const PRIVATE_CHAT_ONLY_TEXT = "💡 请在私聊中使用此指令";
function isExplicitQQBotC2cFrom(from: string | undefined | null): boolean {
const raw = (from ?? "").trim();
const stripped = raw.replace(/^qqbot:/iu, "");
@@ -41,17 +41,25 @@ export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const groupCommandLevel =
from.msgType === "group" || from.msgType === "guild"
? resolveGroupCommandLevelFromAccountConfig(
account.config as unknown as Record<string, unknown>,
from.targetId,
)
: undefined;
if (cmd.c2cOnly && !isExplicitQQBotC2cFrom(ctx.from)) {
return { text: PRIVATE_CHAT_ONLY_TEXT };
}
const from = parseQQBotFrom(ctx.from);
const account = resolveQQBotAccount(ctx.config, ctx.accountId ?? undefined);
const slashCtx = buildFrameworkSlashContext({
ctx,
account,
from,
commandName: cmd.name,
groupCommandLevel,
});
const result = await cmd.handler(slashCtx);
return await dispatchFrameworkSlashResult({
+2
View File
@@ -54,10 +54,12 @@ const QQBotExecApprovalsSchema = z
const QQBotDmPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupPolicySchema = z.enum(["open", "allowlist", "disabled"]).optional();
const QQBotGroupCommandLevelSchema = z.enum(["all", "safety", "strict"]).optional();
const QQBotGroupSchema = z
.object({
requireMention: z.boolean().optional(),
commandLevel: QQBotGroupCommandLevelSchema,
ignoreOtherMentions: z.boolean().optional(),
historyLimit: z.number().optional(),
name: z.string().optional(),
+2 -1
View File
@@ -137,6 +137,7 @@ describe("qqbot config", () => {
groups: {
G1: {
requireMention: true,
commandLevel: "safety",
tools: { deny: ["*"] },
toolsBySender: {
"id:alice": { allow: ["read"] },
@@ -146,7 +147,7 @@ describe("qqbot config", () => {
accounts: {
bot2: {
groups: {
G1: { tools: { allow: [] } },
G1: { commandLevel: "strict", tools: { allow: [] } },
},
},
},
@@ -0,0 +1,86 @@
// Qqbot tests cover group command visibility classification.
import { describe, expect, it } from "vitest";
import { classifyCoreCommandForGroup, parseSlashCommandName } from "./command-visibility.js";
describe("QQBot command visibility", () => {
it("parses slash command names case-insensitively", () => {
expect(parseSlashCommandName(" /NEW now ")).toBe("new");
expect(parseSlashCommandName("/CONFIG: show")).toBe("config");
expect(parseSlashCommandName("/configshow")).toBe("config");
expect(parseSlashCommandName("/config@bot show")).toBe("config");
expect(parseSlashCommandName("hello")).toBeUndefined();
});
it("keeps safe collaboration commands visible in groups", () => {
for (const command of ["/help", "/btw side question", "/stop"]) {
expect(classifyCoreCommandForGroup(command).visibility).toBe("group");
}
});
it("keeps group-session controls callable but hidden from group menus", () => {
for (const command of ["/new", "/reset", "/name", "/compact"]) {
expect(classifyCoreCommandForGroup(command).visibility).toBe("hidden");
}
expect(classifyCoreCommandForGroup("/name", "safety").visibility).toBe("hidden");
});
it("marks sensitive core commands as private-only in groups", () => {
for (const command of [
"/config",
"/bash",
"/export-session",
"/diagnostics",
"/tts",
"/steer",
"/tell",
"/model",
"/models",
"/status",
"/verbose",
"/v",
"/config: show",
"/model@bot sonnet",
]) {
expect(classifyCoreCommandForGroup(command, "safety").visibility).toBe("private");
}
});
it("keeps omitted command level compatible with all mode", () => {
for (const command of ["/config", "/bash", "/new", "/status"]) {
expect(classifyCoreCommandForGroup(command).visibility).not.toBe("private");
}
});
it("allows every recognized core command in all mode", () => {
for (const command of ["/config", "/bash", "/new", "/name", "/status"]) {
expect(classifyCoreCommandForGroup(command, "all").visibility).not.toBe("private");
}
});
it("keeps urgent stop callable in strict mode", () => {
expect(classifyCoreCommandForGroup("/stop", "strict").visibility).toBe("group");
});
it("limits other core commands in strict mode", () => {
expect(classifyCoreCommandForGroup("/new", "strict").visibility).toBe("hidden");
expect(classifyCoreCommandForGroup("/reset", "strict").visibility).toBe("hidden");
expect(classifyCoreCommandForGroup("/name", "strict").visibility).toBe("private");
expect(classifyCoreCommandForGroup("/status", "strict").visibility).toBe("private");
expect(classifyCoreCommandForGroup("/config", "strict").visibility).toBe("private");
});
it("keeps strict mode fail-closed for unclassified slash commands", () => {
expect(classifyCoreCommandForGroup("/bot-dynamic", "strict").visibility).toBe("private");
expect(classifyCoreCommandForGroup("/unknown", "strict").visibility).toBe("private");
});
it("does not make plugin and unknown slash commands private in all mode", () => {
expect(classifyCoreCommandForGroup("/bot-help").visibility).not.toBe("private");
expect(classifyCoreCommandForGroup("/unknown").visibility).not.toBe("private");
});
it("leaves plugin and unknown slash commands to their existing dispatch path in safety mode", () => {
expect(classifyCoreCommandForGroup("/bot-help", "safety").visibility).toBe("unknown");
expect(classifyCoreCommandForGroup("/unknown", "safety").visibility).toBe("unknown");
});
});
@@ -0,0 +1,119 @@
// Qqbot plugin module classifies slash-command visibility for QQ group chats.
import type { QQBotGroupCommandLevel } from "../config/group.js";
export type GroupCommandVisibility = "group" | "hidden" | "private" | "unknown";
export const PRIVATE_CHAT_ONLY_TEXT = "该命令仅限私聊使用,请在私聊中发送。";
const GROUP_VISIBLE_CORE_COMMANDS = new Set(["help", "btw", "side", "stop"]);
const STRICT_CORE_COMMANDS = new Set(["new", "reset"]);
const GROUP_HIDDEN_CORE_COMMANDS = new Set([
"goal",
"usage",
"activation",
"send",
"reset",
"new",
"name",
"compact",
"think",
"thinking",
"t",
"fast",
"reasoning",
"reason",
"queue",
]);
const PRIVATE_ONLY_CORE_COMMANDS = new Set([
"commands",
"tools",
"skill",
"diagnostics",
"crestodian",
"tasks",
"allowlist",
"approve",
"context",
"export-session",
"export",
"export-trajectory",
"trajectory",
"tts",
"whoami",
"id",
"session",
"subagents",
"acp",
"focus",
"unfocus",
"agents",
"steer",
"tell",
"config",
"mcp",
"plugins",
"plugin",
"debug",
"status",
"restart",
"trace",
"verbose",
"v",
"elevated",
"elev",
"exec",
"model",
"models",
"bash",
]);
export function parseSlashCommandName(content: string | undefined | null): string | undefined {
const trimmed = (content ?? "").trim();
if (!trimmed.startsWith("/")) {
return undefined;
}
const firstToken = trimmed.slice(1).split(/\s+/, 1)[0]?.trim().toLowerCase() ?? "";
const commandName = firstToken.split(/[@:]/u, 1)[0] ?? "";
return commandName || undefined;
}
export function classifyCoreCommandForGroup(
content: string | undefined | null,
commandLevel: QQBotGroupCommandLevel = "all",
): {
commandName?: string;
visibility: GroupCommandVisibility;
} {
const commandName = parseSlashCommandName(content);
if (!commandName) {
return { visibility: "unknown" };
}
if (commandLevel === "all") {
return {
commandName,
visibility: GROUP_VISIBLE_CORE_COMMANDS.has(commandName) ? "group" : "hidden",
};
}
if (commandLevel === "strict") {
if (commandName === "stop") {
return { commandName, visibility: "group" };
}
if (STRICT_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "hidden" };
}
return { commandName, visibility: "private" };
}
if (GROUP_VISIBLE_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "group" };
}
if (GROUP_HIDDEN_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "hidden" };
}
if (PRIVATE_ONLY_CORE_COMMANDS.has(commandName)) {
return { commandName, visibility: "private" };
}
return { commandName, visibility: "unknown" };
}
@@ -27,6 +27,27 @@ function createStreamingMessage(): QueuedMessage {
};
}
function createGroupStopMessage(): QueuedMessage {
return {
type: "group",
senderId: "TRUSTED_OPENID",
content: "/stop",
messageId: "msg-stop",
timestamp: "2026-01-01T00:00:00.000Z",
groupOpenid: "GROUP_OPENID",
};
}
function createDmStopMessage(): QueuedMessage {
return {
type: "c2c",
senderId: "TRUSTED_OPENID",
content: "/stop",
messageId: "msg-stop-dm",
timestamp: "2026-01-01T00:00:00.000Z",
};
}
function createAccount(): GatewayAccount {
return {
accountId: "default",
@@ -40,6 +61,10 @@ function createAccount(): GatewayAccount {
};
}
function authorizeGroupCommands(account: GatewayAccount): void {
account.config.groupAllowFrom = ["TRUSTED_OPENID"];
}
describe("trySlashCommand", () => {
beforeEach(() => {
vi.mocked(sendText).mockClear();
@@ -80,4 +105,77 @@ describe("trySlashCommand", () => {
expect(qqbot?.streaming).toBe(true);
expect(vi.mocked(sendText).mock.calls.at(0)?.[1]).toContain("已开启");
});
it("keeps group /stop urgent when command level is strict", async () => {
const account = createAccount();
authorizeGroupCommands(account);
account.config.groups = {
GROUP_OPENID: { commandLevel: "strict" },
};
const result = await trySlashCommand(createGroupStopMessage(), {
account,
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
it("keeps group /stop urgent outside strict command level", async () => {
const account = createAccount();
authorizeGroupCommands(account);
const result = await trySlashCommand(createGroupStopMessage(), {
account,
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
it("does not let unauthorized group /stop bypass the queue", async () => {
const result = await trySlashCommand(createGroupStopMessage(), {
account: createAccount(),
cfg: {},
getMessagePeerId: () => "group:GROUP_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("enqueue");
});
it("keeps open DM /stop urgent", async () => {
const result = await trySlashCommand(createDmStopMessage(), {
account: createAccount(),
cfg: {},
getMessagePeerId: () => "c2c:TRUSTED_OPENID",
getQueueSnapshot: () => ({
totalPending: 0,
activeUsers: 0,
maxConcurrentUsers: 1,
senderPending: 0,
}),
});
expect(result).toBe("urgent");
});
});
@@ -5,6 +5,7 @@
* Handles urgent commands, normal slash commands, and file delivery.
*/
import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js";
import type { QueuedMessage } from "../gateway/message-queue.js";
import type { GatewayAccount, EngineLogger } from "../gateway/types.js";
import { sendDocument } from "../messaging/outbound.js";
@@ -58,20 +59,13 @@ export async function trySlashCommand(
return "enqueue";
}
// Urgent command detection — bypass queue and execute immediately.
const contentLower = content.toLowerCase();
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
);
if (isUrgentCommand) {
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
return "urgent";
}
// Normal slash command — try to match and execute.
const receivedAt = Date.now();
const peerId = ctx.getMessagePeerId(msg);
const isGroup = msg.type === "group" || msg.type === "guild";
const groupCommandLevel = isGroup
? resolveGroupCommandLevelFromAccountConfig(
account.config,
msg.groupOpenid ?? msg.channelId ?? null,
)
: undefined;
const commandsAllowFrom = resolveQQBotCommandsAllowFrom(ctx.cfg);
const commandAuthorized = ctx.resolveCommandAuthorized
? await ctx.resolveCommandAuthorized({
@@ -89,6 +83,23 @@ export async function trySlashCommand(
groupAllowFrom: account.config?.groupAllowFrom,
commandsAllowFrom,
});
// Urgent command detection — bypass queue and execute immediately.
const contentLower = content.toLowerCase();
const isUrgentCommand = URGENT_COMMANDS.some(
(cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "),
);
if (isUrgentCommand) {
if (isGroup && !commandAuthorized) {
return "enqueue";
}
log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
return "urgent";
}
// Normal slash command — try to match and execute.
const receivedAt = Date.now();
const peerId = ctx.getMessagePeerId(msg);
const cmdCtx: SlashCommandContext = {
type: msg.type,
senderId: msg.senderId,
@@ -104,6 +115,7 @@ export async function trySlashCommand(
appId: account.appId,
accountConfig: account.config,
commandAuthorized,
groupCommandLevel,
queueSnapshot: ctx.getQueueSnapshot(peerId),
};
@@ -73,6 +73,68 @@ describe("QQBot framework slash commands", () => {
expect(getFrameworkCommands().map((command) => command.name)).toContain("bot-streaming");
});
it("rejects private-only plugin commands in groups with the shared private-chat message", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-me",
groupOpenid: "group-1",
commandAuthorized: true,
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("keeps private-only plugin commands private even when command level is all", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-me",
groupOpenid: "group-1",
commandAuthorized: true,
groupCommandLevel: "all",
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("rejects plugin commands in groups when command level is strict", async () => {
const result = await matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/bot-ping",
groupOpenid: "group-1",
commandAuthorized: true,
groupCommandLevel: "strict",
}),
);
expect(result).toBe("该命令仅限私聊使用,请在私聊中发送。");
});
it("keeps requireAuth commands gated in default all group mode", async () => {
const registry = new SlashCommandRegistry();
registry.register({
name: "shared-admin",
description: "shared admin command",
requireAuth: true,
handler: () => "ok",
});
const result = await registry.matchSlashCommand(
createStreamingContext({
type: "group",
rawContent: "/shared-admin",
groupOpenid: "group-1",
commandAuthorized: false,
}),
);
expect(result).toContain("权限不足");
});
it("does not write streaming config when the sender is not command-authorized", async () => {
const writes: OpenClawConfig[] = [];
installCommandRuntime(
@@ -10,6 +10,9 @@
* Zero external dependencies.
*/
import type { QQBotGroupCommandLevel } from "../config/group.js";
import { PRIVATE_CHAT_ONLY_TEXT } from "./command-visibility.js";
// ============ Types ============
/** Slash command context (message metadata plus runtime state). */
@@ -42,6 +45,8 @@ export interface SlashCommandContext {
accountConfig?: Record<string, unknown>;
/** Whether the sender is authorized per the allowFrom config. */
commandAuthorized: boolean;
/** Effective per-group command level for group invocations. */
groupCommandLevel?: QQBotGroupCommandLevel;
/** Queue snapshot for the current sender. */
queueSnapshot: QueueSnapshot;
}
@@ -168,9 +173,15 @@ export class SlashCommandRegistry {
return null;
}
const isGroup = ctx.type === "group" || ctx.type === "guild";
const groupCommandLevel = ctx.groupCommandLevel ?? "all";
if (isGroup && groupCommandLevel === "strict") {
return PRIVATE_CHAT_ONLY_TEXT;
}
// Reject c2cOnly commands when invoked outside private chat.
if (cmd.c2cOnly && ctx.type !== "c2c") {
return `💡 请在私聊中使用此指令`;
return PRIVATE_CHAT_ONLY_TEXT;
}
// Gate sensitive commands behind the allowFrom authorization check.
@@ -178,7 +189,6 @@ export class SlashCommandRegistry {
log?.info?.(
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
);
const isGroup = ctx.type === "group" || ctx.type === "guild";
const configHint = isGroup ? "groupAllowFrom" : "allowFrom";
return `⛔ 权限不足:请先在 channels.qqbot.${configHint} 中配置明确的发送者列表后再使用 /${cmd.name}`;
}
@@ -2,10 +2,15 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
DEFAULT_GROUP_PROMPT,
resolveGroupCommandLevelFromAccountConfig,
resolveGroupConfig,
resolveGroupName,
resolveGroupPrompt,
resolveGroupSettings,
resolveHistoryLimit,
resolveIgnoreOtherMentions,
resolveMentionPatterns,
resolveRequireMention,
} from "./group.js";
describe("engine/config/group", () => {
@@ -15,6 +20,7 @@ describe("engine/config/group", () => {
expect(cfg).toStrictEqual({
requireMention: true,
ignoreOtherMentions: false,
commandLevel: "all",
name: "",
prompt: undefined,
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
@@ -29,6 +35,7 @@ describe("engine/config/group", () => {
groups: {
"*": {
requireMention: false,
commandLevel: "strict",
historyLimit: 20,
name: "wild",
},
@@ -38,6 +45,7 @@ describe("engine/config/group", () => {
};
const resolved = resolveGroupConfig(cfg, "G1");
expect(resolved.requireMention).toBe(false);
expect(resolved.commandLevel).toBe("strict");
expect(resolved.historyLimit).toBe(20);
expect(resolved.name).toBe("wild");
});
@@ -48,14 +56,15 @@ describe("engine/config/group", () => {
qqbot: {
appId: "1",
groups: {
"*": { requireMention: true, historyLimit: 20 },
GROUPA: { requireMention: false, historyLimit: 5, name: "A" },
"*": { requireMention: true, commandLevel: "strict", historyLimit: 20 },
GROUPA: { requireMention: false, commandLevel: "all", historyLimit: 5, name: "A" },
},
},
},
};
const resolved = resolveGroupConfig(cfg, "GROUPA");
expect(resolved.requireMention).toBe(false);
expect(resolved.commandLevel).toBe("all");
expect(resolved.historyLimit).toBe(5);
expect(resolved.name).toBe("A");
});
@@ -66,7 +75,7 @@ describe("engine/config/group", () => {
qqbot: { appId: "1", groups: { "*": { historyLimit: -3.7 } } },
},
};
expect(resolveGroupConfig(cfg, "G").historyLimit).toBe(0);
expect(resolveHistoryLimit(cfg, "G")).toBe(0);
});
it("non-finite historyLimit falls back to default", () => {
@@ -75,7 +84,7 @@ describe("engine/config/group", () => {
qqbot: { appId: "1", groups: { "*": { historyLimit: "not a number" } } },
},
};
expect(resolveGroupConfig(cfg, "G").historyLimit).toBe(DEFAULT_GROUP_HISTORY_LIMIT);
expect(resolveHistoryLimit(cfg, "G")).toBe(DEFAULT_GROUP_HISTORY_LIMIT);
});
describe("account-level defaultRequireMention layer", () => {
@@ -99,7 +108,7 @@ describe("engine/config/group", () => {
},
},
};
expect(resolveGroupConfig(cfg, "G1", "bot2").requireMention).toBe(false);
expect(resolveRequireMention(cfg, "G1", "bot2")).toBe(false);
});
it("wildcard overrides account-level defaultRequireMention", () => {
@@ -149,28 +158,47 @@ describe("engine/config/group", () => {
},
},
};
const resolved = resolveGroupConfig(cfg, "G", "bot2");
expect(resolved.requireMention).toBe(false);
expect(resolved.historyLimit).toBe(7);
expect(resolveRequireMention(cfg, "G", "bot2")).toBe(false);
expect(resolveHistoryLimit(cfg, "G", "bot2")).toBe(7);
});
});
describe("resolveGroupSettings name", () => {
describe("resolveGroupCommandLevelFromAccountConfig", () => {
it("defaults to all when unset", () => {
expect(resolveGroupCommandLevelFromAccountConfig({}, "G")).toBe("all");
});
it("uses specific group before wildcard", () => {
expect(
resolveGroupCommandLevelFromAccountConfig(
{
groups: {
"*": { commandLevel: "strict" },
G1: { commandLevel: "all" },
},
},
"G1",
),
).toBe("all");
});
});
describe("resolveGroupName", () => {
it("uses the first 8 chars of openid when name is unset", () => {
expect(resolveGroupSettings({ cfg: {}, groupOpenid: "ABCDEFGH1234" }).name).toBe("ABCDEFGH");
expect(resolveGroupName({}, "ABCDEFGH1234")).toBe("ABCDEFGH");
});
it("prefers the configured name", () => {
const cfg = {
channels: { qqbot: { appId: "1", groups: { ABCDEFGH1234: { name: "Foo" } } } },
};
expect(resolveGroupSettings({ cfg, groupOpenid: "ABCDEFGH1234" }).name).toBe("Foo");
expect(resolveGroupName(cfg, "ABCDEFGH1234")).toBe("Foo");
});
});
describe("resolveGroupConfig prompt", () => {
describe("resolveGroupPrompt", () => {
it("returns the default prompt when nothing configured", () => {
expect(resolveGroupConfig({}, "G").prompt ?? DEFAULT_GROUP_PROMPT).toContain("bot");
expect(resolveGroupPrompt({}, "G")).toContain("bot");
});
it("prefers specific over wildcard", () => {
@@ -182,21 +210,21 @@ describe("engine/config/group", () => {
},
},
};
expect(resolveGroupConfig(cfg, "G1").prompt).toBe("SPEC");
expect(resolveGroupConfig(cfg, "G2").prompt).toBe("WILD");
expect(resolveGroupPrompt(cfg, "G1")).toBe("SPEC");
expect(resolveGroupPrompt(cfg, "G2")).toBe("WILD");
});
});
describe("resolveGroupConfig ignoreOtherMentions", () => {
describe("resolveIgnoreOtherMentions", () => {
it("defaults to false", () => {
expect(resolveGroupConfig({}, "G").ignoreOtherMentions).toBe(false);
expect(resolveIgnoreOtherMentions({}, "G")).toBe(false);
});
it("honours wildcard override", () => {
const cfg = {
channels: { qqbot: { appId: "1", groups: { "*": { ignoreOtherMentions: true } } } },
};
expect(resolveGroupConfig(cfg, "G").ignoreOtherMentions).toBe(true);
expect(resolveIgnoreOtherMentions(cfg, "G")).toBe(true);
});
});
@@ -6,12 +6,18 @@ import { resolveAccountBase } from "./resolve.js";
interface GroupConfig {
requireMention: boolean;
ignoreOtherMentions: boolean;
commandLevel: QQBotGroupCommandLevel;
name: string;
prompt?: string;
historyLimit: number;
}
export type QQBotGroupCommandLevel = "all" | "safety" | "strict";
export const DEFAULT_GROUP_HISTORY_LIMIT = 50;
// Omitted commandLevel preserves shipped QQBot group behavior. Operators opt in to
// the fail-closed safety/strict modes per group or wildcard group config.
export const DEFAULT_GROUP_COMMAND_LEVEL: QQBotGroupCommandLevel = "all";
export const DEFAULT_GROUP_PROMPT =
"If the sender is a bot, respond only when they explicitly @mention you to ask a question or request assistance with a specific task; keep your replies concise and clear, avoiding the urge to race other bots to answer or engage in lengthy, unproductive exchanges. In group chats, prioritize responding to messages from human users; bots should maintain a collaborative rather than competitive dynamic to ensure the conversation remains orderly and does not result in message flooding.";
@@ -19,6 +25,7 @@ export const DEFAULT_GROUP_PROMPT =
const DEFAULT_GROUP_CONFIG: Readonly<Omit<GroupConfig, "prompt">> = {
requireMention: true,
ignoreOtherMentions: false,
commandLevel: DEFAULT_GROUP_COMMAND_LEVEL,
name: "",
historyLimit: DEFAULT_GROUP_HISTORY_LIMIT,
};
@@ -51,6 +58,14 @@ function readString(obj: Record<string, unknown>, key: string): string | undefin
return typeof v === "string" && v.length > 0 ? v : undefined;
}
function readCommandLevel(
obj: Record<string, unknown>,
key: string,
): QQBotGroupCommandLevel | undefined {
const v = readString(obj, key);
return v === "all" || v === "safety" || v === "strict" ? v : undefined;
}
function readHistoryLimit(obj: Record<string, unknown>, key: string): number | undefined {
const v = obj[key];
if (typeof v !== "number" || !Number.isFinite(v)) {
@@ -82,6 +97,10 @@ export function resolveGroupConfig(
readBoolean(specific, "ignoreOtherMentions") ??
readBoolean(wildcard, "ignoreOtherMentions") ??
DEFAULT_GROUP_CONFIG.ignoreOtherMentions,
commandLevel:
readCommandLevel(specific, "commandLevel") ??
readCommandLevel(wildcard, "commandLevel") ??
DEFAULT_GROUP_CONFIG.commandLevel,
name: readString(specific, "name") ?? readString(wildcard, "name") ?? DEFAULT_GROUP_CONFIG.name,
prompt: readString(specific, "prompt") ?? readString(wildcard, "prompt"),
historyLimit:
@@ -91,6 +110,71 @@ export function resolveGroupConfig(
};
}
export function resolveGroupCommandLevelFromAccountConfig(
accountConfig: Record<string, unknown> | undefined,
groupOpenid?: string | null,
): QQBotGroupCommandLevel {
const groups = asRecord(accountConfig?.groups);
const wildcard = asRecord(groups?.["*"]) ?? {};
const specific = groupOpenid ? (asRecord(groups?.[groupOpenid]) ?? {}) : {};
return (
readCommandLevel(specific, "commandLevel") ??
readCommandLevel(wildcard, "commandLevel") ??
DEFAULT_GROUP_CONFIG.commandLevel
);
}
export function resolveHistoryLimit(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): number {
return resolveGroupConfig(cfg, groupOpenid, accountId).historyLimit;
}
export function resolveRequireMention(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): boolean {
return resolveGroupConfig(cfg, groupOpenid, accountId).requireMention;
}
export function resolveIgnoreOtherMentions(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): boolean {
return resolveGroupConfig(cfg, groupOpenid, accountId).ignoreOtherMentions;
}
/**
* Resolve the behaviour prompt (PE) for a group. Falls back to the built-in
* default when neither specific nor wildcard configuration provides one.
*/
export function resolveGroupPrompt(
cfg: Record<string, unknown>,
groupOpenid?: string | null,
accountId?: string | null,
): string {
return resolveGroupConfig(cfg, groupOpenid, accountId).prompt ?? DEFAULT_GROUP_PROMPT;
}
/**
* Resolve the display name for a group.
*
* When no name is configured, the first 8 characters of the openid are used
* as a short identifier so log lines stay compact.
*/
export function resolveGroupName(
cfg: Record<string, unknown>,
groupOpenid: string,
accountId?: string | null,
): string {
const name = resolveGroupConfig(cfg, groupOpenid, accountId).name;
return name || groupOpenid.slice(0, 8);
}
// ============ GroupSettings (aggregate) ============
/**
@@ -93,6 +93,31 @@ describe("engine/config/resolve", () => {
expect(base.enabled).toBe(true);
});
it("merges accounts.default into the default account config", () => {
const cfg = {
channels: {
qqbot: {
appId: "123456",
name: "Top Bot",
groups: { G1: { commandLevel: "all" } },
accounts: {
default: {
appId: "654321",
name: "Default Bot",
groups: { G1: { commandLevel: "safety" } },
},
},
},
},
};
const base = resolveAccountBase(cfg, DEFAULT_ACCOUNT_ID);
expect(base.name).toBe("Default Bot");
expect(base.appId).toBe("654321");
expect(base.config.groups).toEqual({ G1: { commandLevel: "safety" } });
});
it("resolves base account info for named account", () => {
const cfg = {
channels: {
@@ -146,8 +146,11 @@ export function resolveAccountBase(
let appId;
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
accountConfig = normalizeAccountConfig(asRecord(qqbot));
appId = normalizeAppId(qqbot?.appId);
accountConfig = normalizeAccountConfig({
...asRecord(qqbot),
...asRecord(qqbot?.accounts?.[DEFAULT_ACCOUNT_ID]),
});
appId = normalizeAppId(accountConfig.appId);
} else {
const account = qqbot?.accounts?.[resolvedAccountId];
accountConfig = normalizeAccountConfig(asRecord(account));
@@ -1,6 +1,11 @@
// Qqbot plugin module implements gateway behavior.
import path from "node:path";
import {
classifyCoreCommandForGroup,
PRIVATE_CHAT_ONLY_TEXT,
} from "../commands/command-visibility.js";
import { initCommands } from "../commands/slash-commands-impl.js";
import { resolveGroupCommandLevelFromAccountConfig } from "../config/group.js";
import { createNodeSessionStoreReader } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import { setOutboundAudioPort } from "../messaging/outbound.js";
@@ -12,6 +17,8 @@ import {
sendInputNotify as senderSendInputNotify,
createRawInputNotifyFn,
accountToCreds,
buildDeliveryTarget,
sendText as senderSendText,
} from "../messaging/sender.js";
import { setRefIndex } from "../ref/store.js";
import { runDiagnostics } from "../utils/diagnostics.js";
@@ -144,6 +151,25 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
}
if (inbound.skipped) {
if (inbound.skipReason === "private_command_only") {
log?.info("Rejected private-only command in qqbot group before mention gate", {
accountId: account.accountId,
messageId: event.messageId,
senderId: event.senderId,
type: event.type,
groupOpenid: event.groupOpenid,
});
await senderSendText(
buildDeliveryTarget(event),
PRIVATE_CHAT_ONLY_TEXT,
accountToCreds(account),
{
msgId: event.messageId,
},
);
inbound.typing.keepAlive?.stop();
return;
}
log?.info(
`Skipped group inbound: reason=${inbound.skipReason ?? "unknown"} group=${event.groupOpenid ?? ""}`,
{
@@ -157,6 +183,43 @@ export async function startGateway(ctx: CoreGatewayContext): Promise<void> {
return;
}
// Keep this after buildInboundContext() so ingress access policy can silently drop
// unauthorized group senders before we emit any command-specific reply.
const groupCommandLevel =
event.type === "group" || event.type === "guild"
? (inbound.group?.commandLevel ??
resolveGroupCommandLevelFromAccountConfig(
account.config,
event.groupOpenid ?? event.channelId ?? null,
))
: undefined;
const groupCommandVisibility =
event.type === "group" || event.type === "guild"
? classifyCoreCommandForGroup(inbound.agentBody, groupCommandLevel)
: { visibility: "unknown" as const };
if (groupCommandVisibility.visibility === "private") {
log?.info(
`Rejected private-only command in qqbot group: /${groupCommandVisibility.commandName}`,
{
accountId: account.accountId,
messageId: event.messageId,
senderId: event.senderId,
type: event.type,
groupOpenid: event.groupOpenid,
},
);
await senderSendText(
buildDeliveryTarget(event),
PRIVATE_CHAT_ONLY_TEXT,
accountToCreds(account),
{
msgId: event.messageId,
},
);
inbound.typing.keepAlive?.stop();
return;
}
try {
await runWithRequestContext(
{
@@ -1,6 +1,7 @@
// Qqbot plugin module implements inbound context behavior.
import type { ChannelIngressDecision } from "openclaw/plugin-sdk/channel-ingress-runtime";
import type { EngineAdapters } from "../adapter/index.js";
import type { QQBotGroupCommandLevel } from "../config/group.js";
import type { GroupActivationMode, SessionStoreReader } from "../group/activation.js";
import type { HistoryEntry } from "../group/history.js";
import type { GroupMessageGateResult } from "../group/message-gating.js";
@@ -18,6 +19,7 @@ export interface ReplyToInfo {
export interface InboundGroupInfo {
gate: GroupMessageGateResult;
activation: GroupActivationMode;
commandLevel: QQBotGroupCommandLevel;
historyLimit: number;
isMerged: boolean;
mergedMessages?: readonly QueuedMessage[];
@@ -56,7 +58,11 @@ export interface InboundContext {
blockReasonCode?: string;
accessDecision?: ChannelIngressDecision["decision"];
skipped: boolean;
skipReason?: "drop_other_mention" | "block_unauthorized_command" | "skip_no_mention";
skipReason?:
| "drop_other_mention"
| "block_unauthorized_command"
| "skip_no_mention"
| "private_command_only";
typing: { keepAlive: TypingKeepAlive | null };
inputNotifyRefIdx?: string;
}
@@ -18,6 +18,7 @@ function makeGroupInfo(partial: Partial<InboundGroupInfo["display"]> = {}): Inbo
return {
gate: makeGate(),
activation: "mention",
commandLevel: "safety",
historyLimit: 50,
isMerged: false,
display: {
@@ -0,0 +1,206 @@
// Qqbot tests cover group gate command-level enforcement.
import { describe, expect, it, vi } from "vitest";
import type { QQBotInboundAccess } from "../../adapter/index.js";
import type { InboundPipelineDeps } from "../inbound-context.js";
import type { QueuedMessage } from "../message-queue.js";
import { runGroupGateStage } from "./group-gate-stage.js";
function buildGroupEvent(content: string): QueuedMessage {
return {
type: "group",
senderId: "U1",
content,
messageId: "M1",
timestamp: "0",
groupOpenid: "G1",
};
}
function buildAccess(): QQBotInboundAccess {
return {
senderAccess: { decision: "allow" },
commandAccess: { authorized: true },
} as unknown as QQBotInboundAccess;
}
function buildDeps(): InboundPipelineDeps {
return {
account: {
accountId: "default",
appId: "1000000",
clientSecret: "secret",
markdownSupport: false,
config: {},
},
cfg: {
channels: {
qqbot: {
appId: "1000000",
groups: {
G1: { requireMention: true, commandLevel: "safety" },
},
},
},
},
runtime: {} as InboundPipelineDeps["runtime"],
startTyping: vi.fn(),
isControlCommand: (content) => content.trim().startsWith("/"),
adapters: {
mentionGate: {
resolveInboundMentionDecision: vi.fn(() => ({
effectiveWasMentioned: false,
shouldSkip: true,
shouldBypassMention: false,
implicitMention: false,
})),
},
} as unknown as InboundPipelineDeps["adapters"],
};
}
function setMentionDecision(
deps: InboundPipelineDeps,
decision: ReturnType<
InboundPipelineDeps["adapters"]["mentionGate"]["resolveInboundMentionDecision"]
>,
): void {
const mentionGate = deps.adapters.mentionGate as {
resolveInboundMentionDecision: ReturnType<typeof vi.fn>;
};
mentionGate.resolveInboundMentionDecision.mockReturnValue(decision);
}
describe("runGroupGateStage", () => {
it("surfaces private-only commands before the mention skip hides them", () => {
const result = runGroupGateStage({
event: buildGroupEvent("/config: show"),
deps: buildDeps(),
accountId: "default",
sessionKey: "qqbot:group:G1",
userContent: "/config: show",
access: buildAccess(),
});
expect(result.kind).toBe("skip");
if (result.kind === "skip") {
expect(result.skipReason).toBe("private_command_only");
}
});
it("classifies mention-stripped private commands", () => {
const event = buildGroupEvent("<@BOT_OPENID> /config show");
event.mentions = [
{
member_openid: "BOT_OPENID",
username: "OpenClaw",
},
];
const result = runGroupGateStage({
event,
deps: buildDeps(),
accountId: "default",
sessionKey: "qqbot:group:G1",
userContent: "/config show",
access: buildAccess(),
});
expect(result.kind).toBe("skip");
if (result.kind === "skip") {
expect(result.skipReason).toBe("private_command_only");
}
});
it("enforces command level from accounts.default group config", () => {
const deps = buildDeps();
deps.cfg = {
channels: {
qqbot: {
appId: "1000000",
groups: {
G1: { requireMention: true, commandLevel: "all" },
},
accounts: {
default: {
groups: {
G1: { requireMention: true, commandLevel: "safety" },
},
},
},
},
},
};
const result = runGroupGateStage({
event: buildGroupEvent("/config show"),
deps,
accountId: "default",
sessionKey: "qqbot:group:G1",
userContent: "/config show",
access: buildAccess(),
});
expect(result.kind).toBe("skip");
if (result.kind === "skip") {
expect(result.skipReason).toBe("private_command_only");
}
});
it("does not reply to private commands that only mention someone else", () => {
const deps = buildDeps();
(
deps.cfg as { channels: { qqbot: { groups: { G1: { ignoreOtherMentions: boolean } } } } }
).channels.qqbot.groups.G1.ignoreOtherMentions = true;
setMentionDecision(deps, {
effectiveWasMentioned: false,
shouldSkip: false,
shouldBypassMention: false,
implicitMention: false,
});
const event = buildGroupEvent("/config @someone");
event.mentions = [
{
member_openid: "SOMEONE_OPENID",
username: "Someone",
},
];
const result = runGroupGateStage({
event,
deps,
accountId: "default",
sessionKey: "qqbot:group:G1",
userContent: "/config @Someone",
access: buildAccess(),
});
expect(result.kind).toBe("skip");
if (result.kind === "skip") {
expect(result.skipReason).toBe("drop_other_mention");
}
});
it("does not reject urgent stop in strict groups", () => {
const deps = buildDeps();
(
deps.cfg as { channels: { qqbot: { groups: { G1: { commandLevel: string } } } } }
).channels.qqbot.groups.G1.commandLevel = "strict";
setMentionDecision(deps, {
effectiveWasMentioned: true,
shouldSkip: false,
shouldBypassMention: true,
implicitMention: false,
});
const result = runGroupGateStage({
event: buildGroupEvent("/stop"),
deps,
accountId: "default",
sessionKey: "qqbot:group:G1",
userContent: "/stop",
access: buildAccess(),
});
expect(result.kind).toBe("pass");
});
});
@@ -2,6 +2,7 @@
import type { HistoryPort } from "../../adapter/history.port.js";
import type { QQBotInboundAccess } from "../../adapter/index.js";
import type { MentionGatePort } from "../../adapter/mention-gate.port.js";
import { classifyCoreCommandForGroup } from "../../commands/command-visibility.js";
import { DEFAULT_GROUP_PROMPT, resolveGroupSettings } from "../../config/group.js";
import { resolveGroupActivation } from "../../group/activation.js";
import { toAttachmentSummaries, type HistoryEntry } from "../../group/history.js";
@@ -95,6 +96,7 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
const groupInfo: InboundGroupInfo = {
gate,
activation,
commandLevel: settings.config.commandLevel,
historyLimit,
isMerged: isMergedTurn(event),
mergedMessages: event.merge?.messages,
@@ -106,6 +108,15 @@ export function runGroupGateStage(input: GroupGateStageInput): GroupGateStageRes
},
};
const commandVisibility = classifyCoreCommandForGroup(userContent, settings.config.commandLevel);
if (
commandAuthorized &&
commandVisibility.visibility === "private" &&
gate.action !== "drop_other_mention"
) {
return { kind: "skip", groupInfo, skipReason: "private_command_only" };
}
if (gate.action === "pass") {
return { kind: "pass", groupInfo };
}
+14
View File
@@ -1,6 +1,8 @@
import type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/channel-policy";
// Qqbot type declarations define plugin contracts.
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
import type { QQBotDmPolicy, QQBotGroupPolicy } from "./engine/access/index.js";
import type { QQBotGroupCommandLevel } from "./engine/config/group.js";
export type { QQBotDmPolicy, QQBotGroupPolicy };
@@ -35,6 +37,17 @@ export interface QQBotExecApprovalConfig {
target?: "dm" | "channel" | "both";
}
export interface QQBotGroupConfig {
requireMention?: boolean;
commandLevel?: QQBotGroupCommandLevel;
ignoreOtherMentions?: boolean;
historyLimit?: number;
name?: string;
prompt?: string;
tools?: GroupToolPolicyConfig;
toolsBySender?: Record<string, GroupToolPolicyConfig>;
}
/** QQ Bot account config from user settings. */
export interface QQBotAccountConfig {
enabled?: boolean;
@@ -117,6 +130,7 @@ export interface QQBotAccountConfig {
/** @deprecated Prefer `streaming: true`. */
c2cStreamApi?: boolean;
};
groups?: Record<string, QQBotGroupConfig>;
}
/** Audio format policy controlling which formats can skip transcoding. */
File diff suppressed because one or more lines are too long