@zhin.js/core 1.0.51 → 1.0.52

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/src/types.ts CHANGED
@@ -64,6 +64,22 @@ export type MaybeArray<T>=T|T[]
64
64
  * 消息发送内容类型
65
65
  */
66
66
  export type SendContent=MaybeArray<string|MessageElement>
67
+
68
+ /** 出站回复来源(指令 / AI),仅当经 MessageDispatcher.replyWithPolish 发出时由框架填入异步上下文 */
69
+ export type OutboundReplySource = 'command' | 'ai'
70
+
71
+ /**
72
+ * 出站润色上下文(`dispatcher.addOutboundPolish` 的 handler 签名的同构类型)。
73
+ * 与 {@link Adapter.sendMessage} → `before.sendMessage` 同一管道;需 `message`/`source` 时见 `getOutboundReplyStore`(dispatcher 导出)。
74
+ */
75
+ export interface OutboundPolishContext {
76
+ message: Message
77
+ content: SendContent
78
+ source: OutboundReplySource
79
+ }
80
+
81
+ /** 返回 `SendContent` 则替换后续 `before.sendMessage` 与发送中的 content */
82
+ export type OutboundPolishMiddleware = (ctx: OutboundPolishContext) => MaybePromise<SendContent | void>
67
83
  /**
68
84
  * 消息发送者信息
69
85
  */
package/src/utils.ts CHANGED
@@ -107,7 +107,7 @@ export namespace segment {
107
107
  const toString = (template: string | MessageElement) => {
108
108
  if (typeof template !== "string") return [template];
109
109
 
110
- const MAX_TEMPLATE_LENGTH = 100000;
110
+ const MAX_TEMPLATE_LENGTH = 400000;
111
111
  if (template.length > MAX_TEMPLATE_LENGTH) {
112
112
  throw new Error(`Template too large: ${template.length} > ${MAX_TEMPLATE_LENGTH}`);
113
113
  }
@@ -107,9 +107,8 @@ describe('Adapter Core Functionality', () => {
107
107
  expect(adapter).toBeInstanceOf(EventEmitter)
108
108
  })
109
109
 
110
- it('should register message.receive listener', () => {
111
- const listeners = adapter.listeners('message.receive')
112
- expect(listeners.length).toBeGreaterThan(0)
110
+ it('should route message.receive via emit without default listener', () => {
111
+ expect(adapter.listenerCount('message.receive')).toBe(0)
113
112
  })
114
113
 
115
114
  it('should register call.recallMessage listener', () => {
@@ -199,13 +198,15 @@ describe('Adapter Core Functionality', () => {
199
198
 
200
199
  it('should remove all event listeners', async () => {
201
200
  await adapter.start()
201
+ const noop = () => {}
202
+ adapter.on('message.receive', noop)
202
203
  const beforeCount = adapter.listenerCount('message.receive')
203
-
204
+ expect(beforeCount).toBe(1)
205
+
204
206
  await adapter.stop()
205
207
  const afterCount = adapter.listenerCount('message.receive')
206
-
208
+
207
209
  expect(afterCount).toBe(0)
208
- expect(beforeCount).toBeGreaterThan(0)
209
210
  })
210
211
 
211
212
  it('should handle bot disconnect errors gracefully', async () => {
@@ -377,97 +378,92 @@ describe('Adapter Core Functionality', () => {
377
378
  })
378
379
 
379
380
  describe('message.receive', () => {
380
- it('should process received message through middleware when no dispatcher', async () => {
381
+ it('should still dispatch plugin message.receive when dispatcher is missing (no middleware fallback)', async () => {
381
382
  const config = [{ id: 'bot1' }]
382
383
  const adapter = new MockAdapter(plugin, 'test', config)
383
384
  await adapter.start()
384
-
385
+
385
386
  let middlewareCalled = false
386
- plugin.addMiddleware(async (message, next) => {
387
+ plugin.addMiddleware(async (_message, next) => {
387
388
  middlewareCalled = true
388
389
  await next()
389
390
  })
390
-
391
+
392
+ let lifecycleCalled = false
393
+ plugin.on('message.receive', () => {
394
+ lifecycleCalled = true
395
+ })
396
+
391
397
  const message = {
392
398
  $bot: 'bot1',
393
399
  $adapter: 'test',
394
400
  $channel: { id: 'channel-id', type: 'text' },
395
- $content: 'Hello'
401
+ $content: 'Hello',
396
402
  } as any
397
-
403
+
398
404
  adapter.emit('message.receive', message)
399
-
400
- // 等待异步处理
401
- await new Promise(resolve => setTimeout(resolve, 10))
402
- expect(middlewareCalled).toBe(true)
405
+ await new Promise((r) => setTimeout(r, 20))
406
+ expect(middlewareCalled).toBe(false)
407
+ expect(lifecycleCalled).toBe(true)
403
408
  })
404
409
 
405
- it('should use MessageDispatcher when available', async () => {
410
+ it('should await MessageDispatcher then plugin lifecycle', async () => {
406
411
  const config = [{ id: 'bot1' }]
407
412
  const adapter = new MockAdapter(plugin, 'test', config)
408
413
  await adapter.start()
409
414
 
410
- let dispatchCalled = false
411
- let middlewareCalled = false
412
-
413
- // 注册 dispatcher context
415
+ const order: string[] = []
414
416
  plugin.$contexts.set('dispatcher', {
415
417
  name: 'dispatcher',
416
418
  description: 'mock dispatcher',
417
419
  value: {
418
- dispatch: (msg: any) => { dispatchCalled = true; return Promise.resolve() },
420
+ dispatch: async (_msg: any) => {
421
+ order.push('dispatcher')
422
+ },
419
423
  },
420
424
  } as any)
421
425
 
422
- plugin.addMiddleware(async (message, next) => {
423
- middlewareCalled = true
424
- await next()
426
+ plugin.on('message.receive', () => {
427
+ order.push('lifecycle')
425
428
  })
426
429
 
427
430
  const message = {
428
431
  $bot: 'bot1',
429
432
  $adapter: 'test',
430
433
  $channel: { id: 'channel-id', type: 'text' },
431
- $content: 'Hello'
434
+ $content: 'Hello',
432
435
  } as any
433
436
 
434
437
  adapter.emit('message.receive', message)
435
-
436
- await new Promise(resolve => setTimeout(resolve, 10))
437
- expect(dispatchCalled).toBe(true)
438
- expect(middlewareCalled).toBe(false)
438
+ await new Promise((r) => setTimeout(r, 20))
439
+ expect(order).toEqual(['dispatcher', 'lifecycle'])
439
440
  })
440
441
 
441
- it('should fallback to middleware when dispatcher has no dispatch method', async () => {
442
+ it('should call adapter.on observers after plugin lifecycle', async () => {
442
443
  const config = [{ id: 'bot1' }]
443
444
  const adapter = new MockAdapter(plugin, 'test', config)
444
445
  await adapter.start()
445
446
 
446
- let middlewareCalled = false
447
-
448
- // 注册一个没有 dispatch 方法的 dispatcher
449
447
  plugin.$contexts.set('dispatcher', {
450
448
  name: 'dispatcher',
451
- description: 'broken dispatcher',
452
- value: { noDispatch: true },
449
+ description: 'mock dispatcher',
450
+ value: { dispatch: async () => {} },
453
451
  } as any)
454
452
 
455
- plugin.addMiddleware(async (message, next) => {
456
- middlewareCalled = true
457
- await next()
458
- })
453
+ const order: string[] = []
454
+ plugin.on('message.receive', () => order.push('lifecycle'))
455
+ adapter.on('message.receive', () => order.push('adapterObserver'))
459
456
 
460
457
  const message = {
461
458
  $bot: 'bot1',
462
459
  $adapter: 'test',
463
460
  $channel: { id: 'channel-id', type: 'text' },
464
- $content: 'Hello'
461
+ $content: 'Hello',
465
462
  } as any
466
463
 
467
464
  adapter.emit('message.receive', message)
468
-
469
- await new Promise(resolve => setTimeout(resolve, 10))
470
- expect(middlewareCalled).toBe(true)
465
+ await new Promise((r) => setTimeout(r, 20))
466
+ expect(order).toEqual(['lifecycle', 'adapterObserver'])
471
467
  })
472
468
  })
473
469
  })
@@ -617,90 +613,6 @@ describe('Adapter Core Functionality', () => {
617
613
  })
618
614
  })
619
615
 
620
- describe('Adapter declareSkill', () => {
621
- it('should register a skill when SkillFeature is available', () => {
622
- const plugin = new Plugin('/test/adapter-plugin.ts')
623
-
624
- // 模拟 SkillFeature
625
- const mockSkillFeature = {
626
- add: vi.fn(() => vi.fn()),
627
- }
628
-
629
- // 设置 root plugin 和 inject
630
- ;(plugin as any)._root = plugin
631
- const originalInject = plugin.inject.bind(plugin)
632
- plugin.inject = ((name: string) => {
633
- if (name === 'skill') return mockSkillFeature
634
- return originalInject(name)
635
- }) as any
636
- ;(plugin as any).recordFeatureContribution = vi.fn()
637
-
638
- const adapter = new MockAdapter(plugin, 'test-adapter')
639
-
640
- // 添加一个工具
641
- adapter.addTool({
642
- name: 'test_tool',
643
- description: '测试工具',
644
- parameters: { type: 'object', properties: {} },
645
- execute: async () => '',
646
- keywords: ['test'],
647
- tags: ['testing'],
648
- })
649
-
650
- adapter.declareSkill({
651
- description: '测试适配器的技能',
652
- keywords: ['adapter'],
653
- tags: ['adapter-tag'],
654
- })
655
-
656
- expect(mockSkillFeature.add).toHaveBeenCalledTimes(1)
657
- const [skill] = mockSkillFeature.add.mock.calls[0]
658
- expect(skill.name).toContain('test-adapter')
659
- expect(skill.description).toBe('测试适配器的技能')
660
- expect(skill.tools).toHaveLength(1)
661
- // keywords 应合并适配器和工具的关键词
662
- expect(skill.keywords).toContain('adapter')
663
- expect(skill.keywords).toContain('test')
664
- // tags 应合并
665
- expect(skill.tags).toContain('adapter-tag')
666
- expect(skill.tags).toContain('testing')
667
- })
668
-
669
- it('should clean up skill on stop', async () => {
670
- const disposeSkill = vi.fn()
671
- const plugin = new Plugin('/test/adapter-plugin.ts')
672
-
673
- const mockSkillFeature = {
674
- add: vi.fn(() => disposeSkill),
675
- }
676
- ;(plugin as any)._root = plugin
677
- plugin.inject = ((name: string) => {
678
- if (name === 'skill') return mockSkillFeature
679
- return undefined
680
- }) as any
681
- ;(plugin as any).recordFeatureContribution = vi.fn()
682
-
683
- const adapter = new MockAdapter(plugin, 'test-adapter')
684
- adapter.declareSkill({ description: '测试' })
685
-
686
- await adapter.stop()
687
-
688
- expect(disposeSkill).toHaveBeenCalledTimes(1)
689
- })
690
-
691
- it('should skip when SkillFeature is not available', () => {
692
- const plugin = new Plugin('/test/adapter-plugin.ts')
693
- ;(plugin as any)._root = plugin
694
-
695
- const adapter = new MockAdapter(plugin, 'test-adapter')
696
-
697
- // 不应抛错
698
- expect(() => {
699
- adapter.declareSkill({ description: '测试' })
700
- }).not.toThrow()
701
- })
702
- })
703
-
704
616
  describe('Adapter Registry', () => {
705
617
  it('should have a Registry Map', () => {
706
618
  expect(Adapter.Registry).toBeInstanceOf(Map)
@@ -2,8 +2,14 @@
2
2
  * MessageDispatcher 测试
3
3
  */
4
4
  import { describe, it, expect, vi, beforeEach } from 'vitest';
5
- import { createMessageDispatcher, type MessageDispatcherService, type RouteResult } from '../src/built/dispatcher.js';
5
+ import { EventEmitter } from 'node:events';
6
+ import {
7
+ createMessageDispatcher,
8
+ type MessageDispatcherService,
9
+ } from '../src/built/dispatcher.js';
6
10
  import type { Message } from '../src/message.js';
11
+ import type { Plugin } from '../src/plugin.js';
12
+ import type { BeforeSendHandler } from '../src/types.js';
7
13
 
8
14
  function makeMessage(text: string, overrides: Partial<Message<any>> = {}): Message<any> {
9
15
  return {
@@ -21,6 +27,42 @@ function makeMessage(text: string, overrides: Partial<Message<any>> = {}): Messa
21
27
  } as any;
22
28
  }
23
29
 
30
+ function makeRootWithCommand(
31
+ handleImpl?: (msg: Message<any>, root: Plugin) => Promise<string | void>,
32
+ ) {
33
+ const handle = handleImpl ?? vi.fn(async () => 'cmd-ok');
34
+ const cmdService = {
35
+ items: [{ pattern: '/help ping' }],
36
+ handle,
37
+ };
38
+ const root = new EventEmitter() as unknown as Plugin;
39
+ (root as any).inject = (name: string) => {
40
+ if (name === 'command') return cmdService;
41
+ return undefined;
42
+ };
43
+ (root as any).root = root;
44
+ return root;
45
+ }
46
+
47
+ /** 模拟 $reply → adapter.sendMessage → renderSendMessage(before.sendMessage) */
48
+ function wireMessageReplyThroughBeforeSend(msg: Message<any>, root: EventEmitter) {
49
+ msg.$reply = vi.fn(async (content: any) => {
50
+ let options = {
51
+ content,
52
+ bot: msg.$bot,
53
+ id: msg.$channel.id,
54
+ type: msg.$channel.type,
55
+ context: 'test',
56
+ };
57
+ const fns = root.listeners('before.sendMessage') as BeforeSendHandler[];
58
+ for (const fn of fns) {
59
+ const r = await fn(options);
60
+ if (r) options = r;
61
+ }
62
+ return 'mock-msg-id';
63
+ });
64
+ }
65
+
24
66
  describe('createMessageDispatcher', () => {
25
67
  let context: ReturnType<typeof createMessageDispatcher>;
26
68
  let service: MessageDispatcherService;
@@ -40,6 +82,10 @@ describe('createMessageDispatcher', () => {
40
82
  expect(typeof context.value.setAITriggerMatcher).toBe('function');
41
83
  expect(typeof context.value.setAIHandler).toBe('function');
42
84
  expect(typeof context.value.hasAIHandler).toBe('function');
85
+ expect(typeof context.value.addOutboundPolish).toBe('function');
86
+ expect(typeof context.value.replyWithPolish).toBe('function');
87
+ expect(typeof service.matchCommand).toBe('function');
88
+ expect(typeof service.matchAI).toBe('function');
43
89
  });
44
90
 
45
91
  describe('Guardrail', () => {
@@ -98,7 +144,7 @@ describe('createMessageDispatcher', () => {
98
144
  it('AI 触发时应调用 handler', async () => {
99
145
  const handler = vi.fn();
100
146
  service.setAIHandler(handler);
101
- service.setAITriggerMatcher((msg) => ({
147
+ service.setAITriggerMatcher(() => ({
102
148
  triggered: true,
103
149
  content: 'processed content',
104
150
  }));
@@ -121,20 +167,118 @@ describe('createMessageDispatcher', () => {
121
167
  });
122
168
  });
123
169
 
124
- describe('Command Matcher', () => {
125
- it('自定义命令匹配器应优先', async () => {
170
+ describe('Command Matcher (exclusive)', () => {
171
+ it('自定义命令匹配器应优先并阻断 AI(互斥模式)', async () => {
172
+ const exclusiveCtx = createMessageDispatcher({ dualRoute: { mode: 'exclusive' } });
173
+ const d = exclusiveCtx.value;
174
+ const aiHandler = vi.fn();
175
+ d.setAIHandler(aiHandler);
176
+ d.setAITriggerMatcher(() => ({ triggered: true, content: '' }));
177
+ d.setCommandMatcher((text) => text.startsWith('/'));
178
+
179
+ const msg = makeMessage('/help');
180
+ await d.dispatch(msg);
181
+
182
+ expect(aiHandler).not.toHaveBeenCalled();
183
+ });
184
+ });
185
+
186
+ describe('双轨 dual 模式', () => {
187
+ it('同时命中指令与 AI 时应各执行一次(默认 command-first)', async () => {
188
+ const root = makeRootWithCommand();
189
+ const fakePlugin = { root } as Plugin;
190
+ context.mounted(fakePlugin);
191
+
126
192
  const aiHandler = vi.fn();
127
193
  service.setAIHandler(aiHandler);
128
- service.setAITriggerMatcher(() => ({ triggered: true, content: '' }));
194
+ service.setAITriggerMatcher(() => ({ triggered: true, content: 'hi' }));
195
+ service.setDualRouteConfig({ mode: 'dual', order: 'command-first', allowDualReply: true });
196
+
197
+ const msg = makeMessage('/help');
198
+ wireMessageReplyThroughBeforeSend(msg, root as unknown as EventEmitter);
199
+ await service.dispatch(msg);
129
200
 
130
- // 设置命令匹配器:以 / 开头视为命令
131
- service.setCommandMatcher((text) => text.startsWith('/'));
201
+ const cmd = root.inject('command') as any;
202
+ expect(cmd.handle).toHaveBeenCalled();
203
+ expect(aiHandler).toHaveBeenCalledWith(msg, 'hi');
204
+ expect(msg.$reply).toHaveBeenCalledWith('cmd-ok');
205
+ });
206
+
207
+ it('ai-first 时应先 AI 后指令', async () => {
208
+ const order: string[] = [];
209
+ const root = makeRootWithCommand(async () => {
210
+ order.push('cmd');
211
+ return 'c';
212
+ });
213
+ const fakePlugin = { root } as Plugin;
214
+ context.mounted(fakePlugin);
215
+
216
+ service.setAIHandler(async () => {
217
+ order.push('ai');
218
+ });
219
+ service.setAITriggerMatcher(() => ({ triggered: true, content: 'x' }));
220
+ service.setDualRouteConfig({ mode: 'dual', order: 'ai-first', allowDualReply: true });
132
221
 
133
222
  const msg = makeMessage('/help');
223
+ wireMessageReplyThroughBeforeSend(msg, root as unknown as EventEmitter);
224
+ await service.dispatch(msg);
225
+
226
+ expect(order).toEqual(['ai', 'cmd']);
227
+ });
228
+
229
+ it('allowDualReply false 且 command-first 时仅执行指令', async () => {
230
+ const root = makeRootWithCommand();
231
+ const fakePlugin = { root } as Plugin;
232
+ context.mounted(fakePlugin);
233
+
234
+ const aiHandler = vi.fn();
235
+ service.setAIHandler(aiHandler);
236
+ service.setAITriggerMatcher(() => ({ triggered: true, content: 'x' }));
237
+ service.setDualRouteConfig({ mode: 'dual', order: 'command-first', allowDualReply: false });
238
+
239
+ const msg = makeMessage('/help');
240
+ wireMessageReplyThroughBeforeSend(msg, root as unknown as EventEmitter);
134
241
  await service.dispatch(msg);
135
242
 
136
- // 命令路径不应触发 AI
137
243
  expect(aiHandler).not.toHaveBeenCalled();
244
+ expect(msg.$reply).toHaveBeenCalled();
245
+ });
246
+ });
247
+
248
+ describe('出站润色', () => {
249
+ it('replyWithPolish 应经 before.sendMessage 链式润色再发出($reply 入参仍为原文)', async () => {
250
+ const root = new EventEmitter() as unknown as Plugin;
251
+ (root as any).inject = () => undefined;
252
+ (root as any).root = root;
253
+ context.mounted({ root } as Plugin);
254
+
255
+ const p1 = vi.fn(async (ctx: any) => `[1]${ctx.content}`);
256
+ const p2 = vi.fn(async (ctx: any) => `${ctx.content}[2]`);
257
+ service.addOutboundPolish(p1);
258
+ service.addOutboundPolish(p2);
259
+
260
+ const msg = makeMessage('x');
261
+ wireMessageReplyThroughBeforeSend(msg, root as unknown as EventEmitter);
262
+ await service.replyWithPolish(msg, 'ai', 'hello');
263
+
264
+ expect(p1).toHaveBeenCalled();
265
+ expect(p2).toHaveBeenCalled();
266
+ expect(msg.$reply).toHaveBeenCalledWith('hello');
267
+ });
268
+
269
+ it('指令路径应经过润色', async () => {
270
+ const root = makeRootWithCommand(async () => 'out');
271
+ const fakePlugin = { root } as Plugin;
272
+ context.mounted(fakePlugin);
273
+
274
+ service.addOutboundPolish(async (ctx) => `<<${ctx.content}>>`);
275
+ service.setDualRouteConfig({ mode: 'exclusive' });
276
+
277
+ const msg = makeMessage('/help');
278
+ wireMessageReplyThroughBeforeSend(msg, root as unknown as EventEmitter);
279
+ await service.dispatch(msg);
280
+
281
+ expect(msg.$reply).toHaveBeenCalledWith('out');
138
282
  });
139
283
  });
140
284
 
@@ -142,5 +286,8 @@ describe('createMessageDispatcher', () => {
142
286
  it('应提供 addGuardrail 扩展', () => {
143
287
  expect(typeof context.extensions.addGuardrail).toBe('function');
144
288
  });
289
+ it('应提供 addOutboundPolish 扩展', () => {
290
+ expect(typeof context.extensions.addOutboundPolish).toBe('function');
291
+ });
145
292
  });
146
293
  });
@@ -72,12 +72,13 @@ describe('ReDoS Protection Tests', () => {
72
72
  });
73
73
 
74
74
  it('should reject extremely large templates', () => {
75
- // 测试超大模板
76
- const hugeTemplate = '<tag>content</tag>'.repeat(10000);
77
-
75
+ // segment.from 在 utils 中限制单段模板长度 > 400_000(见 MAX_TEMPLATE_LENGTH)
76
+ const chunk = '<tag>content</tag>';
77
+ const hugeTemplate = chunk.repeat(Math.ceil(400_001 / chunk.length));
78
+
78
79
  expect(() => {
79
80
  segment.from(hugeTemplate);
80
- }).toThrow('Template too large');
81
+ }).toThrow(/Template too large/);
81
82
  });
82
83
 
83
84
  it('should handle nested tags without exponential backtracking', () => {