@zaofan/dsh-qqbot 1.8.6 → 1.9.2
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/README.md +114 -27
- package/README_EN.md +122 -8
- package/dist/features/ext-capabilities.d.ts +85 -0
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js +4 -0
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-hooks.d.ts +84 -0
- package/dist/features/ext-hooks.d.ts.map +1 -0
- package/dist/features/ext-hooks.js +299 -0
- package/dist/features/ext-hooks.js.map +1 -0
- package/dist/features/ext-inbound-context.d.ts +202 -0
- package/dist/features/ext-inbound-context.d.ts.map +1 -0
- package/dist/features/ext-inbound-context.js +724 -0
- package/dist/features/ext-inbound-context.js.map +1 -0
- package/dist/features/ext-inbound-state.d.ts +153 -0
- package/dist/features/ext-inbound-state.d.ts.map +1 -0
- package/dist/features/ext-inbound-state.js +419 -0
- package/dist/features/ext-inbound-state.js.map +1 -0
- package/dist/features/ext-inbound.d.ts +117 -0
- package/dist/features/ext-inbound.d.ts.map +1 -0
- package/dist/features/ext-inbound.js +518 -0
- package/dist/features/ext-inbound.js.map +1 -0
- package/dist/features/ext-reload.d.ts +22 -0
- package/dist/features/ext-reload.d.ts.map +1 -1
- package/dist/features/ext-reload.js +32 -0
- package/dist/features/ext-reload.js.map +1 -1
- package/dist/features/extension-store.d.ts +13 -0
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +76 -18
- package/dist/features/extension-store.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts +14 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +61 -2
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts +43 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +71 -35
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +88 -1
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -9
- package/dist/index.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +7 -3
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/surface-guard.d.ts +1 -1
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/utils.d.ts +40 -0
- package/dist/shared/utils.d.ts.map +1 -1
- package/dist/shared/utils.js +59 -2
- package/dist/shared/utils.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +23 -3
- package/dist/transport/inbound.js.map +1 -1
- package/docs/USER-GUIDE.md +93 -36
- package/docs/botplay-contract.md +2 -0
- package/examples/ext-hooks/README.md +95 -0
- package/examples/ext-hooks/hello-hook.mjs +54 -0
- package/examples/ext-hooks/session-watch-hook.mjs +119 -0
- package/examples/ext-inbound/README.md +230 -0
- package/examples/ext-inbound/class-advisor.mjs +306 -0
- package/examples/ext-inbound/no-wake-on-child-quote.mjs +192 -0
- package/examples/ext-tools/README.md +22 -0
- package/examples/ext-tools/lib/preset_resolve.mjs +85 -0
- package/examples/ext-tools/lib/session_watch_lib.mjs +350 -0
- package/examples/ext-tools/session_create.mjs +201 -0
- package/examples/ext-tools/session_send.mjs +141 -0
- package/examples/ext-tools/session_watch.mjs +93 -0
- package/examples/ext-tools/sessions_list.mjs +180 -0
- package/package.json +141 -141
- package/settings-host.js +22 -5
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { lastBotReplyOf } from './bot-reply-memo.js';
|
|
3
|
+
// ★ 统一能力包(2026-10-12 主人定「能力面对齐」):入站扩展与另外四套共用同一份能力实现,
|
|
4
|
+
// 见 `decorate()` 里那段「★★ 能力面对齐 ★★」;别再给入站侧另造一套平行 API。
|
|
5
|
+
import { makeExtCaps } from './ext-capabilities.js';
|
|
6
|
+
import { replaceBotMention } from '../shared/mention-clean.js';
|
|
7
|
+
import { appendAudit, checkDestructiveAllowed, isMuted as stateIsMuted, mutedUsers as stateMutedUsers, muteUser as stateMuteUser, noteDestructive, readExtState, unmuteUser as stateUnmuteUser, writeExtState, } from './ext-inbound-state.js';
|
|
8
|
+
const SESSIONS = new WeakMap();
|
|
9
|
+
/** 取(必要时建)某个 ctx 的扩展会话,并把动作 API 挂上去(幂等) */
|
|
10
|
+
export function ensureExtSession(ctx, env) {
|
|
11
|
+
const key = ctx;
|
|
12
|
+
let s = SESSIONS.get(key);
|
|
13
|
+
if (!s) {
|
|
14
|
+
s = {
|
|
15
|
+
env,
|
|
16
|
+
phase: 'inbound',
|
|
17
|
+
rewriteCount: 0,
|
|
18
|
+
destructiveCount: 0,
|
|
19
|
+
noted: new Set(),
|
|
20
|
+
warned: new Set(),
|
|
21
|
+
};
|
|
22
|
+
SESSIONS.set(key, s);
|
|
23
|
+
decorate(ctx, s);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
// 同一个 ctx 可能先后经过两个挂点:只补缺的环境字段(appId / resolveAgent 可能后到)
|
|
27
|
+
if (!s.env.dataRoot && env.dataRoot)
|
|
28
|
+
s.env.dataRoot = env.dataRoot;
|
|
29
|
+
if (env.appId && !s.env.appId)
|
|
30
|
+
s.env.appId = env.appId;
|
|
31
|
+
if (!s.env.owners.length && env.owners.length)
|
|
32
|
+
s.env.owners = env.owners;
|
|
33
|
+
if (env.resolveAgent && !s.env.resolveAgent)
|
|
34
|
+
s.env.resolveAgent = env.resolveAgent;
|
|
35
|
+
}
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
/** 只读会话(没挂过则 undefined) */
|
|
39
|
+
export function sessionOf(ctx) {
|
|
40
|
+
return SESSIONS.get(ctx);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* 进入某个扩展的执行:设置归属 + 挂点,返回会话。
|
|
44
|
+
* 调用方(运行器)负责在用完之后吞掉异常 —— 本模块的动作自己不会抛。
|
|
45
|
+
*/
|
|
46
|
+
export function enterExtension(ctx, env, ext, phase) {
|
|
47
|
+
const s = ensureExtSession(ctx, env);
|
|
48
|
+
s.current = { file: ext.file, name: ext.name };
|
|
49
|
+
s.phase = phase;
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
// ══════════════════════════════════════════════════════════════
|
|
53
|
+
// 小工具:告警 / 审计 / 字段读取
|
|
54
|
+
// ══════════════════════════════════════════════════════════════
|
|
55
|
+
/** 扩展展示名(日志/审计署名):没有 current 时兜一个匿名 */
|
|
56
|
+
function who(s) {
|
|
57
|
+
return s.current?.name || s.current?.file || '(匿名扩展)';
|
|
58
|
+
}
|
|
59
|
+
/** 告警(带扩展名;日志接口缺 warn 时静默) */
|
|
60
|
+
function warn(s, msg) {
|
|
61
|
+
try {
|
|
62
|
+
s.env.logger?.warn?.(`[ext-inbound:${who(s)}] ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
/** 同一句提示只告警一次(群消息多,别刷屏;改文件热重载后自然重置) */
|
|
67
|
+
function warnOnce(s, key, msg) {
|
|
68
|
+
if (s.warned.has(key))
|
|
69
|
+
return;
|
|
70
|
+
s.warned.add(key);
|
|
71
|
+
warn(s, msg);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 数据根可用性检查。
|
|
75
|
+
* ⚠️ dataRoot 为空时,**名单 / kv / 审计一律降级为空操作** —— 否则 `join('', '.qqbot-extensions', …)`
|
|
76
|
+
* 会按相对路径在当前工作目录里造目录写文件(测试或嵌入方没传 dataRoot 时就会发生)。
|
|
77
|
+
*/
|
|
78
|
+
function rootOk(s) {
|
|
79
|
+
if (s.env.dataRoot)
|
|
80
|
+
return true;
|
|
81
|
+
warnOnce(s, 'no-dataroot', '运行环境没给 dataRoot → 禁言名单 / kv / 审计本次全部降级为空操作(不写盘)');
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
/** 写审计(署名当前扩展;**永不抛出**) */
|
|
85
|
+
function audit(s, entry) {
|
|
86
|
+
try {
|
|
87
|
+
if (!rootOk(s))
|
|
88
|
+
return;
|
|
89
|
+
appendAudit(s.env.dataRoot, { file: s.current?.file, name: s.current?.name, ...entry });
|
|
90
|
+
}
|
|
91
|
+
catch { /* ignore */ }
|
|
92
|
+
}
|
|
93
|
+
/** 当前发送者 openid */
|
|
94
|
+
function senderOf(ctx) {
|
|
95
|
+
try {
|
|
96
|
+
return String(ctx.message?.senderId ?? '');
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** 当前消息文本 */
|
|
103
|
+
function textOf(ctx) {
|
|
104
|
+
try {
|
|
105
|
+
return String(ctx.message?.content ?? '');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** 是否主人(白名单判定:与 botplay/ext-caps 同口径 —— config.groupAdmin.owners 里有没有这个 openid) */
|
|
112
|
+
function ownerOf(s, id) {
|
|
113
|
+
return id !== '' && s.env.owners.includes(id);
|
|
114
|
+
}
|
|
115
|
+
/** 与 SDK mentionGate 同款三选一判据(本扩展点在门控之前,读不到 ctx.state.mention,只能自己判) */
|
|
116
|
+
function platformMentioned(ctx, appId) {
|
|
117
|
+
try {
|
|
118
|
+
const m = ctx.message;
|
|
119
|
+
if (String(m?.rawEventType ?? '') === 'GROUP_AT_MESSAGE_CREATE')
|
|
120
|
+
return true;
|
|
121
|
+
if (Array.isArray(m?.mentions) && m.mentions.some((x) => x?.is_you === true))
|
|
122
|
+
return true;
|
|
123
|
+
return detectMentionInContent(String(m?.content ?? ''), appId);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** 内容里是否含 `<@{appId}>` / `<@!{appId}>`(与 media-history / SDK mention-gate 同一份判据) */
|
|
130
|
+
function detectMentionInContent(content, appId) {
|
|
131
|
+
const id = String(appId ?? '');
|
|
132
|
+
if (!content || !id)
|
|
133
|
+
return false;
|
|
134
|
+
const safe = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
135
|
+
return content.includes(`<@${safe}>`) || content.includes(`<@!${safe}>`);
|
|
136
|
+
}
|
|
137
|
+
/** 可调用 + 带属性的对象(契约里 `ctx.quoted({text,fromSelf})` 两种读法都支持) */
|
|
138
|
+
function callableWith(make) {
|
|
139
|
+
const f = (() => make());
|
|
140
|
+
try {
|
|
141
|
+
Object.assign(f, make());
|
|
142
|
+
}
|
|
143
|
+
catch { /* ignore */ }
|
|
144
|
+
return f;
|
|
145
|
+
}
|
|
146
|
+
/** 被引用的原文(`ctx.state.quote` 是 SDK quoteRef 赋的,别自己解析引用) */
|
|
147
|
+
function quotedOf(ctx) {
|
|
148
|
+
try {
|
|
149
|
+
const q = ctx.state?.quote;
|
|
150
|
+
const text = String(q?.text ?? '');
|
|
151
|
+
// 被引那条消息的发送者 openid(SDK quoteRef 在 store 命中时给的 entry.senderId)。
|
|
152
|
+
// 用途:辅导员类扩展"回复某人再下令 → 对**被回复的那个人**动手"。
|
|
153
|
+
// ⚠️ 拿不到(引用的是没见过的消息 / 走 msg_elements 解析)时是空串,自己判空。
|
|
154
|
+
const id = String(q?.entry?.senderId ?? '');
|
|
155
|
+
if (!text)
|
|
156
|
+
return { text: '', fromSelf: false, id };
|
|
157
|
+
// fromSelf 是**尽力而为**的启发式:插件没记住 bot 自己的 openid,
|
|
158
|
+
// 但 transport/outbound-buffer 会把"她刚说的那句"记进 bot-reply-memo(10 分钟内有效),
|
|
159
|
+
// 两者文本对得上(含被平台截断的前缀情形)就认为"引的是她自己那条"。
|
|
160
|
+
const key = ctx.message.kind === 'group'
|
|
161
|
+
? String(ctx.message.groupOpenid ?? '')
|
|
162
|
+
: String(ctx.message.senderId ?? '');
|
|
163
|
+
const last = lastBotReplyOf(key);
|
|
164
|
+
if (!last)
|
|
165
|
+
return { text, fromSelf: false, id };
|
|
166
|
+
const a = last.text.trim();
|
|
167
|
+
const b = text.trim();
|
|
168
|
+
const hit = a !== '' && b !== '' && (a === b || b.startsWith(a) || a.startsWith(b));
|
|
169
|
+
return { text, fromSelf: hit, id };
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { text: '', fromSelf: false, id: '' };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ══════════════════════════════════════════════════════════════
|
|
176
|
+
// 动作实现
|
|
177
|
+
// ══════════════════════════════════════════════════════════════
|
|
178
|
+
/** 消灭:只置标记 —— 真正"不往下走"由运行器的 next 包装执行(置位后再调 next() 也无效) */
|
|
179
|
+
function annihilate(ctx, s, opts) {
|
|
180
|
+
try {
|
|
181
|
+
const reason = typeof opts?.reason === 'string' ? opts.reason : undefined;
|
|
182
|
+
// ① 挂点检查:晚挂点已经记过群历史了,"消灭=不进历史"在那一步做不到
|
|
183
|
+
if (s.phase !== 'inbound') {
|
|
184
|
+
warn(s, 'annihilate 只在早挂点 onInbound 有效(晚挂点 already 记过群历史;想拦掉这条就直接不调 next())');
|
|
185
|
+
audit(s, { action: 'annihilate', reason, result: 'refused-late-hook' });
|
|
186
|
+
s.annihilateRefused = { reason: 'annihilate 只在早挂点 onInbound 有效' };
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
// ② 白名单最高优先:主人永不可被消灭
|
|
190
|
+
const sender = senderOf(ctx);
|
|
191
|
+
if (ownerOf(s, sender)) {
|
|
192
|
+
warn(s, `拒绝消灭:发送者 ${sender} 在主人口令白名单里(白名单最高优先,本条照常放行)`);
|
|
193
|
+
audit(s, { action: 'annihilate', target: sender, reason, result: 'refused-owner' });
|
|
194
|
+
s.annihilateRefused = { reason: '发送者是主人(白名单最高优先)' };
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
// ③ 熔断
|
|
198
|
+
const gate = checkDestructiveAllowed(s.current?.file ?? '');
|
|
199
|
+
if (!gate.ok) {
|
|
200
|
+
warn(s, `熔断中,annihilate 被拒(${gate.reason})—— 本条消息照常放行`);
|
|
201
|
+
audit(s, { action: 'annihilate', target: sender, reason, result: 'refused-breaker' });
|
|
202
|
+
s.annihilateRefused = { reason: `熔断:${gate.reason}` };
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
s.annihilate = reason ? { reason } : {};
|
|
206
|
+
s.destructiveCount += 1;
|
|
207
|
+
noteDestructive(s.current?.file ?? '');
|
|
208
|
+
audit(s, { action: 'annihilate', target: sender, reason, text: textOf(ctx), result: 'ok' });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
warn(s, `annihilate 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** 改写文本:直接改 `ctx.message.content`(早挂点在记历史之前 ⇒ 群历史里也是改后的文本) */
|
|
217
|
+
function rewrite(ctx, s, opts) {
|
|
218
|
+
try {
|
|
219
|
+
const raw = opts?.text;
|
|
220
|
+
if (typeof raw !== 'string') {
|
|
221
|
+
warn(s, 'rewrite 需要 { text: "改写后的文本" }(收到非字符串,已忽略)');
|
|
222
|
+
audit(s, { action: 'rewrite', result: 'refused-invalid' });
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const before = textOf(ctx);
|
|
226
|
+
ctx.message.content = raw;
|
|
227
|
+
s.rewriteCount += 1;
|
|
228
|
+
audit(s, {
|
|
229
|
+
action: 'rewrite',
|
|
230
|
+
target: senderOf(ctx),
|
|
231
|
+
reason: typeof opts?.reason === 'string' ? opts.reason : undefined,
|
|
232
|
+
text: `→ ${raw}`,
|
|
233
|
+
result: s.phase === 'inbound' ? 'ok' : 'ok-late-hook(群历史里仍是原文)',
|
|
234
|
+
});
|
|
235
|
+
// 改写前后都记一笔(debug 级):排查"到底改没改"最直接
|
|
236
|
+
try {
|
|
237
|
+
s.env.logger?.debug?.(`[ext-inbound:${who(s)}] rewrite: "${before.slice(0, 30)}" → "${raw.slice(0, 30)}"`);
|
|
238
|
+
}
|
|
239
|
+
catch { /* ignore */ }
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
warn(s, `rewrite 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
// ── ★ silentInto:静默进上下文(2026-10-12 新增;两个挂点都能用)──────────────
|
|
248
|
+
/**
|
|
249
|
+
* 缺省正文 = **当前这条入站消息洗过之后的正文**。
|
|
250
|
+
*
|
|
251
|
+
* "洗过"在这里有两层,都是复用插件现成的东西,不另造一套:
|
|
252
|
+
* ① `rewrite()` 之后的 `ctx.message.content`(早挂点里扩展自己刚改的那版);
|
|
253
|
+
* ② 再把"@她"的 32 位长 id 换成短标记 `@bot` —— 复用 `shared/mention-clean.ts` 的
|
|
254
|
+
* `replaceBotMention`,与"记群历史 / 进上下文"那条正常通路**同一份判据**
|
|
255
|
+
* (见 `middleware/media-history.ts` 与 `transport/inbound.ts` 的调用点)。
|
|
256
|
+
* 不洗的话,群里每条被 @ 的消息都会往上下文里灌一截 32 位 hex(白烧 token)。
|
|
257
|
+
*/
|
|
258
|
+
function defaultSilentText(ctx, s) {
|
|
259
|
+
const mentions = ctx.message?.mentions;
|
|
260
|
+
return replaceBotMention(textOf(ctx), mentions ?? null, platformMentioned(ctx, s.env.appId));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* `silentInto` 的参数归一化。三种收法(其余一律拒,避免 `String(未知对象)` 塞进上下文):
|
|
264
|
+
* · 不传 / `null` → 当前正文(见 {@link defaultSilentText})
|
|
265
|
+
* · `'文本'` → 直接用(主人点名的签名就是 `ctx.silentInto(text?)`)
|
|
266
|
+
* · `{ text?, reason? }` → 便捷写法:可以只给 `reason`(正文仍取当前正文)方便审计溯源
|
|
267
|
+
* 返回 `null` = 形状不合法。
|
|
268
|
+
*/
|
|
269
|
+
function parseSilentArg(ctx, s, arg) {
|
|
270
|
+
if (arg === undefined || arg === null)
|
|
271
|
+
return { text: defaultSilentText(ctx, s) };
|
|
272
|
+
if (typeof arg === 'string')
|
|
273
|
+
return { text: arg };
|
|
274
|
+
if (typeof arg === 'object' && !Array.isArray(arg)) {
|
|
275
|
+
const o = arg;
|
|
276
|
+
if (o.text !== undefined && typeof o.text !== 'string')
|
|
277
|
+
return null; // { text: 123 } 这类不收
|
|
278
|
+
const reason = typeof o.reason === 'string' && o.reason ? o.reason : undefined;
|
|
279
|
+
if (o.text === undefined && reason === undefined)
|
|
280
|
+
return null; // 空对象 {} 也拒
|
|
281
|
+
return {
|
|
282
|
+
text: typeof o.text === 'string' ? o.text : defaultSilentText(ctx, s),
|
|
283
|
+
...(reason ? { reason } : {}),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
/** 问运行环境要 agent(解析器自己抛错也当"拿不到",绝不外抛) */
|
|
289
|
+
function resolveAgentOf(ctx, s) {
|
|
290
|
+
const resolve = s.env.resolveAgent;
|
|
291
|
+
if (typeof resolve !== 'function')
|
|
292
|
+
return undefined;
|
|
293
|
+
try {
|
|
294
|
+
return resolve(ctx);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* ★ `ctx.silentInto(text?)` —— **把这条消息静默放进当前会话的 AI 上下文,不唤醒**。
|
|
302
|
+
*
|
|
303
|
+
* 与 `annihilate` 的分工:
|
|
304
|
+
* · `annihilate` = "不调 next()" ⇒ 链断在这里,**内容是消失的**(后面组装上下文的那步看不到它);
|
|
305
|
+
* · `silentInto` = 内容**塞进收件箱**(`agent.inject`,`next-step` / `wakeup:false`)⇒
|
|
306
|
+
* 她空闲时它就静静躺着,**等下一次真有回合跑起来时自然出现**;同时扩展若 `return`(不调 next),
|
|
307
|
+
* 那条消息本身仍不进群历史 / 不占冷却 / 不过评分 —— "内容留下,但不叫醒"。
|
|
308
|
+
*
|
|
309
|
+
* 参数:见 {@link parseSilentArg}。返回值:
|
|
310
|
+
* · `true` = **内容已经在上下文里了**(本次注入成功;或之前已经注入过 —— 幂等命中,没重复塞);
|
|
311
|
+
* · `false` = **没注入成功**(空文本 / 参数不合法 / 熔断 / 拿不到 agent / inject 抛错)——
|
|
312
|
+
* 具体原因看日志的 warn 与审计里的 `result`(`refused-*` / `failed-*`)。
|
|
313
|
+
*
|
|
314
|
+
* ⚠️ **拿不到 agent 时不会"替你把消息放行"**:引擎的兜底放行只认 `annihilate` 被拒与"发送者是主人"。
|
|
315
|
+
* 所以推荐写法是**自己判返回值**,失败时走正常链路(示例见 `examples/ext-inbound/README.md`):
|
|
316
|
+
* ```js
|
|
317
|
+
* if (!ctx.silentInto()) return next(); // 没注进去就别把这条也吞了
|
|
318
|
+
* return; // 注进去了:这条到此为止(不叫醒)
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* ⚠️ 白名单:**主人(owners)的消息允许被 `silentInto`**(内容仍进上下文,不存在"被吞"的风险),
|
|
322
|
+
* 但要**落审计**、并**计入熔断**(见文件头 ① 的说明)。这条不受 `annihilate` 的白名单限制。
|
|
323
|
+
*/
|
|
324
|
+
function silentInto(ctx, s, arg) {
|
|
325
|
+
let body = '';
|
|
326
|
+
let reason;
|
|
327
|
+
try {
|
|
328
|
+
// ① 参数归一化(形状不合法直接拒:绝不把未知对象 String() 之后塞进上下文)
|
|
329
|
+
const parsed = parseSilentArg(ctx, s, arg);
|
|
330
|
+
if (parsed === null) {
|
|
331
|
+
warn(s, 'silentInto 只认 ctx.silentInto() / ctx.silentInto("文本") / ctx.silentInto({ text, reason });' +
|
|
332
|
+
'收到别的形状,本次不注入');
|
|
333
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), result: 'refused-invalid' });
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
body = parsed.text.trim();
|
|
337
|
+
reason = parsed.reason;
|
|
338
|
+
// ② 空文本不注入(别往上下文里塞空消息)
|
|
339
|
+
if (!body) {
|
|
340
|
+
warn(s, 'silentInto 拿到空文本(这条没有正文,或者你传了空白)⇒ 不注入');
|
|
341
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, result: 'refused-empty' });
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
// ③ 幂等:同一条消息只注入一次(早/晚挂点共用一份会话 ⇒ 跨挂点也只塞一次)
|
|
345
|
+
if (s.silentInto?.injected) {
|
|
346
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, text: body, result: 'ok-duplicate' });
|
|
347
|
+
return true; // 内容是**在**上下文里的(先前那次已成功),所以这里算成功
|
|
348
|
+
}
|
|
349
|
+
// ④ 熔断:silentInto 计入"破坏性动作"(它同样能大规模改变会话内容),超阈值与 annihilate 一起停
|
|
350
|
+
const gate = checkDestructiveAllowed(s.current?.file ?? '');
|
|
351
|
+
if (!gate.ok) {
|
|
352
|
+
warn(s, `熔断中,silentInto 被拒(${gate.reason})—— 没注入成功;` +
|
|
353
|
+
'要稳妥请自己判返回值(`if (!ctx.silentInto()) return next()`)');
|
|
354
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, text: body, result: 'refused-breaker' });
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
// ⑤ 拿 agent(拿不到只告警:该会话还没建立是常见情形,不算扩展写错)
|
|
358
|
+
const agent = resolveAgentOf(ctx, s);
|
|
359
|
+
const inject = agent?.inject;
|
|
360
|
+
if (typeof inject !== 'function') {
|
|
361
|
+
warnOnce(s, 'silent-no-agent', 'silentInto 拿不到宿主 agent(该会话还没建立?)或它没有 inject() ⇒ 本条没进上下文。' +
|
|
362
|
+
'注意:这里**不会**替你放行这条消息 —— 若你接着 return 且不调 next(),它就真的既不进上下文也不进历史了');
|
|
363
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, text: body, result: 'failed-no-agent' });
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
// ⑥ 注入:**只走宿主的 agent.inject**(next-step / wakeup:false,不拆 tool_calls);
|
|
367
|
+
// 消息形状复用 createUserMessage(与 group-hub / debounce / commands/inject 同款),
|
|
368
|
+
// 绝不 session.append / caps.appendSilent 硬写。
|
|
369
|
+
const msg = createUserMessage({ content: [{ type: 'text', text: body }], source: { kind: 'user' } });
|
|
370
|
+
inject.call(agent, msg);
|
|
371
|
+
s.silentInto = { injected: true, result: 'ok', chars: body.length };
|
|
372
|
+
s.destructiveCount += 1; // 熔断统计认它(与 annihilate/muteUser 同一本账)
|
|
373
|
+
noteDestructive(s.current?.file ?? '');
|
|
374
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, text: body, result: 'ok' });
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
warn(s, `silentInto 注入失败(已忽略, 未抛到链上): ${err instanceof Error ? err.message : String(err)}`);
|
|
379
|
+
try {
|
|
380
|
+
audit(s, { action: 'silentInto', target: senderOf(ctx), reason, text: body, result: 'failed-inject' });
|
|
381
|
+
}
|
|
382
|
+
catch { /* audit 自身永不抛,这里只是保险 */ }
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/** 强制入站:写 `ctx.state.qqInboundForceWake` —— 门控/评分/冷却三处都认这个标记 */
|
|
387
|
+
function forceWake(ctx, s, opts) {
|
|
388
|
+
try {
|
|
389
|
+
const reason = typeof opts?.reason === 'string' ? opts.reason : undefined;
|
|
390
|
+
const st = ctx.state;
|
|
391
|
+
st.qqInboundForceWake = true;
|
|
392
|
+
if (reason)
|
|
393
|
+
st.qqInboundForceWakeReason = reason;
|
|
394
|
+
s.forceWake = reason ? { reason } : {};
|
|
395
|
+
audit(s, {
|
|
396
|
+
action: 'forceWake',
|
|
397
|
+
target: senderOf(ctx),
|
|
398
|
+
reason,
|
|
399
|
+
text: textOf(ctx),
|
|
400
|
+
result: s.phase === 'inbound' ? 'ok' : 'ok-late-hook(门控已过,只免评分/冷却)',
|
|
401
|
+
});
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
warn(s, `forceWake 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** 覆盖 @ 判定(晚挂点专用:`ctx.state.mention` 是 mentionGate 刚写的) */
|
|
410
|
+
function decide(ctx, s, opts) {
|
|
411
|
+
try {
|
|
412
|
+
const mode = opts?.mention === 'force' ? 'force' : opts?.mention === 'ignore' ? 'ignore' : undefined;
|
|
413
|
+
const reason = typeof opts?.reason === 'string' ? opts.reason : undefined;
|
|
414
|
+
if (!mode) {
|
|
415
|
+
warn(s, "decide 需要 { mention: 'ignore' | 'force' }(收到别的值,已忽略)");
|
|
416
|
+
audit(s, { action: 'decide', reason, result: 'refused-invalid' });
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
if (s.phase !== 'decide') {
|
|
420
|
+
warn(s, 'decide 只在晚挂点 onDecide 有效(早挂点在 mentionGate 之前,ctx.state.mention 还没写出来)');
|
|
421
|
+
audit(s, { action: 'decide', reason, result: 'refused-early-hook' });
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const st = ctx.state;
|
|
425
|
+
const want = mode === 'force';
|
|
426
|
+
if (st.mention && typeof st.mention === 'object')
|
|
427
|
+
st.mention.wasMentioned = want;
|
|
428
|
+
else
|
|
429
|
+
st.mention = { wasMentioned: want };
|
|
430
|
+
s.decide = reason ? { mention: mode, reason } : { mention: mode };
|
|
431
|
+
audit(s, {
|
|
432
|
+
action: 'decide',
|
|
433
|
+
target: senderOf(ctx),
|
|
434
|
+
reason,
|
|
435
|
+
text: `mention=${mode}`,
|
|
436
|
+
result: 'ok',
|
|
437
|
+
});
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
catch (err) {
|
|
441
|
+
warn(s, `decide 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/** 禁言:写持久名单(`minutes <= 0` = 永久)。白名单与熔断同样拦在前面 */
|
|
446
|
+
function muteUser(ctx, s, opts) {
|
|
447
|
+
try {
|
|
448
|
+
const id = String(opts?.id ?? senderOf(ctx) ?? '').trim();
|
|
449
|
+
const reason = typeof opts?.reason === 'string' ? opts.reason : undefined;
|
|
450
|
+
if (!id) {
|
|
451
|
+
warn(s, 'muteUser 需要 { id }(缺省 = 当前发送者;这条两个都没拿到,已忽略)');
|
|
452
|
+
audit(s, { action: 'muteUser', reason, result: 'refused-invalid' });
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
if (ownerOf(s, id)) {
|
|
456
|
+
warn(s, `拒绝禁言:${id} 在主人口令白名单里(白名单最高优先)`);
|
|
457
|
+
audit(s, { action: 'muteUser', target: id, reason, result: 'refused-owner' });
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
const gate = checkDestructiveAllowed(s.current?.file ?? '');
|
|
461
|
+
if (!gate.ok) {
|
|
462
|
+
warn(s, `熔断中,muteUser 被拒(${gate.reason})`);
|
|
463
|
+
audit(s, { action: 'muteUser', target: id, reason, result: 'refused-breaker' });
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
if (!rootOk(s))
|
|
467
|
+
return false;
|
|
468
|
+
const minutes = Number(opts?.minutes);
|
|
469
|
+
const entry = stateMuteUser(s.env.dataRoot, s.current?.file ?? '(unknown)', {
|
|
470
|
+
id,
|
|
471
|
+
minutes: Number.isFinite(minutes) ? minutes : 0,
|
|
472
|
+
...(reason ? { reason } : {}),
|
|
473
|
+
by: typeof opts?.by === 'string' && opts.by ? opts.by : who(s),
|
|
474
|
+
});
|
|
475
|
+
if (!entry) {
|
|
476
|
+
warn(s, `muteUser 写名单失败(数据根不可写?): ${id}`);
|
|
477
|
+
audit(s, { action: 'muteUser', target: id, reason, result: 'failed-write' });
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
s.destructiveCount += 1;
|
|
481
|
+
noteDestructive(s.current?.file ?? '');
|
|
482
|
+
audit(s, {
|
|
483
|
+
action: 'muteUser',
|
|
484
|
+
target: id,
|
|
485
|
+
reason,
|
|
486
|
+
text: entry.until === 0 ? '永久' : `至 ${new Date(entry.until).toISOString()}`,
|
|
487
|
+
result: 'ok',
|
|
488
|
+
});
|
|
489
|
+
return entry;
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
warn(s, `muteUser 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/** 解禁(可撤销通道) */
|
|
497
|
+
function unmuteUser(ctx, s, opts) {
|
|
498
|
+
try {
|
|
499
|
+
const id = String(opts?.id ?? senderOf(ctx) ?? '').trim();
|
|
500
|
+
if (!id || !rootOk(s))
|
|
501
|
+
return false;
|
|
502
|
+
const ok = stateUnmuteUser(s.env.dataRoot, s.current?.file ?? '(unknown)', id);
|
|
503
|
+
audit(s, { action: 'unmuteUser', target: id, result: ok ? 'ok' : 'not-found' });
|
|
504
|
+
return ok;
|
|
505
|
+
}
|
|
506
|
+
catch (err) {
|
|
507
|
+
warn(s, `unmuteUser 异常(已忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** 查一条是否被禁言(只读,不写审计) */
|
|
512
|
+
function isMuted(ctx, s, id) {
|
|
513
|
+
try {
|
|
514
|
+
const key = String(id ?? senderOf(ctx) ?? '').trim();
|
|
515
|
+
if (!key || !rootOk(s))
|
|
516
|
+
return null;
|
|
517
|
+
return stateIsMuted(s.env.dataRoot, s.current?.file ?? '(unknown)', key);
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** 读整份名单(只读,不写审计) */
|
|
524
|
+
function mutedUsers(_ctx, s) {
|
|
525
|
+
try {
|
|
526
|
+
if (!rootOk(s))
|
|
527
|
+
return [];
|
|
528
|
+
return stateMutedUsers(s.env.dataRoot, s.current?.file ?? '(unknown)');
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
return [];
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** kv 读(只读,不写审计) */
|
|
535
|
+
function kvGet(_ctx, s, key) {
|
|
536
|
+
try {
|
|
537
|
+
if (!rootOk(s))
|
|
538
|
+
return undefined;
|
|
539
|
+
return readExtState(s.env.dataRoot, s.current?.file ?? '(unknown)').kv[String(key)];
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return undefined;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/** kv 写;`value === undefined` 等价删除该键(JSON 里会消失) */
|
|
546
|
+
function kvSet(_ctx, s, key, value) {
|
|
547
|
+
try {
|
|
548
|
+
if (!rootOk(s))
|
|
549
|
+
return false;
|
|
550
|
+
const file = s.current?.file ?? '(unknown)';
|
|
551
|
+
const st = readExtState(s.env.dataRoot, file);
|
|
552
|
+
st.kv[String(key)] = value;
|
|
553
|
+
return writeExtState(s.env.dataRoot, file, st);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ══════════════════════════════════════════════════════════════
|
|
560
|
+
// 挂到 ctx 上
|
|
561
|
+
// ══════════════════════════════════════════════════════════════
|
|
562
|
+
/** 定义不可枚举属性(不参与展开/序列化);失败静默(绝不影响消息链) */
|
|
563
|
+
function def(target, key, desc) {
|
|
564
|
+
try {
|
|
565
|
+
Object.defineProperty(target, key, { enumerable: false, configurable: true, ...desc });
|
|
566
|
+
}
|
|
567
|
+
catch { /* ignore */ }
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* 把动作 API 挂到 ctx 上(每个 ctx 只挂一次)。
|
|
571
|
+
* ⚠️ 一律**不可枚举**:middleware-setup 的命令层会 `{...ctx}` 组 handler 参数,
|
|
572
|
+
* 可枚举的话会把这些动作/取值器一起复制过去(还可能覆盖扩展命令自己的 caps)。
|
|
573
|
+
*/
|
|
574
|
+
/**
|
|
575
|
+
* ★ 把「入站操控」这一组**打包**(2026-10-12 主人定:它也要进统一能力包)。
|
|
576
|
+
*
|
|
577
|
+
* 和上面那些 def 在 ctx 上的是**同一份实现**(不是两套):这里只是换个入口交出去,
|
|
578
|
+
* 好让契约写成「一套包、五套扩展」——包里的 `inbound` 组"有值"就等于"你现在正握着
|
|
579
|
+
* 一条消息的处置权"。别的四套拿它会是 undefined,那是语义,不是缺失。
|
|
580
|
+
*/
|
|
581
|
+
export function inboundCapsOf(ctx, s) {
|
|
582
|
+
return {
|
|
583
|
+
annihilate: (o) => annihilate(ctx, s, o),
|
|
584
|
+
rewrite: (o) => rewrite(ctx, s, o),
|
|
585
|
+
forceWake: (o) => forceWake(ctx, s, o),
|
|
586
|
+
decide: (o) => decide(ctx, s, o),
|
|
587
|
+
silentInto: (t) => silentInto(ctx, s, t),
|
|
588
|
+
muteUser: (o) => muteUser(ctx, s, o),
|
|
589
|
+
unmuteUser: (o) => unmuteUser(ctx, s, o),
|
|
590
|
+
isMuted: (id) => isMuted(ctx, s, id),
|
|
591
|
+
mutedUsers: () => mutedUsers(ctx, s),
|
|
592
|
+
message: {
|
|
593
|
+
get text() { return textOf(ctx); },
|
|
594
|
+
get sender() { return senderOf(ctx); },
|
|
595
|
+
get senderName() { return String(ctx.message.senderName ?? ''); },
|
|
596
|
+
get scope() { return (ctx.message.kind === 'group' ? 'group' : 'c2c'); },
|
|
597
|
+
get groupId() { return String(ctx.message.groupOpenid ?? ''); },
|
|
598
|
+
get isOwner() { return ownerOf(s, senderOf(ctx)); },
|
|
599
|
+
get mentions() { return (ctx.message.mentions ?? []); },
|
|
600
|
+
get quoted() { return quotedOf(ctx); },
|
|
601
|
+
get platform() { return { mentioned: platformMentioned(ctx, s.env.appId) }; },
|
|
602
|
+
},
|
|
603
|
+
kv: {
|
|
604
|
+
get: (key) => kvGet(ctx, s, key),
|
|
605
|
+
set: (key, value) => kvSet(ctx, s, key, value),
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function decorate(ctx, s) {
|
|
610
|
+
const c = ctx;
|
|
611
|
+
// ── 动作(消息维)──
|
|
612
|
+
def(c, 'annihilate', { value: (opts) => annihilate(ctx, s, opts) });
|
|
613
|
+
def(c, 'rewrite', { value: (opts) => rewrite(ctx, s, opts) });
|
|
614
|
+
def(c, 'forceWake', { value: (opts) => forceWake(ctx, s, opts) });
|
|
615
|
+
def(c, 'decide', { value: (opts) => decide(ctx, s, opts) });
|
|
616
|
+
// ★ 静默进上下文:`ctx.silentInto()` / `ctx.silentInto('文本')` / `ctx.silentInto({ text, reason })`
|
|
617
|
+
// 两个挂点都能调;同一条消息只注入一次(幂等,见 silentInto 的注释)
|
|
618
|
+
def(c, 'silentInto', { value: (text) => silentInto(ctx, s, text) });
|
|
619
|
+
// ── 动作(用户维)──
|
|
620
|
+
def(c, 'muteUser', { value: (opts) => muteUser(ctx, s, opts) });
|
|
621
|
+
def(c, 'unmuteUser', { value: (opts) => unmuteUser(ctx, s, opts) });
|
|
622
|
+
def(c, 'isMuted', { value: (id) => isMuted(ctx, s, id) });
|
|
623
|
+
def(c, 'mutedUsers', { value: () => mutedUsers(ctx, s) });
|
|
624
|
+
// ── 通用取值(读的时候现算,不缓存快照)──
|
|
625
|
+
// text:读 = 当前文本;写 = 直接改 ctx.message.content(等价于 unsafely 改写,审计请用 rewrite)
|
|
626
|
+
def(c, 'text', {
|
|
627
|
+
get: () => textOf(ctx),
|
|
628
|
+
set: (v) => { ctx.message.content = String(v ?? ''); },
|
|
629
|
+
});
|
|
630
|
+
def(c, 'scope', { get: () => (ctx.message.kind === 'group' ? 'group' : 'c2c') });
|
|
631
|
+
def(c, 'sender', { get: () => senderOf(ctx) });
|
|
632
|
+
def(c, 'senderName', { get: () => String(ctx.message.senderName ?? '') });
|
|
633
|
+
def(c, 'isOwner', { get: () => ownerOf(s, senderOf(ctx)) });
|
|
634
|
+
def(c, 'groupId', { get: () => String(ctx.message.groupOpenid ?? '') });
|
|
635
|
+
def(c, 'mentions', { get: () => (ctx.message.mentions ?? []) });
|
|
636
|
+
def(c, 'quoted', { get: () => callableWith(() => quotedOf(ctx)) });
|
|
637
|
+
def(c, 'platform', {
|
|
638
|
+
get: () => callableWith(() => ({ mentioned: platformMentioned(ctx, s.env.appId) })),
|
|
639
|
+
});
|
|
640
|
+
def(c, 'logger', { get: () => s.env.logger });
|
|
641
|
+
def(c, 'dataRoot', { get: () => s.env.dataRoot });
|
|
642
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
643
|
+
// ★★ 2026-10-12 主人定:**能力面对齐** ★★
|
|
644
|
+
//
|
|
645
|
+
// 入站扩展(第五套)必须和另外四套 —— 自定义命令 / 扩展工具 / botplay 卡片 /
|
|
646
|
+
// 启动钩子 —— 拿到**同一套**能力包。依据是 `docs/USER-GUIDE.md` 的
|
|
647
|
+
// §「★ 统一能力包(v1.7.0+):三套扩展能力面对齐」,原文一句话:
|
|
648
|
+
// 「写扩展时**不用再自己接生**」。
|
|
649
|
+
//
|
|
650
|
+
// 为什么这条对齐是**必须**的(而不是"锦上添花"):
|
|
651
|
+
// · 那套包里本来就有 **`appendSilent`**(= "进上下文(静默)、不唤醒")——
|
|
652
|
+
// 入站侧想要的"消灭消息但内容静默进上下文",**契约里早就有了**;
|
|
653
|
+
// 没对齐才会以为要另造一个注入动作。**接上它,而不是再造一份。**
|
|
654
|
+
// · 还有 `store`(升级不丢的持久化)、`api()`(自带 token 打官方接口)、
|
|
655
|
+
// `user/owners/peer`(身份)、`kernel`(遥控器)等等 —— 五套之间不该有"二等公民"。
|
|
656
|
+
//
|
|
657
|
+
// 三条纪律(第 ① 条照抄命令层 2026-10-12 那次实测修复的口径):
|
|
658
|
+
// ① **只补不覆盖**:判据是"值为 undefined 才补",不是"有没有这个键" ——
|
|
659
|
+
// 中间件 ctx 上不少键**存在但值是 undefined**,只问键名会把好值全跳过;
|
|
660
|
+
// ② 一律**不可枚举**(`def` 已保证):命令层会 `{...ctx}` 组 handler 参数,可枚举会串味;
|
|
661
|
+
// ③ 构造失败**只告警一次**,绝不抛:能力包拿不到 = 那几项优雅降级(契约写明不抛),
|
|
662
|
+
// 不能让入站主链因为"能力包没造出来"而断掉。
|
|
663
|
+
//
|
|
664
|
+
// ⚠️ **唯一一处同名冲突(有意为之,别"顺手"改)**:`text`。
|
|
665
|
+
// · 其他四套:`env.text('...')` = **发一条纯文本消息**;
|
|
666
|
+
// · 入站(这里):`ctx.text` = **当前这条入站消息的正文**(getter)。
|
|
667
|
+
// 入站侧这个读法先占了名字,而且改写/判断全靠它,所以**保留入站语义**;
|
|
668
|
+
// 想在入站扩展里发文本,走命名空间:`ctx.caps.text('...')`。
|
|
669
|
+
// 其余能力都平铺在 ctx 上(`ctx.markdown` / `ctx.appendSilent` / `ctx.store` …)。
|
|
670
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
671
|
+
try {
|
|
672
|
+
const mc = ctx.message;
|
|
673
|
+
const isGroup = mc?.kind === 'group';
|
|
674
|
+
const caps = makeExtCaps({
|
|
675
|
+
dataRoot: s.env.dataRoot,
|
|
676
|
+
logger: s.env.logger,
|
|
677
|
+
appId: s.env.appId,
|
|
678
|
+
appSecret: s.env.appSecret,
|
|
679
|
+
owners: s.env.owners,
|
|
680
|
+
sender: s.env.sender,
|
|
681
|
+
replyTarget: s.env.replyTarget,
|
|
682
|
+
agent: (s.env.resolveAgent ? s.env.resolveAgent(ctx) : undefined),
|
|
683
|
+
scope: isGroup ? 'group' : 'c2c',
|
|
684
|
+
peerId: String((isGroup ? mc?.groupOpenid : mc?.senderId) ?? ''),
|
|
685
|
+
actorOpenid: senderOf(ctx),
|
|
686
|
+
actorName: String(mc?.senderName ?? ''),
|
|
687
|
+
apiCall: s.env.apiCall,
|
|
688
|
+
selfName: who(s),
|
|
689
|
+
kind: 'inbound',
|
|
690
|
+
kernel: s.env.kernel,
|
|
691
|
+
inbound: inboundCapsOf(ctx, s), // ★ 入站操控这一组也进包
|
|
692
|
+
});
|
|
693
|
+
def(c, 'caps', { value: caps }); // 命名空间:ctx.caps.text(...) 等
|
|
694
|
+
for (const [k, v] of Object.entries(caps)) {
|
|
695
|
+
if (v === undefined)
|
|
696
|
+
continue;
|
|
697
|
+
if (!(k in c) || c[k] === undefined)
|
|
698
|
+
def(c, k, { value: v });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
warnOnce(s, 'caps', '统一能力包未接上(入站专属动作不受影响):' + (err instanceof Error ? err.message : String(err)));
|
|
703
|
+
}
|
|
704
|
+
// ── 每个扩展自己的持久化(与禁言名单同一个文件)──
|
|
705
|
+
def(c, 'kv', {
|
|
706
|
+
get: () => ({
|
|
707
|
+
get: (key) => kvGet(ctx, s, key),
|
|
708
|
+
set: (key, value) => kvSet(ctx, s, key, value),
|
|
709
|
+
}),
|
|
710
|
+
});
|
|
711
|
+
// ── 内部诊断(也给用户看:这些动作到底生效没有)──
|
|
712
|
+
def(c, 'extState', {
|
|
713
|
+
get: () => ({
|
|
714
|
+
phase: s.phase,
|
|
715
|
+
extension: s.current?.name ?? '',
|
|
716
|
+
annihilated: s.annihilate !== undefined,
|
|
717
|
+
forceWake: s.forceWake !== undefined,
|
|
718
|
+
mentionOverride: s.decide?.mention,
|
|
719
|
+
rewrites: s.rewriteCount,
|
|
720
|
+
destructive: s.destructiveCount,
|
|
721
|
+
}),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
//# sourceMappingURL=ext-inbound-context.js.map
|