@dsh-overdrive/gateway 0.2.0 → 0.3.1
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 +5 -0
- package/dist/commands.js +14 -4
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +57 -8
- 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 +9 -4
- package/src/adapter.ts +0 -42
- package/src/adapters/cli.ts +0 -37
- package/src/adapters/dingtalk.ts +0 -206
- package/src/adapters/discord.ts +0 -127
- package/src/adapters/feishu.ts +0 -224
- package/src/adapters/slack.ts +0 -123
- package/src/adapters/telegram.ts +0 -142
- package/src/adapters/wechat.ts +0 -247
- package/src/adapters/wecom.ts +0 -218
- package/src/adapters/whatsapp.ts +0 -249
- package/src/asr.ts +0 -83
- package/src/commands.ts +0 -89
- package/src/config.ts +0 -104
- package/src/feed.ts +0 -190
- package/src/index.ts +0 -456
- package/src/memory.ts +0 -133
- package/src/pending-buttons.ts +0 -45
- package/src/session.ts +0 -23
- package/src/setup.ts +0 -252
- package/src/status.ts +0 -63
- package/src/trajectory.ts +0 -45
- package/test/adapters.dingtalk.test.ts +0 -64
- package/test/adapters.discord.test.ts +0 -41
- package/test/adapters.feishu.test.ts +0 -66
- package/test/adapters.slack.test.ts +0 -45
- package/test/adapters.telegram.test.ts +0 -37
- package/test/adapters.wechat.test.ts +0 -78
- package/test/adapters.wecom.test.ts +0 -62
- package/test/adapters.whatsapp.test.ts +0 -138
- package/test/asr.test.ts +0 -77
- package/test/commands.test.ts +0 -43
- package/test/config.test.ts +0 -30
- package/test/feed.test.ts +0 -111
- package/test/memory.test.ts +0 -79
- package/test/multi.test.ts +0 -258
- package/test/outbound.test.ts +0 -29
- package/test/pending-buttons.test.ts +0 -61
- package/test/session.test.ts +0 -26
- package/test/status.test.ts +0 -41
- package/test/streaming.test.ts +0 -162
- package/test/trajectory.test.ts +0 -58
- package/tsconfig.json +0 -5
package/src/index.ts
DELETED
|
@@ -1,456 +0,0 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
5
|
-
import type { Adapter, OutboundPayload } from './adapter.js';
|
|
6
|
-
import { Allowlist, buildSessionKey } from './session.js';
|
|
7
|
-
import { adapterEnvFromProcess, createAdapter, parseAdapterIds } from './config.js';
|
|
8
|
-
import { CliAdapter } from './adapters/cli.js';
|
|
9
|
-
import { parseCommand, HELP_TEXT, type ParsedCommand } from './commands.js';
|
|
10
|
-
import { TrajectoryAggregator, formatTrajectorySummary } from './trajectory.js';
|
|
11
|
-
import { createStatusServer } from './status.js';
|
|
12
|
-
import { createTranscriber, type AsrTranscriber } from './asr.js';
|
|
13
|
-
import { MemoryStore, memoryScope, formatMemories, extractAutoMemories } from './memory.js';
|
|
14
|
-
import { FeedStore, FeedPoller } from './feed.js';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* message.delta → 打字指示去重:同一 turn 内首个 delta 触发一次 typing,
|
|
18
|
-
* complete 后复位,下一 turn 可再次触发。纯逻辑,便于单测。
|
|
19
|
-
*/
|
|
20
|
-
export class DeltaTracker {
|
|
21
|
-
private readonly fired = new Set<string>();
|
|
22
|
-
|
|
23
|
-
onDelta(sessionId: string, fireTyping: () => void): void {
|
|
24
|
-
if (this.fired.has(sessionId)) return;
|
|
25
|
-
this.fired.add(sessionId);
|
|
26
|
-
fireTyping();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** turn 结束(message.complete / error)时复位,允许下一 turn 再次触发 typing。 */
|
|
30
|
-
onComplete(sessionId: string): void {
|
|
31
|
-
this.fired.delete(sessionId);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** 事件 → 平台输出。纯函数,便于单测。返回 null 表示该事件不产出消息。 */
|
|
36
|
-
export function planOutbound(ev: ServerEvent): { payload: OutboundPayload } | null {
|
|
37
|
-
switch (ev.type) {
|
|
38
|
-
case 'message.complete':
|
|
39
|
-
return { payload: { text: ev.text } };
|
|
40
|
-
case 'message.delta':
|
|
41
|
-
return null; // 流式渲染走 sendTyping(见 wireAdapter),不产出文本
|
|
42
|
-
case 'trajectory.step': {
|
|
43
|
-
const icon = ev.step.kind === 'tool' ? '🛠️' : ev.step.kind === 'subagent' ? '🤖' : '🧠';
|
|
44
|
-
return { payload: { text: `${icon} ${ev.step.label}` } };
|
|
45
|
-
}
|
|
46
|
-
case 'trajectory.summary':
|
|
47
|
-
return { payload: { text: formatTrajectorySummary(ev.steps) } };
|
|
48
|
-
case 'approval.request':
|
|
49
|
-
return {
|
|
50
|
-
payload: {
|
|
51
|
-
text: `⚠️ 需要批准:${ev.summary}(${Math.round(ev.timeoutMs / 1000)}s 内有效)`,
|
|
52
|
-
buttons: [
|
|
53
|
-
{ id: `approve:${ev.reqId}`, label: '✅ 同意' },
|
|
54
|
-
{ id: `reject:${ev.reqId}`, label: '🚫 拒绝' },
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
case 'agent.status':
|
|
59
|
-
return ev.status === 'subagent-spawned'
|
|
60
|
-
? { payload: { text: '🤖 派生子任务…' } }
|
|
61
|
-
: null;
|
|
62
|
-
case 'task.done':
|
|
63
|
-
return { payload: { text: ev.ok ? `✅ 任务完成 ${ev.taskId}` : `❌ 任务失败 ${ev.taskId}` } };
|
|
64
|
-
case 'error':
|
|
65
|
-
return { payload: { text: `❌ 出错了:${ev.message}` } };
|
|
66
|
-
default:
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface WireOptions {
|
|
72
|
-
allowlist: string[];
|
|
73
|
-
/** 开发逃生口:ALLOW_ALL=1 跳过白名单(生产勿开)。 */
|
|
74
|
-
allowAll?: boolean;
|
|
75
|
-
/** ASR 转写器;配置了 API key 时启用,语音消息转成文本再发给 agent。 */
|
|
76
|
-
asr?: AsrTranscriber;
|
|
77
|
-
/** 记忆系统(OpenClaw 式长期记忆);未提供则记忆命令返回不可用。 */
|
|
78
|
-
memory?: MemoryStore;
|
|
79
|
-
/** 人设(persona):每条用户消息前注入的固定上下文,如「你是一个毒舌但贴心的私人助理」。 */
|
|
80
|
-
persona?: string;
|
|
81
|
-
/** RSS 订阅存储(/feed 命令)。 */
|
|
82
|
-
feed?: FeedStore;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** 纯函数:一次性提醒的时间 → cron 5 字段表达式(分钟精度)。 */
|
|
86
|
-
export function remindSchedule(minutes: number, atTime: string | null, now = new Date()): string {
|
|
87
|
-
const target = new Date(now);
|
|
88
|
-
if (atTime) {
|
|
89
|
-
const [h, m] = atTime.split(':').map(Number);
|
|
90
|
-
target.setHours(h, m, 0, 0);
|
|
91
|
-
if (target <= now) target.setDate(target.getDate() + 1); // 已过则明天
|
|
92
|
-
} else {
|
|
93
|
-
target.setMinutes(target.getMinutes() + minutes);
|
|
94
|
-
}
|
|
95
|
-
return `${target.getMinutes()} ${target.getHours()} ${target.getDate()} ${target.getMonth() + 1} *`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** 纯函数:本地文件路径 → 出站媒体类型(图片/语音/其他文件)。 */
|
|
99
|
-
export function mediaKindFromPath(path: string): 'image' | 'voice' | 'file' {
|
|
100
|
-
const ext = path.toLowerCase().split('.').pop() ?? '';
|
|
101
|
-
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'].includes(ext)) return 'image';
|
|
102
|
-
if (['ogg', 'oga', 'opus', 'mp3', 'wav', 'webm', 'm4a', 'aac'].includes(ext)) return 'voice';
|
|
103
|
-
return 'file';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** 命令面分发:/trace /new /task /cron /agents /help /remind /remember /recall /forget(M4 + v0.3)。 */
|
|
107
|
-
async function handleCommand(
|
|
108
|
-
adapter: Adapter,
|
|
109
|
-
client: GatewayClient,
|
|
110
|
-
command: ParsedCommand,
|
|
111
|
-
sessionId: string,
|
|
112
|
-
chatId: string,
|
|
113
|
-
aggregator: TrajectoryAggregator,
|
|
114
|
-
memory: MemoryStore | undefined,
|
|
115
|
-
feed: FeedStore | undefined,
|
|
116
|
-
): Promise<void> {
|
|
117
|
-
switch (command.kind) {
|
|
118
|
-
case 'trace': {
|
|
119
|
-
const summary = aggregator.recentSummary(sessionId);
|
|
120
|
-
await adapter.send(chatId, { text: summary ?? '暂无轨迹(尚未运行或已重置)。' });
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
case 'new': {
|
|
124
|
-
await client.resetSession(sessionId);
|
|
125
|
-
await adapter.send(chatId, { text: '🆕 会话已重置' });
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
case 'task': {
|
|
129
|
-
await client.createTask({ sessionId, kind: 'subagent', prompt: command.prompt });
|
|
130
|
-
await adapter.send(chatId, { text: '🤖 子任务已派出' });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
case 'cron': {
|
|
134
|
-
await client.createTask({ sessionId, kind: 'cron', prompt: command.prompt, schedule: command.schedule });
|
|
135
|
-
await adapter.send(chatId, { text: '⏰ 定时任务已注册' });
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
case 'crons': {
|
|
139
|
-
const res = await client.listTasks();
|
|
140
|
-
const text = res.tasks.length
|
|
141
|
-
? res.tasks.map((task) => {
|
|
142
|
-
const next = task.nextRunAt
|
|
143
|
-
? new Date(task.nextRunAt).toLocaleString('zh-CN', { hour12: false })
|
|
144
|
-
: '(无下次触发)';
|
|
145
|
-
return `- \`${task.id}\` ${task.schedule} — ${task.prompt}(下次 ${next})`;
|
|
146
|
-
}).join('\n')
|
|
147
|
-
: '暂无定时任务。';
|
|
148
|
-
await adapter.send(chatId, { text: `⏰ 定时任务(${res.tasks.length}):\n${text}` });
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
case 'cronrm': {
|
|
152
|
-
const res = await client.removeTask(command.taskId);
|
|
153
|
-
await adapter.send(chatId, {
|
|
154
|
-
text: res.ok ? `🗑️ 已删除定时任务 \`${command.taskId}\`` : `未找到定时任务 \`${command.taskId}\``,
|
|
155
|
-
});
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
case 'agents': {
|
|
159
|
-
await adapter.send(chatId, { text: '(M4 简化)子任务状态由 agent 汇报,/task 派发' });
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
case 'remember': {
|
|
163
|
-
if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
|
|
164
|
-
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
165
|
-
const entry = memory.add(scope, command.text);
|
|
166
|
-
await adapter.send(chatId, { text: `✅ 已记住(\`${entry.id}\`):${command.text}` });
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
case 'recall': {
|
|
170
|
-
if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
|
|
171
|
-
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
172
|
-
const hits = memory.search(scope, command.query);
|
|
173
|
-
if (hits.length === 0) { await adapter.send(chatId, { text: '没有相关记忆。' }); return; }
|
|
174
|
-
await adapter.send(chatId, {
|
|
175
|
-
text: `🧠 ${hits.length} 条记忆:\n` + hits.map((e) => `- \`${e.id}\` ${e.text}`).join('\n'),
|
|
176
|
-
});
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
case 'forget': {
|
|
180
|
-
if (!memory) { await adapter.send(chatId, { text: '记忆系统未启用。' }); return; }
|
|
181
|
-
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
182
|
-
const ok = memory.remove(scope, command.memoryId);
|
|
183
|
-
await adapter.send(chatId, { text: ok ? `🗑️ 已删除记忆 \`${command.memoryId}\`` : `未找到记忆 \`${command.memoryId}\`` });
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
case 'remind': {
|
|
187
|
-
const schedule = remindSchedule(command.inMinutes ?? 0, command.atTime);
|
|
188
|
-
await client.createTask({ sessionId, kind: 'cron', prompt: `⏰ 提醒:${command.text}`, schedule, once: true });
|
|
189
|
-
await adapter.send(chatId, {
|
|
190
|
-
text: `⏰ 已设置提醒「${command.text}」(${command.atTime ? `at ${command.atTime}` : `${command.inMinutes} 分钟后`},一次性)`,
|
|
191
|
-
});
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
case 'send': {
|
|
195
|
-
// 媒体发送(/send <path>):读本地文件 → 类型判定 → 交给适配器;不支持的平台降级为文本
|
|
196
|
-
const path = command.path;
|
|
197
|
-
if (!existsSync(path)) {
|
|
198
|
-
await adapter.send(chatId, { text: `❌ 找不到文件:${path}` });
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
await adapter.send(chatId, {
|
|
202
|
-
text: `📎 ${path}`,
|
|
203
|
-
media: { kind: mediaKindFromPath(path), path, caption: basename(path) },
|
|
204
|
-
});
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
case 'status': {
|
|
208
|
-
const connected = adapter.status?.().connected ?? false;
|
|
209
|
-
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
210
|
-
const memCount = memory ? memory.count(scope) : 0;
|
|
211
|
-
const crons = await client.listTasks();
|
|
212
|
-
await adapter.send(chatId, {
|
|
213
|
-
text: [
|
|
214
|
-
`📊 状态`,
|
|
215
|
-
`- 适配器 ${adapter.id}: ${connected ? '✅ 已连接' : '❌ 未连接'}`,
|
|
216
|
-
`- 你的记忆: ${memCount} 条`,
|
|
217
|
-
`- 定时任务: ${crons.tasks.length} 个`,
|
|
218
|
-
].join('\n'),
|
|
219
|
-
});
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
case 'digest': {
|
|
223
|
-
const scope = memoryScope(adapter.id, sessionId.split(':')[2] ?? '');
|
|
224
|
-
const mems = memory ? memory.list(scope) : [];
|
|
225
|
-
const crons = await client.listTasks();
|
|
226
|
-
await adapter.send(chatId, {
|
|
227
|
-
text: [
|
|
228
|
-
`📋 今日摘要`,
|
|
229
|
-
`- 你的记忆: ${mems.length} 条${mems.slice(0, 5).map((e) => `\n · ${e.text}`).join('')}`,
|
|
230
|
-
`- 定时任务: ${crons.tasks.length} 个`,
|
|
231
|
-
`- 订阅源: ${feed ? feed.list().filter((f) => f.chatId === chatId).length : 0} 个`,
|
|
232
|
-
].join('\n'),
|
|
233
|
-
});
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
case 'digestdaily': {
|
|
237
|
-
const schedule = remindSchedule(0, command.time);
|
|
238
|
-
await client.createTask({ sessionId, kind: 'cron', prompt: '⏰ 每日摘要:请基于今天的对话输出一份简短摘要。', schedule });
|
|
239
|
-
await adapter.send(chatId, { text: `📋 已设置每日摘要(${command.time} 触发)` });
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
case 'feedadd': {
|
|
243
|
-
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
244
|
-
const entry = feed.add(adapter.id, chatId, command.url);
|
|
245
|
-
await adapter.send(chatId, { text: `✅ 已订阅 RSS(\`${entry.id}\`):${command.url}` });
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
case 'feedlist': {
|
|
249
|
-
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
250
|
-
const mine = feed.list().filter((f) => f.chatId === chatId);
|
|
251
|
-
await adapter.send(chatId, {
|
|
252
|
-
text: mine.length
|
|
253
|
-
? `📡 订阅(${mine.length}):\n` + mine.map((f) => `- \`${f.id}\` ${f.url}`).join('\n')
|
|
254
|
-
: '暂无订阅。用 /feed add <rss链接> 添加。',
|
|
255
|
-
});
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
case 'feedrm': {
|
|
259
|
-
if (!feed) { await adapter.send(chatId, { text: 'RSS 订阅未启用。' }); return; }
|
|
260
|
-
const ok = feed.remove(command.feedId);
|
|
261
|
-
await adapter.send(chatId, { text: ok ? `🗑️ 已删除订阅 \`${command.feedId}\`` : `未找到订阅 \`${command.feedId}\`` });
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
case 'help': {
|
|
265
|
-
await adapter.send(chatId, { text: HELP_TEXT });
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** 单个适配器的接线:白名单 → 命令面/upsert → sendMessage;按钮 → resolveApproval;事件流经轨迹聚合。 */
|
|
272
|
-
export async function wireAdapter(
|
|
273
|
-
adapter: Adapter,
|
|
274
|
-
client: GatewayClient,
|
|
275
|
-
opts: WireOptions,
|
|
276
|
-
): Promise<void> {
|
|
277
|
-
const allow = new Allowlist(opts.allowlist, opts.allowAll);
|
|
278
|
-
const chatIds = new Map<string, string>();
|
|
279
|
-
const aggregator = new TrajectoryAggregator();
|
|
280
|
-
const deltas = new DeltaTracker();
|
|
281
|
-
|
|
282
|
-
adapter.onMessage(async (msg) => {
|
|
283
|
-
const key = buildSessionKey(adapter.id, msg);
|
|
284
|
-
console.log(`[gateway][${adapter.id}] 收到消息 chat=${msg.chatId} user=${msg.userId} text="${msg.text.slice(0, 40)}"`);
|
|
285
|
-
try {
|
|
286
|
-
if (!allow.allows(key)) {
|
|
287
|
-
await adapter.send(msg.chatId, { text: '⛔ 你不在白名单里。' });
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
chatIds.set(key, msg.chatId);
|
|
291
|
-
|
|
292
|
-
const command = parseCommand(msg.text);
|
|
293
|
-
if (command) {
|
|
294
|
-
console.log(`[gateway][${adapter.id}] 命令: ${JSON.stringify(command)}`);
|
|
295
|
-
await handleCommand(adapter, client, command, key, msg.chatId, aggregator, opts.memory, opts.feed);
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// OpenClaw 式记忆注入:入站消息前检索相关记忆,拼到文本后让 agent「记得你」
|
|
300
|
-
if (opts.memory) {
|
|
301
|
-
const scope = memoryScope(adapter.id, msg.userId);
|
|
302
|
-
const hits = opts.memory.search(scope, msg.text);
|
|
303
|
-
if (hits.length > 0) {
|
|
304
|
-
msg.text = `${msg.text}${formatMemories(hits)}`;
|
|
305
|
-
console.log(`[gateway][${adapter.id}] 注入 ${hits.length} 条相关记忆`);
|
|
306
|
-
}
|
|
307
|
-
// 自动记忆:识别「我叫XX」「我住在XX」等自我事实,自动沉淀(不重复保存)
|
|
308
|
-
for (const fact of extractAutoMemories(msg.text)) {
|
|
309
|
-
if (!opts.memory.list(scope).some((e) => e.text === fact)) {
|
|
310
|
-
opts.memory.add(scope, fact);
|
|
311
|
-
console.log(`[gateway][${adapter.id}] 自动记住: ${fact}`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
// persona:每条用户消息前置人设上下文(消息级注入,任何模型都生效)
|
|
316
|
-
if (opts.persona) {
|
|
317
|
-
msg.text = `【人设】${opts.persona}\n${msg.text}`;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ASR 语音转写:配置了 API key 时把语音消息转成文本;失败/未配置走原降级路径
|
|
321
|
-
if (msg.media?.kind === 'voice' && opts.asr?.enabled) {
|
|
322
|
-
const transcript = await opts.asr.transcribe(msg.media);
|
|
323
|
-
if (transcript) {
|
|
324
|
-
msg.text = msg.text ? `${msg.text}\n[语音转写] ${transcript}` : `[语音转写] ${transcript}`;
|
|
325
|
-
msg.media = undefined;
|
|
326
|
-
console.log(`[gateway][${adapter.id}] 语音转写: ${transcript.slice(0, 60)}`);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
await client.upsertSession({ platform: adapter.id, channel: msg.chatId, user: msg.userId });
|
|
331
|
-
console.log(`[gateway][${adapter.id}] upsertSession OK -> ${key}`);
|
|
332
|
-
await client.sendMessage(key, { text: msg.text, media: msg.media });
|
|
333
|
-
console.log(`[gateway][${adapter.id}] sendMessage OK`);
|
|
334
|
-
} catch (error) {
|
|
335
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
336
|
-
console.error(`[gateway][${adapter.id}] 处理失败: ${message}`);
|
|
337
|
-
await adapter.send(msg.chatId, { text: `❌ 出错了:${message}` }).catch(() => undefined);
|
|
338
|
-
}
|
|
339
|
-
});
|
|
340
|
-
|
|
341
|
-
adapter.onReply(async (buttonId, sender) => {
|
|
342
|
-
try {
|
|
343
|
-
const idx = buttonId.indexOf(':');
|
|
344
|
-
if (idx < 0) return;
|
|
345
|
-
const action = buttonId.slice(0, idx) as 'approve' | 'reject';
|
|
346
|
-
const reqId = buttonId.slice(idx + 1);
|
|
347
|
-
if ((action === 'approve' || action === 'reject') && reqId) {
|
|
348
|
-
// 安全边界:审批按钮必须校验点击者(维护者评审指出:未授权用户可代授权)
|
|
349
|
-
const senderKey = buildSessionKey(adapter.id, { chatId: sender.chatId, userId: sender.userId });
|
|
350
|
-
if (!allow.allows(senderKey)) {
|
|
351
|
-
console.warn(`[gateway][${adapter.id}] 按钮点击者不在白名单,拒绝批准: ${senderKey}`);
|
|
352
|
-
if (sender.chatId) {
|
|
353
|
-
await adapter.send(sender.chatId, { text: '⛔ 你不在白名单里,不能批准该操作。' }).catch(() => undefined);
|
|
354
|
-
}
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
await client.resolveApproval(reqId, action);
|
|
358
|
-
}
|
|
359
|
-
} catch (error) {
|
|
360
|
-
console.error(`[gateway] resolveApproval 失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
361
|
-
}
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
const chatIdFor = (sessionId: string): string => {
|
|
365
|
-
const known = chatIds.get(sessionId);
|
|
366
|
-
if (known) return known;
|
|
367
|
-
return sessionId.split(':')[1] ?? sessionId;
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
await client.connect((ev) => {
|
|
371
|
-
// 流式渲染:delta → 打字指示(去重),complete/error → 复位(下一 turn 可再触发)
|
|
372
|
-
if (ev.type === 'message.delta') {
|
|
373
|
-
deltas.onDelta(ev.sessionId, () => void adapter.sendTyping?.(chatIdFor(ev.sessionId)));
|
|
374
|
-
} else if (ev.type === 'message.complete' || ev.type === 'error') {
|
|
375
|
-
deltas.onComplete(ev.sessionId);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// 自动发送:agent 产出的文件(file.created,base64)→ 写临时文件 → 发到聊天 → 清理
|
|
379
|
-
if (ev.type === 'file.created') {
|
|
380
|
-
const chatId = chatIdFor(ev.sessionId);
|
|
381
|
-
const tmp = join(tmpdir(), `dsh-out-${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${ev.name}`);
|
|
382
|
-
try {
|
|
383
|
-
writeFileSync(tmp, Buffer.from(ev.data, 'base64'));
|
|
384
|
-
} catch (error) {
|
|
385
|
-
console.error(`[gateway][${adapter.id}] 写入自动发送临时文件失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
void adapter.send(chatId, { text: `📎 ${ev.name}`, media: { kind: ev.kind, path: tmp, caption: ev.name } })
|
|
389
|
-
.then(
|
|
390
|
-
() => console.log(`[gateway][${adapter.id}] 已自动发送 ${ev.name} 到 ${chatId}`),
|
|
391
|
-
(error) => console.error(`[gateway][${adapter.id}] 自动发送失败 ${ev.name}: ${error instanceof Error ? error.message : String(error)}`),
|
|
392
|
-
)
|
|
393
|
-
.finally(() => rmSync(tmp, { force: true }));
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// 轨迹聚合:trajectory.step 攒批,idle 时产出 trajectory.summary(减少刷屏)
|
|
398
|
-
aggregator.onEvent(ev, (out) => {
|
|
399
|
-
const planned = planOutbound(out);
|
|
400
|
-
if (!planned) return;
|
|
401
|
-
const chatId = chatIdFor(out.sessionId);
|
|
402
|
-
console.log(`[gateway][${adapter.id}] 事件 ${out.type} -> 发送到 ${chatId}`);
|
|
403
|
-
void adapter.send(chatId, planned.payload).then(
|
|
404
|
-
() => console.log(`[gateway][${adapter.id}] 已发送 ${out.type} 到 ${chatId}`),
|
|
405
|
-
(error) => console.error(`[gateway][${adapter.id}] 发送失败 ${out.type}: ${error instanceof Error ? error.message : String(error)}`),
|
|
406
|
-
);
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async function main(): Promise<void> {
|
|
412
|
-
const dshBaseUrl = process.env.DSH_BASE_URL ?? 'http://127.0.0.1:3191';
|
|
413
|
-
const dshToken = process.env.DSH_OVERDRIVE_TOKEN ?? process.env.DSH_TOKEN ?? 'dev-token';
|
|
414
|
-
const allowlist = (process.env.ALLOWLIST ?? '')
|
|
415
|
-
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
416
|
-
const allowAll = process.env.ALLOW_ALL === '1';
|
|
417
|
-
const adapterIds = parseAdapterIds(process.env.GATEWAY_ADAPTERS ?? 'cli');
|
|
418
|
-
const env = adapterEnvFromProcess();
|
|
419
|
-
const asr = createTranscriber({
|
|
420
|
-
apiKey: env.asrApiKey,
|
|
421
|
-
baseUrl: env.asrBaseUrl,
|
|
422
|
-
model: env.asrModel,
|
|
423
|
-
});
|
|
424
|
-
if (asr.enabled) console.log('[gateway] ASR 语音转写已启用');
|
|
425
|
-
const memory = new MemoryStore(process.env.MEMORY_FILE ?? 'data/memory.json');
|
|
426
|
-
console.log(`[gateway] 记忆系统已启用(文件: ${process.env.MEMORY_FILE ?? 'data/memory.json'})`);
|
|
427
|
-
const feedStore = new FeedStore(process.env.FEED_FILE ?? 'data/feeds.json');
|
|
428
|
-
|
|
429
|
-
const client = new GatewayClient(dshBaseUrl, dshToken);
|
|
430
|
-
await client.health(); // 确认 DSH 侧(或 mock)活着
|
|
431
|
-
|
|
432
|
-
const adapters: Adapter[] = adapterIds.map((id) => createAdapter(id, env));
|
|
433
|
-
const adaptersMap = new Map(adapters.map((a) => [a.id, a]));
|
|
434
|
-
const feedPoller = new FeedPoller(feedStore, adaptersMap);
|
|
435
|
-
feedPoller.start();
|
|
436
|
-
console.log('[gateway] RSS 订阅轮询已启动');
|
|
437
|
-
for (const adapter of adapters) {
|
|
438
|
-
await adapter.connect();
|
|
439
|
-
// 人设:PERSONA_<ADAPTER_ID>(如 PERSONA_TELEGRAM)优先,回退 PERSONA
|
|
440
|
-
const persona = process.env[`PERSONA_${adapter.id.toUpperCase()}`] ?? process.env.PERSONA;
|
|
441
|
-
await wireAdapter(adapter, client, { allowlist, allowAll, asr, memory, persona, feed: feedStore });
|
|
442
|
-
console.log(`[gateway] ${adapter.id} 适配器已就绪${persona ? `(人设: ${persona.slice(0, 20)}…)` : ''}`);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const consolePort = Number(process.env.GATEWAY_CONSOLE_PORT ?? 3190);
|
|
446
|
-
const status = createStatusServer({ adapters, client, version: '0.1.0' });
|
|
447
|
-
await status.listen(consolePort);
|
|
448
|
-
console.log(`[gateway] 控制台 http://0.0.0.0:${consolePort}/`);
|
|
449
|
-
|
|
450
|
-
process.stdout.write(`[gateway] 就绪(适配器: ${adapterIds.join(', ')})。Ctrl+C 退出。\n`);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
if (process.argv[1]?.endsWith('index.js')) void main();
|
|
454
|
-
|
|
455
|
-
// 保留 CLI 的直接导入(E2E 用)
|
|
456
|
-
export { CliAdapter };
|
package/src/memory.ts
DELETED
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// 记忆系统(对标 OpenClaw 的 long-term memory)。
|
|
2
|
-
// 按 platform:userId 作用域存储用户显式记忆(/remember),支持搜索(/recall)与删除(/forget);
|
|
3
|
-
// 入站消息时自动检索相关记忆注入上下文,让 agent「记得你」。
|
|
4
|
-
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { dirname } from 'node:path';
|
|
7
|
-
import { randomUUID } from 'node:crypto';
|
|
8
|
-
|
|
9
|
-
export interface MemoryEntry {
|
|
10
|
-
id: string;
|
|
11
|
-
text: string;
|
|
12
|
-
createdAt: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** 作用域键:platform:userId(记忆跟随用户,跨频道共享,与 OpenClaw 一致)。 */
|
|
16
|
-
export function memoryScope(adapterId: string, userId: string): string {
|
|
17
|
-
return `${adapterId}:${userId}`;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** 字符 2-gram:CJK 无空格分词,用共享 bigram 判断相关性(比子串包含更鲁棒)。 */
|
|
21
|
-
function bigrams(text: string): Set<string> {
|
|
22
|
-
const clean = text.toLowerCase();
|
|
23
|
-
const out = new Set<string>();
|
|
24
|
-
for (let i = 0; i < clean.length - 1; i++) out.add(clean.slice(i, i + 2));
|
|
25
|
-
return out;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** 纯函数:按查询文本检索记忆——与查询共享任意 2-gram 即相关;空查询返回全部。 */
|
|
29
|
-
export function searchMemories(entries: MemoryEntry[], query: string): MemoryEntry[] {
|
|
30
|
-
const queryBigrams = bigrams(query);
|
|
31
|
-
if (queryBigrams.size === 0) return entries;
|
|
32
|
-
return entries.filter((entry) => {
|
|
33
|
-
const memoryBigrams = bigrams(entry.text);
|
|
34
|
-
for (const gram of queryBigrams) {
|
|
35
|
-
if (memoryBigrams.has(gram)) return true;
|
|
36
|
-
}
|
|
37
|
-
return false;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 纯函数:记忆列表 → 注入文本(拼在用户消息后)。 */
|
|
42
|
-
export function formatMemories(entries: MemoryEntry[]): string {
|
|
43
|
-
if (entries.length === 0) return '';
|
|
44
|
-
const lines = entries.map((e) => `- ${e.text}`).join('\n');
|
|
45
|
-
return `\n📌 相关记忆:\n${lines}`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 自动记忆(OpenClaw 式 auto-memory 的轻量版):识别用户明确陈述的自我事实。
|
|
49
|
-
const AUTO_MEMORY_PATTERNS: RegExp[] = [
|
|
50
|
-
/我叫\s*([^\s,,。!?!?]{1,20})/,
|
|
51
|
-
/我的名字是\s*([^\s,,。!?!?]{1,20})/,
|
|
52
|
-
/我住在\s*([^\s,,。!?!?]{1,30})/,
|
|
53
|
-
/我的邮箱是\s*([^\s,,。!?!?]{1,50})/,
|
|
54
|
-
/我的电话(?:是|号码)?[::]?\s*([^\s,,。!?!?]{1,30})/,
|
|
55
|
-
/我喜欢\s*([^\s,,。!?!?]{1,30})/,
|
|
56
|
-
/我的职业是\s*([^\s,,。!?!?]{1,30})/,
|
|
57
|
-
/我是做\s*([^\s,,。!?!?]{1,30})/,
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
/** 纯函数:从用户消息中提取可自动记忆的自我事实(如「我叫XX」「我住在XX」)。 */
|
|
61
|
-
export function extractAutoMemories(text: string): string[] {
|
|
62
|
-
const out: string[] = [];
|
|
63
|
-
for (const re of AUTO_MEMORY_PATTERNS) {
|
|
64
|
-
const m = re.exec(text);
|
|
65
|
-
if (m) {
|
|
66
|
-
const full = m[0].replace(/\s+/g, ' ').trim();
|
|
67
|
-
if (full.length >= 3 && !out.includes(full)) out.push(full);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** JSON 文件持久化的记忆存储;file 缺省时仅内存(测试/无盘环境用)。 */
|
|
74
|
-
export class MemoryStore {
|
|
75
|
-
private readonly data = new Map<string, MemoryEntry[]>();
|
|
76
|
-
private readonly file?: string;
|
|
77
|
-
|
|
78
|
-
constructor(file?: string) {
|
|
79
|
-
this.file = file;
|
|
80
|
-
if (file && existsSync(file)) {
|
|
81
|
-
try {
|
|
82
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Record<string, MemoryEntry[]>;
|
|
83
|
-
for (const [scope, entries] of Object.entries(parsed)) {
|
|
84
|
-
if (Array.isArray(entries)) this.data.set(scope, entries);
|
|
85
|
-
}
|
|
86
|
-
} catch {
|
|
87
|
-
/* 损坏则从空开始 */
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private persist(): void {
|
|
93
|
-
if (!this.file) return;
|
|
94
|
-
try {
|
|
95
|
-
mkdirSync(dirname(this.file), { recursive: true });
|
|
96
|
-
writeFileSync(this.file, JSON.stringify(Object.fromEntries(this.data), null, 2), 'utf8');
|
|
97
|
-
} catch {
|
|
98
|
-
/* 持久化失败不阻断 */
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
add(scope: string, text: string): MemoryEntry {
|
|
103
|
-
const entry: MemoryEntry = { id: randomUUID().slice(0, 8), text: text.trim(), createdAt: new Date().toISOString() };
|
|
104
|
-
const list = this.data.get(scope) ?? [];
|
|
105
|
-
list.push(entry);
|
|
106
|
-
this.data.set(scope, list);
|
|
107
|
-
this.persist();
|
|
108
|
-
return entry;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
list(scope: string): MemoryEntry[] {
|
|
112
|
-
return this.data.get(scope) ?? [];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
search(scope: string, query: string): MemoryEntry[] {
|
|
116
|
-
return searchMemories(this.list(scope), query);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** 删除某作用域下指定 id 的记忆;不存在返回 false。 */
|
|
120
|
-
remove(scope: string, id: string): boolean {
|
|
121
|
-
const list = this.data.get(scope);
|
|
122
|
-
if (!list) return false;
|
|
123
|
-
const next = list.filter((e) => e.id !== id);
|
|
124
|
-
if (next.length === list.length) return false;
|
|
125
|
-
this.data.set(scope, next);
|
|
126
|
-
this.persist();
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
count(scope: string): number {
|
|
131
|
-
return this.list(scope).length;
|
|
132
|
-
}
|
|
133
|
-
}
|
package/src/pending-buttons.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { OutboundButton } from './adapter.js';
|
|
2
|
-
|
|
3
|
-
export const PENDING_BUTTONS_TTL_MS = 5 * 60_000;
|
|
4
|
-
|
|
5
|
-
interface PendingButtonEntry {
|
|
6
|
-
buttons: OutboundButton[];
|
|
7
|
-
expiresAt: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 编号回复兜底的按钮暂存(带 TTL)。
|
|
12
|
-
*
|
|
13
|
-
* 审批/危险操作按钮发出后,用户在聊天里回复数字("1"/"2"…)选择;
|
|
14
|
-
* 若按钮长期不消费,后续的普通数字消息会被误判成按钮回复。
|
|
15
|
-
* 本类在 TTL(默认 5 分钟)后自动失效,杜绝"过期按钮吞消息"。
|
|
16
|
-
*/
|
|
17
|
-
export class PendingButtons {
|
|
18
|
-
private readonly map = new Map<string, PendingButtonEntry>();
|
|
19
|
-
|
|
20
|
-
constructor(private readonly ttlMs: number = PENDING_BUTTONS_TTL_MS) {}
|
|
21
|
-
|
|
22
|
-
set(chatId: string, buttons: OutboundButton[]): void {
|
|
23
|
-
this.map.set(chatId, { buttons, expiresAt: Date.now() + this.ttlMs });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** 数字回复命中:返回匹配按钮并消费(删除);无 pending / 已过期 / 非数字或越界返回 undefined。 */
|
|
27
|
-
match(chatId: string, text: string): OutboundButton | undefined {
|
|
28
|
-
const entry = this.map.get(chatId);
|
|
29
|
-
if (!entry) return undefined;
|
|
30
|
-
if (entry.expiresAt < Date.now()) {
|
|
31
|
-
this.map.delete(chatId);
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
const n = Number(text.trim());
|
|
35
|
-
if (!Number.isInteger(n) || n < 1 || n > entry.buttons.length) return undefined;
|
|
36
|
-
const button = entry.buttons[n - 1];
|
|
37
|
-
if (button) this.map.delete(chatId);
|
|
38
|
-
return button;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 消费原生按钮点击(如 WhatsApp 原生交互按钮):删除该 chat 的 pending。 */
|
|
42
|
-
consume(chatId: string): void {
|
|
43
|
-
this.map.delete(chatId);
|
|
44
|
-
}
|
|
45
|
-
}
|
package/src/session.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { sessionKey } from '@dsh-overdrive/sdk';
|
|
2
|
-
|
|
3
|
-
export function buildSessionKey(
|
|
4
|
-
adapterId: string,
|
|
5
|
-
msg: { chatId: string; userId: string },
|
|
6
|
-
): string {
|
|
7
|
-
return sessionKey(adapterId, msg.chatId, msg.userId);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* 白名单:默认 fail-closed —— 只有显式配置了条目才放行;
|
|
12
|
-
* 开发环境可用 ALLOW_ALL=1 显式放行所有(比空列表隐式放行安全得多)。
|
|
13
|
-
*/
|
|
14
|
-
export class Allowlist {
|
|
15
|
-
constructor(
|
|
16
|
-
private readonly entries: string[],
|
|
17
|
-
private readonly allowAll = false,
|
|
18
|
-
) {}
|
|
19
|
-
|
|
20
|
-
allows(key: string): boolean {
|
|
21
|
-
return this.allowAll || (this.entries.length > 0 && this.entries.includes(key));
|
|
22
|
-
}
|
|
23
|
-
}
|