@zaofan/dsh-qqbot 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +308 -0
- package/README_EN.md +273 -0
- package/client/qqbot-settings.js +1463 -0
- package/cordis.patch.yml +36 -0
- package/dist/api/group-admin.d.ts +112 -0
- package/dist/api/group-admin.d.ts.map +1 -0
- package/dist/api/group-admin.js +179 -0
- package/dist/api/group-admin.js.map +1 -0
- package/dist/channel-tools.d.ts +14 -0
- package/dist/channel-tools.d.ts.map +1 -0
- package/dist/channel-tools.js +880 -0
- package/dist/channel-tools.js.map +1 -0
- package/dist/commands/help.d.ts +11 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +21 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/index.d.ts +13 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +29 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/misc.d.ts +14 -0
- package/dist/commands/misc.d.ts.map +1 -0
- package/dist/commands/misc.js +39 -0
- package/dist/commands/misc.js.map +1 -0
- package/dist/commands/model.d.ts +7 -0
- package/dist/commands/model.d.ts.map +1 -0
- package/dist/commands/model.js +58 -0
- package/dist/commands/model.js.map +1 -0
- package/dist/commands/session.d.ts +10 -0
- package/dist/commands/session.d.ts.map +1 -0
- package/dist/commands/session.js +26 -0
- package/dist/commands/session.js.map +1 -0
- package/dist/commands/status.d.ts +8 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +24 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/types.d.ts +12 -0
- package/dist/commands/types.d.ts.map +1 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/types.js.map +1 -0
- package/dist/config.d.ts +205 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +203 -0
- package/dist/config.js.map +1 -0
- package/dist/features/chat-ledger.d.ts +53 -0
- package/dist/features/chat-ledger.d.ts.map +1 -0
- package/dist/features/chat-ledger.js +185 -0
- package/dist/features/chat-ledger.js.map +1 -0
- package/dist/features/face-tags.d.ts +17 -0
- package/dist/features/face-tags.d.ts.map +1 -0
- package/dist/features/face-tags.js +79 -0
- package/dist/features/face-tags.js.map +1 -0
- package/dist/features/group-join-request.d.ts +65 -0
- package/dist/features/group-join-request.d.ts.map +1 -0
- package/dist/features/group-join-request.js +190 -0
- package/dist/features/group-join-request.js.map +1 -0
- package/dist/features/history-store.d.ts +8 -0
- package/dist/features/history-store.d.ts.map +1 -0
- package/dist/features/history-store.js +24 -0
- package/dist/features/history-store.js.map +1 -0
- package/dist/features/qq-approval.d.ts +78 -0
- package/dist/features/qq-approval.d.ts.map +1 -0
- package/dist/features/qq-approval.js +165 -0
- package/dist/features/qq-approval.js.map +1 -0
- package/dist/features/schedule-store.d.ts +62 -0
- package/dist/features/schedule-store.d.ts.map +1 -0
- package/dist/features/schedule-store.js +253 -0
- package/dist/features/schedule-store.js.map +1 -0
- package/dist/features/scheduler.d.ts +9 -0
- package/dist/features/scheduler.d.ts.map +1 -0
- package/dist/features/scheduler.js +146 -0
- package/dist/features/scheduler.js.map +1 -0
- package/dist/features/sticker-gate.d.ts +90 -0
- package/dist/features/sticker-gate.d.ts.map +1 -0
- package/dist/features/sticker-gate.js +391 -0
- package/dist/features/sticker-gate.js.map +1 -0
- package/dist/features/sticker-store.d.ts +158 -0
- package/dist/features/sticker-store.d.ts.map +1 -0
- package/dist/features/sticker-store.js +676 -0
- package/dist/features/sticker-store.js.map +1 -0
- package/dist/features/sticker-tagger.d.ts +10 -0
- package/dist/features/sticker-tagger.d.ts.map +1 -0
- package/dist/features/sticker-tagger.js +139 -0
- package/dist/features/sticker-tagger.js.map +1 -0
- package/dist/gateway/bootstrap.d.ts +12 -0
- package/dist/gateway/bootstrap.d.ts.map +1 -0
- package/dist/gateway/bootstrap.js +346 -0
- package/dist/gateway/bootstrap.js.map +1 -0
- package/dist/gateway/debounce.d.ts +40 -0
- package/dist/gateway/debounce.d.ts.map +1 -0
- package/dist/gateway/debounce.js +262 -0
- package/dist/gateway/debounce.js.map +1 -0
- package/dist/gateway/index.d.ts +3 -0
- package/dist/gateway/index.d.ts.map +1 -0
- package/dist/gateway/index.js +3 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/gateway/middleware-setup.d.ts +12 -0
- package/dist/gateway/middleware-setup.d.ts.map +1 -0
- package/dist/gateway/middleware-setup.js +156 -0
- package/dist/gateway/middleware-setup.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +187 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/attachment.d.ts +11 -0
- package/dist/middleware/attachment.d.ts.map +1 -0
- package/dist/middleware/attachment.js +17 -0
- package/dist/middleware/attachment.js.map +1 -0
- package/dist/middleware/media-history.d.ts +30 -0
- package/dist/middleware/media-history.d.ts.map +1 -0
- package/dist/middleware/media-history.js +54 -0
- package/dist/middleware/media-history.js.map +1 -0
- package/dist/middleware/sticker-capture.d.ts +17 -0
- package/dist/middleware/sticker-capture.d.ts.map +1 -0
- package/dist/middleware/sticker-capture.js +97 -0
- package/dist/middleware/sticker-capture.js.map +1 -0
- package/dist/model/index.d.ts +10 -0
- package/dist/model/index.d.ts.map +1 -0
- package/dist/model/index.js +9 -0
- package/dist/model/index.js.map +1 -0
- package/dist/model/model-resolver.d.ts +88 -0
- package/dist/model/model-resolver.d.ts.map +1 -0
- package/dist/model/model-resolver.js +151 -0
- package/dist/model/model-resolver.js.map +1 -0
- package/dist/model/prefs-store.d.ts +24 -0
- package/dist/model/prefs-store.d.ts.map +1 -0
- package/dist/model/prefs-store.js +126 -0
- package/dist/model/prefs-store.js.map +1 -0
- package/dist/model/settings-reader.d.ts +19 -0
- package/dist/model/settings-reader.d.ts.map +1 -0
- package/dist/model/settings-reader.js +76 -0
- package/dist/model/settings-reader.js.map +1 -0
- package/dist/model/types.d.ts +15 -0
- package/dist/model/types.d.ts.map +1 -0
- package/dist/model/types.js +5 -0
- package/dist/model/types.js.map +1 -0
- package/dist/session/idle-evictor.d.ts +18 -0
- package/dist/session/idle-evictor.d.ts.map +1 -0
- package/dist/session/idle-evictor.js +31 -0
- package/dist/session/idle-evictor.js.map +1 -0
- package/dist/session/index.d.ts +9 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +8 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/session-manager.d.ts +97 -0
- package/dist/session/session-manager.d.ts.map +1 -0
- package/dist/session/session-manager.js +624 -0
- package/dist/session/session-manager.js.map +1 -0
- package/dist/session/types.d.ts +130 -0
- package/dist/session/types.d.ts.map +1 -0
- package/dist/session/types.js +2 -0
- package/dist/session/types.js.map +1 -0
- package/dist/session/workspace-attach.d.ts +22 -0
- package/dist/session/workspace-attach.d.ts.map +1 -0
- package/dist/session/workspace-attach.js +21 -0
- package/dist/session/workspace-attach.js.map +1 -0
- package/dist/setup.d.ts +25 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +198 -0
- package/dist/setup.js.map +1 -0
- package/dist/shared/index.d.ts +7 -0
- package/dist/shared/index.d.ts.map +1 -0
- package/dist/shared/index.js +7 -0
- package/dist/shared/index.js.map +1 -0
- package/dist/shared/scope.d.ts +14 -0
- package/dist/shared/scope.d.ts.map +1 -0
- package/dist/shared/scope.js +12 -0
- package/dist/shared/scope.js.map +1 -0
- package/dist/shared/send-helper.d.ts +11 -0
- package/dist/shared/send-helper.d.ts.map +1 -0
- package/dist/shared/send-helper.js +13 -0
- package/dist/shared/send-helper.js.map +1 -0
- package/dist/shared/utils.d.ts +28 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/dist/shared/utils.js +143 -0
- package/dist/shared/utils.js.map +1 -0
- package/dist/transport/attachment.d.ts +18 -0
- package/dist/transport/attachment.d.ts.map +1 -0
- package/dist/transport/attachment.js +106 -0
- package/dist/transport/attachment.js.map +1 -0
- package/dist/transport/chunker.d.ts +14 -0
- package/dist/transport/chunker.d.ts.map +1 -0
- package/dist/transport/chunker.js +78 -0
- package/dist/transport/chunker.js.map +1 -0
- package/dist/transport/events.d.ts +80 -0
- package/dist/transport/events.d.ts.map +1 -0
- package/dist/transport/events.js +62 -0
- package/dist/transport/events.js.map +1 -0
- package/dist/transport/inbound.d.ts +8 -0
- package/dist/transport/inbound.d.ts.map +1 -0
- package/dist/transport/inbound.js +292 -0
- package/dist/transport/inbound.js.map +1 -0
- package/dist/transport/index.d.ts +10 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +10 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/inject-rules.d.ts +24 -0
- package/dist/transport/inject-rules.d.ts.map +1 -0
- package/dist/transport/inject-rules.js +72 -0
- package/dist/transport/inject-rules.js.map +1 -0
- package/dist/transport/outbound-buffer.d.ts +56 -0
- package/dist/transport/outbound-buffer.d.ts.map +1 -0
- package/dist/transport/outbound-buffer.js +131 -0
- package/dist/transport/outbound-buffer.js.map +1 -0
- package/dist/transport/outbound.d.ts +30 -0
- package/dist/transport/outbound.d.ts.map +1 -0
- package/dist/transport/outbound.js +139 -0
- package/dist/transport/outbound.js.map +1 -0
- package/dist/transport/rich-media.d.ts +41 -0
- package/dist/transport/rich-media.d.ts.map +1 -0
- package/dist/transport/rich-media.js +104 -0
- package/dist/transport/rich-media.js.map +1 -0
- package/dist/transport/streaming-writer.d.ts +47 -0
- package/dist/transport/streaming-writer.d.ts.map +1 -0
- package/dist/transport/streaming-writer.js +110 -0
- package/dist/transport/streaming-writer.js.map +1 -0
- package/dist/transport/tool-presenter.d.ts +38 -0
- package/dist/transport/tool-presenter.d.ts.map +1 -0
- package/dist/transport/tool-presenter.js +121 -0
- package/dist/transport/tool-presenter.js.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +106 -0
- package/settings-host.js +927 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler.ts — 定时唤醒(M3, 2026-09-03 新增, v3 改造)
|
|
3
|
+
*
|
|
4
|
+
* 对应设计文档 M3「定时+互动召回」。
|
|
5
|
+
*
|
|
6
|
+
* 机制(v3,主人指正后重构):
|
|
7
|
+
* - 配置按"目标分组":config.schedule.targets[] = {id,name?,scope(group|c2c),
|
|
8
|
+
* targetId, tasks:[{id,time "HH:MM",enabled,prompt?}]} —— 号码只填一次, 下面挂多条时刻。
|
|
9
|
+
* - 每 30s 检查;当前本地时刻 HH:MM 命中某 target 下某条 enabled 任务且今天没触发 → 触发。
|
|
10
|
+
* - **触发 = 伪造一条"定时任务发来"的入站消息,直接喂 handleInbound**——
|
|
11
|
+
* 与真实 QQ 消息走完全同一条链路:getOrCreate 用同 peerKey → 命中/resume 该聊天
|
|
12
|
+
* 的**原 dsh 会话**(有群历史记忆/人格/工具上下文),mention 标记 (@you);
|
|
13
|
+
* AI 的正常回复经既有 outbound 链路以 bot 身份主动推送到目标聊天。
|
|
14
|
+
* ✅ 不再另起游离对话、上下文连续、群/私聊都能看到。
|
|
15
|
+
* - 平台主动消息限制:群需开通"主动消息"权限(已开通, 有频控);私聊需近 48h
|
|
16
|
+
* 互动窗,窗外拒收 → bootstrap sendMarkdown 三级降级(markdown→text→wakeup)。
|
|
17
|
+
* - lastRun 落盘 {cwd}/schedule-state.json,触发瞬间乐观记账,每天至多一次;
|
|
18
|
+
* 错过的时刻不补发(等明天)。lastError 留痕最近失败。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 本地手改功能(fork 新增):维护清单见工作区根《插件改动维护注意事项.md》。
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { handleInbound } from '../transport/inbound.js';
|
|
25
|
+
/** 轮询间隔: 30s(分钟级精度足够, 不会跳整分钟) */
|
|
26
|
+
const TICK_MS = 30_000;
|
|
27
|
+
/** 本地 HH:MM */
|
|
28
|
+
function nowHHMM(d = new Date()) {
|
|
29
|
+
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
30
|
+
}
|
|
31
|
+
function todayKey(d = new Date()) {
|
|
32
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
33
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
34
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 启动定时调度器。返回 stop():停止轮询并落盘。
|
|
38
|
+
* config 为 live 对象引用(schedule.targets 现读, Web 设置热更新即时生效)。
|
|
39
|
+
*/
|
|
40
|
+
export function startScheduler(manager, config, logger) {
|
|
41
|
+
const stateFile = join(config.cwd || process.cwd(), 'schedule-state.json');
|
|
42
|
+
const state = { lastRun: {} };
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(stateFile, 'utf8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
state.lastRun = parsed?.lastRun ?? {};
|
|
47
|
+
state.lastError = parsed?.lastError;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
state.lastRun = {};
|
|
51
|
+
}
|
|
52
|
+
const inFlight = new Set();
|
|
53
|
+
function persist() {
|
|
54
|
+
try {
|
|
55
|
+
writeFileSync(stateFile, JSON.stringify(state, null, 1), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
logger.warn?.(`[scheduler] state 落盘失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** 触发 = 伪造一条"主人发来"的入站消息, 走与真实 QQ 消息完全相同的链路 */
|
|
62
|
+
async function fireTask(target, task) {
|
|
63
|
+
const scope = target.scope === 'c2c' ? 'c2c' : 'group';
|
|
64
|
+
const key = `${scope}:${target.targetId}`;
|
|
65
|
+
logger.info(`[scheduler] fire task=${task.id} (${target.name || target.id}) → ${key} @ ${task.time}`);
|
|
66
|
+
try {
|
|
67
|
+
// 伪造入站消息(与 SDK 消息同形; 无 messageId → 出站自动走主动推送)
|
|
68
|
+
// - 本质仍是"伪造一条消息唤醒 AI 回合"(定时任务必须触发回合), 但内容自报家门:
|
|
69
|
+
// 前缀带 [定时任务 + 系统日期时间], senderName 用中性名, 不打假 (@you) —— AI 干活照干,
|
|
70
|
+
// 但不会把定时触发记成"主人真人发言/被真人点名"(防污染主人交互记忆)。
|
|
71
|
+
// - group: peerId 取 groupOpenid(=target.targetId)
|
|
72
|
+
// - c2c: peerId 取 senderId —— 必须等于 target.targetId(该用户 openid), 才能命中原会话
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
75
|
+
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
76
|
+
const rawPrompt = task.prompt?.trim() || '在吗~ 出来说说话吧';
|
|
77
|
+
const trigger = `[定时任务 ${ts}] ${rawPrompt}`;
|
|
78
|
+
const fakeMsg = {
|
|
79
|
+
kind: scope,
|
|
80
|
+
senderId: scope === 'c2c' ? target.targetId : 'master',
|
|
81
|
+
senderName: '定时任务',
|
|
82
|
+
content: trigger,
|
|
83
|
+
messageId: '',
|
|
84
|
+
timestamp: now.toISOString(),
|
|
85
|
+
groupOpenid: scope === 'group' ? target.targetId : undefined,
|
|
86
|
+
msgType: 0,
|
|
87
|
+
attachments: undefined,
|
|
88
|
+
};
|
|
89
|
+
await handleInbound(fakeMsg, manager, config, logger, undefined);
|
|
90
|
+
logger.info(`[scheduler] injected inbound → handleInbound: task=${task.id} key=${key}`);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
94
|
+
state.lastError = { taskId: task.id, at: new Date().toISOString(), message: msg };
|
|
95
|
+
persist();
|
|
96
|
+
logger.error(`[scheduler] fire failed: task=${task.id} ${msg}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function tick() {
|
|
100
|
+
let targets = [];
|
|
101
|
+
try {
|
|
102
|
+
targets = config.schedule?.targets ?? [];
|
|
103
|
+
}
|
|
104
|
+
catch { /* config 未就绪 */ }
|
|
105
|
+
const tasksNow = [];
|
|
106
|
+
for (const target of targets) {
|
|
107
|
+
if (!target?.id || !target.targetId)
|
|
108
|
+
continue;
|
|
109
|
+
for (const task of target.tasks ?? []) {
|
|
110
|
+
if (!task?.id || !task.enabled)
|
|
111
|
+
continue;
|
|
112
|
+
tasksNow.push({ target, task });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (tasksNow.length === 0)
|
|
116
|
+
return;
|
|
117
|
+
const hhmm = nowHHMM();
|
|
118
|
+
const today = todayKey();
|
|
119
|
+
for (const { target, task } of tasksNow) {
|
|
120
|
+
if ((task.time ?? '') !== hhmm)
|
|
121
|
+
continue;
|
|
122
|
+
if (state.lastRun[task.id] === today)
|
|
123
|
+
continue; // 今天已触发
|
|
124
|
+
if (inFlight.has(task.id))
|
|
125
|
+
continue; // 同分钟并发防重
|
|
126
|
+
inFlight.add(task.id);
|
|
127
|
+
// 触发瞬间即记账(乐观) —— 宁可漏发也不重复打扰
|
|
128
|
+
state.lastRun[task.id] = today;
|
|
129
|
+
persist();
|
|
130
|
+
void fireTask(target, task).finally(() => inFlight.delete(task.id));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const timer = setInterval(tick, TICK_MS);
|
|
134
|
+
timer.unref?.();
|
|
135
|
+
// 启动后先跑一次: 若此刻正好命中某任务且今天没发过则触发;
|
|
136
|
+
// 错过的时刻不补发(准点触发, 避免新设任务被立即补发打扰), 等明天同一时刻。
|
|
137
|
+
tick();
|
|
138
|
+
const targetCount = (config.schedule?.targets ?? []).length;
|
|
139
|
+
logger.info(`[scheduler] started (${targetCount} targets, state=${stateFile})`);
|
|
140
|
+
return () => {
|
|
141
|
+
clearInterval(timer);
|
|
142
|
+
persist();
|
|
143
|
+
logger.info('[scheduler] stopped');
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scheduler.js","sourceRoot":"","sources":["../../src/features/scheduler.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { StickerGatesConfig } from '../config.js';
|
|
2
|
+
import type { Logger } from '../types.js';
|
|
3
|
+
import type { MiddlewareContext } from '@tencent-connect/qqbot-nodejs';
|
|
4
|
+
import type { MediaKind } from '../transport/rich-media.js';
|
|
5
|
+
/** 闸门拒绝信号: sender.sendMedia 抛此错; send_media 工具 catch 转 ok:false, [MEDIA] 通道记日志 */
|
|
6
|
+
export declare class StickerGateDenied extends Error {
|
|
7
|
+
constructor(reason: string);
|
|
8
|
+
}
|
|
9
|
+
export declare function isStickerGateDenied(err: unknown): err is StickerGateDenied;
|
|
10
|
+
/** 单条媒体发送的判定上下文 */
|
|
11
|
+
export interface MediaGateCtx {
|
|
12
|
+
/** group openid; 私聊(null/undefined)不闸 */
|
|
13
|
+
groupId?: string;
|
|
14
|
+
kind: MediaKind;
|
|
15
|
+
/** 本地绝对路径(已 resolve); URL 发送不算 sticker, 不闸 */
|
|
16
|
+
localPath?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface MediaGateVerdict {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
/** 该发送是否属于"库内表情包图"(即便 allowed, 也用于调用方决定是否记录流水) */
|
|
21
|
+
isSticker: boolean;
|
|
22
|
+
/** 拒绝原因(中文, 给工具回执/日志) */
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
/** 是否库内表情包路径: roots = [dataDir/lib] + gates.libRoots(追加前缀) */
|
|
26
|
+
export declare function isStickerPath(p: string | undefined, dataDir: string, libRoots?: string[]): boolean;
|
|
27
|
+
export declare function bindStickerGates(getter: () => StickerGatesConfig): void;
|
|
28
|
+
/**
|
|
29
|
+
* 闸门状态(跨重启持久: used 去重 / budget 日预算)。
|
|
30
|
+
* 窗口频率 ring / 活性 ring 为内存态(窗口短, 重启重计可接受, 与定稿一致)。
|
|
31
|
+
*/
|
|
32
|
+
declare class StickerGate {
|
|
33
|
+
readonly dataDir: string;
|
|
34
|
+
private readonly logger?;
|
|
35
|
+
/** 每群活性 RingBuffer: groupId -> 群消息时间戳(ms) */
|
|
36
|
+
private readonly activity;
|
|
37
|
+
/** 每群我方发送窗口 ring: groupId -> 放行表情包时间戳(ms) */
|
|
38
|
+
private readonly sentTimes;
|
|
39
|
+
/** 同图去重: 本地路径 -> 最近发送 ts(惰性按 dupTTL 清理) */
|
|
40
|
+
private used;
|
|
41
|
+
/** 日预算: groupId -> {YYYY-MM-DD: 张数} */
|
|
42
|
+
private budget;
|
|
43
|
+
private saveTimer;
|
|
44
|
+
private dirty;
|
|
45
|
+
constructor(dataDir: string, logger?: Logger | undefined);
|
|
46
|
+
private usedPath;
|
|
47
|
+
private budgetPath;
|
|
48
|
+
private sendlogPath;
|
|
49
|
+
private load;
|
|
50
|
+
/** 防抖落盘(发送低频, 1s 合并足够); 进程退出兜底走 flush() */
|
|
51
|
+
private save;
|
|
52
|
+
/** 立即落盘(退出兜底) */
|
|
53
|
+
flush(): void;
|
|
54
|
+
recordActivity(groupId: string, ts?: number): void;
|
|
55
|
+
/** 近 windowSec 秒内的群消息条数 */
|
|
56
|
+
countActivity(groupId: string, windowSec: number): number;
|
|
57
|
+
private countSentInWindow;
|
|
58
|
+
/**
|
|
59
|
+
* 单张媒体发送判定(配置现读)。规则顺序:
|
|
60
|
+
* 非库内 image(URL/非库路径/非图)→放行; 总开关关→放行; 私聊→放行;
|
|
61
|
+
* 禁发群 → 活性下限 → 窗口频率 → 日预算 → 同图去重。
|
|
62
|
+
* 放行后发送成功再调 noteSent 记录(发送失败不占预算)。
|
|
63
|
+
*/
|
|
64
|
+
checkMedia(ctx: MediaGateCtx): MediaGateVerdict;
|
|
65
|
+
/** 放行且发送成功后才调: 记发送窗口/日预算/同图去重 + 追加发送流水 */
|
|
66
|
+
noteSent(groupId: string | undefined, localPath: string | undefined): void;
|
|
67
|
+
/** 把媒体段原文 source 归一到本地绝对路径; URL 返回 undefined */
|
|
68
|
+
resolveLocalPath(rawSource: string, cwd: string | undefined): string | undefined;
|
|
69
|
+
/** 某媒体段是否属于库内表情包图(perTurn 计数用; 不发只判) */
|
|
70
|
+
isStickerMediaSegment(kind: MediaKind, rawSource: string, cwd: string | undefined): boolean;
|
|
71
|
+
}
|
|
72
|
+
/** 启动早期按 config.cwd 解析的 dataDir 预初始化(防目录分裂, 与 configureStickerStore 同款纪律) */
|
|
73
|
+
export declare function initStickerGate(dataDir: string, logger?: Logger): StickerGate;
|
|
74
|
+
/** primary 是否已初始化(未初始化时不应无参调用以免误建目录) */
|
|
75
|
+
export declare function hasStickerGate(): boolean;
|
|
76
|
+
/** 获取闸门实例。带 dataDir → 按目录取; 无参 → primary(主账号)。 */
|
|
77
|
+
export declare function getStickerGate(dataDir?: string, logger?: Logger): StickerGate;
|
|
78
|
+
/** 出站侧把待落盘状态写盘(进程退出兜底; 全部实例都 flush) */
|
|
79
|
+
export declare function flushStickerGate(): void;
|
|
80
|
+
/**
|
|
81
|
+
* sendRichOutbound 用: 组一个 perTurn(每轮张数)判定上下文。
|
|
82
|
+
* 未初始化闸门(如单测/无 bot 场景) → perTurnMax=0 不限制, isSticker 恒 false, 不误建目录。
|
|
83
|
+
*/
|
|
84
|
+
export declare function stickerPerTurnCtx(cwd?: string): {
|
|
85
|
+
perTurnMax: number;
|
|
86
|
+
isSticker(kind: MediaKind, rawSource: string): boolean;
|
|
87
|
+
};
|
|
88
|
+
export declare function stickerActivityRecorder(dataDir?: string): (ctx: MiddlewareContext, next: () => Promise<void>) => Promise<void>;
|
|
89
|
+
export {};
|
|
90
|
+
//# sourceMappingURL=sticker-gate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sticker-gate.d.ts","sourceRoot":"","sources":["../../src/features/sticker-gate.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sticker-gate.ts — 表情包主动发送闸门执行器(P1,2026-09-03 新增)
|
|
3
|
+
*
|
|
4
|
+
* 对应《表情包功能_设计v2_分模块定稿.md》模块C「触发信号与闸门(插件侧,出站收口)」。
|
|
5
|
+
* 把 Web 设置里 sticker.gates 那排开关变成真正的"刹车线":
|
|
6
|
+
* 总开关 enabled / 禁发群 / 活性下限(热闹才发) / 窗口频率 / 每群日预算 /
|
|
7
|
+
* 24h 同图去重 / 路径白名单(仅表情包库内图算 sticker)。
|
|
8
|
+
*
|
|
9
|
+
* 设计要点:
|
|
10
|
+
* - 配置现读: 单例绑定 live config getter(bindStickerGates), 每次判定现取,
|
|
11
|
+
* Web 设置热更新即时生效, 无需重启(与 behavior 冷却同款纪律)。
|
|
12
|
+
* - 判定咽喉: bootstrap 的 sender.sendMedia 一处调 checkMedia —— [MEDIA] 标记
|
|
13
|
+
* 与 send_media 工具两条通道都汇于此, 天然全覆盖。被拒抛 StickerGateDenied:
|
|
14
|
+
* 工具通道转 ok:false 给 LLM(不回执), [MEDIA] 通道记日志且不影响正文。
|
|
15
|
+
* - perTurnMax(每轮最多张数): "轮"只在整段回复文本(一次 sendRichOutbound)内存在,
|
|
16
|
+
* 由 sendRichOutbound 循环内计数实现; 工具通道单次调用天然=1张不受限。
|
|
17
|
+
* - 活性计数: 中间件 stickerActivityRecorder 挂在 mentionGate 之前, 未@消息也流经,
|
|
18
|
+
* 每群 RingBuffer<ts> 记群消息; 发送窗口频率用我方放行时间戳 ring(内存)。
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ 本地手改功能(fork 新增):维护清单见工作区根《插件改动维护注意事项.md》。
|
|
21
|
+
* 运行数据与 sticker-store 共用 dataDir(默认 {agent cwd}/表情包):
|
|
22
|
+
* state/used.json(同图去重,重启不丢) state/budget.json(日预算,重启不丢)
|
|
23
|
+
* log/sendlog.jsonl(发送流水,append-only)
|
|
24
|
+
* 单例必须在启动早期按 config.cwd 解析的 dataDir 预初始化(防 C 盘幽灵库同款坑)。
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync, appendFileSync } from 'node:fs';
|
|
27
|
+
import { join, resolve, sep } from 'node:path';
|
|
28
|
+
/** 活性 RingBuffer 单群保留上限(超限裁剪, 防群轰炸撑爆内存) */
|
|
29
|
+
const ACTIVITY_RING_MAX = 2000;
|
|
30
|
+
/** 我方发送窗口 ring 单群上限 */
|
|
31
|
+
const SENT_RING_MAX = 500;
|
|
32
|
+
/** 未绑定配置时的回落(全部默认=不限制) */
|
|
33
|
+
const FALLBACK_GATES = {
|
|
34
|
+
enabled: false,
|
|
35
|
+
perTurnMax: 0,
|
|
36
|
+
perWindowSec: 600,
|
|
37
|
+
maxPerWindow: 0,
|
|
38
|
+
dailyBudgetPerGroup: 0,
|
|
39
|
+
dupTTLHours: 0,
|
|
40
|
+
activityWindowSec: 3600,
|
|
41
|
+
activityMinMsgs: 0,
|
|
42
|
+
bannedGroups: [],
|
|
43
|
+
libRoots: [],
|
|
44
|
+
};
|
|
45
|
+
/** 闸门拒绝信号: sender.sendMedia 抛此错; send_media 工具 catch 转 ok:false, [MEDIA] 通道记日志 */
|
|
46
|
+
export class StickerGateDenied extends Error {
|
|
47
|
+
constructor(reason) {
|
|
48
|
+
super(`[sticker-gate] ${reason}`);
|
|
49
|
+
this.name = 'StickerGateDenied';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function isStickerGateDenied(err) {
|
|
53
|
+
return err instanceof StickerGateDenied;
|
|
54
|
+
}
|
|
55
|
+
/** 判断路径是否位于 root 目录内(Windows 大小写不敏感) */
|
|
56
|
+
function isChildOf(p, root) {
|
|
57
|
+
const pp = resolve(p).toLowerCase();
|
|
58
|
+
const rr = resolve(root).toLowerCase();
|
|
59
|
+
return pp === rr || pp.startsWith(rr.endsWith(sep) ? rr : rr + sep);
|
|
60
|
+
}
|
|
61
|
+
/** 是否库内表情包路径: roots = [dataDir/lib] + gates.libRoots(追加前缀) */
|
|
62
|
+
export function isStickerPath(p, dataDir, libRoots) {
|
|
63
|
+
if (!p)
|
|
64
|
+
return false;
|
|
65
|
+
const roots = [join(dataDir, 'lib')];
|
|
66
|
+
for (const r of libRoots ?? []) {
|
|
67
|
+
if (r.trim())
|
|
68
|
+
roots.push(r.trim());
|
|
69
|
+
}
|
|
70
|
+
return roots.some((r) => isChildOf(p, r));
|
|
71
|
+
}
|
|
72
|
+
/** 今日日期键(本地时区 YYYY-MM-DD) */
|
|
73
|
+
function todayKey(now = Date.now()) {
|
|
74
|
+
const d = new Date(now);
|
|
75
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
76
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
77
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
78
|
+
}
|
|
79
|
+
/** 当前 live gates(getter 由 bootstrap 绑定到 config.sticker.gates, 每次现读) */
|
|
80
|
+
let _gatesGetter = null;
|
|
81
|
+
export function bindStickerGates(getter) {
|
|
82
|
+
_gatesGetter = getter;
|
|
83
|
+
}
|
|
84
|
+
function liveGates() {
|
|
85
|
+
try {
|
|
86
|
+
return _gatesGetter?.() ?? FALLBACK_GATES;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return FALLBACK_GATES;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 闸门状态(跨重启持久: used 去重 / budget 日预算)。
|
|
94
|
+
* 窗口频率 ring / 活性 ring 为内存态(窗口短, 重启重计可接受, 与定稿一致)。
|
|
95
|
+
*/
|
|
96
|
+
class StickerGate {
|
|
97
|
+
dataDir;
|
|
98
|
+
logger;
|
|
99
|
+
/** 每群活性 RingBuffer: groupId -> 群消息时间戳(ms) */
|
|
100
|
+
activity = new Map();
|
|
101
|
+
/** 每群我方发送窗口 ring: groupId -> 放行表情包时间戳(ms) */
|
|
102
|
+
sentTimes = new Map();
|
|
103
|
+
/** 同图去重: 本地路径 -> 最近发送 ts(惰性按 dupTTL 清理) */
|
|
104
|
+
used = new Map();
|
|
105
|
+
/** 日预算: groupId -> {YYYY-MM-DD: 张数} */
|
|
106
|
+
budget = new Map();
|
|
107
|
+
saveTimer = null;
|
|
108
|
+
dirty = false;
|
|
109
|
+
constructor(dataDir, logger) {
|
|
110
|
+
this.dataDir = dataDir;
|
|
111
|
+
this.logger = logger;
|
|
112
|
+
mkdirSync(join(dataDir, 'state'), { recursive: true });
|
|
113
|
+
mkdirSync(join(dataDir, 'log'), { recursive: true });
|
|
114
|
+
this.load();
|
|
115
|
+
}
|
|
116
|
+
// ── 落盘 ──
|
|
117
|
+
usedPath() {
|
|
118
|
+
return join(this.dataDir, 'state', 'used.json');
|
|
119
|
+
}
|
|
120
|
+
budgetPath() {
|
|
121
|
+
return join(this.dataDir, 'state', 'budget.json');
|
|
122
|
+
}
|
|
123
|
+
sendlogPath() {
|
|
124
|
+
return join(this.dataDir, 'log', 'sendlog.jsonl');
|
|
125
|
+
}
|
|
126
|
+
load() {
|
|
127
|
+
try {
|
|
128
|
+
const raw = readFileSync(this.usedPath(), 'utf8');
|
|
129
|
+
this.used = new Map(Object.entries(JSON.parse(raw)));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
this.used = new Map();
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const raw = readFileSync(this.budgetPath(), 'utf8');
|
|
136
|
+
const obj = JSON.parse(raw);
|
|
137
|
+
this.budget = new Map(Object.entries(obj).map(([g, days]) => [g, new Map(Object.entries(days))]));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
this.budget = new Map();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** 防抖落盘(发送低频, 1s 合并足够); 进程退出兜底走 flush() */
|
|
144
|
+
save() {
|
|
145
|
+
this.dirty = true;
|
|
146
|
+
if (this.saveTimer)
|
|
147
|
+
return;
|
|
148
|
+
this.saveTimer = setTimeout(() => {
|
|
149
|
+
this.saveTimer = null;
|
|
150
|
+
this.flush();
|
|
151
|
+
}, 1000);
|
|
152
|
+
this.saveTimer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
/** 立即落盘(退出兜底) */
|
|
155
|
+
flush() {
|
|
156
|
+
if (!this.dirty)
|
|
157
|
+
return;
|
|
158
|
+
this.dirty = false;
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(this.usedPath(), JSON.stringify(Object.fromEntries(this.used), null, 1), 'utf8');
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
this.logger?.warn?.(`[sticker-gate] used.json 写盘失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
const obj = {};
|
|
167
|
+
for (const [g, days] of this.budget)
|
|
168
|
+
obj[g] = Object.fromEntries(days);
|
|
169
|
+
writeFileSync(this.budgetPath(), JSON.stringify(obj, null, 1), 'utf8');
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
this.logger?.warn?.(`[sticker-gate] budget.json 写盘失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ── 活性(入站群消息计数; 中间件在 mentionGate 之前喂) ──
|
|
176
|
+
recordActivity(groupId, ts = Date.now()) {
|
|
177
|
+
let arr = this.activity.get(groupId);
|
|
178
|
+
if (!arr) {
|
|
179
|
+
arr = [];
|
|
180
|
+
this.activity.set(groupId, arr);
|
|
181
|
+
}
|
|
182
|
+
arr.push(ts);
|
|
183
|
+
// 裁剪: 只留最近 2h 的, 超上限截尾(防群轰炸撑爆)
|
|
184
|
+
const cutoff = ts - 2 * 3600 * 1000;
|
|
185
|
+
let i = 0;
|
|
186
|
+
while (i < arr.length && (arr[i] ?? 0) < cutoff)
|
|
187
|
+
i += 1;
|
|
188
|
+
if (i > 0)
|
|
189
|
+
arr.splice(0, i);
|
|
190
|
+
if (arr.length > ACTIVITY_RING_MAX)
|
|
191
|
+
arr.splice(0, arr.length - ACTIVITY_RING_MAX);
|
|
192
|
+
}
|
|
193
|
+
/** 近 windowSec 秒内的群消息条数 */
|
|
194
|
+
countActivity(groupId, windowSec) {
|
|
195
|
+
const arr = this.activity.get(groupId);
|
|
196
|
+
if (!arr?.length)
|
|
197
|
+
return 0;
|
|
198
|
+
const cutoff = Date.now() - windowSec * 1000;
|
|
199
|
+
let n = 0;
|
|
200
|
+
for (const ts of arr)
|
|
201
|
+
if (ts >= cutoff)
|
|
202
|
+
n += 1;
|
|
203
|
+
return n;
|
|
204
|
+
}
|
|
205
|
+
// ── 我方发送窗口频率(内存) ──
|
|
206
|
+
countSentInWindow(groupId, windowSec) {
|
|
207
|
+
const arr = this.sentTimes.get(groupId);
|
|
208
|
+
if (!arr?.length)
|
|
209
|
+
return 0;
|
|
210
|
+
const cutoff = Date.now() - windowSec * 1000;
|
|
211
|
+
return arr.filter((ts) => ts >= cutoff).length;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 单张媒体发送判定(配置现读)。规则顺序:
|
|
215
|
+
* 非库内 image(URL/非库路径/非图)→放行; 总开关关→放行; 私聊→放行;
|
|
216
|
+
* 禁发群 → 活性下限 → 窗口频率 → 日预算 → 同图去重。
|
|
217
|
+
* 放行后发送成功再调 noteSent 记录(发送失败不占预算)。
|
|
218
|
+
*/
|
|
219
|
+
checkMedia(ctx) {
|
|
220
|
+
const gates = liveGates();
|
|
221
|
+
const isSticker = ctx.kind === 'image'
|
|
222
|
+
&& !!ctx.localPath
|
|
223
|
+
&& isStickerPath(ctx.localPath, this.dataDir, gates.libRoots);
|
|
224
|
+
if (!isSticker)
|
|
225
|
+
return { allowed: true, isSticker: false };
|
|
226
|
+
if (gates.enabled !== true)
|
|
227
|
+
return { allowed: true, isSticker: true };
|
|
228
|
+
if (!ctx.groupId)
|
|
229
|
+
return { allowed: true, isSticker: true };
|
|
230
|
+
if ((gates.bannedGroups ?? []).includes(ctx.groupId)) {
|
|
231
|
+
return { allowed: false, isSticker: true, reason: '此群已列入表情包禁发名单' };
|
|
232
|
+
}
|
|
233
|
+
if ((gates.activityMinMsgs ?? 0) > 0) {
|
|
234
|
+
const win = (gates.activityWindowSec ?? 3600) > 0 ? gates.activityWindowSec : 3600;
|
|
235
|
+
const n = this.countActivity(ctx.groupId, win);
|
|
236
|
+
if (n < gates.activityMinMsgs) {
|
|
237
|
+
return { allowed: false, isSticker: true, reason: `群活跃度不足(近${Math.round(win / 60)}分钟仅${n}条, 需≥${gates.activityMinMsgs}条才许主动发)` };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if ((gates.maxPerWindow ?? 0) > 0 && (gates.perWindowSec ?? 0) > 0) {
|
|
241
|
+
const n = this.countSentInWindow(ctx.groupId, gates.perWindowSec);
|
|
242
|
+
if (n >= gates.maxPerWindow) {
|
|
243
|
+
return { allowed: false, isSticker: true, reason: `发送频率超限(每${Math.round(gates.perWindowSec / 60)}分钟≤${gates.maxPerWindow}张, 窗口内已${n}张)` };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if ((gates.dailyBudgetPerGroup ?? 0) > 0) {
|
|
247
|
+
const days = this.budget.get(ctx.groupId);
|
|
248
|
+
const usedToday = days?.get(todayKey()) ?? 0;
|
|
249
|
+
if (usedToday >= gates.dailyBudgetPerGroup) {
|
|
250
|
+
return { allowed: false, isSticker: true, reason: `今日此群预算已用完(${usedToday}/${gates.dailyBudgetPerGroup}张)` };
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if ((gates.dupTTLHours ?? 0) > 0) {
|
|
254
|
+
const last = this.used.get(ctx.localPath);
|
|
255
|
+
if (last && Date.now() - last < gates.dupTTLHours * 3600 * 1000) {
|
|
256
|
+
return { allowed: false, isSticker: true, reason: `同图去重(${gates.dupTTLHours}h 内已发过这张)` };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { allowed: true, isSticker: true };
|
|
260
|
+
}
|
|
261
|
+
/** 放行且发送成功后才调: 记发送窗口/日预算/同图去重 + 追加发送流水 */
|
|
262
|
+
noteSent(groupId, localPath) {
|
|
263
|
+
if (!groupId || !localPath)
|
|
264
|
+
return;
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
let arr = this.sentTimes.get(groupId);
|
|
267
|
+
if (!arr) {
|
|
268
|
+
arr = [];
|
|
269
|
+
this.sentTimes.set(groupId, arr);
|
|
270
|
+
}
|
|
271
|
+
arr.push(now);
|
|
272
|
+
if (arr.length > SENT_RING_MAX)
|
|
273
|
+
arr.splice(0, arr.length - SENT_RING_MAX);
|
|
274
|
+
let days = this.budget.get(groupId);
|
|
275
|
+
if (!days) {
|
|
276
|
+
days = new Map();
|
|
277
|
+
this.budget.set(groupId, days);
|
|
278
|
+
}
|
|
279
|
+
const tk = todayKey(now);
|
|
280
|
+
days.set(tk, (days.get(tk) ?? 0) + 1);
|
|
281
|
+
this.used.set(localPath, now);
|
|
282
|
+
this.save();
|
|
283
|
+
try {
|
|
284
|
+
appendFileSync(this.sendlogPath(), JSON.stringify({ ts: now, group: groupId, path: localPath, day: tk }) + '\n', 'utf8');
|
|
285
|
+
}
|
|
286
|
+
catch { /* 流水失败不影响 */ }
|
|
287
|
+
}
|
|
288
|
+
// ── 每轮计数辅助(供 sendRichOutbound 判断该段是否算"本轮的库内表情包图") ──
|
|
289
|
+
/** 把媒体段原文 source 归一到本地绝对路径; URL 返回 undefined */
|
|
290
|
+
resolveLocalPath(rawSource, cwd) {
|
|
291
|
+
const s = (rawSource ?? '').trim();
|
|
292
|
+
if (!s || /^https?:\/\//i.test(s))
|
|
293
|
+
return undefined;
|
|
294
|
+
if (s.startsWith('file://'))
|
|
295
|
+
return s.replace(/^file:\/\//, '');
|
|
296
|
+
if (/^[a-zA-Z]:[\\/]/.test(s) || s.startsWith('/'))
|
|
297
|
+
return s;
|
|
298
|
+
return cwd ? join(cwd, s) : s;
|
|
299
|
+
}
|
|
300
|
+
/** 某媒体段是否属于库内表情包图(perTurn 计数用; 不发只判) */
|
|
301
|
+
isStickerMediaSegment(kind, rawSource, cwd) {
|
|
302
|
+
if (kind !== 'image')
|
|
303
|
+
return false;
|
|
304
|
+
const p = this.resolveLocalPath(rawSource, cwd);
|
|
305
|
+
if (!p)
|
|
306
|
+
return false;
|
|
307
|
+
return isStickerPath(p, this.dataDir, liveGates().libRoots);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// ── 多例注册表(多账号支持, 2026-09-03): 每账号实例一个 dataDir → 各自闸门状态/预算/日志。
|
|
311
|
+
const _gates = new Map();
|
|
312
|
+
let _primaryGateDir;
|
|
313
|
+
/** 启动早期按 config.cwd 解析的 dataDir 预初始化(防目录分裂, 与 configureStickerStore 同款纪律) */
|
|
314
|
+
export function initStickerGate(dataDir, logger) {
|
|
315
|
+
const key = resolve(dataDir);
|
|
316
|
+
let g = _gates.get(key);
|
|
317
|
+
if (!g) {
|
|
318
|
+
g = new StickerGate(dataDir, logger);
|
|
319
|
+
_gates.set(key, g);
|
|
320
|
+
}
|
|
321
|
+
if (!_primaryGateDir)
|
|
322
|
+
_primaryGateDir = key;
|
|
323
|
+
return g;
|
|
324
|
+
}
|
|
325
|
+
/** primary 是否已初始化(未初始化时不应无参调用以免误建目录) */
|
|
326
|
+
export function hasStickerGate() {
|
|
327
|
+
return _primaryGateDir !== undefined && _gates.has(_primaryGateDir);
|
|
328
|
+
}
|
|
329
|
+
/** 获取闸门实例。带 dataDir → 按目录取; 无参 → primary(主账号)。 */
|
|
330
|
+
export function getStickerGate(dataDir, logger) {
|
|
331
|
+
if (dataDir) {
|
|
332
|
+
const key = resolve(dataDir);
|
|
333
|
+
let g = _gates.get(key);
|
|
334
|
+
if (!g) {
|
|
335
|
+
g = new StickerGate(dataDir, logger);
|
|
336
|
+
_gates.set(key, g);
|
|
337
|
+
}
|
|
338
|
+
if (!_primaryGateDir)
|
|
339
|
+
_primaryGateDir = key;
|
|
340
|
+
return g;
|
|
341
|
+
}
|
|
342
|
+
if (_primaryGateDir) {
|
|
343
|
+
const g = _gates.get(_primaryGateDir);
|
|
344
|
+
if (g)
|
|
345
|
+
return g;
|
|
346
|
+
}
|
|
347
|
+
if (_gates.size === 1)
|
|
348
|
+
return _gates.values().next().value;
|
|
349
|
+
const dir = join(process.cwd(), '表情包');
|
|
350
|
+
const g = new StickerGate(dir, logger);
|
|
351
|
+
_gates.set(resolve(dir), g);
|
|
352
|
+
if (!_primaryGateDir)
|
|
353
|
+
_primaryGateDir = resolve(dir);
|
|
354
|
+
return g;
|
|
355
|
+
}
|
|
356
|
+
/** 出站侧把待落盘状态写盘(进程退出兜底; 全部实例都 flush) */
|
|
357
|
+
export function flushStickerGate() {
|
|
358
|
+
for (const g of _gates.values()) {
|
|
359
|
+
try {
|
|
360
|
+
g.flush();
|
|
361
|
+
}
|
|
362
|
+
catch { /* ignore */ }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* sendRichOutbound 用: 组一个 perTurn(每轮张数)判定上下文。
|
|
367
|
+
* 未初始化闸门(如单测/无 bot 场景) → perTurnMax=0 不限制, isSticker 恒 false, 不误建目录。
|
|
368
|
+
*/
|
|
369
|
+
export function stickerPerTurnCtx(cwd) {
|
|
370
|
+
if (!hasStickerGate())
|
|
371
|
+
return { perTurnMax: 0, isSticker: () => false };
|
|
372
|
+
const gate = getStickerGate();
|
|
373
|
+
return {
|
|
374
|
+
perTurnMax: liveGates().perTurnMax ?? 0,
|
|
375
|
+
isSticker: (kind, raw) => gate.isStickerMediaSegment(kind, raw, cwd),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// ── 中间件: 群消息活性记录(必须在 mentionGate 之前, 未@消息也流经; 由 setup 挂接) ──
|
|
379
|
+
// dataDir: 每账号实例传自己目录; 缺省走 primary(兼容旧调用)
|
|
380
|
+
export function stickerActivityRecorder(dataDir) {
|
|
381
|
+
return async (ctx, next) => {
|
|
382
|
+
if (ctx.message.kind === 'group' && ctx.message.groupOpenid) {
|
|
383
|
+
try {
|
|
384
|
+
getStickerGate(dataDir).recordActivity(ctx.message.groupOpenid);
|
|
385
|
+
}
|
|
386
|
+
catch { /* 记录失败不影响主链 */ }
|
|
387
|
+
}
|
|
388
|
+
await next();
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
//# sourceMappingURL=sticker-gate.js.map
|