opc-agent 2.0.0 → 2.0.2

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 (157) hide show
  1. package/README.md +545 -365
  2. package/dist/channels/email.d.ts +32 -26
  3. package/dist/channels/email.js +239 -62
  4. package/dist/channels/feishu.d.ts +21 -6
  5. package/dist/channels/feishu.js +225 -126
  6. package/dist/channels/websocket.d.ts +46 -3
  7. package/dist/channels/websocket.js +306 -37
  8. package/dist/channels/wechat.d.ts +33 -13
  9. package/dist/channels/wechat.js +229 -42
  10. package/dist/cli.js +712 -11
  11. package/dist/core/a2a.d.ts +17 -0
  12. package/dist/core/a2a.js +43 -1
  13. package/dist/core/agent.d.ts +16 -0
  14. package/dist/core/agent.js +108 -0
  15. package/dist/core/runtime.d.ts +6 -0
  16. package/dist/core/runtime.js +161 -2
  17. package/dist/core/sandbox.d.ts +26 -0
  18. package/dist/core/sandbox.js +117 -0
  19. package/dist/core/workflow-graph.d.ts +93 -0
  20. package/dist/core/workflow-graph.js +247 -0
  21. package/dist/doctor.d.ts +15 -0
  22. package/dist/doctor.js +183 -0
  23. package/dist/eval/index.d.ts +65 -0
  24. package/dist/eval/index.js +191 -0
  25. package/dist/index.d.ts +32 -6
  26. package/dist/index.js +63 -4
  27. package/dist/plugins/content-filter.d.ts +7 -0
  28. package/dist/plugins/content-filter.js +25 -0
  29. package/dist/plugins/index.d.ts +42 -0
  30. package/dist/plugins/index.js +108 -2
  31. package/dist/plugins/logger.d.ts +6 -0
  32. package/dist/plugins/logger.js +20 -0
  33. package/dist/plugins/rate-limiter.d.ts +7 -0
  34. package/dist/plugins/rate-limiter.js +35 -0
  35. package/dist/protocols/a2a/client.d.ts +25 -0
  36. package/dist/protocols/a2a/client.js +115 -0
  37. package/dist/protocols/a2a/index.d.ts +6 -0
  38. package/dist/protocols/a2a/index.js +12 -0
  39. package/dist/protocols/a2a/server.d.ts +41 -0
  40. package/dist/protocols/a2a/server.js +295 -0
  41. package/dist/protocols/a2a/types.d.ts +91 -0
  42. package/dist/protocols/a2a/types.js +15 -0
  43. package/dist/protocols/a2a/utils.d.ts +6 -0
  44. package/dist/protocols/a2a/utils.js +47 -0
  45. package/dist/protocols/agui/client.d.ts +10 -0
  46. package/dist/protocols/agui/client.js +75 -0
  47. package/dist/protocols/agui/index.d.ts +4 -0
  48. package/dist/protocols/agui/index.js +25 -0
  49. package/dist/protocols/agui/server.d.ts +37 -0
  50. package/dist/protocols/agui/server.js +191 -0
  51. package/dist/protocols/agui/types.d.ts +107 -0
  52. package/dist/protocols/agui/types.js +17 -0
  53. package/dist/protocols/index.d.ts +2 -0
  54. package/dist/protocols/index.js +19 -0
  55. package/dist/protocols/mcp/agent-tools.d.ts +11 -0
  56. package/dist/protocols/mcp/agent-tools.js +129 -0
  57. package/dist/protocols/mcp/index.d.ts +5 -0
  58. package/dist/protocols/mcp/index.js +11 -0
  59. package/dist/protocols/mcp/server.d.ts +31 -0
  60. package/dist/protocols/mcp/server.js +248 -0
  61. package/dist/protocols/mcp/types.d.ts +92 -0
  62. package/dist/protocols/mcp/types.js +17 -0
  63. package/dist/publish/index.d.ts +45 -0
  64. package/dist/publish/index.js +350 -0
  65. package/dist/schema/oad.d.ts +682 -65
  66. package/dist/schema/oad.js +36 -3
  67. package/dist/security/approval.d.ts +36 -0
  68. package/dist/security/approval.js +113 -0
  69. package/dist/security/index.d.ts +4 -0
  70. package/dist/security/index.js +8 -0
  71. package/dist/security/keys.d.ts +16 -0
  72. package/dist/security/keys.js +117 -0
  73. package/dist/studio/server.d.ts +63 -0
  74. package/dist/studio/server.js +625 -0
  75. package/dist/studio-ui/index.html +662 -0
  76. package/dist/telemetry/index.d.ts +93 -0
  77. package/dist/telemetry/index.js +285 -0
  78. package/package.json +5 -3
  79. package/scripts/install.ps1 +31 -0
  80. package/scripts/install.sh +40 -0
  81. package/src/channels/email.ts +351 -177
  82. package/src/channels/feishu.ts +349 -236
  83. package/src/channels/websocket.ts +399 -87
  84. package/src/channels/wechat.ts +329 -149
  85. package/src/cli.ts +783 -12
  86. package/src/core/a2a.ts +60 -0
  87. package/src/core/agent.ts +125 -0
  88. package/src/core/runtime.ts +127 -0
  89. package/src/core/sandbox.ts +143 -0
  90. package/src/core/workflow-graph.ts +365 -0
  91. package/src/doctor.ts +156 -0
  92. package/src/eval/index.ts +211 -0
  93. package/src/eval/suites/basic.json +16 -0
  94. package/src/eval/suites/memory.json +12 -0
  95. package/src/eval/suites/safety.json +14 -0
  96. package/src/index.ts +58 -6
  97. package/src/plugins/content-filter.ts +23 -0
  98. package/src/plugins/index.ts +133 -2
  99. package/src/plugins/logger.ts +18 -0
  100. package/src/plugins/rate-limiter.ts +38 -0
  101. package/src/protocols/a2a/client.ts +132 -0
  102. package/src/protocols/a2a/index.ts +8 -0
  103. package/src/protocols/a2a/server.ts +333 -0
  104. package/src/protocols/a2a/types.ts +88 -0
  105. package/src/protocols/a2a/utils.ts +50 -0
  106. package/src/protocols/agui/client.ts +83 -0
  107. package/src/protocols/agui/index.ts +4 -0
  108. package/src/protocols/agui/server.ts +218 -0
  109. package/src/protocols/agui/types.ts +153 -0
  110. package/src/protocols/index.ts +2 -0
  111. package/src/protocols/mcp/agent-tools.ts +134 -0
  112. package/src/protocols/mcp/index.ts +8 -0
  113. package/src/protocols/mcp/server.ts +262 -0
  114. package/src/protocols/mcp/types.ts +69 -0
  115. package/src/publish/index.ts +376 -0
  116. package/src/schema/oad.ts +39 -2
  117. package/src/security/approval.ts +131 -0
  118. package/src/security/index.ts +3 -0
  119. package/src/security/keys.ts +87 -0
  120. package/src/studio/server.ts +629 -0
  121. package/src/studio-ui/index.html +662 -0
  122. package/src/telemetry/index.ts +324 -0
  123. package/src/types/agent-workstation.d.ts +2 -0
  124. package/tests/a2a-protocol.test.ts +285 -0
  125. package/tests/agui-protocol.test.ts +246 -0
  126. package/tests/channels/discord.test.ts +79 -0
  127. package/tests/channels/email.test.ts +148 -0
  128. package/tests/channels/feishu.test.ts +123 -0
  129. package/tests/channels/telegram.test.ts +129 -0
  130. package/tests/channels/websocket.test.ts +53 -0
  131. package/tests/channels/wechat.test.ts +170 -0
  132. package/tests/chat-cli.test.ts +160 -0
  133. package/tests/daemon.test.ts +135 -0
  134. package/tests/deepbrain-wire.test.ts +234 -0
  135. package/tests/doctor.test.ts +38 -0
  136. package/tests/eval.test.ts +173 -0
  137. package/tests/init-role.test.ts +124 -0
  138. package/tests/mcp-client.test.ts +92 -0
  139. package/tests/mcp-server.test.ts +178 -0
  140. package/tests/plugin-a2a-enhanced.test.ts +230 -0
  141. package/tests/publish.test.ts +231 -0
  142. package/tests/scheduler.test.ts +200 -0
  143. package/tests/security-enhanced.test.ts +233 -0
  144. package/tests/skill-learner.test.ts +161 -0
  145. package/tests/studio.test.ts +229 -0
  146. package/tests/subagent.test.ts +63 -0
  147. package/tests/telemetry.test.ts +186 -0
  148. package/tests/tools/builtin-extended.test.ts +138 -0
  149. package/tests/workflow-graph.test.ts +279 -0
  150. package/tutorial/customer-service-agent/README.md +612 -0
  151. package/tutorial/customer-service-agent/SOUL.md +26 -0
  152. package/tutorial/customer-service-agent/agent.yaml +63 -0
  153. package/tutorial/customer-service-agent/package.json +19 -0
  154. package/tutorial/customer-service-agent/src/index.ts +69 -0
  155. package/tutorial/customer-service-agent/src/skills/faq.ts +27 -0
  156. package/tutorial/customer-service-agent/src/skills/ticket.ts +22 -0
  157. package/tutorial/customer-service-agent/tsconfig.json +14 -0
@@ -0,0 +1,246 @@
1
+ // AG-UI Protocol Tests
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
3
+ import {
4
+ AGUIEventEmitter,
5
+ AGUIServer,
6
+ AGUIClient,
7
+ AGUI_EVENT_TYPES,
8
+ isValidEventType,
9
+ } from '../src/protocols/agui';
10
+ import type { AGUIEvent, AGUIRunRequest } from '../src/protocols/agui';
11
+
12
+ // ─── Mock ServerResponse ─────────────────────────────────────
13
+
14
+ function createMockRes() {
15
+ const chunks: string[] = [];
16
+ return {
17
+ chunks,
18
+ writeHead: vi.fn(),
19
+ write: vi.fn((data: string) => { chunks.push(data); return true; }),
20
+ end: vi.fn(),
21
+ };
22
+ }
23
+
24
+ function parseSSEChunks(chunks: string[]): AGUIEvent[] {
25
+ return chunks
26
+ .filter(c => c.startsWith('data: '))
27
+ .map(c => JSON.parse(c.slice(6).trim()));
28
+ }
29
+
30
+ // ─── Tests ───────────────────────────────────────────────────
31
+
32
+ describe('AG-UI Protocol', () => {
33
+ describe('isValidEventType', () => {
34
+ it('should validate known event types', () => {
35
+ expect(isValidEventType('TEXT_MESSAGE_START')).toBe(true);
36
+ expect(isValidEventType('RUN_FINISHED')).toBe(true);
37
+ expect(isValidEventType('CUSTOM')).toBe(true);
38
+ });
39
+
40
+ it('should reject unknown event types', () => {
41
+ expect(isValidEventType('UNKNOWN')).toBe(false);
42
+ expect(isValidEventType('')).toBe(false);
43
+ });
44
+
45
+ it('should have all 15 event types', () => {
46
+ expect(AGUI_EVENT_TYPES.length).toBe(15);
47
+ });
48
+ });
49
+
50
+ describe('AGUIEventEmitter', () => {
51
+ let res: ReturnType<typeof createMockRes>;
52
+ let emitter: AGUIEventEmitter;
53
+
54
+ beforeEach(() => {
55
+ res = createMockRes();
56
+ emitter = new AGUIEventEmitter(res as any);
57
+ });
58
+
59
+ it('should emit SSE-formatted events', () => {
60
+ emitter.emit({ type: 'RUN_STARTED', runId: 'r1', timestamp: '2025-01-01T00:00:00Z' });
61
+ expect(res.chunks.length).toBe(1);
62
+ expect(res.chunks[0]).toMatch(/^data: \{.*\}\n\n$/);
63
+ const parsed = JSON.parse(res.chunks[0].slice(6));
64
+ expect(parsed.type).toBe('RUN_STARTED');
65
+ expect(parsed.runId).toBe('r1');
66
+ });
67
+
68
+ it('should emit textStart/textContent/textEnd flow', () => {
69
+ emitter.textStart('msg1');
70
+ emitter.textContent('msg1', 'Hello');
71
+ emitter.textContent('msg1', ' world');
72
+ emitter.textEnd('msg1');
73
+ const events = parseSSEChunks(res.chunks);
74
+ expect(events.length).toBe(4);
75
+ expect(events[0].type).toBe('TEXT_MESSAGE_START');
76
+ expect(events[0].messageId).toBe('msg1');
77
+ expect((events[0] as any).role).toBe('assistant');
78
+ expect(events[1].type).toBe('TEXT_MESSAGE_CONTENT');
79
+ expect((events[1] as any).delta).toBe('Hello');
80
+ expect(events[2].type).toBe('TEXT_MESSAGE_CONTENT');
81
+ expect((events[2] as any).delta).toBe(' world');
82
+ expect(events[3].type).toBe('TEXT_MESSAGE_END');
83
+ });
84
+
85
+ it('should emit tool call flow', () => {
86
+ emitter.toolCallStart('tc1', 'search');
87
+ emitter.toolCallArgs('tc1', '{"q":"test"}');
88
+ emitter.toolCallEnd('tc1');
89
+ const events = parseSSEChunks(res.chunks);
90
+ expect(events.length).toBe(3);
91
+ expect(events[0].type).toBe('TOOL_CALL_START');
92
+ expect((events[0] as any).toolCallName).toBe('search');
93
+ expect(events[1].type).toBe('TOOL_CALL_ARGS');
94
+ expect(events[2].type).toBe('TOOL_CALL_END');
95
+ });
96
+
97
+ it('should emit runStarted/runFinished', () => {
98
+ emitter.runStarted('r1', 'thread1');
99
+ emitter.runFinished('r1');
100
+ const events = parseSSEChunks(res.chunks);
101
+ expect(events[0].type).toBe('RUN_STARTED');
102
+ expect((events[0] as any).threadId).toBe('thread1');
103
+ expect(events[1].type).toBe('RUN_FINISHED');
104
+ });
105
+
106
+ it('should emit runError', () => {
107
+ emitter.runError('r1', 'Something went wrong', 'INTERNAL');
108
+ const events = parseSSEChunks(res.chunks);
109
+ expect(events[0].type).toBe('RUN_ERROR');
110
+ expect((events[0] as any).message).toBe('Something went wrong');
111
+ expect((events[0] as any).code).toBe('INTERNAL');
112
+ });
113
+
114
+ it('should emit stateSnapshot', () => {
115
+ emitter.stateSnapshot({ count: 42 });
116
+ const events = parseSSEChunks(res.chunks);
117
+ expect(events[0].type).toBe('STATE_SNAPSHOT');
118
+ expect((events[0] as any).snapshot.count).toBe(42);
119
+ });
120
+
121
+ it('should emit stateDelta', () => {
122
+ emitter.stateDelta([{ op: 'replace', path: '/count', value: 43 }]);
123
+ const events = parseSSEChunks(res.chunks);
124
+ expect(events[0].type).toBe('STATE_DELTA');
125
+ expect((events[0] as any).delta[0].op).toBe('replace');
126
+ });
127
+
128
+ it('should not emit after close', () => {
129
+ emitter.close();
130
+ emitter.textStart('msg1');
131
+ expect(res.chunks.length).toBe(0);
132
+ expect(res.end).toHaveBeenCalled();
133
+ });
134
+
135
+ it('should include timestamp on convenience methods', () => {
136
+ emitter.textStart('msg1');
137
+ const events = parseSSEChunks(res.chunks);
138
+ expect(events[0].timestamp).toBeDefined();
139
+ expect(typeof events[0].timestamp).toBe('string');
140
+ });
141
+
142
+ it('should emit step events', () => {
143
+ emitter.stepStarted('s1', 'process');
144
+ emitter.stepFinished('s1');
145
+ const events = parseSSEChunks(res.chunks);
146
+ expect(events[0].type).toBe('STEP_STARTED');
147
+ expect((events[0] as any).stepName).toBe('process');
148
+ expect(events[1].type).toBe('STEP_FINISHED');
149
+ });
150
+
151
+ it('should emit custom events', () => {
152
+ emitter.custom('my_event', { foo: 'bar' });
153
+ const events = parseSSEChunks(res.chunks);
154
+ expect(events[0].type).toBe('CUSTOM');
155
+ expect((events[0] as any).name).toBe('my_event');
156
+ expect((events[0] as any).value.foo).toBe('bar');
157
+ });
158
+
159
+ it('should emit messagesSnapshot', () => {
160
+ emitter.messagesSnapshot([{ id: 'm1', role: 'user', content: 'hi' }]);
161
+ const events = parseSSEChunks(res.chunks);
162
+ expect(events[0].type).toBe('MESSAGES_SNAPSHOT');
163
+ expect((events[0] as any).messages[0].content).toBe('hi');
164
+ });
165
+ });
166
+
167
+ describe('AGUIServer', () => {
168
+ it('should construct with default path', () => {
169
+ const mockAgent = { handleMessage: vi.fn(), handleMessageStream: vi.fn() };
170
+ const server = new AGUIServer(mockAgent as any);
171
+ expect(server).toBeDefined();
172
+ });
173
+
174
+ it('should handle run request with mock agent', async () => {
175
+ const mockAgent = {
176
+ handleMessage: vi.fn().mockResolvedValue({ content: 'Hello!' }),
177
+ handleMessageStream: async function* () { yield 'Hel'; yield 'lo!'; },
178
+ };
179
+ const server = new AGUIServer(mockAgent as any);
180
+ const res = createMockRes();
181
+
182
+ // Simulate request
183
+ const body = JSON.stringify({
184
+ messages: [{ id: 'msg1', role: 'user', content: 'Hi' }],
185
+ });
186
+ const req = {
187
+ [Symbol.asyncIterator]: async function* () { yield body; },
188
+ };
189
+
190
+ await server.handleRun(req as any, res as any);
191
+
192
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
193
+ 'Content-Type': 'text/event-stream',
194
+ }));
195
+
196
+ const events = parseSSEChunks(res.chunks);
197
+ const types = events.map(e => e.type);
198
+ expect(types).toContain('RUN_STARTED');
199
+ expect(types).toContain('TEXT_MESSAGE_START');
200
+ expect(types).toContain('TEXT_MESSAGE_CONTENT');
201
+ expect(types).toContain('TEXT_MESSAGE_END');
202
+ expect(types).toContain('RUN_FINISHED');
203
+ });
204
+
205
+ it('should return 400 for invalid JSON', async () => {
206
+ const mockAgent = { handleMessage: vi.fn() };
207
+ const server = new AGUIServer(mockAgent as any);
208
+ const res = createMockRes();
209
+ const req = { [Symbol.asyncIterator]: async function* () { yield 'not json'; } };
210
+
211
+ await server.handleRun(req as any, res as any);
212
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
213
+ });
214
+
215
+ it('should return 400 for missing messages', async () => {
216
+ const mockAgent = { handleMessage: vi.fn() };
217
+ const server = new AGUIServer(mockAgent as any);
218
+ const res = createMockRes();
219
+ const req = { [Symbol.asyncIterator]: async function* () { yield '{}'; } };
220
+
221
+ await server.handleRun(req as any, res as any);
222
+ expect(res.writeHead).toHaveBeenCalledWith(400, expect.any(Object));
223
+ });
224
+ });
225
+
226
+ describe('AGUIClient', () => {
227
+ it('should construct with endpoint', () => {
228
+ const client = new AGUIClient('http://localhost:3000/agui');
229
+ expect(client).toBeDefined();
230
+ });
231
+
232
+ it('should abort cleanly', () => {
233
+ const client = new AGUIClient('http://localhost:3000/agui');
234
+ // Should not throw when no active request
235
+ client.abort();
236
+ });
237
+ });
238
+
239
+ describe('Protocol list includes agui', () => {
240
+ it('should have agui in AGUI_EVENT_TYPES constants', () => {
241
+ expect(AGUI_EVENT_TYPES).toContain('TEXT_MESSAGE_START');
242
+ expect(AGUI_EVENT_TYPES).toContain('RUN_STARTED');
243
+ expect(AGUI_EVENT_TYPES).toContain('TOOL_CALL_START');
244
+ });
245
+ });
246
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { DiscordChannel } from '../../src/channels/discord';
3
+
4
+ describe('DiscordChannel', () => {
5
+ it('constructor sets config from params', () => {
6
+ const ch = new DiscordChannel({ botToken: 'tok-123', applicationId: 'app-1' });
7
+ expect((ch as any).config.botToken).toBe('tok-123');
8
+ expect((ch as any).config.applicationId).toBe('app-1');
9
+ });
10
+
11
+ it('type is discord', () => {
12
+ const ch = new DiscordChannel({ botToken: 'tok' });
13
+ expect(ch.type).toBe('discord');
14
+ });
15
+
16
+ it('constructor reads token from env var', () => {
17
+ const orig = process.env.DISCORD_BOT_TOKEN;
18
+ process.env.DISCORD_BOT_TOKEN = 'env-discord-tok';
19
+ const ch = new DiscordChannel({});
20
+ expect((ch as any).config.botToken).toBe('env-discord-tok');
21
+ if (orig) process.env.DISCORD_BOT_TOKEN = orig;
22
+ else delete process.env.DISCORD_BOT_TOKEN;
23
+ });
24
+
25
+ it('defaults useThreads to true', () => {
26
+ const ch = new DiscordChannel({ botToken: 'tok' });
27
+ expect((ch as any).config.useThreads).toBe(true);
28
+ });
29
+
30
+ it('guildIds defaults to empty array', () => {
31
+ const ch = new DiscordChannel({ botToken: 'tok' });
32
+ expect((ch as any).config.guildIds).toEqual([]);
33
+ });
34
+
35
+ it('missing token results in empty string', () => {
36
+ const orig = process.env.DISCORD_BOT_TOKEN;
37
+ delete process.env.DISCORD_BOT_TOKEN;
38
+ const ch = new DiscordChannel({});
39
+ expect((ch as any).config.botToken).toBe('');
40
+ if (orig) process.env.DISCORD_BOT_TOKEN = orig;
41
+ });
42
+
43
+ it('start with no token warns but does not throw', async () => {
44
+ const orig = process.env.DISCORD_BOT_TOKEN;
45
+ delete process.env.DISCORD_BOT_TOKEN;
46
+ const ch = new DiscordChannel({});
47
+ const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
48
+ await ch.start();
49
+ expect(spy).toHaveBeenCalled();
50
+ spy.mockRestore();
51
+ if (orig) process.env.DISCORD_BOT_TOKEN = orig;
52
+ });
53
+
54
+ it('message chunking at 2000 chars', () => {
55
+ const longText = 'x'.repeat(3500);
56
+ const chunks: string[] = [];
57
+ for (let i = 0; i < longText.length; i += 2000) {
58
+ chunks.push(longText.slice(i, i + 2000));
59
+ }
60
+ expect(chunks).toHaveLength(2);
61
+ expect(chunks[0].length).toBe(2000);
62
+ expect(chunks[1].length).toBe(1500);
63
+ });
64
+
65
+ it('sequenceNumber starts null', () => {
66
+ const ch = new DiscordChannel({ botToken: 'tok' });
67
+ expect((ch as any).sequenceNumber).toBeNull();
68
+ });
69
+
70
+ it('ws starts null', () => {
71
+ const ch = new DiscordChannel({ botToken: 'tok' });
72
+ expect((ch as any).ws).toBeNull();
73
+ });
74
+
75
+ it('custom guildIds are set', () => {
76
+ const ch = new DiscordChannel({ botToken: 'tok', guildIds: ['g1', 'g2'] });
77
+ expect((ch as any).config.guildIds).toEqual(['g1', 'g2']);
78
+ });
79
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EmailChannel } from '../../src/channels/email';
3
+
4
+ describe('EmailChannel', () => {
5
+ // ── Webhook Payload Parsing ──────────────────────
6
+
7
+ describe('parseWebhookPayload', () => {
8
+ it('should parse standard payload', () => {
9
+ const payload = {
10
+ from: 'alice@example.com',
11
+ to: ['bob@example.com'],
12
+ subject: 'Test Email',
13
+ body: 'Hello from email',
14
+ messageId: '<msg123@example.com>',
15
+ date: '2026-01-15T10:00:00Z',
16
+ };
17
+
18
+ const email = EmailChannel.parseWebhookPayload(payload);
19
+ expect(email).not.toBeNull();
20
+ expect(email!.from).toBe('alice@example.com');
21
+ expect(email!.to).toEqual(['bob@example.com']);
22
+ expect(email!.subject).toBe('Test Email');
23
+ expect(email!.body).toBe('Hello from email');
24
+ expect(email!.messageId).toBe('<msg123@example.com>');
25
+ });
26
+
27
+ it('should parse SendGrid-style payload', () => {
28
+ const payload = {
29
+ from: 'sender@test.com',
30
+ to: 'recipient@test.com',
31
+ subject: 'SG Test',
32
+ text: 'Body from SendGrid',
33
+ html: '<p>Body from SendGrid</p>',
34
+ };
35
+
36
+ const email = EmailChannel.parseWebhookPayload(payload);
37
+ expect(email).not.toBeNull();
38
+ expect(email!.body).toBe('Body from SendGrid');
39
+ expect(email!.html).toBe('<p>Body from SendGrid</p>');
40
+ expect(email!.to).toEqual(['recipient@test.com']);
41
+ });
42
+
43
+ it('should parse Mailgun-style payload', () => {
44
+ const payload = {
45
+ sender: 'mg@example.com',
46
+ recipient: 'user@example.com',
47
+ subject: 'MG Test',
48
+ 'body-plain': 'Plain text body',
49
+ 'body-html': '<p>HTML body</p>',
50
+ 'Message-Id': '<mg123@mailgun>',
51
+ };
52
+
53
+ const email = EmailChannel.parseWebhookPayload(payload);
54
+ expect(email).not.toBeNull();
55
+ expect(email!.from).toBe('mg@example.com');
56
+ expect(email!.body).toBe('Plain text body');
57
+ expect(email!.messageId).toBe('<mg123@mailgun>');
58
+ });
59
+
60
+ it('should return null for payload without from', () => {
61
+ const email = EmailChannel.parseWebhookPayload({ subject: 'No sender' });
62
+ expect(email).toBeNull();
63
+ });
64
+
65
+ it('should handle envelope format', () => {
66
+ const payload = {
67
+ envelope: { from: 'env@test.com', to: ['a@b.com'] },
68
+ subject: 'Envelope test',
69
+ body: 'content',
70
+ };
71
+
72
+ const email = EmailChannel.parseWebhookPayload(payload);
73
+ expect(email).not.toBeNull();
74
+ expect(email!.from).toBe('env@test.com');
75
+ });
76
+
77
+ it('should generate messageId if missing', () => {
78
+ const payload = { from: 'a@b.com', body: 'test' };
79
+ const email = EmailChannel.parseWebhookPayload(payload);
80
+ expect(email).not.toBeNull();
81
+ expect(email!.messageId).toMatch(/^email-/);
82
+ });
83
+
84
+ it('should handle inReplyTo and references', () => {
85
+ const payload = {
86
+ from: 'a@b.com',
87
+ body: 'reply',
88
+ inReplyTo: '<orig@b.com>',
89
+ references: ['<orig@b.com>', '<prev@b.com>'],
90
+ };
91
+
92
+ const email = EmailChannel.parseWebhookPayload(payload);
93
+ expect(email!.inReplyTo).toBe('<orig@b.com>');
94
+ expect(email!.references).toEqual(['<orig@b.com>', '<prev@b.com>']);
95
+ });
96
+ });
97
+
98
+ // ── Filter Matching ──────────────────────────────
99
+
100
+ describe('matchesFilters', () => {
101
+ it('should match all when no filters', () => {
102
+ const channel = new EmailChannel({ mode: 'webhook' });
103
+ const result = channel.matchesFilters({
104
+ messageId: 'x', from: 'a@b.com', to: [], subject: 'test', body: '', date: new Date(),
105
+ });
106
+ expect(result).toBe(true);
107
+ });
108
+
109
+ it('should filter by from address', () => {
110
+ const channel = new EmailChannel({
111
+ mode: 'webhook',
112
+ filters: { from: ['allowed@example.com'] },
113
+ });
114
+
115
+ expect(channel.matchesFilters({
116
+ messageId: 'x', from: 'allowed@example.com', to: [], subject: '', body: '', date: new Date(),
117
+ })).toBe(true);
118
+
119
+ expect(channel.matchesFilters({
120
+ messageId: 'x', from: 'blocked@other.com', to: [], subject: '', body: '', date: new Date(),
121
+ })).toBe(false);
122
+ });
123
+
124
+ it('should filter by subject', () => {
125
+ const channel = new EmailChannel({
126
+ mode: 'webhook',
127
+ filters: { subject: ['[SUPPORT]'] },
128
+ });
129
+
130
+ expect(channel.matchesFilters({
131
+ messageId: 'x', from: 'a@b.com', to: [], subject: '[SUPPORT] Help me', body: '', date: new Date(),
132
+ })).toBe(true);
133
+
134
+ expect(channel.matchesFilters({
135
+ messageId: 'x', from: 'a@b.com', to: [], subject: 'Random email', body: '', date: new Date(),
136
+ })).toBe(false);
137
+ });
138
+ });
139
+
140
+ // ── Constructor ──────────────────────────────────
141
+
142
+ describe('constructor', () => {
143
+ it('should create with webhook mode', () => {
144
+ const channel = new EmailChannel({ mode: 'webhook', webhookPort: 9090 });
145
+ expect(channel.type).toBe('email');
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { FeishuChannel } from '../../src/channels/feishu';
3
+
4
+ describe('FeishuChannel', () => {
5
+ // ── Event Parsing ────────────────────────────────
6
+
7
+ describe('parseEventBody', () => {
8
+ it('should parse url_verification challenge', () => {
9
+ const body = { type: 'url_verification', challenge: 'abc123' };
10
+ const result = FeishuChannel.parseEventBody(body);
11
+ expect(result.type).toBe('url_verification');
12
+ expect(result.challenge).toBe('abc123');
13
+ });
14
+
15
+ it('should parse text message event', () => {
16
+ const body = {
17
+ header: {
18
+ event_id: 'evt_123',
19
+ event_type: 'im.message.receive_v1',
20
+ token: 'tok',
21
+ },
22
+ event: {
23
+ message: {
24
+ message_id: 'msg_123',
25
+ chat_id: 'oc_456',
26
+ message_type: 'text',
27
+ content: JSON.stringify({ text: 'Hello Feishu' }),
28
+ create_time: '1699999999',
29
+ chat_type: 'p2p',
30
+ },
31
+ sender: {
32
+ sender_id: { open_id: 'ou_789' },
33
+ },
34
+ },
35
+ };
36
+
37
+ const result = FeishuChannel.parseEventBody(body);
38
+ expect(result.type).toBe('message');
39
+ expect(result.content).toBe('Hello Feishu');
40
+ expect(result.chatId).toBe('oc_456');
41
+ expect(result.senderId).toBe('ou_789');
42
+ expect(result.messageId).toBe('msg_123');
43
+ });
44
+
45
+ it('should strip @bot mentions from content', () => {
46
+ const body = {
47
+ header: { event_type: 'im.message.receive_v1' },
48
+ event: {
49
+ message: {
50
+ message_id: 'msg_1',
51
+ chat_id: 'oc_1',
52
+ message_type: 'text',
53
+ content: JSON.stringify({ text: '@_user_123 Hello bot' }),
54
+ },
55
+ sender: { sender_id: { open_id: 'ou_1' } },
56
+ },
57
+ };
58
+
59
+ const result = FeishuChannel.parseEventBody(body);
60
+ expect(result.content).toBe('Hello bot');
61
+ });
62
+
63
+ it('should return empty content for non-text messages', () => {
64
+ const body = {
65
+ header: { event_type: 'im.message.receive_v1' },
66
+ event: {
67
+ message: {
68
+ message_id: 'msg_1',
69
+ chat_id: 'oc_1',
70
+ message_type: 'image',
71
+ content: '{}',
72
+ },
73
+ sender: { sender_id: { open_id: 'ou_1' } },
74
+ },
75
+ };
76
+
77
+ const result = FeishuChannel.parseEventBody(body);
78
+ expect(result.content).toBe('');
79
+ });
80
+
81
+ it('should handle unknown event types', () => {
82
+ const body = { header: { event_type: 'some.other.event' }, event: {} };
83
+ const result = FeishuChannel.parseEventBody(body);
84
+ expect(result.type).toBe('unknown');
85
+ });
86
+
87
+ it('should handle malformed content JSON gracefully', () => {
88
+ const body = {
89
+ header: { event_type: 'im.message.receive_v1' },
90
+ event: {
91
+ message: {
92
+ message_id: 'msg_1',
93
+ chat_id: 'oc_1',
94
+ message_type: 'text',
95
+ content: 'not-json',
96
+ },
97
+ sender: { sender_id: { open_id: 'ou_1' } },
98
+ },
99
+ };
100
+
101
+ const result = FeishuChannel.parseEventBody(body);
102
+ expect(result.content).toBe('not-json');
103
+ });
104
+ });
105
+
106
+ // ── Constructor ──────────────────────────────────
107
+
108
+ describe('constructor', () => {
109
+ it('should use defaults', () => {
110
+ const channel = new FeishuChannel();
111
+ expect(channel.type).toBe('feishu');
112
+ });
113
+
114
+ it('should accept config', () => {
115
+ const channel = new FeishuChannel({
116
+ appId: 'myapp',
117
+ appSecret: 'secret',
118
+ port: 9999,
119
+ });
120
+ expect(channel.type).toBe('feishu');
121
+ });
122
+ });
123
+ });