@dsh-overdrive/gateway 0.1.8 → 0.2.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/adapters/feishu.js +36 -0
- package/dist/adapters/feishu.js.map +1 -1
- package/dist/adapters/wecom.js +30 -1
- package/dist/adapters/wecom.js.map +1 -1
- package/dist/commands.d.ts +13 -0
- package/dist/commands.js +17 -0
- 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 +5 -0
- package/dist/index.js +94 -7
- package/dist/index.js.map +1 -1
- package/dist/memory.d.ts +2 -0
- package/dist/memory.js +24 -0
- package/dist/memory.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/feishu.ts +33 -0
- package/src/adapters/wecom.ts +30 -1
- package/src/commands.ts +18 -1
- package/src/feed.ts +190 -0
- package/src/index.ts +92 -6
- package/src/memory.ts +25 -0
- package/test/feed.test.ts +111 -0
- package/test/memory.test.ts +20 -1
- package/test/multi.test.ts +43 -2
package/src/memory.ts
CHANGED
|
@@ -45,6 +45,31 @@ export function formatMemories(entries: MemoryEntry[]): string {
|
|
|
45
45
|
return `\n📌 相关记忆:\n${lines}`;
|
|
46
46
|
}
|
|
47
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
|
+
|
|
48
73
|
/** JSON 文件持久化的记忆存储;file 缺省时仅内存(测试/无盘环境用)。 */
|
|
49
74
|
export class MemoryStore {
|
|
50
75
|
private readonly data = new Map<string, MemoryEntry[]>();
|
|
@@ -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
|
+
});
|
package/test/memory.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { MemoryStore, formatMemories, memoryScope, searchMemories } from '../src/memory.js';
|
|
2
|
+
import { MemoryStore, extractAutoMemories, formatMemories, memoryScope, searchMemories } from '../src/memory.js';
|
|
3
3
|
|
|
4
4
|
describe('memoryScope', () => {
|
|
5
5
|
it('按 platform:userId 作用域(记忆跟随用户跨频道)', () => {
|
|
@@ -58,3 +58,22 @@ describe('MemoryStore(内存模式)', () => {
|
|
|
58
58
|
expect(store.list('whatsapp:u1')).toHaveLength(1);
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe('extractAutoMemories', () => {
|
|
63
|
+
it('识别自我事实(我叫/我住在/我喜欢/我的邮箱等)', () => {
|
|
64
|
+
expect(extractAutoMemories('你好,我叫小明')).toEqual(['我叫小明']);
|
|
65
|
+
expect(extractAutoMemories('我住在杭州')).toEqual(['我住在杭州']);
|
|
66
|
+
expect(extractAutoMemories('我喜欢喝美式咖啡')).toEqual(['我喜欢喝美式咖啡']);
|
|
67
|
+
expect(extractAutoMemories('我的邮箱是 a@b.com,麻烦发我')).toEqual(['我的邮箱是 a@b.com']);
|
|
68
|
+
expect(extractAutoMemories('我的职业是产品经理')).toEqual(['我的职业是产品经理']);
|
|
69
|
+
});
|
|
70
|
+
it('普通消息不触发', () => {
|
|
71
|
+
expect(extractAutoMemories('今天天气如何')).toEqual([]);
|
|
72
|
+
expect(extractAutoMemories('帮我写个脚本')).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
it('同一消息多模式只取各自匹配', () => {
|
|
75
|
+
const facts = extractAutoMemories('我叫小红,我住在上海');
|
|
76
|
+
expect(facts).toContain('我叫小红');
|
|
77
|
+
expect(facts).toContain('我住在上海');
|
|
78
|
+
});
|
|
79
|
+
});
|
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';
|
|
3
|
+
import { GatewayClient, type ServerEvent } from '@dsh-overdrive/sdk';
|
|
4
4
|
import { MemoryStore } 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 逻辑(白名单/会话键/错误兜底)。 */
|
|
@@ -145,6 +145,47 @@ describe('wireAdapter(多适配器装配核心)', () => {
|
|
|
145
145
|
expect(adapter.sent[0].payload.text).toContain('已记住');
|
|
146
146
|
});
|
|
147
147
|
|
|
148
|
+
it('自动记忆:自我事实消息自动沉淀', async () => {
|
|
149
|
+
const adapter = new FakeAdapter('telegram');
|
|
150
|
+
const { client } = fakeClient();
|
|
151
|
+
const memory = new MemoryStore();
|
|
152
|
+
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], memory });
|
|
153
|
+
|
|
154
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '你好,我叫小明,我住在杭州' });
|
|
155
|
+
expect(memory.count('telegram:222')).toBe(2);
|
|
156
|
+
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我叫小明');
|
|
157
|
+
expect(memory.list('telegram:222').map((e) => e.text)).toContain('我住在杭州');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('persona:每条消息前置人设', async () => {
|
|
161
|
+
const adapter = new FakeAdapter('telegram');
|
|
162
|
+
const { client, messages } = fakeClient();
|
|
163
|
+
await wireAdapter(adapter, client, { allowlist: ['telegram:111:222'], persona: '你是一个毒舌助理' });
|
|
164
|
+
|
|
165
|
+
await adapter.emit({ chatId: '111', userId: '222', text: '你好' });
|
|
166
|
+
expect(messages[0].text).toBe('【人设】你是一个毒舌助理\n你好');
|
|
167
|
+
});
|
|
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
|
+
|
|
148
189
|
it('/send <path> 读取文件并以媒体载荷发送', async () => {
|
|
149
190
|
const adapter = new FakeAdapter('telegram');
|
|
150
191
|
const { client } = fakeClient();
|