2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
9
moltbot.plugin.json
Normal file
9
moltbot.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "qqbot",
|
||||||
|
"channels": ["qqbot"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
openclaw.plugin.json
Normal file
9
openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "qqbot",
|
||||||
|
"channels": ["qqbot"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
9852
package-lock.json
generated
Normal file
9852
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "qqbot",
|
"name": "qqbot",
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
@@ -10,6 +10,9 @@
|
|||||||
"moltbot": {
|
"moltbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": ["./index.ts"]
|
||||||
},
|
},
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": ["./index.ts"]
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc || true",
|
"build": "tsc || true",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
@@ -27,6 +30,8 @@
|
|||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"clawdbot": "*"
|
"clawdbot": "*",
|
||||||
|
"moltbot": "*",
|
||||||
|
"openclaw": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
166
src/api.ts
166
src/api.ts
@@ -56,18 +56,21 @@ export function clearTokenCache(): void {
|
|||||||
/**
|
/**
|
||||||
* msg_seq 追踪器 - 用于对同一条消息的多次回复
|
* msg_seq 追踪器 - 用于对同一条消息的多次回复
|
||||||
* key: msg_id, value: 当前 seq 值
|
* key: msg_id, value: 当前 seq 值
|
||||||
|
* 使用时间戳作为基础值,确保进程重启后不会重复
|
||||||
*/
|
*/
|
||||||
const msgSeqTracker = new Map<string, number>();
|
const msgSeqTracker = new Map<string, number>();
|
||||||
|
const seqBaseTime = Math.floor(Date.now() / 1000) % 100000000; // 取秒级时间戳的后8位作为基础
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取并递增消息序号
|
* 获取并递增消息序号
|
||||||
|
* 返回的 seq 会基于时间戳,避免进程重启后重复
|
||||||
*/
|
*/
|
||||||
export function getNextMsgSeq(msgId: string): number {
|
export function getNextMsgSeq(msgId: string): number {
|
||||||
const current = msgSeqTracker.get(msgId) ?? 0;
|
const current = msgSeqTracker.get(msgId) ?? 0;
|
||||||
const next = current + 1;
|
const next = current + 1;
|
||||||
msgSeqTracker.set(msgId, next);
|
msgSeqTracker.set(msgId, next);
|
||||||
|
|
||||||
// 清理过期的序号(超过 5 次或 60 分钟后无意义)
|
// 清理过期的序号
|
||||||
// 简单策略:保留最近 1000 条
|
// 简单策略:保留最近 1000 条
|
||||||
if (msgSeqTracker.size > 1000) {
|
if (msgSeqTracker.size > 1000) {
|
||||||
const keys = Array.from(msgSeqTracker.keys());
|
const keys = Array.from(msgSeqTracker.keys());
|
||||||
@@ -76,7 +79,8 @@ export function getNextMsgSeq(msgId: string): number {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
// 结合时间戳基础值,确保唯一性
|
||||||
|
return seqBaseTime + next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,3 +213,161 @@ export async function sendProactiveGroupMessage(
|
|||||||
msg_type: 0,
|
msg_type: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 富媒体消息支持 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体文件类型
|
||||||
|
*/
|
||||||
|
export enum MediaFileType {
|
||||||
|
IMAGE = 1,
|
||||||
|
VIDEO = 2,
|
||||||
|
VOICE = 3,
|
||||||
|
FILE = 4, // 暂未开放
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传富媒体文件的响应
|
||||||
|
*/
|
||||||
|
export interface UploadMediaResponse {
|
||||||
|
file_uuid: string;
|
||||||
|
file_info: string;
|
||||||
|
ttl: number;
|
||||||
|
id?: string; // 仅当 srv_send_msg=true 时返回
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传富媒体文件到 C2C 单聊
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param openid 用户 openid
|
||||||
|
* @param fileType 文件类型
|
||||||
|
* @param url 媒体资源 URL
|
||||||
|
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
||||||
|
*/
|
||||||
|
export async function uploadC2CMedia(
|
||||||
|
accessToken: string,
|
||||||
|
openid: string,
|
||||||
|
fileType: MediaFileType,
|
||||||
|
url: string,
|
||||||
|
srvSendMsg = false
|
||||||
|
): Promise<UploadMediaResponse> {
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, {
|
||||||
|
file_type: fileType,
|
||||||
|
url,
|
||||||
|
srv_send_msg: srvSendMsg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传富媒体文件到群聊
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param groupOpenid 群 openid
|
||||||
|
* @param fileType 文件类型
|
||||||
|
* @param url 媒体资源 URL
|
||||||
|
* @param srvSendMsg 是否直接发送(推荐 false,获取 file_info 后再发送)
|
||||||
|
*/
|
||||||
|
export async function uploadGroupMedia(
|
||||||
|
accessToken: string,
|
||||||
|
groupOpenid: string,
|
||||||
|
fileType: MediaFileType,
|
||||||
|
url: string,
|
||||||
|
srvSendMsg = false
|
||||||
|
): Promise<UploadMediaResponse> {
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, {
|
||||||
|
file_type: fileType,
|
||||||
|
url,
|
||||||
|
srv_send_msg: srvSendMsg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 C2C 单聊富媒体消息
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param openid 用户 openid
|
||||||
|
* @param fileInfo 从 uploadC2CMedia 获取的 file_info
|
||||||
|
* @param msgId 被动回复时需要的消息 ID
|
||||||
|
* @param content 可选的文字内容
|
||||||
|
*/
|
||||||
|
export async function sendC2CMediaMessage(
|
||||||
|
accessToken: string,
|
||||||
|
openid: string,
|
||||||
|
fileInfo: string,
|
||||||
|
msgId?: string,
|
||||||
|
content?: string
|
||||||
|
): Promise<{ id: string; timestamp: number }> {
|
||||||
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
||||||
|
msg_type: 7, // 富媒体消息类型
|
||||||
|
media: { file_info: fileInfo },
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
...(content ? { content } : {}),
|
||||||
|
...(msgId ? { msg_id: msgId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送群聊富媒体消息
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param groupOpenid 群 openid
|
||||||
|
* @param fileInfo 从 uploadGroupMedia 获取的 file_info
|
||||||
|
* @param msgId 被动回复时需要的消息 ID
|
||||||
|
* @param content 可选的文字内容
|
||||||
|
*/
|
||||||
|
export async function sendGroupMediaMessage(
|
||||||
|
accessToken: string,
|
||||||
|
groupOpenid: string,
|
||||||
|
fileInfo: string,
|
||||||
|
msgId?: string,
|
||||||
|
content?: string
|
||||||
|
): Promise<{ id: string; timestamp: string }> {
|
||||||
|
const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
|
||||||
|
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
||||||
|
msg_type: 7, // 富媒体消息类型
|
||||||
|
media: { file_info: fileInfo },
|
||||||
|
msg_seq: msgSeq,
|
||||||
|
...(content ? { content } : {}),
|
||||||
|
...(msgId ? { msg_id: msgId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送带图片的 C2C 单聊消息(封装上传+发送)
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param openid 用户 openid
|
||||||
|
* @param imageUrl 图片 URL
|
||||||
|
* @param msgId 被动回复时需要的消息 ID
|
||||||
|
* @param content 可选的文字内容
|
||||||
|
*/
|
||||||
|
export async function sendC2CImageMessage(
|
||||||
|
accessToken: string,
|
||||||
|
openid: string,
|
||||||
|
imageUrl: string,
|
||||||
|
msgId?: string,
|
||||||
|
content?: string
|
||||||
|
): Promise<{ id: string; timestamp: number }> {
|
||||||
|
// 先上传图片获取 file_info
|
||||||
|
const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, false);
|
||||||
|
// 再发送富媒体消息
|
||||||
|
return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送带图片的群聊消息(封装上传+发送)
|
||||||
|
* @param accessToken 访问令牌
|
||||||
|
* @param groupOpenid 群 openid
|
||||||
|
* @param imageUrl 图片 URL
|
||||||
|
* @param msgId 被动回复时需要的消息 ID
|
||||||
|
* @param content 可选的文字内容
|
||||||
|
*/
|
||||||
|
export async function sendGroupImageMessage(
|
||||||
|
accessToken: string,
|
||||||
|
groupOpenid: string,
|
||||||
|
imageUrl: string,
|
||||||
|
msgId?: string,
|
||||||
|
content?: string
|
||||||
|
): Promise<{ id: string; timestamp: string }> {
|
||||||
|
// 先上传图片获取 file_info
|
||||||
|
const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, false);
|
||||||
|
// 再发送富媒体消息
|
||||||
|
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
listAccountIds: (cfg) => listQQBotAccountIds(cfg),
|
||||||
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
resolveAccount: (cfg, accountId) => resolveQQBotAccount(cfg, accountId),
|
||||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||||
isConfigured: (account) => Boolean(account.appId && account.clientSecret),
|
isConfigured: (account) => Boolean(account?.appId && account?.clientSecret),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
name: account.name,
|
name: account?.name,
|
||||||
enabled: account.enabled,
|
enabled: account?.enabled ?? false,
|
||||||
configured: Boolean(account.appId && account.clientSecret),
|
configured: Boolean(account?.appId && account?.clientSecret),
|
||||||
tokenSource: account.secretSource,
|
tokenSource: account?.secretSource,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
setup: {
|
setup: {
|
||||||
@@ -63,6 +63,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
clientSecretFile: input.tokenFile,
|
clientSecretFile: input.tokenFile,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
imageServerBaseUrl: input.imageServerBaseUrl,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -118,11 +119,11 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
buildAccountSnapshot: ({ account, runtime }) => ({
|
buildAccountSnapshot: ({ account, runtime }) => ({
|
||||||
accountId: account.accountId,
|
accountId: account?.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||||
name: account.name,
|
name: account?.name,
|
||||||
enabled: account.enabled,
|
enabled: account?.enabled ?? false,
|
||||||
configured: Boolean(account.appId && account.clientSecret),
|
configured: Boolean(account?.appId && account?.clientSecret),
|
||||||
tokenSource: account.secretSource,
|
tokenSource: account?.secretSource,
|
||||||
running: runtime?.running ?? false,
|
running: runtime?.running ?? false,
|
||||||
connected: runtime?.connected ?? false,
|
connected: runtime?.connected ?? false,
|
||||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export function resolveQQBotAccount(
|
|||||||
dmPolicy: qqbot?.dmPolicy,
|
dmPolicy: qqbot?.dmPolicy,
|
||||||
allowFrom: qqbot?.allowFrom,
|
allowFrom: qqbot?.allowFrom,
|
||||||
systemPrompt: qqbot?.systemPrompt,
|
systemPrompt: qqbot?.systemPrompt,
|
||||||
|
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||||
};
|
};
|
||||||
appId = qqbot?.appId ?? "";
|
appId = qqbot?.appId ?? "";
|
||||||
} else {
|
} else {
|
||||||
@@ -97,6 +98,7 @@ export function resolveQQBotAccount(
|
|||||||
clientSecret,
|
clientSecret,
|
||||||
secretSource,
|
secretSource,
|
||||||
systemPrompt: accountConfig.systemPrompt,
|
systemPrompt: accountConfig.systemPrompt,
|
||||||
|
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||||
config: accountConfig,
|
config: accountConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -107,7 +109,7 @@ export function resolveQQBotAccount(
|
|||||||
export function applyQQBotAccountConfig(
|
export function applyQQBotAccountConfig(
|
||||||
cfg: MoltbotConfig,
|
cfg: MoltbotConfig,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string }
|
input: { appId?: string; clientSecret?: string; clientSecretFile?: string; name?: string; imageServerBaseUrl?: string }
|
||||||
): MoltbotConfig {
|
): MoltbotConfig {
|
||||||
const next = { ...cfg };
|
const next = { ...cfg };
|
||||||
|
|
||||||
@@ -124,6 +126,7 @@ export function applyQQBotAccountConfig(
|
|||||||
? { clientSecretFile: input.clientSecretFile }
|
? { clientSecretFile: input.clientSecretFile }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.name ? { name: input.name } : {}),
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -144,6 +147,7 @@ export function applyQQBotAccountConfig(
|
|||||||
? { clientSecretFile: input.clientSecretFile }
|
? { clientSecretFile: input.clientSecretFile }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.name ? { name: input.name } : {}),
|
...(input.name ? { name: input.name } : {}),
|
||||||
|
...(input.imageServerBaseUrl ? { imageServerBaseUrl: input.imageServerBaseUrl } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
383
src/gateway.ts
383
src/gateway.ts
@@ -1,17 +1,43 @@
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
import path from "node:path";
|
||||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache } from "./api.js";
|
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
||||||
import { getQQBotRuntime } from "./runtime.js";
|
import { getQQBotRuntime } from "./runtime.js";
|
||||||
|
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||||
|
|
||||||
// QQ Bot intents
|
// QQ Bot intents - 按权限级别分组
|
||||||
const INTENTS = {
|
const INTENTS = {
|
||||||
|
// 基础权限(默认有)
|
||||||
GUILDS: 1 << 0, // 频道相关
|
GUILDS: 1 << 0, // 频道相关
|
||||||
GUILD_MEMBERS: 1 << 1, // 频道成员
|
GUILD_MEMBERS: 1 << 1, // 频道成员
|
||||||
PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
|
PUBLIC_GUILD_MESSAGES: 1 << 30, // 频道公开消息(公域)
|
||||||
|
// 需要申请的权限
|
||||||
DIRECT_MESSAGE: 1 << 12, // 频道私信
|
DIRECT_MESSAGE: 1 << 12, // 频道私信
|
||||||
GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
|
GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 权限级别:从高到低依次尝试
|
||||||
|
const INTENT_LEVELS = [
|
||||||
|
// Level 0: 完整权限(群聊 + 私信 + 频道)
|
||||||
|
{
|
||||||
|
name: "full",
|
||||||
|
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
|
||||||
|
description: "群聊+私信+频道",
|
||||||
|
},
|
||||||
|
// Level 1: 群聊 + 频道(无私信)
|
||||||
|
{
|
||||||
|
name: "group+channel",
|
||||||
|
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C,
|
||||||
|
description: "群聊+频道",
|
||||||
|
},
|
||||||
|
// Level 2: 仅频道(基础权限)
|
||||||
|
{
|
||||||
|
name: "channel-only",
|
||||||
|
intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS,
|
||||||
|
description: "仅频道消息",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 重连配置
|
// 重连配置
|
||||||
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
|
const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
|
||||||
const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
|
const RATE_LIMIT_DELAY = 60000; // 遇到频率限制时等待 60 秒
|
||||||
@@ -19,6 +45,11 @@ const MAX_RECONNECT_ATTEMPTS = 100;
|
|||||||
const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
|
const MAX_QUICK_DISCONNECT_COUNT = 3; // 连续快速断开次数阈值
|
||||||
const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
|
const QUICK_DISCONNECT_THRESHOLD = 5000; // 5秒内断开视为快速断开
|
||||||
|
|
||||||
|
// 图床服务器配置(可通过环境变量覆盖)
|
||||||
|
const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765", 10);
|
||||||
|
// 使用绝对路径,确保文件保存和读取使用同一目录
|
||||||
|
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-images");
|
||||||
|
|
||||||
export interface GatewayContext {
|
export interface GatewayContext {
|
||||||
account: ResolvedQQBotAccount;
|
account: ResolvedQQBotAccount;
|
||||||
abortSignal: AbortSignal;
|
abortSignal: AbortSignal;
|
||||||
@@ -32,6 +63,31 @@ export interface GatewayContext {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动图床服务器
|
||||||
|
*/
|
||||||
|
async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: string): Promise<string | null> {
|
||||||
|
if (isImageServerRunning()) {
|
||||||
|
return publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config: Partial<ImageServerConfig> = {
|
||||||
|
port: IMAGE_SERVER_PORT,
|
||||||
|
storageDir: IMAGE_SERVER_DIR,
|
||||||
|
// 使用用户配置的公网地址,而不是 0.0.0.0
|
||||||
|
baseUrl: publicBaseUrl || `http://0.0.0.0:${IMAGE_SERVER_PORT}`,
|
||||||
|
ttlSeconds: 3600, // 1 小时过期
|
||||||
|
};
|
||||||
|
await startImageServer(config);
|
||||||
|
log?.info(`[qqbot] Image server started on port ${IMAGE_SERVER_PORT}, baseUrl: ${config.baseUrl}`);
|
||||||
|
return config.baseUrl!;
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot] Failed to start image server: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动 Gateway WebSocket 连接(带自动重连)
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
||||||
*/
|
*/
|
||||||
@@ -42,6 +98,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
throw new Error("QQBot not configured (missing appId or clientSecret)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果配置了公网 URL,启动图床服务器
|
||||||
|
let imageServerBaseUrl: string | null = null;
|
||||||
|
if (account.imageServerBaseUrl) {
|
||||||
|
// 使用用户配置的公网地址作为 baseUrl
|
||||||
|
await ensureImageServer(log, account.imageServerBaseUrl);
|
||||||
|
imageServerBaseUrl = account.imageServerBaseUrl;
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Image server enabled with URL: ${imageServerBaseUrl}`);
|
||||||
|
} else {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Image server disabled (no imageServerBaseUrl configured)`);
|
||||||
|
}
|
||||||
|
|
||||||
let reconnectAttempts = 0;
|
let reconnectAttempts = 0;
|
||||||
let isAborted = false;
|
let isAborted = false;
|
||||||
let currentWs: WebSocket | null = null;
|
let currentWs: WebSocket | null = null;
|
||||||
@@ -53,7 +120,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
let isConnecting = false; // 防止并发连接
|
let isConnecting = false; // 防止并发连接
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
|
||||||
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
||||||
let identifyFailCount = 0; // identify 失败次数
|
let intentLevelIndex = 0; // 当前尝试的权限级别索引
|
||||||
|
let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
|
||||||
|
|
||||||
abortSignal.addEventListener("abort", () => {
|
abortSignal.addEventListener("abort", () => {
|
||||||
isAborted = true;
|
isAborted = true;
|
||||||
@@ -174,22 +242,49 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
const envelopeOptions = pluginRuntime.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||||
|
|
||||||
// 组装消息体,添加系统提示词
|
// 组装消息体,添加系统提示词
|
||||||
const builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL";
|
let builtinPrompt = "由于平台限制,你的回复中不可以包含任何URL。";
|
||||||
|
|
||||||
|
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||||
|
if (imageServerBaseUrl) {
|
||||||
|
builtinPrompt += `
|
||||||
|
|
||||||
|
【发送图片】
|
||||||
|
你可以发送本地图片文件给用户。只需在回复中直接引用图片的绝对路径即可,系统会自动处理。
|
||||||
|
支持 png、jpg、gif、webp 格式。`;
|
||||||
|
}
|
||||||
|
|
||||||
const systemPrompts = [builtinPrompt];
|
const systemPrompts = [builtinPrompt];
|
||||||
if (account.systemPrompt) {
|
if (account.systemPrompt) {
|
||||||
systemPrompts.push(account.systemPrompt);
|
systemPrompts.push(account.systemPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理附件(图片等)
|
// 处理附件(图片等)- 下载到本地供 clawdbot 访问
|
||||||
let attachmentInfo = "";
|
let attachmentInfo = "";
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
// 存到 clawdbot 工作目录下的 downloads 文件夹
|
||||||
|
const downloadDir = path.join(process.env.HOME || "/home/ubuntu", "clawd", "downloads");
|
||||||
|
|
||||||
if (event.attachments?.length) {
|
if (event.attachments?.length) {
|
||||||
for (const att of event.attachments) {
|
for (const att of event.attachments) {
|
||||||
if (att.content_type?.startsWith("image/")) {
|
// 下载附件到本地,使用原始文件名
|
||||||
imageUrls.push(att.url);
|
const localPath = await downloadFile(att.url, downloadDir, att.filename);
|
||||||
attachmentInfo += `\n[图片: ${att.url}]`;
|
if (localPath) {
|
||||||
|
if (att.content_type?.startsWith("image/")) {
|
||||||
|
imageUrls.push(localPath);
|
||||||
|
attachmentInfo += `\n[图片: ${localPath}]`;
|
||||||
|
} else {
|
||||||
|
attachmentInfo += `\n[附件: ${localPath}]`;
|
||||||
|
}
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Downloaded attachment to: ${localPath}`);
|
||||||
} else {
|
} else {
|
||||||
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}]`;
|
// 下载失败,提供原始 URL 作为后备
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to download attachment: ${att.url}`);
|
||||||
|
if (att.content_type?.startsWith("image/")) {
|
||||||
|
imageUrls.push(att.url);
|
||||||
|
attachmentInfo += `\n[图片: ${att.url}] (下载失败,可能无法访问)`;
|
||||||
|
} else {
|
||||||
|
attachmentInfo += `\n[附件: ${att.filename ?? att.content_type}] (下载失败)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,39 +391,185 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
responsePrefix: messagesConfig.responsePrefix,
|
responsePrefix: messagesConfig.responsePrefix,
|
||||||
deliver: async (payload: { text?: string }) => {
|
deliver: async (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) => {
|
||||||
hasResponse = true;
|
hasResponse = true;
|
||||||
if (timeoutId) {
|
if (timeoutId) {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let replyText = payload.text ?? "";
|
log?.info(`[qqbot:${account.accountId}] deliver called, payload keys: ${Object.keys(payload).join(", ")}`);
|
||||||
if (!replyText.trim()) return;
|
|
||||||
|
|
||||||
// 处理回复内容,避免被 QQ 识别为 URL
|
let replyText = payload.text ?? "";
|
||||||
const originalText = replyText;
|
|
||||||
|
|
||||||
// 把所有可能被识别为 URL 的点替换为下划线
|
// 收集所有图片路径
|
||||||
// 匹配:字母/数字.字母/数字 的模式
|
const imageUrls: string[] = [];
|
||||||
replyText = replyText.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
|
||||||
|
|
||||||
const hasReplacement = replyText !== originalText;
|
// 处理 mediaUrls 和 mediaUrl 字段(本地文件路径)
|
||||||
if (hasReplacement) {
|
const mediaPaths: string[] = [];
|
||||||
replyText += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
if (payload.mediaUrls?.length) {
|
||||||
|
mediaPaths.push(...payload.mediaUrls);
|
||||||
|
}
|
||||||
|
if (payload.mediaUrl && !mediaPaths.includes(payload.mediaUrl)) {
|
||||||
|
mediaPaths.push(payload.mediaUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const localPath of mediaPaths) {
|
||||||
|
if (localPath && imageServerBaseUrl) {
|
||||||
|
try {
|
||||||
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
|
if (savedUrl) {
|
||||||
|
imageUrls.push(savedUrl);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Saved media to server: ${localPath}`);
|
||||||
|
} else {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save media (not found or not image): ${localPath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save media: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有文本也没有图片,跳过
|
||||||
|
if (!replyText.trim() && imageUrls.length === 0) {
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Empty reply, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0. 提取 MEDIA: 前缀的本地文件路径(从文本中)
|
||||||
|
const mediaPathRegex = /MEDIA:([^\s\n]+)/gi;
|
||||||
|
const mediaMatches = [...replyText.matchAll(mediaPathRegex)];
|
||||||
|
|
||||||
|
for (const match of mediaMatches) {
|
||||||
|
const localPath = match[1];
|
||||||
|
if (localPath && imageServerBaseUrl) {
|
||||||
|
// 将本地文件复制到图床
|
||||||
|
try {
|
||||||
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
|
if (savedUrl) {
|
||||||
|
imageUrls.push(savedUrl);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Saved local image to server: ${localPath}`);
|
||||||
|
} else {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save local image (not found or not image): ${localPath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save local image: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从文本中移除 MEDIA: 行
|
||||||
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0.5. 提取本地绝对文件路径(/path/to/image.png 或 /path/to/image_123_png 格式)
|
||||||
|
// 支持标准扩展名和下划线替换后的扩展名
|
||||||
|
const localPathRegex = /(\/[^\s\n]+?(?:\.(?:png|jpg|jpeg|gif|webp)|_(?:png|jpg|jpeg|gif|webp)(?:\s|$)))/gi;
|
||||||
|
const localPathMatches = [...replyText.matchAll(localPathRegex)];
|
||||||
|
|
||||||
|
for (const match of localPathMatches) {
|
||||||
|
let localPath = match[1].trim();
|
||||||
|
if (localPath && imageServerBaseUrl) {
|
||||||
|
// 如果是下划线格式的扩展名,转换回点格式
|
||||||
|
localPath = localPath.replace(/_(?=(?:png|jpg|jpeg|gif|webp)$)/, ".");
|
||||||
|
try {
|
||||||
|
const savedUrl = saveImageFromPath(localPath);
|
||||||
|
if (savedUrl) {
|
||||||
|
imageUrls.push(savedUrl);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Saved local path image to server: ${localPath}`);
|
||||||
|
} else {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Local path not found or not image: ${localPath}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save local path image: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从文本中移除本地路径
|
||||||
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 提取 base64 图片(data:image/xxx;base64,...)
|
||||||
|
const base64ImageRegex = /!\[([^\]]*)\]\((data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)\)|(?<![(\[])(data:image\/[^;]+;base64,[A-Za-z0-9+/=]+)/gi;
|
||||||
|
const base64Matches = [...replyText.matchAll(base64ImageRegex)];
|
||||||
|
|
||||||
|
for (const match of base64Matches) {
|
||||||
|
const dataUrl = match[2] || match[3];
|
||||||
|
if (dataUrl && imageServerBaseUrl) {
|
||||||
|
// 将 base64 保存到本地图床
|
||||||
|
try {
|
||||||
|
const savedUrl = saveImage(dataUrl);
|
||||||
|
imageUrls.push(savedUrl);
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Saved base64 image to local server`);
|
||||||
|
} catch (err) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to save base64 image: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从文本中移除 base64
|
||||||
|
replyText = replyText.replace(match[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 提取 URL 图片(Markdown 格式或纯 URL)
|
||||||
|
const imageUrlRegex = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s)]*)?)\)|(?<![(\[])(https?:\/\/[^\s)]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s]*)?)/gi;
|
||||||
|
const urlMatches = [...replyText.matchAll(imageUrlRegex)];
|
||||||
|
|
||||||
|
for (const match of urlMatches) {
|
||||||
|
// match[2] 是 Markdown 格式的 URL,match[3] 是纯 URL
|
||||||
|
const url = match[2] || match[3];
|
||||||
|
if (url) {
|
||||||
|
imageUrls.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文本中移除图片 URL,避免被 QQ 拦截
|
||||||
|
let textWithoutImages = replyText;
|
||||||
|
for (const match of urlMatches) {
|
||||||
|
textWithoutImages = textWithoutImages.replace(match[0], "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理剩余文本中的 URL 点号(只有在没有图片的情况下才替换,避免误伤)
|
||||||
|
const hasImages = imageUrls.length > 0;
|
||||||
|
let hasReplacement = false;
|
||||||
|
if (!hasImages) {
|
||||||
|
const originalText = textWithoutImages;
|
||||||
|
textWithoutImages = textWithoutImages.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
||||||
|
hasReplacement = textWithoutImages !== originalText;
|
||||||
|
if (hasReplacement && textWithoutImages.trim()) {
|
||||||
|
textWithoutImages += "\n\n(由于平台限制,回复中的部分符号已被替换)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendWithTokenRetry(async (token) => {
|
// 先发送图片(如果有)
|
||||||
if (event.type === "c2c") {
|
for (const imageUrl of imageUrls) {
|
||||||
await sendC2CMessage(token, event.senderId, replyText, event.messageId);
|
try {
|
||||||
} else if (event.type === "group" && event.groupOpenid) {
|
await sendWithTokenRetry(async (token) => {
|
||||||
await sendGroupMessage(token, event.groupOpenid, replyText, event.messageId);
|
if (event.type === "c2c") {
|
||||||
} else if (event.channelId) {
|
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
||||||
await sendChannelMessage(token, event.channelId, replyText, event.messageId);
|
} else if (event.type === "group" && event.groupOpenid) {
|
||||||
|
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
||||||
|
}
|
||||||
|
// 频道消息暂不支持富媒体,跳过图片
|
||||||
|
});
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sent image: ${imageUrl.slice(0, 50)}...`);
|
||||||
|
} catch (imgErr) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
||||||
|
// 图片发送失败时,显示错误信息而不是 URL
|
||||||
|
const errMsg = String(imgErr).slice(0, 200);
|
||||||
|
textWithoutImages = `[图片发送失败: ${errMsg}]\n${textWithoutImages}`;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
log?.info(`[qqbot:${account.accountId}] Sent reply`);
|
|
||||||
|
// 再发送文本(如果有)
|
||||||
|
if (textWithoutImages.trim()) {
|
||||||
|
await sendWithTokenRetry(async (token) => {
|
||||||
|
if (event.type === "c2c") {
|
||||||
|
await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId);
|
||||||
|
} else if (event.type === "group" && event.groupOpenid) {
|
||||||
|
await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
||||||
|
} else if (event.channelId) {
|
||||||
|
await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Sent text reply`);
|
||||||
|
}
|
||||||
|
|
||||||
pluginRuntime.channel.activity.record({
|
pluginRuntime.channel.activity.record({
|
||||||
channel: "qqbot",
|
channel: "qqbot",
|
||||||
@@ -346,12 +587,13 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
timeoutId = null;
|
timeoutId = null;
|
||||||
}
|
}
|
||||||
// 发送错误提示给用户
|
// 发送错误提示给用户,显示完整错误信息
|
||||||
const errMsg = String(err);
|
const errMsg = String(err);
|
||||||
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
||||||
await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
|
await sendErrorMessage("[ClawdBot] 大模型 API Key 可能无效,请检查配置");
|
||||||
} else {
|
} else {
|
||||||
await sendErrorMessage(`[ClawdBot] 处理消息时出错: ${errMsg.slice(0, 100)}`);
|
// 显示完整错误信息,截取前 500 字符
|
||||||
|
await sendErrorMessage(`[ClawdBot] 出错: ${errMsg.slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -372,7 +614,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
||||||
await sendErrorMessage(`[ClawdBot] 处理消息失败: ${String(err).slice(0, 100)}`);
|
await sendErrorMessage(`[ClawdBot] 处理失败: ${String(err).slice(0, 500)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -385,7 +627,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
ws.on("message", async (data) => {
|
ws.on("message", async (data) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data.toString()) as WSPayload;
|
const rawData = data.toString();
|
||||||
|
const payload = JSON.parse(rawData) as WSPayload;
|
||||||
const { op, d, s, t } = payload;
|
const { op, d, s, t } = payload;
|
||||||
|
|
||||||
if (s) lastSeq = s;
|
if (s) lastSeq = s;
|
||||||
@@ -409,22 +652,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// 新连接,发送 Identify
|
// 新连接,发送 Identify
|
||||||
// 如果 identify 失败多次,尝试只使用基础权限
|
// 如果有上次成功的级别,直接使用;否则从当前级别开始尝试
|
||||||
let intents: number;
|
const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex;
|
||||||
if (identifyFailCount >= 3) {
|
const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)];
|
||||||
// 只使用基础权限(频道消息)
|
log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`);
|
||||||
intents = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS;
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Using basic intents only (after ${identifyFailCount} failures): ${intents}`);
|
|
||||||
} else {
|
|
||||||
// 使用完整权限
|
|
||||||
intents = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
|
|
||||||
log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intents}`);
|
|
||||||
}
|
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
op: 2,
|
op: 2,
|
||||||
d: {
|
d: {
|
||||||
token: `QQBot ${accessToken}`,
|
token: `QQBot ${accessToken}`,
|
||||||
intents: intents,
|
intents: intentLevel.intents,
|
||||||
shard: [0, 1],
|
shard: [0, 1],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -445,8 +681,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
if (t === "READY") {
|
if (t === "READY") {
|
||||||
const readyData = d as { session_id: string };
|
const readyData = d as { session_id: string };
|
||||||
sessionId = readyData.session_id;
|
sessionId = readyData.session_id;
|
||||||
identifyFailCount = 0; // 连接成功,重置失败计数
|
// 记录成功的权限级别
|
||||||
log?.info(`[qqbot:${account.accountId}] Ready, session: ${sessionId}`);
|
lastSuccessfulIntentLevel = intentLevelIndex;
|
||||||
|
const successLevel = INTENT_LEVELS[intentLevelIndex];
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
|
||||||
onReady?.(d);
|
onReady?.(d);
|
||||||
} else if (t === "RESUMED") {
|
} else if (t === "RESUMED") {
|
||||||
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
||||||
@@ -511,22 +749,27 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
|
|
||||||
case 9: // Invalid Session
|
case 9: // Invalid Session
|
||||||
const canResume = d as boolean;
|
const canResume = d as boolean;
|
||||||
log?.error(`[qqbot:${account.accountId}] Invalid session, can resume: ${canResume}`);
|
const currentLevel = INTENT_LEVELS[intentLevelIndex];
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`);
|
||||||
|
|
||||||
if (!canResume) {
|
if (!canResume) {
|
||||||
sessionId = null;
|
sessionId = null;
|
||||||
lastSeq = null;
|
lastSeq = null;
|
||||||
identifyFailCount++;
|
|
||||||
// 标记需要刷新 token(可能是 token 过期导致的)
|
|
||||||
shouldRefreshToken = true;
|
|
||||||
|
|
||||||
if (identifyFailCount >= 3) {
|
// 尝试降级到下一个权限级别
|
||||||
log?.error(`[qqbot:${account.accountId}] Identify failed ${identifyFailCount} times. This may be a permission issue.`);
|
if (intentLevelIndex < INTENT_LEVELS.length - 1) {
|
||||||
log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret is correct 2) Bot has GROUP_AND_C2C permission on QQ Open Platform`);
|
intentLevelIndex++;
|
||||||
|
const nextLevel = INTENT_LEVELS[intentLevelIndex];
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`);
|
||||||
|
} else {
|
||||||
|
// 已经是最低权限级别了
|
||||||
|
log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`);
|
||||||
|
shouldRefreshToken = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
// Invalid Session 后等待一段时间再重连
|
// Invalid Session 后等待一段时间再重连
|
||||||
scheduleReconnect(5000);
|
scheduleReconnect(3000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -538,12 +781,28 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
|
log?.info(`[qqbot:${account.accountId}] WebSocket closed: ${code} ${reason.toString()}`);
|
||||||
isConnecting = false; // 释放锁
|
isConnecting = false; // 释放锁
|
||||||
|
|
||||||
// 4903 等错误码表示 session 创建失败,需要刷新 token
|
// 根据错误码处理
|
||||||
if (code === 4903 || code === 4009 || code === 4014) {
|
// 4009: 可以重新发起 resume
|
||||||
log?.info(`[qqbot:${account.accountId}] Session error (${code}), will refresh token`);
|
// 4900-4913: 内部错误,需要重新 identify
|
||||||
|
// 4914: 机器人已下架
|
||||||
|
// 4915: 机器人已封禁
|
||||||
|
if (code === 4914 || code === 4915) {
|
||||||
|
log?.error(`[qqbot:${account.accountId}] Bot is ${code === 4914 ? "offline/sandbox-only" : "banned"}. Please contact QQ platform.`);
|
||||||
|
cleanup();
|
||||||
|
// 不重连,直接退出
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 4009) {
|
||||||
|
// 4009 可以尝试 resume,保留 session
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Error 4009, will try resume`);
|
||||||
shouldRefreshToken = true;
|
shouldRefreshToken = true;
|
||||||
|
} else if (code >= 4900 && code <= 4913) {
|
||||||
|
// 4900-4913 内部错误,清除 session 重新 identify
|
||||||
|
log?.info(`[qqbot:${account.accountId}] Internal error (${code}), will re-identify`);
|
||||||
sessionId = null;
|
sessionId = null;
|
||||||
lastSeq = null;
|
lastSeq = null;
|
||||||
|
shouldRefreshToken = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测是否是快速断开(连接后很快就断了)
|
// 检测是否是快速断开(连接后很快就断了)
|
||||||
@@ -552,12 +811,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|||||||
quickDisconnectCount++;
|
quickDisconnectCount++;
|
||||||
log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
|
log?.info(`[qqbot:${account.accountId}] Quick disconnect detected (${connectionDuration}ms), count: ${quickDisconnectCount}`);
|
||||||
|
|
||||||
// 如果连续快速断开超过阈值,清除 session 并等待更长时间
|
// 如果连续快速断开超过阈值,等待更长时间
|
||||||
if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
|
if (quickDisconnectCount >= MAX_QUICK_DISCONNECT_COUNT) {
|
||||||
log?.info(`[qqbot:${account.accountId}] Too many quick disconnects, clearing session and refreshing token`);
|
log?.error(`[qqbot:${account.accountId}] Too many quick disconnects. This may indicate a permission issue.`);
|
||||||
sessionId = null;
|
log?.error(`[qqbot:${account.accountId}] Please check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform`);
|
||||||
lastSeq = null;
|
|
||||||
shouldRefreshToken = true;
|
|
||||||
quickDisconnectCount = 0;
|
quickDisconnectCount = 0;
|
||||||
cleanup();
|
cleanup();
|
||||||
// 快速断开太多次,等待更长时间再重连
|
// 快速断开太多次,等待更长时间再重连
|
||||||
|
|||||||
446
src/image-server.ts
Normal file
446
src/image-server.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
/**
|
||||||
|
* 本地图床服务器
|
||||||
|
* 提供安全的图片存储和访问服务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from "node:http";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export interface ImageServerConfig {
|
||||||
|
/** 监听端口 */
|
||||||
|
port: number;
|
||||||
|
/** 图片存储目录 */
|
||||||
|
storageDir: string;
|
||||||
|
/** 外部访问的基础 URL(如 http://your-server:port),留空则自动生成 */
|
||||||
|
baseUrl?: string;
|
||||||
|
/** 图片过期时间(秒),0 表示不过期 */
|
||||||
|
ttlSeconds?: number;
|
||||||
|
/** 允许的图片格式 */
|
||||||
|
allowedFormats?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StoredImage {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
createdAt: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: Required<ImageServerConfig> = {
|
||||||
|
port: 18765,
|
||||||
|
storageDir: "./qqbot-images",
|
||||||
|
baseUrl: "",
|
||||||
|
ttlSeconds: 3600, // 默认 1 小时过期
|
||||||
|
allowedFormats: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||||
|
};
|
||||||
|
|
||||||
|
let serverInstance: http.Server | null = null;
|
||||||
|
let currentConfig: Required<ImageServerConfig> = { ...DEFAULT_CONFIG };
|
||||||
|
let imageIndex = new Map<string, StoredImage>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成安全的随机 ID
|
||||||
|
*/
|
||||||
|
function generateImageId(): string {
|
||||||
|
return crypto.randomBytes(16).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证请求路径是否安全(防止目录遍历攻击)
|
||||||
|
*/
|
||||||
|
function isPathSafe(requestPath: string, baseDir: string): boolean {
|
||||||
|
const normalizedBase = path.resolve(baseDir);
|
||||||
|
const normalizedPath = path.resolve(baseDir, requestPath);
|
||||||
|
return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 MIME 类型
|
||||||
|
*/
|
||||||
|
function getMimeType(ext: string): string {
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
};
|
||||||
|
return mimeTypes[ext.toLowerCase()] || "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 MIME 类型获取扩展名
|
||||||
|
*/
|
||||||
|
function getExtFromMime(mimeType: string): string | null {
|
||||||
|
const extMap: Record<string, string> = {
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/webp": "webp",
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"application/json": "json",
|
||||||
|
"text/plain": "txt",
|
||||||
|
"text/csv": "csv",
|
||||||
|
"application/msword": "doc",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
||||||
|
"application/vnd.ms-excel": "xls",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
||||||
|
};
|
||||||
|
return extMap[mimeType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期图片
|
||||||
|
*/
|
||||||
|
function cleanupExpiredImages(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const expiredIds: string[] = [];
|
||||||
|
|
||||||
|
for (const [id, image] of imageIndex) {
|
||||||
|
if (image.ttl > 0 && now - image.createdAt > image.ttl * 1000) {
|
||||||
|
expiredIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of expiredIds) {
|
||||||
|
const image = imageIndex.get(id);
|
||||||
|
if (image) {
|
||||||
|
const filePath = path.join(currentConfig.storageDir, image.filename);
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略删除错误
|
||||||
|
}
|
||||||
|
imageIndex.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载已有的图片索引
|
||||||
|
*/
|
||||||
|
function loadImageIndex(): void {
|
||||||
|
const indexPath = path.join(currentConfig.storageDir, ".index.json");
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(indexPath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
||||||
|
imageIndex = new Map(Object.entries(data));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
imageIndex = new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存图片索引
|
||||||
|
*/
|
||||||
|
function saveImageIndex(): void {
|
||||||
|
const indexPath = path.join(currentConfig.storageDir, ".index.json");
|
||||||
|
try {
|
||||||
|
const data = Object.fromEntries(imageIndex);
|
||||||
|
fs.writeFileSync(indexPath, JSON.stringify(data, null, 2));
|
||||||
|
} catch {
|
||||||
|
// 忽略保存错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 HTTP 请求
|
||||||
|
*/
|
||||||
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
|
const url = new URL(req.url || "/", `http://localhost:${currentConfig.port}`);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// 设置 CORS 头(允许 QQ 服务器访问)
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只允许 GET 请求访问图片
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
res.writeHead(405, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析图片 ID(路径格式: /images/{id}.{ext})
|
||||||
|
const match = pathname.match(/^\/images\/([a-f0-9]{32})\.(\w+)$/);
|
||||||
|
if (!match) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Not Found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, imageId, requestedExt] = match;
|
||||||
|
const image = imageIndex.get(imageId);
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Image Not Found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if (image.ttl > 0 && Date.now() - image.createdAt > image.ttl * 1000) {
|
||||||
|
res.writeHead(410, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Image Expired");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安全检查:确保文件路径在存储目录内
|
||||||
|
const filePath = path.join(currentConfig.storageDir, image.filename);
|
||||||
|
if (!isPathSafe(image.filename, currentConfig.storageDir)) {
|
||||||
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Forbidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取并返回图片
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||||
|
res.end("File Not Found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageData = fs.readFileSync(filePath);
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": image.mimeType,
|
||||||
|
"Content-Length": imageData.length,
|
||||||
|
"Cache-Control": image.ttl > 0 ? `max-age=${image.ttl}` : "max-age=31536000",
|
||||||
|
});
|
||||||
|
res.end(imageData);
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500, { "Content-Type": "text/plain" });
|
||||||
|
res.end("Internal Server Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动图床服务器
|
||||||
|
*/
|
||||||
|
export function startImageServer(config?: Partial<ImageServerConfig>): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (serverInstance) {
|
||||||
|
const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
|
||||||
|
resolve(baseUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentConfig = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
|
||||||
|
// 确保存储目录存在
|
||||||
|
if (!fs.existsSync(currentConfig.storageDir)) {
|
||||||
|
fs.mkdirSync(currentConfig.storageDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载图片索引
|
||||||
|
loadImageIndex();
|
||||||
|
|
||||||
|
// 启动定期清理
|
||||||
|
const cleanupInterval = setInterval(cleanupExpiredImages, 60000); // 每分钟清理一次
|
||||||
|
|
||||||
|
serverInstance = http.createServer(handleRequest);
|
||||||
|
|
||||||
|
serverInstance.on("error", (err) => {
|
||||||
|
clearInterval(cleanupInterval);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverInstance.listen(currentConfig.port, () => {
|
||||||
|
const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
|
||||||
|
resolve(baseUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止图床服务器
|
||||||
|
*/
|
||||||
|
export function stopImageServer(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (serverInstance) {
|
||||||
|
serverInstance.close(() => {
|
||||||
|
serverInstance = null;
|
||||||
|
saveImageIndex();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存图片并返回访问 URL
|
||||||
|
* @param imageData 图片数据(Buffer 或 base64 字符串)
|
||||||
|
* @param mimeType 图片 MIME 类型
|
||||||
|
* @param ttlSeconds 过期时间(秒),默认使用配置值
|
||||||
|
* @returns 图片访问 URL
|
||||||
|
*/
|
||||||
|
export function saveImage(
|
||||||
|
imageData: Buffer | string,
|
||||||
|
mimeType: string = "image/png",
|
||||||
|
ttlSeconds?: number
|
||||||
|
): string {
|
||||||
|
// 转换 base64 为 Buffer
|
||||||
|
let buffer: Buffer;
|
||||||
|
if (typeof imageData === "string") {
|
||||||
|
// 处理 data URL 格式
|
||||||
|
const base64Match = imageData.match(/^data:([^;]+);base64,(.+)$/);
|
||||||
|
if (base64Match) {
|
||||||
|
mimeType = base64Match[1];
|
||||||
|
buffer = Buffer.from(base64Match[2], "base64");
|
||||||
|
} else {
|
||||||
|
buffer = Buffer.from(imageData, "base64");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buffer = imageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成唯一 ID 和文件名
|
||||||
|
const imageId = generateImageId();
|
||||||
|
const ext = getExtFromMime(mimeType) || "png";
|
||||||
|
const filename = `${imageId}.${ext}`;
|
||||||
|
|
||||||
|
// 确保存储目录存在
|
||||||
|
if (!fs.existsSync(currentConfig.storageDir)) {
|
||||||
|
fs.mkdirSync(currentConfig.storageDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const filePath = path.join(currentConfig.storageDir, filename);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
// 记录到索引
|
||||||
|
const image: StoredImage = {
|
||||||
|
id: imageId,
|
||||||
|
filename,
|
||||||
|
mimeType,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
ttl: ttlSeconds ?? currentConfig.ttlSeconds,
|
||||||
|
};
|
||||||
|
imageIndex.set(imageId, image);
|
||||||
|
saveImageIndex();
|
||||||
|
|
||||||
|
// 返回访问 URL
|
||||||
|
const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`;
|
||||||
|
return `${baseUrl}/images/${imageId}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地文件路径保存图片到图床
|
||||||
|
* @param filePath 本地文件路径
|
||||||
|
* @param ttlSeconds 过期时间(秒),默认使用配置值
|
||||||
|
* @returns 图片访问 URL,如果文件不存在或不是图片则返回 null
|
||||||
|
*/
|
||||||
|
export function saveImageFromPath(filePath: string, ttlSeconds?: number): string | null {
|
||||||
|
try {
|
||||||
|
console.log(`[image-server] saveImageFromPath: ${filePath}`);
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.log(`[image-server] File not found: ${filePath}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
const buffer = fs.readFileSync(filePath);
|
||||||
|
console.log(`[image-server] File size: ${buffer.length}`);
|
||||||
|
|
||||||
|
// 根据扩展名获取 MIME 类型
|
||||||
|
const ext = path.extname(filePath).toLowerCase().replace(".", "");
|
||||||
|
console.log(`[image-server] Extension: "${ext}"`);
|
||||||
|
const mimeType = getMimeType(ext);
|
||||||
|
console.log(`[image-server] MIME type: ${mimeType}`);
|
||||||
|
|
||||||
|
// 只处理图片文件
|
||||||
|
if (!mimeType.startsWith("image/")) {
|
||||||
|
console.log(`[image-server] Not an image file`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 saveImage 保存
|
||||||
|
return saveImage(buffer, mimeType, ttlSeconds);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[image-server] saveImageFromPath error:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查图床服务器是否运行中
|
||||||
|
*/
|
||||||
|
export function isImageServerRunning(): boolean {
|
||||||
|
return serverInstance !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载远程文件并保存到本地
|
||||||
|
* @param url 远程文件 URL
|
||||||
|
* @param destDir 目标目录
|
||||||
|
* @param originalFilename 原始文件名(可选,完整文件名包含扩展名)
|
||||||
|
* @returns 本地文件路径,失败返回 null
|
||||||
|
*/
|
||||||
|
export async function downloadFile(
|
||||||
|
url: string,
|
||||||
|
destDir: string,
|
||||||
|
originalFilename?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[image-server] Download failed: ${response.status} ${response.statusText}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// 确定文件名
|
||||||
|
let finalFilename: string;
|
||||||
|
if (originalFilename) {
|
||||||
|
// 使用原始文件名,但添加时间戳避免冲突
|
||||||
|
const ext = path.extname(originalFilename);
|
||||||
|
const baseName = path.basename(originalFilename, ext);
|
||||||
|
const timestamp = Date.now();
|
||||||
|
finalFilename = `${baseName}_${timestamp}${ext}`;
|
||||||
|
} else {
|
||||||
|
// 没有原始文件名,生成随机名
|
||||||
|
finalFilename = `${generateImageId()}.bin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(destDir, finalFilename);
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
console.log(`[image-server] Downloaded file: ${filePath}`);
|
||||||
|
|
||||||
|
return filePath;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[image-server] Download error:`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取图床服务器配置
|
||||||
|
*/
|
||||||
|
export function getImageServerConfig(): Required<ImageServerConfig> {
|
||||||
|
return { ...currentConfig };
|
||||||
|
}
|
||||||
@@ -29,12 +29,14 @@ interface QQBotChannelConfig {
|
|||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
clientSecretFile?: string;
|
clientSecretFile?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
imageServerBaseUrl?: string;
|
||||||
accounts?: Record<string, {
|
accounts?: Record<string, {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
clientSecretFile?: string;
|
clientSecretFile?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
imageServerBaseUrl?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface ResolvedQQBotAccount {
|
|||||||
secretSource: "config" | "file" | "env" | "none";
|
secretSource: "config" | "file" | "env" | "none";
|
||||||
/** 系统提示词 */
|
/** 系统提示词 */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
/** 图床服务器公网地址 */
|
||||||
|
imageServerBaseUrl?: string;
|
||||||
config: QQBotAccountConfig;
|
config: QQBotAccountConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ export interface QQBotAccountConfig {
|
|||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
/** 系统提示词,会添加在用户消息前面 */
|
/** 系统提示词,会添加在用户消息前面 */
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
|
/** 图床服务器公网地址,用于发送图片,例如 http://your-ip:18765 */
|
||||||
|
imageServerBaseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user