feat(qqbot): 定时提醒技能与主动消息系统
**新增提醒技能** - 新增 skills/qqbot-cron/SKILL.md 定时提醒技能定义 - 支持一次性提醒(--at)和周期性提醒(--cron) - 支持设置、查询、取消提醒操作 **主动消息系统** - 新增 src/proactive.ts 主动消息发送模块 - 新增 src/known-users.ts 已知用户管理 - 新增 src/session-store.ts 会话存储 - 支持主动向用户/群组发送消息 **工具脚本** - 新增 scripts/proactive-api-server.ts 主动消息API服务
This commit is contained in:
244
src/api.ts
244
src/api.ts
@@ -26,9 +26,14 @@ export function isMarkdownSupport(): boolean {
|
||||
}
|
||||
|
||||
let cachedToken: { token: string; expiresAt: number } | null = null;
|
||||
// Singleflight: 防止并发获取 Token 的 Promise 缓存
|
||||
let tokenFetchPromise: Promise<string> | null = null;
|
||||
|
||||
/**
|
||||
* 获取 AccessToken(带缓存)
|
||||
* 获取 AccessToken(带缓存 + singleflight 并发安全)
|
||||
*
|
||||
* 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
|
||||
* 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
|
||||
*/
|
||||
export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
|
||||
// 检查缓存,提前 5 分钟刷新
|
||||
@@ -36,6 +41,30 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
// Singleflight: 如果已有进行中的 Token 获取请求,复用它
|
||||
if (tokenFetchPromise) {
|
||||
console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
// 创建新的 Token 获取 Promise(singleflight 入口)
|
||||
tokenFetchPromise = (async () => {
|
||||
try {
|
||||
return await doFetchToken(appId, clientSecret);
|
||||
} finally {
|
||||
// 无论成功失败,都清除 Promise 缓存
|
||||
tokenFetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行 Token 获取的内部函数
|
||||
*/
|
||||
async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
|
||||
|
||||
const requestBody = { appId, clientSecret };
|
||||
const requestHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
@@ -86,6 +115,7 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
||||
expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
|
||||
};
|
||||
|
||||
console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
|
||||
return cachedToken.token;
|
||||
}
|
||||
|
||||
@@ -94,6 +124,22 @@ export async function getAccessToken(appId: string, clientSecret: string): Promi
|
||||
*/
|
||||
export function clearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
// 注意:不清除 tokenFetchPromise,让进行中的请求完成
|
||||
// 下次调用 getAccessToken 时会自动获取新 Token
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 缓存状态(用于监控)
|
||||
*/
|
||||
export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
|
||||
if (tokenFetchPromise) {
|
||||
return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
|
||||
}
|
||||
if (!cachedToken) {
|
||||
return { status: "none", expiresAt: null };
|
||||
}
|
||||
const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
|
||||
return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,32 +366,65 @@ export async function sendGroupMessage(
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建主动消息请求体
|
||||
* 根据 markdownSupport 配置决定消息格式:
|
||||
* - markdown 模式: { markdown: { content }, msg_type: 2 }
|
||||
* - 纯文本模式: { content, msg_type: 0 }
|
||||
*
|
||||
* 注意:主动消息不支持流式发送
|
||||
*/
|
||||
function buildProactiveMessageBody(content: string): Record<string, unknown> {
|
||||
// 主动消息内容校验(参考 Telegram 机制)
|
||||
if (!content || content.trim().length === 0) {
|
||||
throw new Error("主动消息内容不能为空 (markdown.content is empty)");
|
||||
}
|
||||
|
||||
if (currentMarkdownSupport) {
|
||||
return {
|
||||
markdown: { content },
|
||||
msg_type: 2,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content,
|
||||
msg_type: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||
* 2. 不支持流式发送
|
||||
*/
|
||||
export async function sendProactiveC2CMessage(
|
||||
accessToken: string,
|
||||
openid: string,
|
||||
content: string
|
||||
): Promise<{ id: string; timestamp: number }> {
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
});
|
||||
const body = buildProactiveMessageBody(content);
|
||||
console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||
return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送群聊消息(不需要 msg_id,每月限 4 条/群)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 内容不能为空(对应 markdown.content 字段)
|
||||
* 2. 不支持流式发送
|
||||
*/
|
||||
export async function sendProactiveGroupMessage(
|
||||
accessToken: string,
|
||||
groupOpenid: string,
|
||||
content: string
|
||||
): Promise<{ id: string; timestamp: string }> {
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
|
||||
content,
|
||||
msg_type: 0,
|
||||
});
|
||||
const body = buildProactiveMessageBody(content);
|
||||
console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
|
||||
return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
|
||||
}
|
||||
|
||||
// ============ 富媒体消息支持 ============
|
||||
@@ -475,3 +554,150 @@ export async function sendGroupImageMessage(
|
||||
// 再发送富媒体消息
|
||||
return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
|
||||
}
|
||||
|
||||
// ============ 后台 Token 刷新 (P1-1) ============
|
||||
|
||||
/**
|
||||
* 后台 Token 刷新配置
|
||||
*/
|
||||
interface BackgroundTokenRefreshOptions {
|
||||
/** 提前刷新时间(毫秒,默认 5 分钟) */
|
||||
refreshAheadMs?: number;
|
||||
/** 随机偏移范围(毫秒,默认 0-30 秒) */
|
||||
randomOffsetMs?: number;
|
||||
/** 最小刷新间隔(毫秒,默认 1 分钟) */
|
||||
minRefreshIntervalMs?: number;
|
||||
/** 失败后重试间隔(毫秒,默认 5 秒) */
|
||||
retryDelayMs?: number;
|
||||
/** 日志函数 */
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug?: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
// 后台刷新状态
|
||||
let backgroundRefreshRunning = false;
|
||||
let backgroundRefreshAbortController: AbortController | null = null;
|
||||
|
||||
/**
|
||||
* 启动后台 Token 刷新
|
||||
* 在后台定时刷新 Token,避免请求时才发现过期
|
||||
*
|
||||
* @param appId 应用 ID
|
||||
* @param clientSecret 应用密钥
|
||||
* @param options 配置选项
|
||||
*/
|
||||
export function startBackgroundTokenRefresh(
|
||||
appId: string,
|
||||
clientSecret: string,
|
||||
options?: BackgroundTokenRefreshOptions
|
||||
): void {
|
||||
if (backgroundRefreshRunning) {
|
||||
console.log("[qqbot-api] Background token refresh already running");
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
|
||||
randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
|
||||
minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
|
||||
retryDelayMs = 5 * 1000, // 失败后 5 秒重试
|
||||
log,
|
||||
} = options ?? {};
|
||||
|
||||
backgroundRefreshRunning = true;
|
||||
backgroundRefreshAbortController = new AbortController();
|
||||
const signal = backgroundRefreshAbortController.signal;
|
||||
|
||||
const refreshLoop = async () => {
|
||||
log?.info?.("[qqbot-api] Background token refresh started");
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
// 先确保有一个有效 Token
|
||||
await getAccessToken(appId, clientSecret);
|
||||
|
||||
// 计算下次刷新时间
|
||||
if (cachedToken) {
|
||||
const expiresIn = cachedToken.expiresAt - Date.now();
|
||||
// 提前刷新时间 + 随机偏移(避免集群同时刷新)
|
||||
const randomOffset = Math.random() * randomOffsetMs;
|
||||
const refreshIn = Math.max(
|
||||
expiresIn - refreshAheadMs - randomOffset,
|
||||
minRefreshIntervalMs
|
||||
);
|
||||
|
||||
log?.debug?.(
|
||||
`[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
|
||||
);
|
||||
|
||||
// 等待到刷新时间
|
||||
await sleep(refreshIn, signal);
|
||||
} else {
|
||||
// 没有缓存的 Token,等待一段时间后重试
|
||||
log?.debug?.("[qqbot-api] No cached token, retrying soon");
|
||||
await sleep(minRefreshIntervalMs, signal);
|
||||
}
|
||||
} catch (err) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
// 刷新失败,等待后重试
|
||||
log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
|
||||
await sleep(retryDelayMs, signal);
|
||||
}
|
||||
}
|
||||
|
||||
backgroundRefreshRunning = false;
|
||||
log?.info?.("[qqbot-api] Background token refresh stopped");
|
||||
};
|
||||
|
||||
// 异步启动,不阻塞调用者
|
||||
refreshLoop().catch((err) => {
|
||||
backgroundRefreshRunning = false;
|
||||
log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后台 Token 刷新
|
||||
*/
|
||||
export function stopBackgroundTokenRefresh(): void {
|
||||
if (backgroundRefreshAbortController) {
|
||||
backgroundRefreshAbortController.abort();
|
||||
backgroundRefreshAbortController = null;
|
||||
}
|
||||
backgroundRefreshRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查后台 Token 刷新是否正在运行
|
||||
*/
|
||||
export function isBackgroundTokenRefreshRunning(): boolean {
|
||||
return backgroundRefreshRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可中断的 sleep 函数
|
||||
*/
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms);
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Aborted"));
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function resolveQQBotAccount(
|
||||
systemPrompt: qqbot?.systemPrompt,
|
||||
imageServerBaseUrl: qqbot?.imageServerBaseUrl,
|
||||
markdownSupport: qqbot?.markdownSupport,
|
||||
streamEnabled: qqbot?.streamEnabled,
|
||||
};
|
||||
appId = qqbot?.appId ?? "";
|
||||
} else {
|
||||
@@ -113,6 +114,7 @@ export function resolveQQBotAccount(
|
||||
systemPrompt: accountConfig.systemPrompt,
|
||||
imageServerBaseUrl: accountConfig.imageServerBaseUrl || process.env.QQBOT_IMAGE_SERVER_BASE_URL,
|
||||
markdownSupport: accountConfig.markdownSupport,
|
||||
streamEnabled: accountConfig.streamEnabled,
|
||||
config: accountConfig,
|
||||
};
|
||||
}
|
||||
|
||||
450
src/gateway.ts
450
src/gateway.ts
@@ -2,7 +2,9 @@ import WebSocket from "ws";
|
||||
import path from "node:path";
|
||||
import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
|
||||
import { StreamState } from "./types.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, sendC2CInputNotify } from "./api.js";
|
||||
import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh } 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";
|
||||
import { startImageServer, saveImage, saveImageFromPath, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
||||
import { createStreamSender } from "./outbound.js";
|
||||
@@ -55,7 +57,79 @@ const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || path.join(process
|
||||
// 流式消息配置
|
||||
const STREAM_CHUNK_INTERVAL = 500; // 流式消息分片间隔(毫秒)
|
||||
const STREAM_MIN_CHUNK_SIZE = 10; // 最小分片大小(字符)
|
||||
const STREAM_KEEPALIVE_INTERVAL = 8000; // 流式心跳间隔(毫秒),需要在 10 秒内发送
|
||||
const STREAM_KEEPALIVE_FIRST_DELAY = 3000; // 首次状态保持延迟(毫秒),openclaw 3s 内未回复时发送
|
||||
const STREAM_KEEPALIVE_GAP = 10000; // 状态保持消息之间的间隔(毫秒)
|
||||
const STREAM_KEEPALIVE_MAX_PER_CHUNK = 2; // 每 2 个消息分片之间最多发送的状态保持消息数量
|
||||
const STREAM_MAX_DURATION = 3 * 60 * 1000; // 流式消息最大持续时间(毫秒),超过 3 分钟自动结束
|
||||
|
||||
// 消息队列配置(异步处理,防止阻塞心跳)
|
||||
const MESSAGE_QUEUE_SIZE = 1000; // 最大队列长度
|
||||
const MESSAGE_QUEUE_WARN_THRESHOLD = 800; // 队列告警阈值
|
||||
|
||||
// ============ 消息回复限流器 ============
|
||||
// 同一 message_id 1小时内最多回复 4 次,超过1小时需降级为主动消息
|
||||
const MESSAGE_REPLY_LIMIT = 4;
|
||||
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
||||
|
||||
interface MessageReplyRecord {
|
||||
count: number;
|
||||
firstReplyAt: number;
|
||||
}
|
||||
|
||||
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
||||
|
||||
/**
|
||||
* 检查是否可以回复该消息(限流检查)
|
||||
* @param messageId 消息ID
|
||||
* @returns { allowed: boolean, remaining: number } allowed=是否允许回复,remaining=剩余次数
|
||||
*/
|
||||
function checkMessageReplyLimit(messageId: string): { allowed: boolean; remaining: number } {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
// 清理过期记录(定期清理,避免内存泄漏)
|
||||
if (messageReplyTracker.size > 10000) {
|
||||
for (const [id, rec] of messageReplyTracker) {
|
||||
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.delete(messageId);
|
||||
return { allowed: true, remaining: MESSAGE_REPLY_LIMIT };
|
||||
}
|
||||
|
||||
// 检查是否超过限制
|
||||
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
||||
return { allowed: remaining > 0, remaining: Math.max(0, remaining) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次消息回复
|
||||
* @param messageId 消息ID
|
||||
*/
|
||||
function recordMessageReply(messageId: string): void {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
if (!record) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
// 检查是否过期,过期则重新计数
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface GatewayContext {
|
||||
account: ResolvedQQBotAccount;
|
||||
@@ -70,6 +144,22 @@ export interface GatewayContext {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息队列项类型(用于异步处理消息,防止阻塞心跳)
|
||||
*/
|
||||
interface QueuedMessage {
|
||||
type: "c2c" | "guild" | "dm" | "group";
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
content: string;
|
||||
messageId: string;
|
||||
timestamp: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
groupOpenid?: string;
|
||||
attachments?: Array<{ content_type: string; url: string; filename?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动图床服务器
|
||||
*/
|
||||
@@ -137,6 +227,74 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
let intentLevelIndex = 0; // 当前尝试的权限级别索引
|
||||
let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
|
||||
|
||||
// ============ P1-2: 尝试从持久化存储恢复 Session ============
|
||||
const savedSession = loadSession(account.accountId);
|
||||
if (savedSession) {
|
||||
sessionId = savedSession.sessionId;
|
||||
lastSeq = savedSession.lastSeq;
|
||||
intentLevelIndex = savedSession.intentLevelIndex;
|
||||
lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
|
||||
log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
|
||||
}
|
||||
|
||||
// ============ 消息队列(异步处理,防止阻塞心跳) ============
|
||||
const messageQueue: QueuedMessage[] = [];
|
||||
let messageProcessorRunning = false;
|
||||
let messagesProcessed = 0; // 统计已处理消息数
|
||||
|
||||
/**
|
||||
* 将消息加入队列(非阻塞)
|
||||
*/
|
||||
const enqueueMessage = (msg: QueuedMessage): void => {
|
||||
if (messageQueue.length >= MESSAGE_QUEUE_SIZE) {
|
||||
// 队列满了,丢弃最旧的消息
|
||||
const dropped = messageQueue.shift();
|
||||
log?.error(`[qqbot:${account.accountId}] Message queue full, dropping oldest message from ${dropped?.senderId}`);
|
||||
}
|
||||
if (messageQueue.length >= MESSAGE_QUEUE_WARN_THRESHOLD) {
|
||||
log?.info(`[qqbot:${account.accountId}] Message queue size: ${messageQueue.length}/${MESSAGE_QUEUE_SIZE}`);
|
||||
}
|
||||
messageQueue.push(msg);
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Message enqueued, queue size: ${messageQueue.length}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动消息处理循环(独立于 WS 消息循环)
|
||||
*/
|
||||
const startMessageProcessor = (handleMessageFn: (msg: QueuedMessage) => Promise<void>): void => {
|
||||
if (messageProcessorRunning) return;
|
||||
messageProcessorRunning = true;
|
||||
|
||||
const processLoop = async () => {
|
||||
while (!isAborted) {
|
||||
if (messageQueue.length === 0) {
|
||||
// 队列为空,等待一小段时间
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
continue;
|
||||
}
|
||||
|
||||
const msg = messageQueue.shift()!;
|
||||
try {
|
||||
await handleMessageFn(msg);
|
||||
messagesProcessed++;
|
||||
} catch (err) {
|
||||
// 捕获处理异常,防止影响队列循环
|
||||
log?.error(`[qqbot:${account.accountId}] Message processor error: ${err}`);
|
||||
}
|
||||
}
|
||||
messageProcessorRunning = false;
|
||||
log?.info(`[qqbot:${account.accountId}] Message processor stopped`);
|
||||
};
|
||||
|
||||
// 异步启动,不阻塞调用者
|
||||
processLoop().catch(err => {
|
||||
log?.error(`[qqbot:${account.accountId}] Message processor crashed: ${err}`);
|
||||
messageProcessorRunning = false;
|
||||
});
|
||||
|
||||
log?.info(`[qqbot:${account.accountId}] Message processor started`);
|
||||
};
|
||||
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
isAborted = true;
|
||||
if (reconnectTimer) {
|
||||
@@ -144,6 +302,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
reconnectTimer = null;
|
||||
}
|
||||
cleanup();
|
||||
// P1-1: 停止后台 Token 刷新
|
||||
stopBackgroundTokenRefresh();
|
||||
// P1-3: 保存已知用户数据
|
||||
flushKnownUsers();
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
@@ -232,16 +394,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
log?.info(`[qqbot:${account.accountId}] Attachments: ${event.attachments.length}`);
|
||||
}
|
||||
|
||||
// 对于 C2C 消息,先发送输入状态提示用户机器人正在输入
|
||||
if (event.type === "c2c") {
|
||||
try {
|
||||
const token = await getAccessToken(account.appId, account.clientSecret);
|
||||
await sendC2CInputNotify(token, event.senderId, event.messageId, 60);
|
||||
log?.info(`[qqbot:${account.accountId}] Sent input notify to ${event.senderId}`);
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Failed to send input notify: ${err}`);
|
||||
}
|
||||
}
|
||||
// 流式消息开关(默认启用,仅 c2c 支持)
|
||||
const streamEnabled = account.streamEnabled !== false;
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Stream enabled: ${streamEnabled}`);
|
||||
|
||||
pluginRuntime.channel.activity.record({
|
||||
channel: "qqbot",
|
||||
@@ -269,6 +424,51 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
// 组装消息体,添加系统提示词
|
||||
let builtinPrompt = "";
|
||||
|
||||
// ============ 用户标识信息(用于定时提醒和主动消息) ============
|
||||
const isGroupChat = event.type === "group";
|
||||
const targetAddress = isGroupChat ? `group:${event.groupOpenid}` : event.senderId;
|
||||
|
||||
builtinPrompt += `
|
||||
【当前用户信息】
|
||||
- 用户 openid: ${event.senderId}
|
||||
- 用户昵称: ${event.senderName || "未知"}
|
||||
- 消息类型: ${isGroupChat ? "群聊" : "私聊"}
|
||||
- 当前消息 message_id: ${event.messageId}${isGroupChat ? `
|
||||
- 群组 group_openid: ${event.groupOpenid}` : ""}
|
||||
|
||||
【定时提醒能力】
|
||||
你可以帮助用户设置定时提醒。使用 openclaw cron 命令:
|
||||
|
||||
示例:5分钟后提醒用户喝水
|
||||
\`\`\`bash
|
||||
openclaw cron add \\
|
||||
--name "提醒喝水-${event.senderName || "用户"}" \\
|
||||
--at "5m" \\
|
||||
--session isolated \\
|
||||
--message "💧 该喝水啦!" \\
|
||||
--deliver \\
|
||||
--channel qqbot \\
|
||||
--to "${targetAddress}" \\
|
||||
--reply-to "${event.messageId}" \\
|
||||
--delete-after-run
|
||||
\`\`\`
|
||||
|
||||
关键参数说明:
|
||||
- \`--to\`: 目标地址(当前用户: ${targetAddress})
|
||||
- \`--reply-to\`: 回复消息ID(当前消息: ${event.messageId},使提醒能引用原消息)
|
||||
- \`--at\`: 一次性定时任务的触发时间
|
||||
- 相对时间格式:数字+单位,如 \`5m\`(5分钟)、\`1h\`(1小时)、\`2d\`(2天)【注意:不要加 + 号】
|
||||
- 绝对时间格式:ISO 8601 带时区,如 \`2026-02-01T14:00:00+08:00\`
|
||||
- \`--cron\`: 周期性任务(如 \`0 8 * * *\` 每天早上8点)
|
||||
- \`--tz "Asia/Shanghai"\`: 周期任务务必设置时区
|
||||
- \`--delete-after-run\`: 一次性任务必须添加此参数
|
||||
- \`--message\`: 消息内容(必填,不能为空!对应 QQ API 的 markdown.content 字段)
|
||||
|
||||
⚠️ 重要注意事项:
|
||||
1. --at 参数格式:相对时间用 \`5m\`、\`1h\` 等(不要加 + 号!);绝对时间用完整 ISO 格式
|
||||
2. 定时提醒消息不支持流式发送,命令中不要添加 --stream 参数
|
||||
3. --message 参数必须有实际内容,不能为空字符串`;
|
||||
|
||||
// 只有配置了图床公网地址,才告诉 AI 可以发送图片
|
||||
if (imageServerBaseUrl) {
|
||||
builtinPrompt += `
|
||||
@@ -400,7 +600,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
|
||||
// 追踪是否有响应
|
||||
let hasResponse = false;
|
||||
const responseTimeout = 30000; // 30秒超时
|
||||
const responseTimeout = 60000; // 60秒超时(1分钟)
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
@@ -417,19 +617,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
: event.type === "group" ? `group:${event.groupOpenid}`
|
||||
: `channel:${event.channelId}`;
|
||||
|
||||
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式)
|
||||
const supportsStream = event.type === "c2c";
|
||||
// 判断是否支持流式(仅 c2c 支持,群聊不支持流式,且需要开关启用)
|
||||
const supportsStream = event.type === "c2c" && streamEnabled;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream support: ${supportsStream} (type=${event.type}, enabled=${streamEnabled})`);
|
||||
|
||||
// 创建流式发送器
|
||||
const streamSender = supportsStream ? createStreamSender(account, targetTo, event.messageId) : null;
|
||||
let streamBuffer = ""; // 累积的全部文本(用于记录完整内容)
|
||||
let lastSentLength = 0; // 上次发送时的文本长度(用于计算增量)
|
||||
let lastSentText = ""; // 上次发送时的完整文本(用于检测新段落)
|
||||
let currentSegmentStart = 0; // 当前段落在 streamBuffer 中的起始位置
|
||||
let lastStreamSendTime = 0; // 上次流式发送时间
|
||||
let streamStarted = false; // 是否已开始流式发送
|
||||
let streamEnded = false; // 流式是否已结束
|
||||
let streamStartTime = 0; // 流式消息开始时间(用于超时检查)
|
||||
let sendingLock = false; // 发送锁,防止并发发送
|
||||
let pendingFullText = ""; // 待发送的完整文本(在锁定期间积累)
|
||||
let keepaliveTimer: ReturnType<typeof setTimeout> | null = null; // 心跳定时器
|
||||
let keepaliveCountSinceLastChunk = 0; // 自上次分片以来发送的状态保持消息数量
|
||||
let lastChunkSendTime = 0; // 上次分片发送时间(用于判断是否需要发送状态保持)
|
||||
|
||||
// 清理心跳定时器
|
||||
const clearKeepalive = () => {
|
||||
@@ -440,26 +646,78 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
};
|
||||
|
||||
// 重置心跳定时器(每次发送后调用)
|
||||
const resetKeepalive = () => {
|
||||
// isContentChunk: 是否为内容分片(非状态保持消息)
|
||||
const resetKeepalive = (isContentChunk: boolean = false) => {
|
||||
clearKeepalive();
|
||||
|
||||
// 如果是内容分片,重置状态保持计数器和时间
|
||||
if (isContentChunk) {
|
||||
keepaliveCountSinceLastChunk = 0;
|
||||
lastChunkSendTime = Date.now();
|
||||
}
|
||||
|
||||
if (streamSender && streamStarted && !streamEnded) {
|
||||
// 计算下次状态保持消息的延迟时间
|
||||
// - 首次:3s(STREAM_KEEPALIVE_FIRST_DELAY)
|
||||
// - 后续:10s(STREAM_KEEPALIVE_GAP)
|
||||
const delay = keepaliveCountSinceLastChunk === 0
|
||||
? STREAM_KEEPALIVE_FIRST_DELAY
|
||||
: STREAM_KEEPALIVE_GAP;
|
||||
|
||||
keepaliveTimer = setTimeout(async () => {
|
||||
// 10 秒内没有新消息,发送空分片保持连接
|
||||
// 检查流式消息是否超时(超过 3 分钟自动结束)
|
||||
const elapsed = Date.now() - streamStartTime;
|
||||
if (elapsed >= STREAM_MAX_DURATION) {
|
||||
log?.info(`[qqbot:${account.accountId}] Stream timeout after ${Math.round(elapsed / 1000)}s, auto ending stream`);
|
||||
if (!streamEnded && !sendingLock) {
|
||||
sendingLock = true;
|
||||
try {
|
||||
// 发送结束标记
|
||||
await streamSender!.send("", true);
|
||||
streamEnded = true;
|
||||
clearKeepalive();
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Stream auto-end failed: ${err}`);
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
}
|
||||
return; // 超时后不再继续心跳
|
||||
}
|
||||
|
||||
// 检查是否已达到每2个分片之间的最大状态保持消息数量
|
||||
if (keepaliveCountSinceLastChunk >= STREAM_KEEPALIVE_MAX_PER_CHUNK) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Max keepalive reached (${keepaliveCountSinceLastChunk}/${STREAM_KEEPALIVE_MAX_PER_CHUNK}), waiting for next content chunk`);
|
||||
// 不再发送状态保持,但继续监控超时
|
||||
resetKeepalive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查距上次分片是否超过 3s
|
||||
const timeSinceLastChunk = Date.now() - lastChunkSendTime;
|
||||
if (timeSinceLastChunk < STREAM_KEEPALIVE_FIRST_DELAY) {
|
||||
// 还未到发送状态保持的时机,继续等待
|
||||
resetKeepalive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送状态保持消息
|
||||
if (!streamEnded && !sendingLock) {
|
||||
log?.info(`[qqbot:${account.accountId}] Sending keepalive empty chunk`);
|
||||
log?.info(`[qqbot:${account.accountId}] Sending keepalive #${keepaliveCountSinceLastChunk + 1} (elapsed: ${Math.round(elapsed / 1000)}s, since chunk: ${Math.round(timeSinceLastChunk / 1000)}s)`);
|
||||
sendingLock = true;
|
||||
try {
|
||||
// 发送空内容
|
||||
await streamSender!.send("", false);
|
||||
lastStreamSendTime = Date.now();
|
||||
resetKeepalive(); // 继续下一个心跳
|
||||
keepaliveCountSinceLastChunk++;
|
||||
resetKeepalive(false); // 继续下一个状态保持(非内容分片)
|
||||
} catch (err) {
|
||||
log?.error(`[qqbot:${account.accountId}] Keepalive failed: ${err}`);
|
||||
} finally {
|
||||
sendingLock = false;
|
||||
}
|
||||
}
|
||||
}, STREAM_KEEPALIVE_INTERVAL);
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -486,8 +744,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
streamEnded = true;
|
||||
clearKeepalive();
|
||||
} else {
|
||||
// 发送成功后重置心跳
|
||||
resetKeepalive();
|
||||
// 发送成功后重置心跳,如果是有内容的分片则重置计数器
|
||||
const isContentChunk = text.length > 0;
|
||||
resetKeepalive(isContentChunk);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -505,11 +764,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
// 发送当前增量
|
||||
if (fullText.length > lastSentLength) {
|
||||
const increment = fullText.slice(lastSentLength);
|
||||
// 首次发送前,先设置流式状态和开始时间
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
streamStartTime = Date.now();
|
||||
log?.info(`[qqbot:${account.accountId}] Stream started, max duration: ${STREAM_MAX_DURATION / 1000}s`);
|
||||
}
|
||||
const success = await sendStreamChunk(increment, forceEnd);
|
||||
if (success) {
|
||||
lastSentLength = fullText.length;
|
||||
lastSentText = fullText; // 记录完整发送文本,用于检测新段落
|
||||
lastStreamSendTime = Date.now();
|
||||
streamStarted = true;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream partial #${streamSender!.getContext().index}, increment: ${increment.length} chars, total: ${fullText.length} chars`);
|
||||
}
|
||||
} else if (forceEnd && !streamEnded) {
|
||||
@@ -530,6 +795,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
};
|
||||
|
||||
// onPartialReply 回调 - 实时接收 AI 生成的文本(payload.text 是累积的全文)
|
||||
// 注意:agent 在一次对话中可能产生多个回复段落(如思考、工具调用后继续回复)
|
||||
// 每个新段落的 text 会从头开始累积,需要检测并处理
|
||||
const handlePartialReply = async (payload: { text?: string }) => {
|
||||
if (!streamSender || streamEnded) {
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply skipped: streamSender=${!!streamSender}, streamEnded=${streamEnded}`);
|
||||
@@ -542,11 +809,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 始终更新累积缓冲区(即使不发送,也要记录最新内容)
|
||||
streamBuffer = fullText;
|
||||
hasResponse = true;
|
||||
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}`);
|
||||
// 检测是否是新段落:
|
||||
// 1. lastSentText 不为空(说明已经发送过内容)
|
||||
// 2. 当前文本不是以 lastSentText 开头(说明不是同一段落的增量)
|
||||
// 3. 当前文本长度小于 lastSentLength(说明文本被重置了)
|
||||
const isNewSegment = lastSentText.length > 0 &&
|
||||
(fullText.length < lastSentLength || !fullText.startsWith(lastSentText.slice(0, Math.min(10, lastSentText.length))));
|
||||
|
||||
if (isNewSegment) {
|
||||
// 新段落开始,将之前的内容追加到 streamBuffer,并重置发送位置
|
||||
log?.info(`[qqbot:${account.accountId}] New segment detected! lastSentLength=${lastSentLength}, newTextLength=${fullText.length}, lastSentText="${lastSentText.slice(0, 20)}...", newText="${fullText.slice(0, 20)}..."`);
|
||||
|
||||
// 记录当前段落在 streamBuffer 中的起始位置
|
||||
currentSegmentStart = streamBuffer.length;
|
||||
|
||||
// 追加换行分隔符(如果前面有内容且不以换行结尾)
|
||||
if (streamBuffer.length > 0 && !streamBuffer.endsWith("\n")) {
|
||||
streamBuffer += "\n\n";
|
||||
currentSegmentStart = streamBuffer.length;
|
||||
}
|
||||
|
||||
// 重置发送位置,从新段落开始发送
|
||||
lastSentLength = 0;
|
||||
lastSentText = "";
|
||||
}
|
||||
|
||||
// 更新当前段落内容到 streamBuffer
|
||||
// streamBuffer = 之前的段落内容 + 当前段落的完整内容
|
||||
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||
streamBuffer = beforeCurrentSegment + fullText;
|
||||
|
||||
log?.debug?.(`[qqbot:${account.accountId}] handlePartialReply: fullText.length=${fullText.length}, lastSentLength=${lastSentLength}, streamBuffer.length=${streamBuffer.length}, isNewSegment=${isNewSegment}`);
|
||||
|
||||
// 如果没有新内容,跳过
|
||||
if (fullText.length <= lastSentLength) return;
|
||||
@@ -578,9 +873,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
|
||||
let replyText = payload.text ?? "";
|
||||
|
||||
// 更新 streamBuffer,确保最终内容不会丢失
|
||||
if (replyText.length > streamBuffer.length) {
|
||||
streamBuffer = replyText;
|
||||
// 更新当前段落内容到 streamBuffer
|
||||
// deliver 中的 replyText 是当前段落的完整文本
|
||||
if (replyText.length > 0) {
|
||||
const beforeCurrentSegment = streamBuffer.slice(0, currentSegmentStart);
|
||||
const newStreamBuffer = beforeCurrentSegment + replyText;
|
||||
if (newStreamBuffer.length > streamBuffer.length) {
|
||||
streamBuffer = newStreamBuffer;
|
||||
log?.debug?.(`[qqbot:${account.accountId}] deliver: updated streamBuffer, replyText=${replyText.length}, total=${streamBuffer.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有图片路径
|
||||
@@ -796,18 +1097,18 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
}
|
||||
|
||||
// 确保所有待发送内容都发送出去
|
||||
// 优先使用 pendingFullText,因为它可能包含最新的完整文本
|
||||
const finalFullText = pendingFullText && pendingFullText.length > streamBuffer.length
|
||||
// 当前段落的最新完整文本
|
||||
const currentSegmentText = pendingFullText && pendingFullText.length > (streamBuffer.length - currentSegmentStart)
|
||||
? pendingFullText
|
||||
: streamBuffer;
|
||||
: streamBuffer.slice(currentSegmentStart);
|
||||
|
||||
// 计算剩余未发送的增量内容
|
||||
const remainingIncrement = finalFullText.slice(lastSentLength);
|
||||
// 计算当前段落剩余未发送的增量内容
|
||||
const remainingIncrement = currentSegmentText.slice(lastSentLength);
|
||||
if (remainingIncrement || streamStarted) {
|
||||
// 有剩余内容或者已开始流式,都需要发送结束标记
|
||||
await streamSender.end(remainingIncrement);
|
||||
streamEnded = true;
|
||||
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total: ${finalFullText.length} chars, chunks: ${streamSender.getContext().index}`);
|
||||
log?.info(`[qqbot:${account.accountId}] Stream completed, final increment: ${remainingIncrement.length} chars, total streamBuffer: ${streamBuffer.length} chars, chunks: ${streamSender.getContext().index}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -832,6 +1133,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
isConnecting = false; // 连接完成,释放锁
|
||||
reconnectAttempts = 0; // 连接成功,重置重试计数
|
||||
lastConnectTime = Date.now(); // 记录连接时间
|
||||
// 启动消息处理器(异步处理,防止阻塞心跳)
|
||||
startMessageProcessor(handleMessage);
|
||||
// P1-1: 启动后台 Token 刷新
|
||||
startBackgroundTokenRefresh(account.appId, account.clientSecret, {
|
||||
log: log as { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void },
|
||||
});
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
@@ -840,7 +1147,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
const payload = JSON.parse(rawData) as WSPayload;
|
||||
const { op, d, s, t } = payload;
|
||||
|
||||
if (s) lastSeq = s;
|
||||
if (s) {
|
||||
lastSeq = s;
|
||||
// P1-2: 更新持久化存储中的 lastSeq(节流保存)
|
||||
if (sessionId) {
|
||||
saveSession({
|
||||
sessionId,
|
||||
lastSeq,
|
||||
lastConnectedAt: lastConnectTime,
|
||||
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
|
||||
accountId: account.accountId,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
log?.debug?.(`[qqbot:${account.accountId}] Received op=${op} t=${t}`);
|
||||
|
||||
@@ -894,12 +1214,39 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
lastSuccessfulIntentLevel = intentLevelIndex;
|
||||
const successLevel = INTENT_LEVELS[intentLevelIndex];
|
||||
log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
|
||||
// P1-2: 保存新的 Session 状态
|
||||
saveSession({
|
||||
sessionId,
|
||||
lastSeq,
|
||||
lastConnectedAt: Date.now(),
|
||||
intentLevelIndex,
|
||||
accountId: account.accountId,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
onReady?.(d);
|
||||
} else if (t === "RESUMED") {
|
||||
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
||||
// P1-2: 更新 Session 连接时间
|
||||
if (sessionId) {
|
||||
saveSession({
|
||||
sessionId,
|
||||
lastSeq,
|
||||
lastConnectedAt: Date.now(),
|
||||
intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
|
||||
accountId: account.accountId,
|
||||
savedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} else if (t === "C2C_MESSAGE_CREATE") {
|
||||
const event = d as C2CMessageEvent;
|
||||
await handleMessage({
|
||||
// P1-3: 记录已知用户
|
||||
recordKnownUser({
|
||||
openid: event.author.user_openid,
|
||||
type: "c2c",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
// 使用消息队列异步处理,防止阻塞心跳
|
||||
enqueueMessage({
|
||||
type: "c2c",
|
||||
senderId: event.author.user_openid,
|
||||
content: event.content,
|
||||
@@ -909,7 +1256,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
});
|
||||
} else if (t === "AT_MESSAGE_CREATE") {
|
||||
const event = d as GuildMessageEvent;
|
||||
await handleMessage({
|
||||
// P1-3: 记录已知用户(频道用户)
|
||||
recordKnownUser({
|
||||
openid: event.author.id,
|
||||
type: "c2c", // 频道用户按 c2c 类型存储
|
||||
nickname: event.author.username,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
enqueueMessage({
|
||||
type: "guild",
|
||||
senderId: event.author.id,
|
||||
senderName: event.author.username,
|
||||
@@ -922,7 +1276,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
});
|
||||
} else if (t === "DIRECT_MESSAGE_CREATE") {
|
||||
const event = d as GuildMessageEvent;
|
||||
await handleMessage({
|
||||
// P1-3: 记录已知用户(频道私信用户)
|
||||
recordKnownUser({
|
||||
openid: event.author.id,
|
||||
type: "c2c",
|
||||
nickname: event.author.username,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
enqueueMessage({
|
||||
type: "dm",
|
||||
senderId: event.author.id,
|
||||
senderName: event.author.username,
|
||||
@@ -934,7 +1295,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
});
|
||||
} else if (t === "GROUP_AT_MESSAGE_CREATE") {
|
||||
const event = d as GroupMessageEvent;
|
||||
await handleMessage({
|
||||
// P1-3: 记录已知用户(群组用户)
|
||||
recordKnownUser({
|
||||
openid: event.author.member_openid,
|
||||
type: "group",
|
||||
groupOpenid: event.group_openid,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
enqueueMessage({
|
||||
type: "group",
|
||||
senderId: event.author.member_openid,
|
||||
content: event.content,
|
||||
@@ -964,6 +1332,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
||||
if (!canResume) {
|
||||
sessionId = null;
|
||||
lastSeq = null;
|
||||
// P1-2: 清除持久化的 Session
|
||||
clearSession(account.accountId);
|
||||
|
||||
// 尝试降级到下一个权限级别
|
||||
if (intentLevelIndex < INTENT_LEVELS.length - 1) {
|
||||
|
||||
358
src/known-users.ts
Normal file
358
src/known-users.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* 已知用户存储
|
||||
* 记录与机器人交互过的所有用户
|
||||
* 支持主动消息和批量通知功能
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// 已知用户信息接口
|
||||
export interface KnownUser {
|
||||
/** 用户 openid(唯一标识) */
|
||||
openid: string;
|
||||
/** 消息类型:私聊用户 / 群组 */
|
||||
type: "c2c" | "group";
|
||||
/** 用户昵称(如有) */
|
||||
nickname?: string;
|
||||
/** 群组 openid(如果是群消息) */
|
||||
groupOpenid?: string;
|
||||
/** 关联的机器人账户 ID */
|
||||
accountId: string;
|
||||
/** 首次交互时间戳 */
|
||||
firstSeenAt: number;
|
||||
/** 最后交互时间戳 */
|
||||
lastSeenAt: number;
|
||||
/** 交互次数 */
|
||||
interactionCount: number;
|
||||
}
|
||||
|
||||
// 存储文件路径
|
||||
const KNOWN_USERS_DIR = path.join(
|
||||
process.env.HOME || "/tmp",
|
||||
"clawd",
|
||||
"qqbot-data"
|
||||
);
|
||||
|
||||
const KNOWN_USERS_FILE = path.join(KNOWN_USERS_DIR, "known-users.json");
|
||||
|
||||
// 内存缓存
|
||||
let usersCache: Map<string, KnownUser> | null = null;
|
||||
|
||||
// 写入节流配置
|
||||
const SAVE_THROTTLE_MS = 5000; // 5秒写入一次
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let isDirty = false;
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(KNOWN_USERS_DIR)) {
|
||||
fs.mkdirSync(KNOWN_USERS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载用户数据到缓存
|
||||
*/
|
||||
function loadUsersFromFile(): Map<string, KnownUser> {
|
||||
if (usersCache !== null) {
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
usersCache = new Map();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||
const users = JSON.parse(data) as KnownUser[];
|
||||
|
||||
for (const user of users) {
|
||||
// 使用复合键:accountId + type + openid(群组还要加 groupOpenid)
|
||||
const key = makeUserKey(user);
|
||||
usersCache.set(key, user);
|
||||
}
|
||||
|
||||
console.log(`[known-users] Loaded ${usersCache.size} users from file`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[known-users] Failed to load users: ${err}`);
|
||||
usersCache = new Map();
|
||||
}
|
||||
|
||||
return usersCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户数据到文件(节流版本)
|
||||
*/
|
||||
function saveUsersToFile(): void {
|
||||
if (!isDirty) return;
|
||||
|
||||
if (saveTimer) {
|
||||
return; // 已有定时器在等待
|
||||
}
|
||||
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
doSaveUsersToFile();
|
||||
}, SAVE_THROTTLE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行保存
|
||||
*/
|
||||
function doSaveUsersToFile(): void {
|
||||
if (!usersCache || !isDirty) return;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const users = Array.from(usersCache.values());
|
||||
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
||||
isDirty = false;
|
||||
console.log(`[known-users] Saved ${users.length} users to file`);
|
||||
} catch (err) {
|
||||
console.error(`[known-users] Failed to save users: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制立即保存(用于进程退出前)
|
||||
*/
|
||||
export function flushKnownUsers(): void {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
doSaveUsersToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户唯一键
|
||||
*/
|
||||
function makeUserKey(user: Partial<KnownUser>): string {
|
||||
const base = `${user.accountId}:${user.type}:${user.openid}`;
|
||||
if (user.type === "group" && user.groupOpenid) {
|
||||
return `${base}:${user.groupOpenid}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录已知用户(收到消息时调用)
|
||||
* @param user 用户信息(部分字段)
|
||||
*/
|
||||
export function recordKnownUser(user: {
|
||||
openid: string;
|
||||
type: "c2c" | "group";
|
||||
nickname?: string;
|
||||
groupOpenid?: string;
|
||||
accountId: string;
|
||||
}): void {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey(user);
|
||||
const now = Date.now();
|
||||
|
||||
const existing = cache.get(key);
|
||||
|
||||
if (existing) {
|
||||
// 更新已存在的用户
|
||||
existing.lastSeenAt = now;
|
||||
existing.interactionCount++;
|
||||
if (user.nickname && user.nickname !== existing.nickname) {
|
||||
existing.nickname = user.nickname;
|
||||
}
|
||||
console.log(`[known-users] Updated user ${user.openid}, interactions: ${existing.interactionCount}`);
|
||||
} else {
|
||||
// 新用户
|
||||
const newUser: KnownUser = {
|
||||
openid: user.openid,
|
||||
type: user.type,
|
||||
nickname: user.nickname,
|
||||
groupOpenid: user.groupOpenid,
|
||||
accountId: user.accountId,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
interactionCount: 1,
|
||||
};
|
||||
cache.set(key, newUser);
|
||||
console.log(`[known-users] New user recorded: ${user.openid} (${user.type})`);
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个用户信息
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
* @param type 消息类型
|
||||
* @param groupOpenid 群组 openid(可选)
|
||||
*/
|
||||
export function getKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string
|
||||
): KnownUser | undefined {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有已知用户
|
||||
* @param options 筛选选项
|
||||
*/
|
||||
export function listKnownUsers(options?: {
|
||||
/** 筛选特定机器人账户的用户 */
|
||||
accountId?: string;
|
||||
/** 筛选消息类型 */
|
||||
type?: "c2c" | "group";
|
||||
/** 最近活跃时间(毫秒,如 86400000 表示最近 24 小时) */
|
||||
activeWithin?: number;
|
||||
/** 返回数量限制 */
|
||||
limit?: number;
|
||||
/** 排序方式 */
|
||||
sortBy?: "lastSeenAt" | "firstSeenAt" | "interactionCount";
|
||||
/** 排序方向 */
|
||||
sortOrder?: "asc" | "desc";
|
||||
}): KnownUser[] {
|
||||
const cache = loadUsersFromFile();
|
||||
let users = Array.from(cache.values());
|
||||
|
||||
// 筛选
|
||||
if (options?.accountId) {
|
||||
users = users.filter(u => u.accountId === options.accountId);
|
||||
}
|
||||
if (options?.type) {
|
||||
users = users.filter(u => u.type === options.type);
|
||||
}
|
||||
if (options?.activeWithin) {
|
||||
const cutoff = Date.now() - options.activeWithin;
|
||||
users = users.filter(u => u.lastSeenAt >= cutoff);
|
||||
}
|
||||
|
||||
// 排序
|
||||
const sortBy = options?.sortBy ?? "lastSeenAt";
|
||||
const sortOrder = options?.sortOrder ?? "desc";
|
||||
users.sort((a, b) => {
|
||||
const aVal = a[sortBy] ?? 0;
|
||||
const bVal = b[sortBy] ?? 0;
|
||||
return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
// 限制数量
|
||||
if (options?.limit && options.limit > 0) {
|
||||
users = users.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户统计信息
|
||||
* @param accountId 机器人账户 ID(可选,不传则返回所有账户的统计)
|
||||
*/
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
totalUsers: number;
|
||||
c2cUsers: number;
|
||||
groupUsers: number;
|
||||
activeIn24h: number;
|
||||
activeIn7d: number;
|
||||
} {
|
||||
let users = listKnownUsers({ accountId });
|
||||
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
|
||||
return {
|
||||
totalUsers: users.length,
|
||||
c2cUsers: users.filter(u => u.type === "c2c").length,
|
||||
groupUsers: users.filter(u => u.type === "group").length,
|
||||
activeIn24h: users.filter(u => now - u.lastSeenAt < day).length,
|
||||
activeIn7d: users.filter(u => now - u.lastSeenAt < 7 * day).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户记录
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
* @param type 消息类型
|
||||
* @param groupOpenid 群组 openid(可选)
|
||||
*/
|
||||
export function removeKnownUser(
|
||||
accountId: string,
|
||||
openid: string,
|
||||
type: "c2c" | "group" = "c2c",
|
||||
groupOpenid?: string
|
||||
): boolean {
|
||||
const cache = loadUsersFromFile();
|
||||
const key = makeUserKey({ accountId, openid, type, groupOpenid });
|
||||
|
||||
if (cache.has(key)) {
|
||||
cache.delete(key);
|
||||
isDirty = true;
|
||||
saveUsersToFile();
|
||||
console.log(`[known-users] Removed user ${openid}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有用户记录
|
||||
* @param accountId 机器人账户 ID(可选,不传则清除所有)
|
||||
*/
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
const cache = loadUsersFromFile();
|
||||
let count = 0;
|
||||
|
||||
if (accountId) {
|
||||
// 只清除指定账户的用户
|
||||
for (const [key, user] of cache.entries()) {
|
||||
if (user.accountId === accountId) {
|
||||
cache.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 清除所有
|
||||
count = cache.size;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
isDirty = true;
|
||||
doSaveUsersToFile(); // 立即保存
|
||||
console.log(`[known-users] Cleared ${count} users`);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有群组(某用户在哪些群里交互过)
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param openid 用户 openid
|
||||
*/
|
||||
export function getUserGroups(accountId: string, openid: string): string[] {
|
||||
const users = listKnownUsers({ accountId, type: "group" });
|
||||
return users
|
||||
.filter(u => u.openid === openid && u.groupOpenid)
|
||||
.map(u => u.groupOpenid!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取群组的所有成员
|
||||
* @param accountId 机器人账户 ID
|
||||
* @param groupOpenid 群组 openid
|
||||
*/
|
||||
export function getGroupMembers(accountId: string, groupOpenid: string): KnownUser[] {
|
||||
return listKnownUsers({ accountId, type: "group" })
|
||||
.filter(u => u.groupOpenid === groupOpenid);
|
||||
}
|
||||
483
src/openclaw-plugin-sdk.d.ts
vendored
Normal file
483
src/openclaw-plugin-sdk.d.ts
vendored
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* OpenClaw Plugin SDK 类型声明
|
||||
*
|
||||
* 此文件为 openclaw/plugin-sdk 模块提供 TypeScript 类型声明
|
||||
* 仅包含本项目实际使用的类型和函数
|
||||
*/
|
||||
|
||||
declare module "openclaw/plugin-sdk" {
|
||||
// ============ 配置类型 ============
|
||||
|
||||
/**
|
||||
* OpenClaw 主配置对象
|
||||
*/
|
||||
export interface OpenClawConfig {
|
||||
/** 频道配置 */
|
||||
channels?: {
|
||||
qqbot?: unknown;
|
||||
telegram?: unknown;
|
||||
discord?: unknown;
|
||||
slack?: unknown;
|
||||
whatsapp?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** 其他配置字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件运行时 ============
|
||||
|
||||
/**
|
||||
* Channel Activity 接口
|
||||
*/
|
||||
export interface ChannelActivity {
|
||||
record?: (...args: unknown[]) => void;
|
||||
recordActivity?: (key: string, data?: unknown) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel Routing 接口
|
||||
*/
|
||||
export interface ChannelRouting {
|
||||
resolveAgentRoute?: (...args: unknown[]) => unknown;
|
||||
resolveSenderAndSession?: (options: unknown) => unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel Reply 接口
|
||||
*/
|
||||
export interface ChannelReply {
|
||||
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||
formatInboundEnvelope?: (...args: unknown[]) => unknown;
|
||||
finalizeInboundContext?: (...args: unknown[]) => unknown;
|
||||
resolveEnvelopeFormatOptions?: (...args: unknown[]) => unknown;
|
||||
handleAutoReply?: (...args: unknown[]) => Promise<unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel 接口(用于 PluginRuntime)
|
||||
* 注意:这是一个宽松的类型定义,实际 SDK 中的类型更复杂
|
||||
*/
|
||||
export interface ChannelInterface {
|
||||
recordInboundSession?: (options: unknown) => void;
|
||||
handleIncomingMessage?: (options: unknown) => Promise<unknown>;
|
||||
activity?: ChannelActivity;
|
||||
routing?: ChannelRouting;
|
||||
reply?: ChannelReply;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件运行时接口
|
||||
* 注意:channel 属性设为 any 是因为 SDK 内部类型非常复杂,
|
||||
* 且会随 SDK 版本变化。实际使用时 SDK 会提供正确的运行时类型。
|
||||
*/
|
||||
export interface PluginRuntime {
|
||||
/** 获取当前配置 */
|
||||
getConfig(): OpenClawConfig;
|
||||
/** 更新配置 */
|
||||
setConfig(config: OpenClawConfig): void;
|
||||
/** 获取数据目录路径 */
|
||||
getDataDir(): string;
|
||||
/** Channel 接口 - 使用 any 类型以兼容 SDK 内部复杂类型 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
channel?: any;
|
||||
/** 日志函数 */
|
||||
log: {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
error: (message: string, ...args: unknown[]) => void;
|
||||
debug: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
/** 其他运行时方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件 API ============
|
||||
|
||||
/**
|
||||
* OpenClaw 插件 API
|
||||
*/
|
||||
export interface OpenClawPluginApi {
|
||||
/** 运行时实例 */
|
||||
runtime: PluginRuntime;
|
||||
/** 注册频道 */
|
||||
registerChannel<TAccount = unknown>(options: { plugin: ChannelPlugin<TAccount> }): void;
|
||||
/** 其他 API 方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 插件配置 Schema ============
|
||||
|
||||
/**
|
||||
* 空的插件配置 Schema
|
||||
*/
|
||||
export function emptyPluginConfigSchema(): unknown;
|
||||
|
||||
// ============ 频道插件 ============
|
||||
|
||||
/**
|
||||
* 频道插件 Meta 信息
|
||||
*/
|
||||
export interface ChannelPluginMeta {
|
||||
id: string;
|
||||
label: string;
|
||||
selectionLabel?: string;
|
||||
docsPath?: string;
|
||||
blurb?: string;
|
||||
order?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件能力配置
|
||||
*/
|
||||
export interface ChannelPluginCapabilities {
|
||||
chatTypes?: ("direct" | "group" | "channel")[];
|
||||
media?: boolean;
|
||||
reactions?: boolean;
|
||||
threads?: boolean;
|
||||
blockStreaming?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户描述
|
||||
*/
|
||||
export interface AccountDescription {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
tokenSource?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件配置接口(泛型)
|
||||
*/
|
||||
export interface ChannelPluginConfig<TAccount> {
|
||||
listAccountIds: (cfg: OpenClawConfig) => string[];
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount;
|
||||
defaultAccountId: (cfg: OpenClawConfig) => string;
|
||||
setAccountEnabled?: (ctx: { cfg: OpenClawConfig; accountId: string; enabled: boolean }) => OpenClawConfig;
|
||||
deleteAccount?: (ctx: { cfg: OpenClawConfig; accountId: string }) => OpenClawConfig;
|
||||
isConfigured?: (account: TAccount | undefined) => boolean;
|
||||
describeAccount?: (account: TAccount | undefined) => AccountDescription;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup 输入参数(扩展类型以支持 QQBot 特定字段)
|
||||
*/
|
||||
export interface SetupInput {
|
||||
token?: string;
|
||||
tokenFile?: string;
|
||||
useEnv?: boolean;
|
||||
name?: string;
|
||||
imageServerBaseUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Setup 接口
|
||||
*/
|
||||
export interface ChannelPluginSetup {
|
||||
resolveAccountId?: (ctx: { accountId?: string }) => string;
|
||||
applyAccountName?: (ctx: { cfg: OpenClawConfig; accountId: string; name: string }) => OpenClawConfig;
|
||||
validateInput?: (ctx: { input: SetupInput }) => string | null;
|
||||
applyConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||
applyAccountConfig?: (ctx: { cfg: OpenClawConfig; accountId: string; input: SetupInput }) => OpenClawConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息目标解析结果
|
||||
*/
|
||||
export interface NormalizeTargetResult {
|
||||
ok: boolean;
|
||||
to?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标解析器
|
||||
*/
|
||||
export interface TargetResolver {
|
||||
looksLikeId?: (id: string) => boolean;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Messaging 接口
|
||||
*/
|
||||
export interface ChannelPluginMessaging {
|
||||
normalizeTarget?: (target: string) => NormalizeTargetResult;
|
||||
targetResolver?: TargetResolver;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本结果
|
||||
*/
|
||||
export interface SendTextResult {
|
||||
channel: string;
|
||||
messageId?: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本上下文
|
||||
*/
|
||||
export interface SendTextContext {
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送媒体上下文
|
||||
*/
|
||||
export interface SendMediaContext {
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
accountId?: string;
|
||||
replyToId?: string;
|
||||
cfg: OpenClawConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Outbound 接口
|
||||
*/
|
||||
export interface ChannelPluginOutbound {
|
||||
deliveryMode?: "direct" | "queued";
|
||||
chunker?: (text: string, limit: number) => string[];
|
||||
chunkerMode?: "markdown" | "plain";
|
||||
textChunkLimit?: number;
|
||||
sendText?: (ctx: SendTextContext) => Promise<SendTextResult>;
|
||||
sendMedia?: (ctx: SendMediaContext) => Promise<SendTextResult>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户状态
|
||||
*/
|
||||
export interface AccountStatus {
|
||||
running?: boolean;
|
||||
connected?: boolean;
|
||||
lastConnectedAt?: number;
|
||||
lastError?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 启动上下文
|
||||
*/
|
||||
export interface GatewayStartContext<TAccount = unknown> {
|
||||
account: TAccount;
|
||||
accountId: string;
|
||||
abortSignal: AbortSignal;
|
||||
cfg: OpenClawConfig;
|
||||
log?: {
|
||||
info: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
};
|
||||
getStatus: () => AccountStatus;
|
||||
setStatus: (status: AccountStatus) => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 登出上下文
|
||||
*/
|
||||
export interface GatewayLogoutContext {
|
||||
accountId: string;
|
||||
cfg: OpenClawConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway 登出结果
|
||||
*/
|
||||
export interface GatewayLogoutResult {
|
||||
ok: boolean;
|
||||
cleared: boolean;
|
||||
updatedConfig?: OpenClawConfig;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件 Gateway 接口
|
||||
*/
|
||||
export interface ChannelPluginGateway<TAccount = unknown> {
|
||||
startAccount?: (ctx: GatewayStartContext<TAccount>) => Promise<void>;
|
||||
logoutAccount?: (ctx: GatewayLogoutContext) => Promise<GatewayLogoutResult>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 频道插件接口(泛型)
|
||||
*/
|
||||
export interface ChannelPlugin<TAccount = unknown> {
|
||||
/** 插件 ID */
|
||||
id: string;
|
||||
/** 插件 Meta 信息 */
|
||||
meta?: ChannelPluginMeta;
|
||||
/** 插件版本 */
|
||||
version?: string;
|
||||
/** 插件能力 */
|
||||
capabilities?: ChannelPluginCapabilities;
|
||||
/** 重载配置 */
|
||||
reload?: { configPrefixes?: string[] };
|
||||
/** Onboarding 适配器 */
|
||||
onboarding?: ChannelOnboardingAdapter;
|
||||
/** 配置方法 */
|
||||
config?: ChannelPluginConfig<TAccount>;
|
||||
/** Setup 方法 */
|
||||
setup?: ChannelPluginSetup;
|
||||
/** Messaging 配置 */
|
||||
messaging?: ChannelPluginMessaging;
|
||||
/** Outbound 配置 */
|
||||
outbound?: ChannelPluginOutbound;
|
||||
/** Gateway 配置 */
|
||||
gateway?: ChannelPluginGateway<TAccount>;
|
||||
/** 启动函数 */
|
||||
start?: (runtime: PluginRuntime) => void | Promise<void>;
|
||||
/** 停止函数 */
|
||||
stop?: () => void | Promise<void>;
|
||||
/** deliver 函数 - 发送消息 */
|
||||
deliver?: (ctx: unknown) => Promise<unknown>;
|
||||
/** 其他插件属性 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ Onboarding 类型 ============
|
||||
|
||||
/**
|
||||
* Onboarding 状态结果
|
||||
*/
|
||||
export interface ChannelOnboardingStatus {
|
||||
channel?: string;
|
||||
configured: boolean;
|
||||
statusLines?: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 状态字符串枚举(部分 API 使用)
|
||||
*/
|
||||
export type ChannelOnboardingStatusString =
|
||||
| "not-configured"
|
||||
| "configured"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Onboarding 状态上下文
|
||||
*/
|
||||
export interface ChannelOnboardingStatusContext {
|
||||
/** 当前配置 */
|
||||
config: OpenClawConfig;
|
||||
/** 账户 ID */
|
||||
accountId?: string;
|
||||
/** Prompter */
|
||||
prompter?: unknown;
|
||||
/** 其他上下文 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 配置上下文
|
||||
*/
|
||||
export interface ChannelOnboardingConfigureContext {
|
||||
/** 当前配置 */
|
||||
config: OpenClawConfig;
|
||||
/** 账户 ID */
|
||||
accountId?: string;
|
||||
/** 输入参数 */
|
||||
input?: Record<string, unknown>;
|
||||
/** Prompter */
|
||||
prompter?: unknown;
|
||||
/** 其他上下文 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 结果
|
||||
*/
|
||||
export interface ChannelOnboardingResult {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 更新后的配置 */
|
||||
config?: OpenClawConfig;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 消息 */
|
||||
message?: string;
|
||||
/** 其他结果字段 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 适配器接口
|
||||
*/
|
||||
export interface ChannelOnboardingAdapter {
|
||||
/** 获取状态 */
|
||||
getStatus?: (ctx: ChannelOnboardingStatusContext) => ChannelOnboardingStatus | Promise<ChannelOnboardingStatus>;
|
||||
/** 配置函数 */
|
||||
configure?: (ctx: ChannelOnboardingConfigureContext) => ChannelOnboardingResult | Promise<ChannelOnboardingResult>;
|
||||
/** 其他适配器方法 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// ============ 配置辅助函数 ============
|
||||
|
||||
/**
|
||||
* 将账户名称应用到频道配置段
|
||||
*/
|
||||
export function applyAccountNameToChannelSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
name: string;
|
||||
}): OpenClawConfig;
|
||||
|
||||
/**
|
||||
* 从配置段删除账户
|
||||
*/
|
||||
export function deleteAccountFromConfigSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
clearBaseFields?: string[];
|
||||
}): OpenClawConfig;
|
||||
|
||||
/**
|
||||
* 设置账户启用状态
|
||||
*/
|
||||
export function setAccountEnabledInConfigSection(ctx: {
|
||||
cfg: OpenClawConfig;
|
||||
sectionKey: string;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
allowTopLevel?: boolean;
|
||||
}): OpenClawConfig;
|
||||
|
||||
// ============ 其他导出 ============
|
||||
|
||||
/** 默认账户 ID 常量 */
|
||||
export const DEFAULT_ACCOUNT_ID: string;
|
||||
|
||||
/** 规范化账户 ID */
|
||||
export function normalizeAccountId(accountId: string | undefined | null): string;
|
||||
}
|
||||
184
src/outbound.ts
184
src/outbound.ts
@@ -16,6 +16,133 @@ import {
|
||||
type StreamMessageResponse,
|
||||
} from "./api.js";
|
||||
|
||||
// ============ 消息回复限流器 ============
|
||||
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
||||
const MESSAGE_REPLY_LIMIT = 4;
|
||||
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
||||
|
||||
interface MessageReplyRecord {
|
||||
count: number;
|
||||
firstReplyAt: number;
|
||||
}
|
||||
|
||||
const messageReplyTracker = new Map<string, MessageReplyRecord>();
|
||||
|
||||
/** 限流检查结果 */
|
||||
export interface ReplyLimitResult {
|
||||
/** 是否允许被动回复 */
|
||||
allowed: boolean;
|
||||
/** 剩余被动回复次数 */
|
||||
remaining: number;
|
||||
/** 是否需要降级为主动消息(超期或超过次数) */
|
||||
shouldFallbackToProactive: boolean;
|
||||
/** 降级原因 */
|
||||
fallbackReason?: "expired" | "limit_exceeded";
|
||||
/** 提示消息 */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否可以回复该消息(限流检查)
|
||||
* @param messageId 消息ID
|
||||
* @returns ReplyLimitResult 限流检查结果
|
||||
*/
|
||||
export function checkMessageReplyLimit(messageId: string): ReplyLimitResult {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
// 清理过期记录(定期清理,避免内存泄漏)
|
||||
if (messageReplyTracker.size > 10000) {
|
||||
for (const [id, rec] of messageReplyTracker) {
|
||||
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 新消息,首次回复
|
||||
if (!record) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: MESSAGE_REPLY_LIMIT,
|
||||
shouldFallbackToProactive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否超过1小时(message_id 过期)
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
// 超过1小时,被动回复不可用,需要降级为主动消息
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
shouldFallbackToProactive: true,
|
||||
fallbackReason: "expired",
|
||||
message: `消息已超过1小时有效期,将使用主动消息发送`,
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否超过回复次数限制
|
||||
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
||||
if (remaining <= 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
shouldFallbackToProactive: true,
|
||||
fallbackReason: "limit_exceeded",
|
||||
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining,
|
||||
shouldFallbackToProactive: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次消息回复
|
||||
* @param messageId 消息ID
|
||||
*/
|
||||
export function recordMessageReply(messageId: string): void {
|
||||
const now = Date.now();
|
||||
const record = messageReplyTracker.get(messageId);
|
||||
|
||||
if (!record) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
// 检查是否过期,过期则重新计数
|
||||
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
||||
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
||||
} else {
|
||||
record.count++;
|
||||
}
|
||||
}
|
||||
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息回复统计信息
|
||||
*/
|
||||
export function getMessageReplyStats(): { trackedMessages: number; totalReplies: number } {
|
||||
let totalReplies = 0;
|
||||
for (const record of messageReplyTracker.values()) {
|
||||
totalReplies += record.count;
|
||||
}
|
||||
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息回复限制配置(供外部查询)
|
||||
*/
|
||||
export function getMessageReplyConfig(): { limit: number; ttlMs: number; ttlHours: number } {
|
||||
return {
|
||||
limit: MESSAGE_REPLY_LIMIT,
|
||||
ttlMs: MESSAGE_REPLY_TTL,
|
||||
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export interface OutboundContext {
|
||||
to: string;
|
||||
text: string;
|
||||
@@ -211,14 +338,61 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
* - 有 replyToId: 被动回复,无配额限制
|
||||
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
||||
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
||||
*
|
||||
* 注意:
|
||||
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
||||
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
||||
*/
|
||||
export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
const { to, text, replyToId, account } = ctx;
|
||||
const { to, text, account } = ctx;
|
||||
let { replyToId } = ctx;
|
||||
let fallbackToProactive = false;
|
||||
|
||||
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
||||
|
||||
// ============ 消息回复限流检查 ============
|
||||
// 如果有 replyToId,检查是否可以被动回复
|
||||
if (replyToId) {
|
||||
const limitCheck = checkMessageReplyLimit(replyToId);
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
// 检查是否需要降级为主动消息
|
||||
if (limitCheck.shouldFallbackToProactive) {
|
||||
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
|
||||
fallbackToProactive = true;
|
||||
replyToId = null; // 清除 replyToId,改为主动消息
|
||||
} else {
|
||||
// 不应该发生,但作为保底
|
||||
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: limitCheck.message
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 主动消息校验(参考 Telegram 机制) ============
|
||||
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
||||
if (!replyToId) {
|
||||
if (!text || text.trim().length === 0) {
|
||||
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
|
||||
return {
|
||||
channel: "qqbot",
|
||||
error: "主动消息必须有内容 (--message 参数不能为空)"
|
||||
};
|
||||
}
|
||||
if (fallbackToProactive) {
|
||||
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||
} else {
|
||||
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
||||
}
|
||||
@@ -246,12 +420,18 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
|
||||
// 有 replyToId,使用被动回复接口
|
||||
if (target.type === "c2c") {
|
||||
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else if (target.type === "group") {
|
||||
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
} else {
|
||||
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
||||
// 记录回复次数
|
||||
recordMessageReply(replyToId);
|
||||
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
528
src/proactive.ts
Normal file
528
src/proactive.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* QQ Bot 主动发送消息模块
|
||||
*
|
||||
* 该模块提供以下能力:
|
||||
* 1. 记录已知用户(曾与机器人交互过的用户)
|
||||
* 2. 主动发送消息给用户或群组
|
||||
* 3. 查询已知用户列表
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { ResolvedQQBotAccount } from "./types.js";
|
||||
|
||||
// ============ 类型定义(本地) ============
|
||||
|
||||
/**
|
||||
* 已知用户信息
|
||||
*/
|
||||
export interface KnownUser {
|
||||
type: "c2c" | "group" | "channel";
|
||||
openid: string;
|
||||
accountId: string;
|
||||
nickname?: string;
|
||||
firstInteractionAt: number;
|
||||
lastInteractionAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息选项
|
||||
*/
|
||||
export interface ProactiveSendOptions {
|
||||
to: string;
|
||||
text: string;
|
||||
type?: "c2c" | "group" | "channel";
|
||||
imageUrl?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动发送消息结果
|
||||
*/
|
||||
export interface ProactiveSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
timestamp?: number | string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已知用户选项
|
||||
*/
|
||||
export interface ListKnownUsersOptions {
|
||||
type?: "c2c" | "group" | "channel";
|
||||
accountId?: string;
|
||||
sortByLastInteraction?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
import {
|
||||
getAccessToken,
|
||||
sendProactiveC2CMessage,
|
||||
sendProactiveGroupMessage,
|
||||
sendChannelMessage,
|
||||
sendC2CImageMessage,
|
||||
sendGroupImageMessage,
|
||||
} from "./api.js";
|
||||
import { resolveQQBotAccount } from "./config.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
|
||||
// ============ 用户存储管理 ============
|
||||
|
||||
/**
|
||||
* 已知用户存储
|
||||
* 使用简单的 JSON 文件存储,保存在 clawd 目录下
|
||||
*/
|
||||
const STORAGE_DIR = path.join(process.env.HOME || "/home/ubuntu", "clawd", "qqbot-data");
|
||||
const KNOWN_USERS_FILE = path.join(STORAGE_DIR, "known-users.json");
|
||||
|
||||
// 内存缓存
|
||||
let knownUsersCache: Map<string, KnownUser> | null = null;
|
||||
let cacheLastModified = 0;
|
||||
|
||||
/**
|
||||
* 确保存储目录存在
|
||||
*/
|
||||
function ensureStorageDir(): void {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成用户唯一键
|
||||
*/
|
||||
function getUserKey(type: string, openid: string, accountId: string): string {
|
||||
return `${accountId}:${type}:${openid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件加载已知用户
|
||||
*/
|
||||
function loadKnownUsers(): Map<string, KnownUser> {
|
||||
if (knownUsersCache !== null) {
|
||||
// 检查文件是否被修改
|
||||
try {
|
||||
const stat = fs.statSync(KNOWN_USERS_FILE);
|
||||
if (stat.mtimeMs <= cacheLastModified) {
|
||||
return knownUsersCache;
|
||||
}
|
||||
} catch {
|
||||
// 文件不存在,使用缓存
|
||||
return knownUsersCache;
|
||||
}
|
||||
}
|
||||
|
||||
const users = new Map<string, KnownUser>();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(KNOWN_USERS_FILE)) {
|
||||
const data = fs.readFileSync(KNOWN_USERS_FILE, "utf-8");
|
||||
const parsed = JSON.parse(data) as KnownUser[];
|
||||
for (const user of parsed) {
|
||||
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||
users.set(key, user);
|
||||
}
|
||||
cacheLastModified = fs.statSync(KNOWN_USERS_FILE).mtimeMs;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to load known users: ${err}`);
|
||||
}
|
||||
|
||||
knownUsersCache = users;
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存已知用户到文件
|
||||
*/
|
||||
function saveKnownUsers(users: Map<string, KnownUser>): void {
|
||||
try {
|
||||
ensureStorageDir();
|
||||
const data = Array.from(users.values());
|
||||
fs.writeFileSync(KNOWN_USERS_FILE, JSON.stringify(data, null, 2), "utf-8");
|
||||
cacheLastModified = Date.now();
|
||||
knownUsersCache = users;
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to save known users: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个已知用户(当收到用户消息时调用)
|
||||
*
|
||||
* @param user - 用户信息
|
||||
*/
|
||||
export function recordKnownUser(user: Omit<KnownUser, "firstInteractionAt">): void {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(user.type, user.openid, user.accountId);
|
||||
|
||||
const existing = users.get(key);
|
||||
const now = user.lastInteractionAt || Date.now();
|
||||
|
||||
users.set(key, {
|
||||
...user,
|
||||
lastInteractionAt: now,
|
||||
firstInteractionAt: existing?.firstInteractionAt ?? now,
|
||||
// 更新昵称(如果有新的)
|
||||
nickname: user.nickname || existing?.nickname,
|
||||
});
|
||||
|
||||
saveKnownUsers(users);
|
||||
console.log(`[qqbot:proactive] Recorded user: ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个已知用户
|
||||
*
|
||||
* @param type - 用户类型
|
||||
* @param openid - 用户 openid
|
||||
* @param accountId - 账户 ID
|
||||
*/
|
||||
export function getKnownUser(type: string, openid: string, accountId: string): KnownUser | undefined {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(type, openid, accountId);
|
||||
return users.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出已知用户
|
||||
*
|
||||
* @param options - 过滤选项
|
||||
*/
|
||||
export function listKnownUsers(options?: ListKnownUsersOptions): KnownUser[] {
|
||||
const users = loadKnownUsers();
|
||||
let result = Array.from(users.values());
|
||||
|
||||
// 过滤类型
|
||||
if (options?.type) {
|
||||
result = result.filter(u => u.type === options.type);
|
||||
}
|
||||
|
||||
// 过滤账户
|
||||
if (options?.accountId) {
|
||||
result = result.filter(u => u.accountId === options.accountId);
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (options?.sortByLastInteraction !== false) {
|
||||
result.sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
|
||||
}
|
||||
|
||||
// 限制数量
|
||||
if (options?.limit && options.limit > 0) {
|
||||
result = result.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除一个已知用户
|
||||
*
|
||||
* @param type - 用户类型
|
||||
* @param openid - 用户 openid
|
||||
* @param accountId - 账户 ID
|
||||
*/
|
||||
export function removeKnownUser(type: string, openid: string, accountId: string): boolean {
|
||||
const users = loadKnownUsers();
|
||||
const key = getUserKey(type, openid, accountId);
|
||||
const deleted = users.delete(key);
|
||||
if (deleted) {
|
||||
saveKnownUsers(users);
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有已知用户
|
||||
*
|
||||
* @param accountId - 可选,只清除指定账户的用户
|
||||
*/
|
||||
export function clearKnownUsers(accountId?: string): number {
|
||||
const users = loadKnownUsers();
|
||||
let count = 0;
|
||||
|
||||
if (accountId) {
|
||||
for (const [key, user] of users) {
|
||||
if (user.accountId === accountId) {
|
||||
users.delete(key);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
count = users.size;
|
||||
users.clear();
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
saveKnownUsers(users);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// ============ 主动发送消息 ============
|
||||
|
||||
/**
|
||||
* 主动发送消息(带配置解析)
|
||||
* 注意:与 outbound.ts 中的 sendProactiveMessage 不同,这个函数接受 OpenClawConfig 并自动解析账户
|
||||
*
|
||||
* @param options - 发送选项
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @returns 发送结果
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 发送私聊消息
|
||||
* const result = await sendProactive({
|
||||
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4", // 用户 openid
|
||||
* text: "你好!这是一条主动消息",
|
||||
* type: "c2c",
|
||||
* }, cfg);
|
||||
*
|
||||
* // 发送群聊消息
|
||||
* const result = await sendProactive({
|
||||
* to: "A1B2C3D4E5F6A7B8", // 群组 openid
|
||||
* text: "群公告:今天有活动",
|
||||
* type: "group",
|
||||
* }, cfg);
|
||||
*
|
||||
* // 发送带图片的消息
|
||||
* const result = await sendProactive({
|
||||
* to: "E7A8F3B2C1D4E5F6A7B8C9D0E1F2A3B4",
|
||||
* text: "看看这张图片",
|
||||
* imageUrl: "https://example.com/image.png",
|
||||
* type: "c2c",
|
||||
* }, cfg);
|
||||
* ```
|
||||
*/
|
||||
export async function sendProactive(
|
||||
options: ProactiveSendOptions,
|
||||
cfg: OpenClawConfig
|
||||
): Promise<ProactiveSendResult> {
|
||||
const { to, text, type = "c2c", imageUrl, accountId = "default" } = options;
|
||||
|
||||
// 解析账户配置
|
||||
const account = resolveQQBotAccount(cfg, accountId);
|
||||
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
// 如果有图片,先发送图片
|
||||
if (imageUrl) {
|
||||
try {
|
||||
if (type === "c2c") {
|
||||
await sendC2CImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||
} else if (type === "group") {
|
||||
await sendGroupImageMessage(accessToken, to, imageUrl, undefined, undefined);
|
||||
}
|
||||
console.log(`[qqbot:proactive] Sent image to ${type}:${to}`);
|
||||
} catch (err) {
|
||||
console.error(`[qqbot:proactive] Failed to send image: ${err}`);
|
||||
// 图片发送失败不影响文本发送
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文本消息
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||
} else if (type === "group") {
|
||||
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||
} else if (type === "channel") {
|
||||
// 频道消息需要 channel_id,这里暂时不支持主动发送
|
||||
return {
|
||||
success: false,
|
||||
error: "Channel proactive messages are not supported. Please use group or c2c.",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown message type: ${type}`,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[qqbot:proactive] Sent message to ${type}:${to}, id: ${result.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[qqbot:proactive] Failed to send message: ${message}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发送主动消息
|
||||
*
|
||||
* @param recipients - 接收者列表(openid 数组)
|
||||
* @param text - 消息内容
|
||||
* @param type - 消息类型
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @param accountId - 账户 ID
|
||||
* @returns 发送结果列表
|
||||
*/
|
||||
export async function sendBulkProactiveMessage(
|
||||
recipients: string[],
|
||||
text: string,
|
||||
type: "c2c" | "group",
|
||||
cfg: OpenClawConfig,
|
||||
accountId = "default"
|
||||
): Promise<Array<{ to: string; result: ProactiveSendResult }>> {
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
|
||||
for (const to of recipients) {
|
||||
const result = await sendProactive({ to, text, type, accountId }, cfg);
|
||||
results.push({ to, result });
|
||||
|
||||
// 添加延迟,避免频率限制
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给所有已知用户
|
||||
*
|
||||
* @param text - 消息内容
|
||||
* @param cfg - OpenClaw 配置
|
||||
* @param options - 过滤选项
|
||||
* @returns 发送结果统计
|
||||
*/
|
||||
export async function broadcastMessage(
|
||||
text: string,
|
||||
cfg: OpenClawConfig,
|
||||
options?: {
|
||||
type?: "c2c" | "group";
|
||||
accountId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<{
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: Array<{ to: string; result: ProactiveSendResult }>;
|
||||
}> {
|
||||
const users = listKnownUsers({
|
||||
type: options?.type,
|
||||
accountId: options?.accountId,
|
||||
limit: options?.limit,
|
||||
sortByLastInteraction: true,
|
||||
});
|
||||
|
||||
// 过滤掉频道用户(不支持主动发送)
|
||||
const validUsers = users.filter(u => u.type === "c2c" || u.type === "group");
|
||||
|
||||
const results: Array<{ to: string; result: ProactiveSendResult }> = [];
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const user of validUsers) {
|
||||
const result = await sendProactive({
|
||||
to: user.openid,
|
||||
text,
|
||||
type: user.type as "c2c" | "group",
|
||||
accountId: user.accountId,
|
||||
}, cfg);
|
||||
|
||||
results.push({ to: user.openid, result });
|
||||
|
||||
if (result.success) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
|
||||
// 添加延迟,避免频率限制
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
return {
|
||||
total: validUsers.length,
|
||||
success,
|
||||
failed,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 辅助函数 ============
|
||||
|
||||
/**
|
||||
* 根据账户配置直接发送主动消息(不需要 cfg)
|
||||
*
|
||||
* @param account - 已解析的账户配置
|
||||
* @param to - 目标 openid
|
||||
* @param text - 消息内容
|
||||
* @param type - 消息类型
|
||||
*/
|
||||
export async function sendProactiveMessageDirect(
|
||||
account: ResolvedQQBotAccount,
|
||||
to: string,
|
||||
text: string,
|
||||
type: "c2c" | "group" = "c2c"
|
||||
): Promise<ProactiveSendResult> {
|
||||
if (!account.appId || !account.clientSecret) {
|
||||
return {
|
||||
success: false,
|
||||
error: "QQBot not configured (missing appId or clientSecret)",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
||||
|
||||
let result: { id: string; timestamp: number | string };
|
||||
|
||||
if (type === "c2c") {
|
||||
result = await sendProactiveC2CMessage(accessToken, to, text);
|
||||
} else {
|
||||
result = await sendProactiveGroupMessage(accessToken, to, text);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: result.id,
|
||||
timestamp: result.timestamp,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已知用户统计
|
||||
*/
|
||||
export function getKnownUsersStats(accountId?: string): {
|
||||
total: number;
|
||||
c2c: number;
|
||||
group: number;
|
||||
channel: number;
|
||||
} {
|
||||
const users = listKnownUsers({ accountId });
|
||||
|
||||
return {
|
||||
total: users.length,
|
||||
c2c: users.filter(u => u.type === "c2c").length,
|
||||
group: users.filter(u => u.type === "group").length,
|
||||
channel: users.filter(u => u.type === "channel").length,
|
||||
};
|
||||
}
|
||||
292
src/session-store.ts
Normal file
292
src/session-store.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Session 持久化存储
|
||||
* 将 WebSocket 连接状态(sessionId、lastSeq)持久化到文件
|
||||
* 支持进程重启后通过 Resume 机制快速恢复连接
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Session 状态接口
|
||||
export interface SessionState {
|
||||
/** WebSocket Session ID */
|
||||
sessionId: string | null;
|
||||
/** 最后收到的消息序号 */
|
||||
lastSeq: number | null;
|
||||
/** 上次连接成功的时间戳 */
|
||||
lastConnectedAt: number;
|
||||
/** 上次成功的权限级别索引 */
|
||||
intentLevelIndex: number;
|
||||
/** 关联的机器人账户 ID */
|
||||
accountId: string;
|
||||
/** 保存时间 */
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
// Session 文件目录
|
||||
const SESSION_DIR = path.join(
|
||||
process.env.HOME || "/tmp",
|
||||
"clawd",
|
||||
"qqbot-data"
|
||||
);
|
||||
|
||||
// Session 过期时间(5分钟)- Resume 要求在断开后一定时间内恢复
|
||||
const SESSION_EXPIRE_TIME = 5 * 60 * 1000;
|
||||
|
||||
// 写入节流时间(避免频繁写入)
|
||||
const SAVE_THROTTLE_MS = 1000;
|
||||
|
||||
// 每个账户的节流状态
|
||||
const throttleState = new Map<string, {
|
||||
pendingState: SessionState | null;
|
||||
lastSaveTime: number;
|
||||
throttleTimer: ReturnType<typeof setTimeout> | null;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 确保目录存在
|
||||
*/
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Session 文件路径
|
||||
*/
|
||||
function getSessionPath(accountId: string): string {
|
||||
// 清理 accountId 中的特殊字符
|
||||
const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
return path.join(SESSION_DIR, `session-${safeId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 Session 状态
|
||||
* @param accountId 账户 ID
|
||||
* @returns Session 状态,如果不存在或已过期返回 null
|
||||
*/
|
||||
export function loadSession(accountId: string): SessionState | null {
|
||||
const filePath = getSessionPath(accountId);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
// 检查是否过期
|
||||
const now = Date.now();
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
console.log(`[session-store] Session expired for ${accountId}, age: ${Math.round((now - state.savedAt) / 1000)}s`);
|
||||
// 删除过期文件
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// 忽略删除错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证必要字段
|
||||
if (!state.sessionId || state.lastSeq === null || state.lastSeq === undefined) {
|
||||
console.log(`[session-store] Invalid session data for ${accountId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[session-store] Loaded session for ${accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}, age=${Math.round((now - state.savedAt) / 1000)}s`);
|
||||
return state;
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to load session for ${accountId}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Session 状态(带节流,避免频繁写入)
|
||||
* @param state Session 状态
|
||||
*/
|
||||
export function saveSession(state: SessionState): void {
|
||||
const { accountId } = state;
|
||||
|
||||
// 获取或初始化节流状态
|
||||
let throttle = throttleState.get(accountId);
|
||||
if (!throttle) {
|
||||
throttle = {
|
||||
pendingState: null,
|
||||
lastSaveTime: 0,
|
||||
throttleTimer: null,
|
||||
};
|
||||
throttleState.set(accountId, throttle);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastSave = now - throttle.lastSaveTime;
|
||||
|
||||
// 如果距离上次保存时间足够长,立即保存
|
||||
if (timeSinceLastSave >= SAVE_THROTTLE_MS) {
|
||||
doSaveSession(state);
|
||||
throttle.lastSaveTime = now;
|
||||
throttle.pendingState = null;
|
||||
|
||||
// 清除待定的节流定时器
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
throttle.throttleTimer = null;
|
||||
}
|
||||
} else {
|
||||
// 记录待保存的状态
|
||||
throttle.pendingState = state;
|
||||
|
||||
// 如果没有设置定时器,设置一个
|
||||
if (!throttle.throttleTimer) {
|
||||
const delay = SAVE_THROTTLE_MS - timeSinceLastSave;
|
||||
throttle.throttleTimer = setTimeout(() => {
|
||||
const t = throttleState.get(accountId);
|
||||
if (t && t.pendingState) {
|
||||
doSaveSession(t.pendingState);
|
||||
t.lastSaveTime = Date.now();
|
||||
t.pendingState = null;
|
||||
}
|
||||
if (t) {
|
||||
t.throttleTimer = null;
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行保存操作
|
||||
*/
|
||||
function doSaveSession(state: SessionState): void {
|
||||
const filePath = getSessionPath(state.accountId);
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
|
||||
// 更新保存时间
|
||||
const stateToSave: SessionState = {
|
||||
...state,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(stateToSave, null, 2), "utf-8");
|
||||
console.log(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to save session for ${state.accountId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Session 状态
|
||||
* @param accountId 账户 ID
|
||||
*/
|
||||
export function clearSession(accountId: string): void {
|
||||
const filePath = getSessionPath(accountId);
|
||||
|
||||
// 清除节流状态
|
||||
const throttle = throttleState.get(accountId);
|
||||
if (throttle) {
|
||||
if (throttle.throttleTimer) {
|
||||
clearTimeout(throttle.throttleTimer);
|
||||
}
|
||||
throttleState.delete(accountId);
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[session-store] Cleared session for ${accountId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[session-store] Failed to clear session for ${accountId}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 lastSeq(轻量级更新)
|
||||
* @param accountId 账户 ID
|
||||
* @param lastSeq 最新的消息序号
|
||||
*/
|
||||
export function updateLastSeq(accountId: string, lastSeq: number): void {
|
||||
const existing = loadSession(accountId);
|
||||
if (existing && existing.sessionId) {
|
||||
saveSession({
|
||||
...existing,
|
||||
lastSeq,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有保存的 Session 状态
|
||||
*/
|
||||
export function getAllSessions(): SessionState[] {
|
||||
const sessions: SessionState[] = [];
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
sessions.push(state);
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在等错误
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的 Session 文件
|
||||
*/
|
||||
export function cleanupExpiredSessions(): number {
|
||||
let cleaned = 0;
|
||||
|
||||
try {
|
||||
ensureDir();
|
||||
const files = fs.readdirSync(SESSION_DIR);
|
||||
const now = Date.now();
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith("session-") && file.endsWith(".json")) {
|
||||
const filePath = path.join(SESSION_DIR, file);
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, "utf-8");
|
||||
const state = JSON.parse(data) as SessionState;
|
||||
|
||||
if (now - state.savedAt > SESSION_EXPIRE_TIME) {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
console.log(`[session-store] Cleaned expired session: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误,但也删除损坏的文件
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
cleaned++;
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在等错误
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface ResolvedQQBotAccount {
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息 */
|
||||
markdownSupport?: boolean;
|
||||
/** 是否启用流式消息(仅 c2c 私聊支持),默认 true */
|
||||
streamEnabled?: boolean;
|
||||
config: QQBotAccountConfig;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,8 @@ export interface QQBotAccountConfig {
|
||||
imageServerBaseUrl?: string;
|
||||
/** 是否支持 markdown 消息,默认 true */
|
||||
markdownSupport?: boolean;
|
||||
/** 是否启用流式消息,默认 true(仅 c2c 私聊支持) */
|
||||
streamEnabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user