@dsh-overdrive/gateway 0.1.9 → 0.3.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/dist/commands.d.ts +18 -0
- package/dist/commands.js +31 -4
- package/dist/commands.js.map +1 -1
- package/dist/feed.d.ts +48 -0
- package/dist/feed.js +166 -0
- package/dist/feed.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +133 -10
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +10 -0
- package/dist/memory.js +43 -0
- package/dist/memory.js.map +1 -1
- package/dist/mention.d.ts +13 -0
- package/dist/mention.js +43 -0
- package/dist/mention.js.map +1 -0
- package/dist/pending-buttons.d.ts +5 -1
- package/dist/pending-buttons.js +26 -8
- package/dist/pending-buttons.js.map +1 -1
- package/dist/text.d.ts +2 -0
- package/dist/text.js +24 -0
- package/dist/text.js.map +1 -0
- package/package.json +1 -1
- package/src/commands.ts +32 -6
- package/src/feed.ts +190 -0
- package/src/index.ts +137 -12
- package/src/memory.ts +43 -0
- package/src/mention.ts +51 -0
- package/src/pending-buttons.ts +26 -6
- package/src/text.ts +32 -0
- package/test/commands.test.ts +10 -0
- package/test/feed.test.ts +111 -0
- package/test/mention.test.ts +54 -0
- package/test/multi.test.ts +49 -3
- package/test/pending-buttons.test.ts +39 -0
- package/test/text.test.ts +20 -0
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
2
|
-
import { basename } from 'node:path';
|
|
1
|
+
import { existsSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
3
4
|
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
5
|
import type { Adapter, OutboundPayload } from './adapter.js';
|
|
5
6
|
import { Allowlist, buildSessionKey } from './session.js';
|
|
@@ -9,7 +10,10 @@ import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
|
|
|
9
10
|
import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
|
|
10
11
|
import { createStatusServer } from './status.js';
|
|
11
12
|
import { createTranscriber, type AsrTranscriber } from './asr.js';
|
|
12
|
-
import { MemoryStore, memoryScope, formatMemories, extractAutoMemories } from './memory.js';
|
|
13
|
+
import { MemoryStore, TopicStore, memoryScope, formatMemories, extractAutoMemories } from './memory.js';
|
|
14
|
+
import { FeedStore, FeedPoller } from './feed.js';
|
|
15
|
+
import { shouldRespond } from './mention.js';
|
|
16
|
+
import { chunkLongText } from './text.js';
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
19
|
* message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
|
|
@@ -76,6 +80,14 @@ export interface WireOptions {
|
|
|
76
80
|
memory?: MemoryStore;
|
|
77
81
|
/** 人设(persona):每条用户消息前注入的固定上下文,如「你是一个毒舌但贴心的私人助理」。 */
|
|
78
82
|
persona?: string;
|
|
83
|
+
/** RSS 订阅存储(/feed 命令)。 */
|
|
84
|
+
feed?: FeedStore;
|
|
85
|
+
/** 群聊提及策略:true 时群聊中仅在被提及/回复时响应(私聊始终响应)。 */
|
|
86
|
+
requireMention?: boolean;
|
|
87
|
+
/** 机器人身份(telegram @用户名 / discord·slack 用户ID / whatsapp 号码)。 */
|
|
88
|
+
botIdentity?: string;
|
|
89
|
+
/** 会话主题存储(/context)。 */
|
|
90
|
+
topics?: TopicStore;
|
|
79
91
|
}
|
|
80
92
|
|
|
81
93
|
/** 纯函数:一次性提醒的时间 → cron 5 字段表达式(分钟精度)。 */
|
|
@@ -108,6 +120,8 @@ async function handleCommand(
|
|
|
108
120
|
chatId: string,
|
|
109
121
|
aggregator: TrajectoryAggregator,
|
|
110
122
|
memory: MemoryStore | undefined,
|
|
123
|
+
feed: FeedStore | undefined,
|
|
124
|
+
topics: TopicStore | undefined,
|
|
111
125
|
): Promise<void> {
|
|
112
126
|
switch (command.kind) {
|
|
113
127
|
case 'trace': {
|
|
@@ -126,8 +140,24 @@ async function handleCommand(
|
|
|
126
140
|
return;
|
|
127
141
|
}
|
|
128
142
|
case 'cron': {
|
|
129
|
-
await client.createTask({ sessionId, kind: 'cron', prompt: command.prompt, schedule: command.schedule });
|
|
130
|
-
await adapter.send(chatId, { text:
|
|
143
|
+
await client.createTask({ sessionId, kind: 'cron', prompt: command.prompt, schedule: command.schedule, timeZone: command.timeZone });
|
|
144
|
+
await adapter.send(chatId, { text: `⏰ 定时任务已注册${command.timeZone ? `(时区 ${command.timeZone})` : ''}` });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case 'context': {
|
|
148
|
+
if (!topics) { await adapter.send(chatId, { text: '会话主题未启用。' }); return; }
|
|
149
|
+
if (command.action === 'set' && command.topic) {
|
|
150
|
+
topics.set(sessionId, command.topic);
|
|
151
|
+
await adapter.send(chatId, { text: `📌 会话主题已绑定:${command.topic}` });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (command.action === 'clear') {
|
|
155
|
+
const ok = topics.clear(sessionId);
|
|
156
|
+
await adapter.send(chatId, { text: ok ? '📌 会话主题已清除' : '当前没有会话主题。' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const current = topics.get(sessionId);
|
|
160
|
+
await adapter.send(chatId, { text: current ? `📌 当前会话主题:${current}` : '当前没有会话主题。用 /context <主题> 绑定。' });
|
|
131
161
|
return;
|
|
132
162
|
}
|
|
133
163
|
case 'crons': {
|
|
@@ -214,6 +244,48 @@ async function handleCommand(
|
|
|
214
244
|
});
|
|
215
245
|
return;
|
|
216
246
|
}
|
|
247
|
+
case 'digest': {
|
|
248
|
+
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
249
|
+
const mems = memory ? memory.list(scope) : [];
|
|
250
|
+
const crons = await client.listTasks();
|
|
251
|
+
await adapter.send(chatId, {
|
|
252
|
+
text: [
|
|
253
|
+
`📋 今日摘要`,
|
|
254
|
+
`- 你的记忆: ${mems.length} 条${mems.slice(0, 5).map((e) => `\n · ${e.text}`).join('')}`,
|
|
255
|
+
`- 定时任务: ${crons.tasks.length} 个`,
|
|
256
|
+
`- 订阅源: ${feed ? feed.list().filter((f) => f.chatId === chatId).length : 0} 个`,
|
|
257
|
+
].join('\n'),
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
case 'digestdaily': {
|
|
262
|
+
const schedule = remindSchedule(0, command.time);
|
|
263
|
+
await client.createTask({ sessionId, kind: 'cron', prompt: '⏰ 每日摘要:请基于今天的对话输出一份简短摘要。', schedule });
|
|
264
|
+
await adapter.send(chatId, { text: `📋 已设置每日摘要(${command.time} 触发)` });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
case 'feedadd': {
|
|
268
|
+
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
269
|
+
const entry = feed.add(adapter.id, chatId, command.url);
|
|
270
|
+
await adapter.send(chatId, { text: `✅ 已订阅 RSS(\`${entry.id}\`):${command.url}` });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
case 'feedlist': {
|
|
274
|
+
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
275
|
+
const mine = feed.list().filter((f) => f.chatId === chatId);
|
|
276
|
+
await adapter.send(chatId, {
|
|
277
|
+
text: mine.length
|
|
278
|
+
? `📡 订阅(${mine.length}):\n` + mine.map((f) => `- \`${f.id}\` ${f.url}`).join('\n')
|
|
279
|
+
: '暂无订阅。用 /feed add <rss链接> 添加。',
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
case 'feedrm': {
|
|
284
|
+
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
285
|
+
const ok = feed.remove(command.feedId);
|
|
286
|
+
await adapter.send(chatId, { text: ok ? `🗑️ 已删除订阅 \`${command.feedId}\`` : `未找到订阅 \`${command.feedId}\`` });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
217
289
|
case 'help': {
|
|
218
290
|
await adapter.send(chatId, { text: HELP_TEXT });
|
|
219
291
|
return;
|
|
@@ -242,13 +314,27 @@ export async function wireAdapter(
|
|
|
242
314
|
}
|
|
243
315
|
chatIds.set(key, msg.chatId);
|
|
244
316
|
|
|
317
|
+
// 群聊提及策略(对齐竞品):群聊中仅被提及/回复时响应;私聊始终响应
|
|
318
|
+
if (opts.requireMention && !shouldRespond(adapter.id, msg, { requireMention: true, botIdentity: opts.botIdentity ?? '' })) {
|
|
319
|
+
console.log(`[gateway][${adapter.id}] 群聊未提及,跳过: chat=${msg.chatId}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
245
323
|
const command = parseCommand(msg.text);
|
|
246
324
|
if (command) {
|
|
247
325
|
console.log(`[gateway][${adapter.id}] 命令: ${JSON.stringify(command)}`);
|
|
248
|
-
await handleCommand(adapter, client, command, key, msg.chatId, aggregator, opts.memory);
|
|
326
|
+
await handleCommand(adapter, client, command, key, msg.chatId, aggregator, opts.memory, opts.feed, opts.topics);
|
|
249
327
|
return;
|
|
250
328
|
}
|
|
251
329
|
|
|
330
|
+
// 会话主题注入(/context)
|
|
331
|
+
if (opts.topics) {
|
|
332
|
+
const topic = opts.topics.get(key);
|
|
333
|
+
if (topic) {
|
|
334
|
+
msg.text = `【会话主题】${topic}\n${msg.text}`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
252
338
|
// OpenClaw 式记忆注入:入站消息前检索相关记忆,拼到文本后让 agent「记得你」
|
|
253
339
|
if (opts.memory) {
|
|
254
340
|
const scope = memoryScope(adapter.id, msg.userId);
|
|
@@ -328,16 +414,42 @@ export async function wireAdapter(
|
|
|
328
414
|
deltas.onComplete(ev.sessionId);
|
|
329
415
|
}
|
|
330
416
|
|
|
417
|
+
// 自动发送:agent 产出的文件(file.created,base64)→ 写临时文件 → 发到聊天 → 清理
|
|
418
|
+
if (ev.type === 'file.created') {
|
|
419
|
+
const chatId = chatIdFor(ev.sessionId);
|
|
420
|
+
const tmp = join(tmpdir(), `dsh-out-${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${ev.name}`);
|
|
421
|
+
try {
|
|
422
|
+
writeFileSync(tmp, Buffer.from(ev.data, 'base64'));
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error(`[gateway][${adapter.id}] 写入自动发送临时文件失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
void adapter.send(chatId, { text: `📎 ${ev.name}`, media: { kind: ev.kind, path: tmp, caption: ev.name } })
|
|
428
|
+
.then(
|
|
429
|
+
() => console.log(`[gateway][${adapter.id}] 已自动发送 ${ev.name} 到 ${chatId}`),
|
|
430
|
+
(error) => console.error(`[gateway][${adapter.id}] 自动发送失败 ${ev.name}: ${error instanceof Error ? error.message : String(error)}`),
|
|
431
|
+
)
|
|
432
|
+
.finally(() => rmSync(tmp, { force: true }));
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
331
436
|
// 轨迹聚合:trajectory.step 攒批,idle 时产出 trajectory.summary(减少刷屏)
|
|
332
437
|
aggregator.onEvent(ev, (out) => {
|
|
333
438
|
const planned = planOutbound(out);
|
|
334
439
|
if (!planned) return;
|
|
335
440
|
const chatId = chatIdFor(out.sessionId);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
441
|
+
// 长回复智能分片(对齐竞品):>1500 字按换行/句号断行,带(i/n)序号
|
|
442
|
+
const chunks = chunkLongText(planned.payload.text);
|
|
443
|
+
chunks.forEach((chunk, i) => {
|
|
444
|
+
const payload = i === 0
|
|
445
|
+
? planned.payload
|
|
446
|
+
: { text: chunk };
|
|
447
|
+
console.log(`[gateway][${adapter.id}] 事件 ${out.type} -> 发送到 ${chatId}${chunks.length > 1 ? `(${i + 1}/${chunks.length})` : ''}`);
|
|
448
|
+
void adapter.send(chatId, payload).then(
|
|
449
|
+
() => console.log(`[gateway][${adapter.id}] 已发送 ${out.type} 到 ${chatId}`),
|
|
450
|
+
(error) => console.error(`[gateway][${adapter.id}] 发送失败 ${out.type}: ${error instanceof Error ? error.message : String(error)}`),
|
|
451
|
+
);
|
|
452
|
+
});
|
|
341
453
|
});
|
|
342
454
|
});
|
|
343
455
|
}
|
|
@@ -358,16 +470,29 @@ async function main(): Promise<void> {
|
|
|
358
470
|
if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
|
|
359
471
|
const memory = new MemoryStore(process.env.MEMORY_FILE ?? 'data/memory.json');
|
|
360
472
|
console.log(`[gateway] 记忆系统已启用(文件: ${process.env.MEMORY_FILE ?? 'data/memory.json'})`);
|
|
473
|
+
const feedStore = new FeedStore(process.env.FEED_FILE ?? 'data/feeds.json');
|
|
474
|
+
const topics = new TopicStore(process.env.TOPIC_FILE ?? 'data/topics.json');
|
|
475
|
+
const requireMention = process.env.GROUP_MENTION === '1';
|
|
476
|
+
if (requireMention) console.log('[gateway] 群聊提及模式已启用(群聊仅在被提及/回复时响应)');
|
|
361
477
|
|
|
362
478
|
const client = new GatewayClient(dshBaseUrl, dshToken);
|
|
363
479
|
await client.health(); // 确认 DSH 侧(或 mock)活着
|
|
364
480
|
|
|
365
481
|
const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
|
|
482
|
+
const adaptersMap = new Map(adapters.map((a) => [a.id, a]));
|
|
483
|
+
const feedPoller = new FeedPoller(feedStore, adaptersMap);
|
|
484
|
+
feedPoller.start();
|
|
485
|
+
console.log('[gateway] RSS 订阅轮询已启动');
|
|
366
486
|
for (const adapter of adapters) {
|
|
367
487
|
await adapter.connect();
|
|
368
488
|
// 人设:PERSONA_<ADAPTER_ID>(如 PERSONA_TELEGRAM)优先,回退 PERSONA
|
|
369
489
|
const persona = process.env[`PERSONA_${adapter.id.toUpperCase()}`] ?? process.env.PERSONA;
|
|
370
|
-
|
|
490
|
+
// 机器人身份:BOT_IDENTITY_<ADAPTER_ID> 优先,回退 BOT_IDENTITY
|
|
491
|
+
const botIdentity = process.env[`BOT_IDENTITY_${adapter.id.toUpperCase()}`] ?? process.env.BOT_IDENTITY ?? '';
|
|
492
|
+
await wireAdapter(adapter, client, {
|
|
493
|
+
allowlist, allowAll, asr, memory, persona, feed: feedStore, topics,
|
|
494
|
+
requireMention, botIdentity,
|
|
495
|
+
});
|
|
371
496
|
console.log(`[gateway] ${adapter.id} 适配器已就绪${persona ? `(人设: ${persona.slice(0, 20)}…)` : ''}`);
|
|
372
497
|
}
|
|
373
498
|
|
package/src/memory.ts
CHANGED
|
@@ -131,3 +131,46 @@ export class MemoryStore {
|
|
|
131
131
|
return this.list(scope).length;
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
/** 会话主题存储(/context):sessionKey → 主题,JSON 持久化;file 缺省仅内存。 */
|
|
136
|
+
export class TopicStore {
|
|
137
|
+
private readonly map = new Map<string, string>();
|
|
138
|
+
private readonly file?: string;
|
|
139
|
+
|
|
140
|
+
constructor(file?: string) {
|
|
141
|
+
this.file = file;
|
|
142
|
+
if (file && existsSync(file)) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Record<string, string>;
|
|
145
|
+
for (const [k, v] of Object.entries(parsed)) if (typeof v === 'string') this.map.set(k, v);
|
|
146
|
+
} catch {
|
|
147
|
+
/* 损坏则从空开始 */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private persist(): void {
|
|
153
|
+
if (!this.file) return;
|
|
154
|
+
try {
|
|
155
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
156
|
+
writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.map), null, 2), 'utf8');
|
|
157
|
+
} catch {
|
|
158
|
+
/* 持久化失败不阻断 */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
set(sessionKey: string, topic: string): void {
|
|
163
|
+
this.map.set(sessionKey, topic);
|
|
164
|
+
this.persist();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get(sessionKey: string): string | undefined {
|
|
168
|
+
return this.map.get(sessionKey);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
clear(sessionKey: string): boolean {
|
|
172
|
+
const removed = this.map.delete(sessionKey);
|
|
173
|
+
if (removed) this.persist();
|
|
174
|
+
return removed;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/mention.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// 群聊响应策略(对齐竞品 dsh-im 的「私聊直接响应、群聊被提及/回复才响应」)。
|
|
2
|
+
// 纯函数,平台判定基于渠道 ID 特征与提及模式;无法判定的平台默认始终响应。
|
|
3
|
+
|
|
4
|
+
import type { NormalizedMessage } from './adapter.js';
|
|
5
|
+
|
|
6
|
+
/** 纯函数:该渠道 ID 是否为群聊/频道(vs 私聊)。平台特征: */
|
|
7
|
+
export function isGroupChat(adapterId: string, chatId: string): boolean {
|
|
8
|
+
switch (adapterId) {
|
|
9
|
+
case 'telegram':
|
|
10
|
+
// 群/超级群为负 ID(-100 前缀的超群,- 前缀的普通群)
|
|
11
|
+
return chatId.startsWith('-');
|
|
12
|
+
case 'whatsapp':
|
|
13
|
+
return chatId.endsWith('@g.us');
|
|
14
|
+
case 'slack':
|
|
15
|
+
// Slack 私聊 DM 以 D 开头,公共频道/群以 C/G 开头
|
|
16
|
+
return !chatId.startsWith('D');
|
|
17
|
+
default:
|
|
18
|
+
// discord / feishu / dingtalk / wecom / wechat / cli:无法从 ID 可靠区分 → 视为私聊(始终响应)
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 纯函数:消息文本是否提及了机器人(@<identity> 或 <@identity> 平台格式)。 */
|
|
24
|
+
export function isMentioned(adapterId: string, text: string, botIdentity: string): boolean {
|
|
25
|
+
if (!botIdentity) return false;
|
|
26
|
+
switch (adapterId) {
|
|
27
|
+
case 'telegram':
|
|
28
|
+
return text.includes(`@${botIdentity}`);
|
|
29
|
+
case 'whatsapp':
|
|
30
|
+
return text.includes(`@${botIdentity}`);
|
|
31
|
+
case 'discord':
|
|
32
|
+
case 'slack':
|
|
33
|
+
return text.includes(`<@${botIdentity}>`);
|
|
34
|
+
default:
|
|
35
|
+
return true; // 无法检测提及的平台 → 视为已提及(始终响应)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface MentionPolicy {
|
|
40
|
+
/** 群聊中要求被提及/回复才响应;私聊始终响应。 */
|
|
41
|
+
requireMention: boolean;
|
|
42
|
+
/** 机器人身份(telegram @用户名 / discord·slack 用户ID / whatsapp 号码)。 */
|
|
43
|
+
botIdentity: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 纯函数:是否应响应该消息。 */
|
|
47
|
+
export function shouldRespond(adapterId: string, msg: NormalizedMessage, policy: MentionPolicy): boolean {
|
|
48
|
+
if (!policy.requireMention) return true;
|
|
49
|
+
if (!isGroupChat(adapterId, msg.chatId)) return true; // 私聊始终响应
|
|
50
|
+
return isMentioned(adapterId, msg.text, policy.botIdentity);
|
|
51
|
+
}
|
package/src/pending-buttons.ts
CHANGED
|
@@ -23,7 +23,11 @@ export class PendingButtons {
|
|
|
23
23
|
this.map.set(chatId, { buttons, expiresAt: Date.now() + this.ttlMs });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* 回复解析(对齐竞品的文字审批):数字编号("1"/"2"…)或关键词
|
|
28
|
+
* (批准/同意/yes/ok/确认 → approve 按钮;拒绝/不同意/no/取消 → reject 按钮)。
|
|
29
|
+
* 命中即消费;无 pending / 已过期 / 不匹配返回 undefined。
|
|
30
|
+
*/
|
|
27
31
|
match(chatId: string, text: string): OutboundButton | undefined {
|
|
28
32
|
const entry = this.map.get(chatId);
|
|
29
33
|
if (!entry) return undefined;
|
|
@@ -31,11 +35,27 @@ export class PendingButtons {
|
|
|
31
35
|
this.map.delete(chatId);
|
|
32
36
|
return undefined;
|
|
33
37
|
}
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
38
|
-
|
|
38
|
+
const trimmed = text.trim().toLowerCase();
|
|
39
|
+
// 数字回复
|
|
40
|
+
const n = Number(trimmed);
|
|
41
|
+
if (Number.isInteger(n) && n >= 1 && n <= entry.buttons.length) {
|
|
42
|
+
const button = entry.buttons[n - 1];
|
|
43
|
+
if (button) this.map.delete(chatId);
|
|
44
|
+
return button;
|
|
45
|
+
}
|
|
46
|
+
// 文字审批关键词(竞品 dsh-im 同款:回复「批准/拒绝」即可)
|
|
47
|
+
const approveWords = ['批准', '同意', 'yes', 'ok', '确认', 'approve'];
|
|
48
|
+
const rejectWords = ['拒绝', '不同意', 'no', '取消', 'reject'];
|
|
49
|
+
const wantsApprove = approveWords.some((w) => trimmed === w || trimmed.startsWith(`${w} `));
|
|
50
|
+
const wantsReject = rejectWords.some((w) => trimmed === w || trimmed.startsWith(`${w} `));
|
|
51
|
+
if (wantsApprove || wantsReject) {
|
|
52
|
+
const button = entry.buttons.find((b) =>
|
|
53
|
+
wantsApprove ? b.id.startsWith('approve:') : b.id.startsWith('reject:'),
|
|
54
|
+
);
|
|
55
|
+
if (button) this.map.delete(chatId);
|
|
56
|
+
return button;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
/** 消费原生按钮点击(如 WhatsApp 原生交互按钮):删除该 chat 的 pending。 */
|
package/src/text.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// 长文本工具:按渠道可读性分片(对齐竞品的长回复分段能力)。
|
|
2
|
+
|
|
3
|
+
const DEFAULT_CHUNK_LIMIT = 1500;
|
|
4
|
+
|
|
5
|
+
/** 纯函数:长文本按 limit 分片,优先在换行/句号/问号/感叹号处断行;超过 1 段时带(i/n)序号。 */
|
|
6
|
+
export function chunkLongText(text: string, limit = DEFAULT_CHUNK_LIMIT): string[] {
|
|
7
|
+
if (!text) return [text];
|
|
8
|
+
if (text.length <= limit) return [text];
|
|
9
|
+
|
|
10
|
+
const raw: string[] = [];
|
|
11
|
+
let rest = text;
|
|
12
|
+
while (rest.length > limit) {
|
|
13
|
+
const slice = rest.slice(0, limit);
|
|
14
|
+
const breakAt = Math.max(
|
|
15
|
+
slice.lastIndexOf('\n'),
|
|
16
|
+
slice.lastIndexOf('。'),
|
|
17
|
+
slice.lastIndexOf('!'),
|
|
18
|
+
slice.lastIndexOf('?'),
|
|
19
|
+
slice.lastIndexOf('.'),
|
|
20
|
+
slice.lastIndexOf('!'),
|
|
21
|
+
slice.lastIndexOf('?'),
|
|
22
|
+
slice.lastIndexOf(';'),
|
|
23
|
+
);
|
|
24
|
+
const cut = breakAt >= limit * 0.6 ? breakAt + 1 : limit;
|
|
25
|
+
raw.push(rest.slice(0, cut));
|
|
26
|
+
rest = rest.slice(cut);
|
|
27
|
+
}
|
|
28
|
+
if (rest) raw.push(rest);
|
|
29
|
+
|
|
30
|
+
if (raw.length <= 1) return raw;
|
|
31
|
+
return raw.map((chunk, i) => `${chunk}\n(${i + 1}/${raw.length})`);
|
|
32
|
+
}
|
package/test/commands.test.ts
CHANGED
|
@@ -40,4 +40,14 @@ describe('parseCommand', () => {
|
|
|
40
40
|
expect(parseCommand('/status')).toEqual({ kind: 'status' });
|
|
41
41
|
expect(parseCommand('/send')).toBeNull(); // 缺路径
|
|
42
42
|
});
|
|
43
|
+
it('识别 /cron --tz 时区', () => {
|
|
44
|
+
expect(parseCommand('/cron 0 8 * * * 每日汇报 --tz Asia/Shanghai'))
|
|
45
|
+
.toEqual({ kind: 'cron', schedule: '0 8 * * *', prompt: '每日汇报', timeZone: 'Asia/Shanghai' });
|
|
46
|
+
expect(parseCommand('/cron 0 8 * * * 每日汇报')).toEqual({ kind: 'cron', schedule: '0 8 * * *', prompt: '每日汇报', timeZone: undefined });
|
|
47
|
+
});
|
|
48
|
+
it('识别 /context', () => {
|
|
49
|
+
expect(parseCommand('/context 项目重构')).toEqual({ kind: 'context', action: 'set', topic: '项目重构' });
|
|
50
|
+
expect(parseCommand('/context off')).toEqual({ kind: 'context', action: 'clear' });
|
|
51
|
+
expect(parseCommand('/context')).toEqual({ kind: 'context', action: 'show' });
|
|
52
|
+
});
|
|
43
53
|
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { FeedPoller, FeedStore, formatFeedItem, parseRss } from '../src/feed.js';
|
|
3
|
+
import type { Adapter } from '../src/adapter.js';
|
|
4
|
+
|
|
5
|
+
const SAMPLE_RSS = `<?xml version="1.0"?>
|
|
6
|
+
<rss version="2.0"><channel>
|
|
7
|
+
<title>Example Feed</title>
|
|
8
|
+
<item>
|
|
9
|
+
<title>First post</title>
|
|
10
|
+
<link>https://x.com/1</link>
|
|
11
|
+
<guid>g-1</guid>
|
|
12
|
+
<pubDate>Mon, 18 Aug 2026 10:00:00 GMT</pubDate>
|
|
13
|
+
</item>
|
|
14
|
+
<item>
|
|
15
|
+
<title>Second & great post</title>
|
|
16
|
+
<link>https://x.com/2</link>
|
|
17
|
+
<guid>g-2</guid>
|
|
18
|
+
</item>
|
|
19
|
+
</channel></rss>`;
|
|
20
|
+
|
|
21
|
+
describe('parseRss', () => {
|
|
22
|
+
it('解析条目与实体解码', () => {
|
|
23
|
+
const items = parseRss(SAMPLE_RSS);
|
|
24
|
+
expect(items).toHaveLength(2);
|
|
25
|
+
expect(items[0]).toMatchObject({ title: 'First post', link: 'https://x.com/1', guid: 'g-1', pubDate: 'Mon, 18 Aug 2026 10:00:00 GMT' });
|
|
26
|
+
expect(items[1].title).toBe('Second & great post');
|
|
27
|
+
});
|
|
28
|
+
it('空/非法 XML 返回空', () => {
|
|
29
|
+
expect(parseRss('')).toEqual([]);
|
|
30
|
+
expect(parseRss('<rss></rss>')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
it('CDATA 内容解码', () => {
|
|
33
|
+
const xml = '<rss><channel><item><title><![CDATA[Hello <World>]]></title><guid>g</guid></item></channel></rss>';
|
|
34
|
+
expect(parseRss(xml)[0].title).toBe('Hello <World>');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('formatFeedItem', () => {
|
|
39
|
+
it('带链接时输出 标题+链接', () => {
|
|
40
|
+
expect(formatFeedItem({ title: 'A', link: 'https://a', guid: 'g' })).toContain('📰 A');
|
|
41
|
+
expect(formatFeedItem({ title: 'A', link: 'https://a', guid: 'g' })).toContain('https://a');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('FeedStore(内存模式)', () => {
|
|
46
|
+
it('add / list / remove / updateLastGuid', () => {
|
|
47
|
+
const store = new FeedStore();
|
|
48
|
+
const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
|
|
49
|
+
expect(store.list()).toHaveLength(1);
|
|
50
|
+
expect(feed.lastGuid).toBe('');
|
|
51
|
+
store.updateLastGuid(feed.id, 'g-2');
|
|
52
|
+
expect(store.list()[0].lastGuid).toBe('g-2');
|
|
53
|
+
expect(store.remove(feed.id)).toBe(true);
|
|
54
|
+
expect(store.list()).toHaveLength(0);
|
|
55
|
+
expect(store.remove(feed.id)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('FeedPoller.pollOnce', () => {
|
|
60
|
+
const fakeAdapter = { id: 'telegram', send: vi.fn(async () => {}) } as unknown as Adapter;
|
|
61
|
+
|
|
62
|
+
it('首次订阅只建立游标不推送', async () => {
|
|
63
|
+
const store = new FeedStore();
|
|
64
|
+
const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
|
|
65
|
+
const poller = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
|
|
66
|
+
fetchImpl: vi.fn(async () => ({ ok: true, text: async () => SAMPLE_RSS }) as Response),
|
|
67
|
+
});
|
|
68
|
+
const pushed = await poller.pollOnce();
|
|
69
|
+
expect(pushed).toBe(0);
|
|
70
|
+
expect(store.list()[0].lastGuid).toBe('g-1');
|
|
71
|
+
expect(fakeAdapter.send).not.toHaveBeenCalled();
|
|
72
|
+
void feed;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('游标之后的新条目按序推送', async () => {
|
|
76
|
+
const store = new FeedStore();
|
|
77
|
+
const feed = store.add('telegram', 'c1', 'https://feed.example/rss');
|
|
78
|
+
// 首次订阅:游标建立在最新一条 g-1 上
|
|
79
|
+
const poller1 = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
|
|
80
|
+
fetchImpl: vi.fn(async () => ({ ok: true, text: async () => SAMPLE_RSS }) as Response),
|
|
81
|
+
});
|
|
82
|
+
await poller1.pollOnce();
|
|
83
|
+
expect(store.list()[0].lastGuid).toBe('g-1');
|
|
84
|
+
fakeAdapter.send.mockClear();
|
|
85
|
+
// 第二轮出现新条目 g-0(文档序最新在前)→ 只推 g-0
|
|
86
|
+
const NEW_RSS = `<rss version="2.0"><channel><item><title>Brand new</title><link>https://x.com/0</link><guid>g-0</guid></item>${SAMPLE_RSS.slice(SAMPLE_RSS.indexOf('<item>'))}`;
|
|
87
|
+
const poller2 = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
|
|
88
|
+
fetchImpl: vi.fn(async () => ({ ok: true, text: async () => NEW_RSS }) as Response),
|
|
89
|
+
});
|
|
90
|
+
const pushed = await poller2.pollOnce();
|
|
91
|
+
expect(pushed).toBe(1);
|
|
92
|
+
expect(fakeAdapter.send).toHaveBeenCalledWith('c1', expect.objectContaining({ text: expect.stringContaining('Brand new') }));
|
|
93
|
+
expect(store.list()[0].lastGuid).toBe('g-0');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('抓取失败不影响其他源', async () => {
|
|
97
|
+
const store = new FeedStore();
|
|
98
|
+
store.add('telegram', 'c1', 'https://bad.example');
|
|
99
|
+
store.add('telegram', 'c1', 'https://good.example');
|
|
100
|
+
const calls: string[] = [];
|
|
101
|
+
const poller = new FeedPoller(store, new Map([['telegram', fakeAdapter]]), {
|
|
102
|
+
fetchImpl: vi.fn(async (url: string) => {
|
|
103
|
+
calls.push(url);
|
|
104
|
+
if (url.includes('bad')) return { ok: false, status: 500 } as Response;
|
|
105
|
+
return { ok: true, text: async () => SAMPLE_RSS } as Response;
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
await poller.pollOnce();
|
|
109
|
+
expect(calls).toHaveLength(2);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isGroupChat, isMentioned, shouldRespond } from '../src/mention.js';
|
|
3
|
+
|
|
4
|
+
describe('isGroupChat', () => {
|
|
5
|
+
it('telegram 负 ID 为群', () => {
|
|
6
|
+
expect(isGroupChat('telegram', '-1001234567890')).toBe(true);
|
|
7
|
+
expect(isGroupChat('telegram', '-12345')).toBe(true);
|
|
8
|
+
expect(isGroupChat('telegram', '12345')).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it('whatsapp @g.us 为群', () => {
|
|
11
|
+
expect(isGroupChat('whatsapp', '123@ g.us'.replace(' ', ''))).toBe(true);
|
|
12
|
+
expect(isGroupChat('whatsapp', '123@s.whatsapp.net')).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
it('slack D 开头为私聊', () => {
|
|
15
|
+
expect(isGroupChat('slack', 'D123')).toBe(false);
|
|
16
|
+
expect(isGroupChat('slack', 'C123')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it('无法判定的平台默认视为私聊', () => {
|
|
19
|
+
expect(isGroupChat('discord', 'any')).toBe(false);
|
|
20
|
+
expect(isGroupChat('feishu', 'oc_1')).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('isMentioned', () => {
|
|
25
|
+
it('telegram/whatsapp @身份', () => {
|
|
26
|
+
expect(isMentioned('telegram', '@mybot 你好', 'mybot')).toBe(true);
|
|
27
|
+
expect(isMentioned('telegram', '你好', 'mybot')).toBe(false);
|
|
28
|
+
expect(isMentioned('whatsapp', '@8613800000000 hi', '8613800000000')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
it('discord/slack <@ID>', () => {
|
|
31
|
+
expect(isMentioned('discord', '<@123456> hello', '123456')).toBe(true);
|
|
32
|
+
expect(isMentioned('slack', 'hello <@U123>', 'U123')).toBe(true);
|
|
33
|
+
expect(isMentioned('discord', 'hello', '123456')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
it('无法检测的平台视为已提及', () => {
|
|
36
|
+
expect(isMentioned('feishu', '随便', 'x')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('shouldRespond', () => {
|
|
41
|
+
const msg = (text: string) => ({ chatId: '-100123', userId: 'u', text });
|
|
42
|
+
it('未开启提及模式 → 始终响应', () => {
|
|
43
|
+
expect(shouldRespond('telegram', msg('随便'), { requireMention: false, botIdentity: 'b' })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
it('群聊未提及 → 不响应;提及 → 响应', () => {
|
|
46
|
+
const policy = { requireMention: true, botIdentity: 'mybot' };
|
|
47
|
+
expect(shouldRespond('telegram', msg('你好'), policy)).toBe(false);
|
|
48
|
+
expect(shouldRespond('telegram', msg('@mybot 你好'), policy)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('私聊始终响应', () => {
|
|
51
|
+
const policy = { requireMention: true, botIdentity: 'mybot' };
|
|
52
|
+
expect(shouldRespond('telegram', { chatId: '12345', userId: 'u', text: '你好' }, policy)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/test/multi.test.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { mediaKindFromPath, remindSchedule, wireAdapter } from '../src/index.js';
|
|
3
|
-
import { GatewayClient } from '@dsh-overdrive/sdk';
|
|
4
|
-
import { MemoryStore } from '../src/memory.js';
|
|
3
|
+
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
|
+
import { MemoryStore, TopicStore } from '../src/memory.js';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
|
-
import { rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import type { Adapter, NormalizedMessage, OutboundPayload, ReplySender } from '../src/adapter.js';
|
|
9
9
|
|
|
10
10
|
/** 可编程 FakeAdapter:验证 wiring 逻辑(白名单/会话键/错误兜底)。 */
|
|
@@ -166,6 +166,52 @@ describe('wireAdapter(多适配器装配核心)', () => {
|
|
|
166
166
|
expect(messages[0].text).toBe('【人设】你是一个毒舌助理\n你好');
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
+
it('file.created 事件:agent 产出的文件自动发回聊天并清理临时文件', async () => {
|
|
170
|
+
const adapter = new FakeAdapter('telegram');
|
|
171
|
+
let onEvent: ((ev: ServerEvent) => void) | undefined;
|
|
172
|
+
const client = {
|
|
173
|
+
upsertSession: async () => ({ sessionId: 'telegram:111:222' }),
|
|
174
|
+
connect: async (cb: (ev: ServerEvent) => void) => { onEvent = cb; return () => {}; },
|
|
175
|
+
} as unknown as GatewayClient;
|
|
176
|
+
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'] });
|
|
177
|
+
|
|
178
|
+
const bytes = Buffer.from('fake-image-bytes');
|
|
179
|
+
onEvent!({
|
|
180
|
+
type: 'file.created', sessionId: 'telegram:111:222', ts: Date.now(),
|
|
181
|
+
name: 'chart.png', kind: 'image', data: bytes.toString('base64'),
|
|
182
|
+
});
|
|
183
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
184
|
+
expect(adapter.sent[0].payload.text).toContain('chart.png');
|
|
185
|
+
expect(adapter.sent[0].payload.media).toMatchObject({ kind: 'image', caption: 'chart.png' });
|
|
186
|
+
expect(existsSync(adapter.sent[0].payload.media!.path)).toBe(false); // 临时文件已清理
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('群聊提及模式:群聊未提及不响应,提及才响应', async () => {
|
|
190
|
+
const adapter = new FakeAdapter('telegram');
|
|
191
|
+
const { client, messages } = fakeClient();
|
|
192
|
+
await wireAdapter(adapter, client, { allowlist: ['telegram:-100123:222'], requireMention: true, botIdentity: 'mybot' });
|
|
193
|
+
|
|
194
|
+
await adapter.emit({ chatId: '-100123', userId: '222', text: '你好' }); // 群聊(负 ID)未提及
|
|
195
|
+
expect(messages).toHaveLength(0);
|
|
196
|
+
await adapter.emit({ chatId: '-100123', userId: '222', text: '@mybot 你好' }); // 提及
|
|
197
|
+
expect(messages).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('/context 设置/清除会话主题并注入到消息', async () => {
|
|
201
|
+
const adapter = new FakeAdapter('telegram');
|
|
202
|
+
const { client, messages } = fakeClient();
|
|
203
|
+
const topics = new TopicStore();
|
|
204
|
+
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], topics });
|
|
205
|
+
|
|
206
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '/context 项目重构' });
|
|
207
|
+
expect(adapter.sent[0].payload.text).toContain('已绑定');
|
|
208
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '帮我写周报' });
|
|
209
|
+
expect(messages[0].text).toBe('【会话主题】项目重构\n帮我写周报');
|
|
210
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '/context off' });
|
|
211
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '你好' });
|
|
212
|
+
expect(messages[1].text).toBe('你好');
|
|
213
|
+
});
|
|
214
|
+
|
|
169
215
|
it('/send <path> 读取文件并以媒体载荷发送', async () => {
|
|
170
216
|
const adapter = new FakeAdapter('telegram');
|
|
171
217
|
const { client } = fakeClient();
|