@zhin.js/core 1.0.50 → 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.
Files changed (48) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +1 -1
  3. package/lib/adapter.d.ts +10 -19
  4. package/lib/adapter.d.ts.map +1 -1
  5. package/lib/adapter.js +47 -80
  6. package/lib/adapter.js.map +1 -1
  7. package/lib/ai/types.d.ts +11 -0
  8. package/lib/ai/types.d.ts.map +1 -1
  9. package/lib/built/common-adapter-tools.d.ts +2 -2
  10. package/lib/built/common-adapter-tools.js +3 -3
  11. package/lib/built/common-adapter-tools.js.map +1 -1
  12. package/lib/built/dispatcher.d.ts +52 -42
  13. package/lib/built/dispatcher.d.ts.map +1 -1
  14. package/lib/built/dispatcher.js +194 -60
  15. package/lib/built/dispatcher.js.map +1 -1
  16. package/lib/built/login-assist.d.ts +61 -0
  17. package/lib/built/login-assist.d.ts.map +1 -0
  18. package/lib/built/login-assist.js +75 -0
  19. package/lib/built/login-assist.js.map +1 -0
  20. package/lib/built/skill.d.ts +3 -20
  21. package/lib/built/skill.d.ts.map +1 -1
  22. package/lib/built/skill.js +1 -53
  23. package/lib/built/skill.js.map +1 -1
  24. package/lib/index.d.ts +1 -0
  25. package/lib/index.d.ts.map +1 -1
  26. package/lib/index.js +2 -0
  27. package/lib/index.js.map +1 -1
  28. package/lib/plugin.d.ts +18 -0
  29. package/lib/plugin.d.ts.map +1 -1
  30. package/lib/plugin.js +1 -1
  31. package/lib/plugin.js.map +1 -1
  32. package/lib/types.d.ts +13 -0
  33. package/lib/types.d.ts.map +1 -1
  34. package/lib/utils.js +1 -1
  35. package/package.json +6 -6
  36. package/src/adapter.ts +51 -95
  37. package/src/ai/types.ts +3 -1
  38. package/src/built/common-adapter-tools.ts +3 -3
  39. package/src/built/dispatcher.ts +264 -99
  40. package/src/built/login-assist.ts +131 -0
  41. package/src/built/skill.ts +3 -75
  42. package/src/index.ts +2 -0
  43. package/src/plugin.ts +24 -5
  44. package/src/types.ts +16 -0
  45. package/src/utils.ts +1 -1
  46. package/tests/adapter.test.ts +40 -128
  47. package/tests/dispatcher.test.ts +155 -8
  48. package/tests/redos-protection.test.ts +5 -4
@@ -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', () => {