227
bin/qqbot-cli.js
Normal file
227
bin/qqbot-cli.js
Normal file
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* QQBot CLI - 用于升级和管理 QQBot 插件
|
||||
*
|
||||
* 用法:
|
||||
* npx @sliverp/qqbot upgrade # 升级插件
|
||||
* npx @sliverp/qqbot install # 安装插件
|
||||
*/
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// 获取包的根目录
|
||||
const PKG_ROOT = join(__dirname, '..');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
// 检测使用的是 clawdbot 还是 openclaw
|
||||
function detectInstallation() {
|
||||
const home = homedir();
|
||||
if (existsSync(join(home, '.openclaw'))) {
|
||||
return 'openclaw';
|
||||
}
|
||||
if (existsSync(join(home, '.clawdbot'))) {
|
||||
return 'clawdbot';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清理旧版本插件,返回旧的 qqbot 配置
|
||||
function cleanupInstallation(appName) {
|
||||
const home = homedir();
|
||||
const appDir = join(home, `.${appName}`);
|
||||
const configFile = join(appDir, `${appName}.json`);
|
||||
const extensionDir = join(appDir, 'extensions', 'qqbot');
|
||||
|
||||
let oldQqbotConfig = null;
|
||||
|
||||
console.log(`\n>>> 处理 ${appName} 安装...`);
|
||||
|
||||
// 1. 先读取旧的 qqbot 配置
|
||||
if (existsSync(configFile)) {
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configFile, 'utf8'));
|
||||
if (config.channels?.qqbot) {
|
||||
oldQqbotConfig = { ...config.channels.qqbot };
|
||||
console.log('已保存旧的 qqbot 配置');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('读取配置文件失败:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 删除旧的扩展目录
|
||||
if (existsSync(extensionDir)) {
|
||||
console.log(`删除旧版本插件: ${extensionDir}`);
|
||||
rmSync(extensionDir, { recursive: true, force: true });
|
||||
} else {
|
||||
console.log('未找到旧版本插件目录,跳过删除');
|
||||
}
|
||||
|
||||
// 3. 清理配置文件中的 qqbot 相关字段
|
||||
if (existsSync(configFile)) {
|
||||
console.log('清理配置文件中的 qqbot 字段...');
|
||||
try {
|
||||
const config = JSON.parse(readFileSync(configFile, 'utf8'));
|
||||
|
||||
// 删除 channels.qqbot
|
||||
if (config.channels?.qqbot) {
|
||||
delete config.channels.qqbot;
|
||||
console.log(' - 已删除 channels.qqbot');
|
||||
}
|
||||
|
||||
// 删除 plugins.entries.qqbot
|
||||
if (config.plugins?.entries?.qqbot) {
|
||||
delete config.plugins.entries.qqbot;
|
||||
console.log(' - 已删除 plugins.entries.qqbot');
|
||||
}
|
||||
|
||||
// 删除 plugins.installs.qqbot
|
||||
if (config.plugins?.installs?.qqbot) {
|
||||
delete config.plugins.installs.qqbot;
|
||||
console.log(' - 已删除 plugins.installs.qqbot');
|
||||
}
|
||||
|
||||
writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
console.log('配置文件已更新');
|
||||
} catch (err) {
|
||||
console.error('清理配置文件失败:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.log(`未找到配置文件: ${configFile}`);
|
||||
}
|
||||
|
||||
return oldQqbotConfig;
|
||||
}
|
||||
|
||||
// 执行命令并继承 stdio
|
||||
function runCommand(cmd, args = []) {
|
||||
try {
|
||||
execSync([cmd, ...args].join(' '), { stdio: 'inherit' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 升级命令
|
||||
function upgrade() {
|
||||
console.log('=== QQBot 插件升级脚本 ===');
|
||||
|
||||
let foundInstallation = null;
|
||||
let savedConfig = null;
|
||||
const home = homedir();
|
||||
|
||||
// 检查 openclaw
|
||||
if (existsSync(join(home, '.openclaw'))) {
|
||||
savedConfig = cleanupInstallation('openclaw');
|
||||
foundInstallation = 'openclaw';
|
||||
}
|
||||
|
||||
// 检查 clawdbot
|
||||
if (existsSync(join(home, '.clawdbot'))) {
|
||||
const clawdbotConfig = cleanupInstallation('clawdbot');
|
||||
if (!savedConfig) savedConfig = clawdbotConfig;
|
||||
foundInstallation = 'clawdbot';
|
||||
}
|
||||
|
||||
if (!foundInstallation) {
|
||||
console.log('\n未找到 clawdbot 或 openclaw 安装目录');
|
||||
console.log('请确认已安装 clawdbot 或 openclaw');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n=== 清理完成 ===');
|
||||
|
||||
// 自动安装插件
|
||||
console.log('\n[1/2] 安装新版本插件...');
|
||||
runCommand(foundInstallation, ['plugins', 'install', '@sliverp/qqbot']);
|
||||
|
||||
// 自动配置通道(使用保存的 appId 和 clientSecret)
|
||||
console.log('\n[2/2] 配置机器人通道...');
|
||||
if (savedConfig?.appId && savedConfig?.clientSecret) {
|
||||
const token = `${savedConfig.appId}:${savedConfig.clientSecret}`;
|
||||
console.log(`使用已保存的配置: appId=${savedConfig.appId}`);
|
||||
runCommand(foundInstallation, ['channels', 'add', '--channel', 'qqbot', '--token', `"${token}"`]);
|
||||
|
||||
// 恢复其他配置项(如 markdownSupport)
|
||||
if (savedConfig.markdownSupport !== undefined) {
|
||||
runCommand(foundInstallation, ['config', 'set', 'channels.qqbot.markdownSupport', String(savedConfig.markdownSupport)]);
|
||||
}
|
||||
} else {
|
||||
console.log('未找到已保存的 qqbot 配置,请手动配置:');
|
||||
console.log(` ${foundInstallation} channels add --channel qqbot --token "AppID:AppSecret"`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n=== 升级完成 ===');
|
||||
console.log(`\n可以运行以下命令前台运行启动机器人:`);
|
||||
console.log(` ${foundInstallation} gateway stop && ${foundInstallation} gateway --port 18789 --verbose`);
|
||||
}
|
||||
|
||||
// 安装命令
|
||||
function install() {
|
||||
console.log('=== QQBot 插件安装 ===');
|
||||
|
||||
const cmd = detectInstallation();
|
||||
if (!cmd) {
|
||||
console.log('未找到 clawdbot 或 openclaw 安装');
|
||||
console.log('请先安装 openclaw 或 clawdbot');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n使用 ${cmd} 安装插件...`);
|
||||
runCommand(cmd, ['plugins', 'install', '@sliverp/qqbot']);
|
||||
|
||||
console.log('\n=== 安装完成 ===');
|
||||
console.log('\n请配置机器人通道:');
|
||||
console.log(` ${cmd} channels add --channel qqbot --token "AppID:AppSecret"`);
|
||||
}
|
||||
|
||||
// 显示帮助
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
QQBot CLI - QQ机器人插件管理工具
|
||||
|
||||
用法:
|
||||
npx @sliverp/qqbot <命令>
|
||||
|
||||
命令:
|
||||
upgrade 清理旧版本插件(升级前执行)
|
||||
install 安装插件到 openclaw/clawdbot
|
||||
|
||||
示例:
|
||||
npx @sliverp/qqbot upgrade
|
||||
npx @sliverp/qqbot install
|
||||
`);
|
||||
}
|
||||
|
||||
// 主入口
|
||||
switch (command) {
|
||||
case 'upgrade':
|
||||
upgrade();
|
||||
break;
|
||||
case 'install':
|
||||
install();
|
||||
break;
|
||||
case '-h':
|
||||
case '--help':
|
||||
case 'help':
|
||||
showHelp();
|
||||
break;
|
||||
default:
|
||||
if (command) {
|
||||
console.log(`未知命令: ${command}`);
|
||||
}
|
||||
showHelp();
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
1153
package-lock.json
generated
1153
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,9 +1,22 @@
|
||||
{
|
||||
"name": "qqbot",
|
||||
"version": "1.3.0",
|
||||
"name": "@sliverp/qqbot",
|
||||
"version": "1.3.14",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
"qqbot": "./bin/qqbot-cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"src",
|
||||
"index.ts",
|
||||
"tsconfig.json",
|
||||
"openclaw.plugin.json",
|
||||
"clawdbot.plugin.json",
|
||||
"moltbot.plugin.json"
|
||||
],
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
},
|
||||
@@ -33,5 +46,6 @@
|
||||
"clawdbot": "*",
|
||||
"moltbot": "*",
|
||||
"openclaw": "*"
|
||||
}
|
||||
},
|
||||
"homepage": "https://github.com/sliverp/qqbot"
|
||||
}
|
||||
|
||||
@@ -120,6 +120,20 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
configured: Boolean(account?.appId && account?.clientSecret),
|
||||
tokenSource: account?.secretSource,
|
||||
}),
|
||||
// 关键:解析 allowFrom 配置,用于命令授权
|
||||
resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string }) => {
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
const allowFrom = account.config?.allowFrom ?? [];
|
||||
console.log(`[qqbot] resolveAllowFrom: accountId=${accountId}, allowFrom=${JSON.stringify(allowFrom)}`);
|
||||
return allowFrom.map((entry: string | number) => String(entry));
|
||||
},
|
||||
// 格式化 allowFrom 条目(移除 qqbot: 前缀,统一大写)
|
||||
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
|
||||
allowFrom
|
||||
.map((entry: string | number) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry: string) => entry.replace(/^qqbot:/i, ""))
|
||||
.map((entry: string) => entry.toUpperCase()), // QQ openid 是大写的
|
||||
},
|
||||
setup: {
|
||||
// 新增:规范化账户 ID
|
||||
@@ -159,18 +173,6 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
||||
});
|
||||
},
|
||||
},
|
||||
// 新增:消息目标解析
|
||||
messaging: {
|
||||
normalizeTarget: (target) => {
|
||||
// 支持格式: qqbot:openid, qqbot:group:xxx, openid, group:xxx
|
||||
const normalized = target.replace(/^qqbot:/i, "");
|
||||
return { ok: true, to: normalized };
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (id) => /^[A-F0-9]{32}$/i.test(id) || id.startsWith("group:") || id.startsWith("channel:"),
|
||||
hint: "<openid> or group:<groupOpenid>",
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkText,
|
||||
|
||||
@@ -128,11 +128,16 @@ export function applyQQBotAccountConfig(
|
||||
const next = { ...cfg };
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
||||
const existingConfig = (next.channels?.qqbot as QQBotChannelConfig) || {};
|
||||
const allowFrom = existingConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
@@ -144,6 +149,10 @@ export function applyQQBotAccountConfig(
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// 如果没有设置过 allowFrom,默认设置为 ["*"]
|
||||
const existingAccountConfig = (next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {};
|
||||
const allowFrom = existingAccountConfig.allowFrom ?? ["*"];
|
||||
|
||||
next.channels = {
|
||||
...next.channels,
|
||||
qqbot: {
|
||||
@@ -154,6 +163,7 @@ export function applyQQBotAccountConfig(
|
||||
[accountId]: {
|
||||
...((next.channels?.qqbot as QQBotChannelConfig)?.accounts?.[accountId] || {}),
|
||||
enabled: true,
|
||||
allowFrom,
|
||||
...(input.appId ? { appId: input.appId } : {}),
|
||||
...(input.clientSecret
|
||||
? { clientSecret: input.clientSecret }
|
||||
|
||||
@@ -2,7 +2,7 @@ import WebSocket from "ws";
|
||||
import path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } from "./api.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify } from "./api.js";
|
||||
import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
|
||||
import { recordKnownUser, flushKnownUsers } from "./known-users.js";
|
||||
import { getQQBotRuntime } from "./runtime.js";
|
||||
@@ -412,6 +412,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
direction: "inbound",
|
||||
});
|
||||
|
||||
try{
|
||||
await sendC2CInputNotify(accessToken, event.senderId, event.messageId, 60);
|
||||
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
||||
}catch(err){
|
||||
log?.error(`[qqbot:${account.accountId}] sendC2CInputNotify error: ${err}`);
|
||||
}
|
||||
|
||||
const isGroup = event.type === "guild" || event.type === "group";
|
||||
const peerId = event.type === "guild" ? `channel:${event.channelId}`
|
||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||
@@ -568,10 +575,15 @@ openclaw cron add \\
|
||||
}
|
||||
|
||||
const userContent = event.content + attachmentInfo;
|
||||
const messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
|
||||
let messageBody = `【系统提示】\n${systemPrompts.join("\n")}\n\n【用户输入】\n${userContent}`;
|
||||
|
||||
if(userContent.startsWith("/")){ // 保留Openclaw原始命令
|
||||
messageBody = userContent
|
||||
}
|
||||
log?.info(`[qqbot:${account.accountId}] messageBody: ${messageBody}`);
|
||||
|
||||
const body = pluginRuntime.channel.reply.formatInboundEnvelope({
|
||||
channel: "QQBot",
|
||||
channel: "qqbot",
|
||||
from: event.senderName ?? event.senderId,
|
||||
timestamp: new Date(event.timestamp).getTime(),
|
||||
body: messageBody,
|
||||
@@ -590,6 +602,14 @@ openclaw cron add \\
|
||||
: `qqbot:c2c:${event.senderId}`;
|
||||
const toAddress = fromAddress;
|
||||
|
||||
// 计算命令授权状态
|
||||
// allowFrom: ["*"] 表示允许所有人,否则检查 senderId 是否在 allowFrom 列表中
|
||||
const allowFromList = account.config?.allowFrom ?? [];
|
||||
const allowAll = allowFromList.length === 0 || allowFromList.some((entry: string) => entry === "*");
|
||||
const commandAuthorized = allowAll || allowFromList.some((entry: string) =>
|
||||
entry.toUpperCase() === event.senderId.toUpperCase()
|
||||
);
|
||||
|
||||
const ctxPayload = pluginRuntime.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: event.content,
|
||||
@@ -610,6 +630,7 @@ openclaw cron add \\
|
||||
QQChannelId: event.channelId,
|
||||
QQGuildId: event.guildId,
|
||||
QQGroupOpenid: event.groupOpenid,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
// 发送消息的辅助函数,带 token 过期重试
|
||||
@@ -810,7 +831,7 @@ openclaw cron add \\
|
||||
log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
|
||||
await sendErrorMessage(`发送图片失败: ${err}`);
|
||||
await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1250,7 +1271,9 @@ openclaw cron add \\
|
||||
}
|
||||
},
|
||||
},
|
||||
replyOptions: {},
|
||||
replyOptions: {
|
||||
disableBlockStreaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 等待分发完成或超时
|
||||
|
||||
@@ -21,6 +21,7 @@ interface QQBotChannelConfig {
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
accounts?: Record<string, {
|
||||
enabled?: boolean;
|
||||
appId?: string;
|
||||
@@ -28,6 +29,7 @@ interface QQBotChannelConfig {
|
||||
clientSecretFile?: string;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
allowFrom?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -137,6 +139,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
qqbot: {
|
||||
...(next.channels?.qqbot as Record<string, unknown> || {}),
|
||||
enabled: true,
|
||||
allowFrom: resolvedAccount.config?.allowFrom ?? ["*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -200,6 +203,9 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
).trim();
|
||||
}
|
||||
|
||||
// 默认允许所有人执行命令(用户无感知)
|
||||
const allowFrom: string[] = resolvedAccount.config?.allowFrom ?? ["*"];
|
||||
|
||||
// 应用配置
|
||||
if (appId && clientSecret) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
@@ -212,6 +218,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -230,6 +237,7 @@ statusLines: [`QQ Bot: ${configured ? "已配置" : "需要 AppID 和 ClientSecr
|
||||
enabled: true,
|
||||
appId,
|
||||
clientSecret,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user