Files
openclaw/extensions/qqbot/src/slash-commands.ts
Vincent Koc 72af92ba4e qqbot: require explicit allowlist for /bot-logs to prevent info disclosure (#58895)
* qqbot: harden /bot-logs authorization fallback

* fix(qqbot): harden bot logs allowlist guard

* fix(qqbot): normalize bot logs allowlist entries
2026-04-01 18:40:46 +09:00

600 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* QQBot plugin-level slash command handler.
*
* Design goals:
* 1. Intercept plugin commands before messages enter the AI queue.
* 2. Let unmatched "/" messages continue through the normal framework path.
* 3. Keep command registration small and explicit.
*/
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
import type { QQBotAccountConfig } from "./types.js";
import { debugLog } from "./utils/debug-log.js";
import { getHomeDir, getQQBotDataDir, isWindows } from "./utils/platform.js";
const require = createRequire(import.meta.url);
// Read the package version from package.json.
let PLUGIN_VERSION = "unknown";
try {
const pkg = require("../package.json");
PLUGIN_VERSION = pkg.version ?? "unknown";
} catch {
// fallback
}
const QQBOT_PLUGIN_GITHUB_URL = "https://github.com/openclaw/openclaw/tree/main/extensions/qqbot";
const QQBOT_UPGRADE_GUIDE_URL = "https://q.qq.com/qqbot/openclaw/upgrade.html";
// ============ Types ============
/** Slash command context (message metadata plus runtime state). */
export interface SlashCommandContext {
/** Message type. */
type: "c2c" | "guild" | "dm" | "group";
/** Sender ID. */
senderId: string;
/** Sender display name. */
senderName?: string;
/** Message ID used for passive replies. */
messageId: string;
/** Event timestamp from QQ as an ISO string. */
eventTimestamp: string;
/** Local receipt timestamp in milliseconds. */
receivedAt: number;
/** Raw message content. */
rawContent: string;
/** Command arguments after stripping the command name. */
args: string;
/** Channel ID for guild messages. */
channelId?: string;
/** Group openid for group messages. */
groupOpenid?: string;
/** Account ID. */
accountId: string;
/** Bot App ID. */
appId: string;
/** Account config available to the command handler. */
accountConfig?: QQBotAccountConfig;
/** Whether the sender is authorized per the allowFrom config. */
commandAuthorized: boolean;
/** Queue snapshot for the current sender. */
queueSnapshot: QueueSnapshot;
}
/** Queue status snapshot. */
export interface QueueSnapshot {
/** Total pending messages across all sender queues. */
totalPending: number;
/** Number of senders currently being processed. */
activeUsers: number;
/** Maximum concurrent sender count. */
maxConcurrentUsers: number;
/** Pending messages for the current sender. */
senderPending: number;
}
/** Slash command result: text, a text+file result, or null to skip handling. */
export type SlashCommandResult = string | SlashCommandFileResult | null;
/** Slash command result that sends text first and then a local file. */
export interface SlashCommandFileResult {
text: string;
/** Local file path to send. */
filePath: string;
}
/** Slash command definition. */
interface SlashCommand {
/** Command name without the leading slash. */
name: string;
/** Short description. */
description: string;
/** Detailed usage text shown by `/command ?`. */
usage?: string;
/** When true, the command requires the sender to pass the allowFrom authorization check. */
requireAuth?: boolean;
/** Command handler. */
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
/** Framework command definition for commands that require authorization. */
export interface QQBotFrameworkCommand {
name: string;
description: string;
usage?: string;
handler: (ctx: SlashCommandContext) => SlashCommandResult | Promise<SlashCommandResult>;
}
function hasExplicitCommandAllowlist(accountConfig?: QQBotAccountConfig): boolean {
const allowFrom = accountConfig?.allowFrom;
if (!Array.isArray(allowFrom) || allowFrom.length === 0) {
return false;
}
return allowFrom.every((entry) => {
const normalized = String(entry)
.trim()
.replace(/^qqbot:\s*/i, "")
.trim();
return normalized.length > 0 && normalized !== "*";
});
}
// ============ Command registry ============
// Pre-dispatch commands (requireAuth: false) — handled immediately before queuing.
const commands: Map<string, SlashCommand> = new Map();
// Framework commands (requireAuth: true) — registered via api.registerCommand() so that
// resolveCommandAuthorization() applies commands.allowFrom.qqbot precedence and
// qqbot: prefix normalization before the handler runs.
const frameworkCommands: Map<string, SlashCommand> = new Map();
function registerCommand(cmd: SlashCommand): void {
if (cmd.requireAuth) {
frameworkCommands.set(cmd.name.toLowerCase(), cmd);
} else {
commands.set(cmd.name.toLowerCase(), cmd);
}
}
/**
* Return all commands that require authorization, for registration with the
* framework via api.registerCommand() in registerFull().
*/
export function getFrameworkCommands(): QQBotFrameworkCommand[] {
return Array.from(frameworkCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
usage: cmd.usage,
handler: cmd.handler,
}));
}
// ============ Built-in commands ============
/**
* /bot-ping — test current network latency between OpenClaw and QQ.
*/
registerCommand({
name: "bot-ping",
description: "测试 OpenClaw 与 QQ 之间的网络延迟",
usage: [
`/bot-ping`,
``,
`测试当前 OpenClaw 宿主机与 QQ 服务器之间的网络延迟。`,
`返回网络传输耗时和插件处理耗时。`,
].join("\n"),
handler: (ctx) => {
const now = Date.now();
const eventTime = new Date(ctx.eventTimestamp).getTime();
if (isNaN(eventTime)) {
return `✅ pong!`;
}
const totalMs = now - eventTime;
const qqToPlugin = ctx.receivedAt - eventTime;
const pluginProcess = now - ctx.receivedAt;
const lines = [
`✅ pong!`,
``,
`⏱ 延迟:${totalMs}ms`,
` ├ 网络传输:${qqToPlugin}ms`,
` └ 插件处理:${pluginProcess}ms`,
];
return lines.join("\n");
},
});
/**
* /bot-version — show the OpenClaw framework version.
*/
registerCommand({
name: "bot-version",
description: "查看 OpenClaw 框架版本",
usage: [`/bot-version`, ``, `查看当前 OpenClaw 框架版本。`].join("\n"),
handler: async () => {
const frameworkVersion = resolveRuntimeServiceVersion();
const lines = [`🦞 OpenClaw 版本:${frameworkVersion}`];
lines.push(`🌟 官方 GitHub 仓库:[点击前往](${QQBOT_PLUGIN_GITHUB_URL})`);
return lines.join("\n");
},
});
/**
* /bot-upgrade — show the upgrade guide.
*/
registerCommand({
name: "bot-upgrade",
description: "查看 QQBot 升级指引",
usage: [`/bot-upgrade`, ``, `查看 QQBot 升级说明。`].join("\n"),
handler: () =>
[`📘 QQBot 升级指引:`, `[点击查看升级说明](${QQBOT_UPGRADE_GUIDE_URL})`].join("\n"),
});
/**
* /bot-help — list all built-in QQBot commands.
*/
registerCommand({
name: "bot-help",
description: "查看所有内置命令",
usage: [
`/bot-help`,
``,
`查看所有可用的 QQBot 内置命令及其简要说明。`,
`在命令后追加 ? 可查看详细用法。`,
].join("\n"),
handler: () => {
const lines = [`### QQBot 内置命令`, ``];
for (const [name, cmd] of commands) {
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
}
for (const [name, cmd] of frameworkCommands) {
lines.push(`<qqbot-cmd-input text="/${name}" show="/${name}"/> ${cmd.description}`);
}
return lines.join("\n");
},
});
/** Read user-configured log file paths from local config files. */
function getConfiguredLogFiles(): string[] {
const homeDir = getHomeDir();
const files: string[] = [];
for (const cli of ["openclaw", "clawdbot", "moltbot"]) {
try {
const cfgPath = path.join(homeDir, `.${cli}`, `${cli}.json`);
if (!fs.existsSync(cfgPath)) continue;
const cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
const logFile = cfg?.logging?.file;
if (logFile && typeof logFile === "string") {
files.push(path.resolve(logFile));
}
break;
} catch {
// ignore
}
}
return files;
}
/** Collect directories that may contain runtime logs across common install layouts. */
function collectCandidateLogDirs(): string[] {
const homeDir = getHomeDir();
const dirs = new Set<string>();
const pushDir = (p?: string) => {
if (!p) return;
const normalized = path.resolve(p);
dirs.add(normalized);
};
const pushStateDir = (stateDir?: string) => {
if (!stateDir) return;
pushDir(stateDir);
pushDir(path.join(stateDir, "logs"));
};
for (const logFile of getConfiguredLogFiles()) {
pushDir(path.dirname(logFile));
}
for (const [key, value] of Object.entries(process.env)) {
if (!value) continue;
if (/STATE_DIR$/i.test(key) && /(OPENCLAW|CLAWDBOT|MOLTBOT)/i.test(key)) {
pushStateDir(value);
}
}
for (const name of [".openclaw", ".clawdbot", ".moltbot", "openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(homeDir, name));
pushDir(path.join(homeDir, name, "logs"));
}
const searchRoots = new Set<string>([homeDir, process.cwd(), path.dirname(process.cwd())]);
if (process.env.APPDATA) searchRoots.add(process.env.APPDATA);
if (process.env.LOCALAPPDATA) searchRoots.add(process.env.LOCALAPPDATA);
for (const root of searchRoots) {
try {
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!/(openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
const base = path.join(root, entry.name);
pushDir(base);
pushDir(path.join(base, "logs"));
}
} catch {
// Ignore missing or inaccessible directories.
}
}
// Common Linux log directories under /var/log.
if (!isWindows()) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join("/var/log", name));
}
}
// Temporary directories may also contain gateway logs.
const tmpRoots = new Set<string>();
if (isWindows()) {
// Windows temp locations.
tmpRoots.add("C:\\tmp");
if (process.env.TEMP) tmpRoots.add(process.env.TEMP);
if (process.env.TMP) tmpRoots.add(process.env.TMP);
if (process.env.LOCALAPPDATA) tmpRoots.add(path.join(process.env.LOCALAPPDATA, "Temp"));
} else {
tmpRoots.add("/tmp");
}
for (const tmpRoot of tmpRoots) {
for (const name of ["openclaw", "clawdbot", "moltbot"]) {
pushDir(path.join(tmpRoot, name));
}
}
return Array.from(dirs);
}
type LogCandidate = {
filePath: string;
sourceDir: string;
mtimeMs: number;
};
function collectRecentLogFiles(logDirs: string[]): LogCandidate[] {
const candidates: LogCandidate[] = [];
const dedupe = new Set<string>();
const pushFile = (filePath: string, sourceDir: string) => {
const normalized = path.resolve(filePath);
if (dedupe.has(normalized)) return;
try {
const stat = fs.statSync(normalized);
if (!stat.isFile()) return;
dedupe.add(normalized);
candidates.push({ filePath: normalized, sourceDir, mtimeMs: stat.mtimeMs });
} catch {
// Ignore missing or inaccessible files.
}
};
// Highest priority: explicit logging.file paths from config.
for (const logFile of getConfiguredLogFiles()) {
pushFile(logFile, path.dirname(logFile));
}
for (const dir of logDirs) {
pushFile(path.join(dir, "gateway.log"), dir);
pushFile(path.join(dir, "gateway.err.log"), dir);
pushFile(path.join(dir, "openclaw.log"), dir);
pushFile(path.join(dir, "clawdbot.log"), dir);
pushFile(path.join(dir, "moltbot.log"), dir);
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!/\.(log|txt)$/i.test(entry.name)) continue;
if (!/(gateway|openclaw|clawdbot|moltbot)/i.test(entry.name)) continue;
pushFile(path.join(dir, entry.name), dir);
}
} catch {
// Ignore missing or inaccessible directories.
}
}
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates;
}
/**
* Read the last N lines of a file without loading the entire file into memory.
* Uses a reverse-read strategy: reads fixed-size chunks from the end of the
* file until the requested number of newline characters are found.
*
* Also estimates the total line count from the file size and the average bytes
* per line observed in the tail portion (exact count is not feasible for
* multi-GB files without a full scan).
*/
function tailFileLines(
filePath: string,
maxLines: number,
): { tail: string[]; totalFileLines: number } {
const fd = fs.openSync(filePath, "r");
try {
const stat = fs.fstatSync(fd);
const fileSize = stat.size;
if (fileSize === 0) {
return { tail: [], totalFileLines: 0 };
}
const CHUNK_SIZE = 64 * 1024;
const chunks: Buffer[] = [];
let bytesRead = 0;
let position = fileSize;
let newlineCount = 0;
while (position > 0 && newlineCount <= maxLines) {
const readSize = Math.min(CHUNK_SIZE, position);
position -= readSize;
const buf = Buffer.alloc(readSize);
fs.readSync(fd, buf, 0, readSize, position);
chunks.unshift(buf);
bytesRead += readSize;
for (let i = 0; i < readSize; i++) {
if (buf[i] === 0x0a) newlineCount++;
}
}
const tailContent = Buffer.concat(chunks).toString("utf8");
const allLines = tailContent.split("\n");
const tail = allLines.slice(-maxLines);
let totalFileLines: number;
if (bytesRead >= fileSize) {
totalFileLines = allLines.length;
} else {
const avgBytesPerLine = bytesRead / Math.max(allLines.length, 1);
totalFileLines = Math.round(fileSize / avgBytesPerLine);
}
return { tail, totalFileLines };
} finally {
fs.closeSync(fd);
}
}
/**
* Build the /bot-logs result: collect recent log files, write them to a temp
* file, and return the summary text plus the temp file path.
*
* Authorization is enforced upstream by the framework (registerCommand with
* requireAuth:true); this function contains no auth logic.
*
* Returns a SlashCommandFileResult on success (text + filePath), or a plain
* string error message when no logs are found or files cannot be read.
*/
function buildBotLogsResult(): SlashCommandResult {
const logDirs = collectCandidateLogDirs();
const recentFiles = collectRecentLogFiles(logDirs).slice(0, 4);
if (recentFiles.length === 0) {
const existingDirs = logDirs.filter((d) => {
try {
return fs.existsSync(d);
} catch {
return false;
}
});
const searched =
existingDirs.length > 0
? existingDirs.map((d) => `${d}`).join("\n")
: logDirs
.slice(0, 6)
.map((d) => `${d}`)
.join("\n") + (logDirs.length > 6 ? `\n …以及另外 ${logDirs.length - 6} 个路径` : "");
return [
`⚠️ 未找到日志文件`,
``,
`已搜索以下${existingDirs.length > 0 ? "存在的" : ""}路径:`,
searched,
``,
`💡 如果日志存放在自定义路径,请在配置中添加:`,
` "logging": { "file": "/path/to/your/logfile.log" }`,
].join("\n");
}
const lines: string[] = [];
let totalIncluded = 0;
let totalOriginal = 0;
let truncatedCount = 0;
const MAX_LINES_PER_FILE = 1000;
for (const logFile of recentFiles) {
try {
const { tail, totalFileLines } = tailFileLines(logFile.filePath, MAX_LINES_PER_FILE);
if (tail.length > 0) {
const fileName = path.basename(logFile.filePath);
lines.push(
`\n========== ${fileName} (last ${tail.length} of ${totalFileLines} lines) ==========`,
);
lines.push(`from: ${logFile.sourceDir}`);
lines.push(...tail);
totalIncluded += tail.length;
totalOriginal += totalFileLines;
if (totalFileLines > MAX_LINES_PER_FILE) truncatedCount++;
}
} catch {
lines.push(`[Failed to read ${path.basename(logFile.filePath)}]`);
}
}
if (lines.length === 0) {
return `⚠️ 找到了日志文件,但无法读取。请检查文件权限。`;
}
const tmpDir = getQQBotDataDir("downloads");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const tmpFile = path.join(tmpDir, `bot-logs-${timestamp}.txt`);
fs.writeFileSync(tmpFile, lines.join("\n"), "utf8");
const fileCount = recentFiles.length;
const topSources = Array.from(new Set(recentFiles.map((item) => item.sourceDir))).slice(0, 3);
let summaryText = `${fileCount} 个日志文件,包含 ${totalIncluded} 行内容`;
if (truncatedCount > 0) {
summaryText += `(其中 ${truncatedCount} 个文件已截断为最后 ${MAX_LINES_PER_FILE} 行,总计原始 ${totalOriginal} 行)`;
}
return {
text: `📋 ${summaryText}\n📂 来源:${topSources.join(" | ")}`,
filePath: tmpFile,
};
}
registerCommand({
name: "bot-logs",
description: "导出本地日志文件",
requireAuth: true,
usage: [
`/bot-logs`,
``,
`导出最近的 OpenClaw 日志文件(最多 4 个文件)。`,
`每个文件只保留最后 1000 行,并作为附件返回。`,
].join("\n"),
handler: (ctx) => {
// Defense in depth: require an explicit QQ allowlist entry for log export.
// This keeps `/bot-logs` closed when setup leaves allowFrom in permissive mode.
if (!hasExplicitCommandAllowlist(ctx.accountConfig)) {
return `⛔ 权限不足:请先在 channels.qqbot.allowFrom或对应账号 allowFrom中配置明确的发送者列表后再使用 /bot-logs。`;
}
return buildBotLogsResult();
},
});
// Slash command entry point.
/**
* Try to match and execute a plugin-level slash command.
*
* @returns A reply when matched, or null when the message should continue through normal routing.
*/
export async function matchSlashCommand(ctx: SlashCommandContext): Promise<SlashCommandResult> {
const content = ctx.rawContent.trim();
if (!content.startsWith("/")) return null;
// Parse the command name and trailing arguments.
const spaceIdx = content.indexOf(" ");
const cmdName = (spaceIdx === -1 ? content.slice(1) : content.slice(1, spaceIdx)).toLowerCase();
const args = spaceIdx === -1 ? "" : content.slice(spaceIdx + 1).trim();
const cmd = commands.get(cmdName);
if (!cmd) return null;
// Gate sensitive commands behind the allowFrom authorization check.
if (cmd.requireAuth && !ctx.commandAuthorized) {
debugLog(
`[qqbot] Slash command /${cmd.name} rejected: sender ${ctx.senderId} is not authorized`,
);
return `⛔ 权限不足:/${cmd.name} 需要管理员权限。`;
}
// `/command ?` returns usage help.
if (args === "?") {
if (cmd.usage) {
return `📖 /${cmd.name} 用法:\n\n${cmd.usage}`;
}
return `/${cmd.name} - ${cmd.description}`;
}
ctx.args = args;
const result = await cmd.handler(ctx);
return result;
}
/** Return the plugin version for external callers. */
export function getPluginVersion(): string {
return PLUGIN_VERSION;
}