opc-agent 1.4.0 → 2.0.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/CHANGELOG.md +25 -0
- package/README.md +91 -32
- package/dist/channels/email.d.ts +32 -26
- package/dist/channels/email.js +239 -62
- package/dist/channels/feishu.d.ts +21 -6
- package/dist/channels/feishu.js +225 -126
- package/dist/channels/telegram.d.ts +30 -9
- package/dist/channels/telegram.js +125 -33
- package/dist/channels/websocket.d.ts +46 -3
- package/dist/channels/websocket.js +306 -37
- package/dist/channels/wechat.d.ts +33 -13
- package/dist/channels/wechat.js +229 -42
- package/dist/cli.js +1127 -19
- package/dist/core/a2a.d.ts +17 -0
- package/dist/core/a2a.js +43 -1
- package/dist/core/agent.d.ts +39 -0
- package/dist/core/agent.js +228 -3
- package/dist/core/runtime.d.ts +7 -0
- package/dist/core/runtime.js +205 -2
- package/dist/core/sandbox.d.ts +26 -0
- package/dist/core/sandbox.js +117 -0
- package/dist/core/scheduler.d.ts +52 -0
- package/dist/core/scheduler.js +168 -0
- package/dist/core/subagent.d.ts +28 -0
- package/dist/core/subagent.js +65 -0
- package/dist/core/workflow-graph.d.ts +93 -0
- package/dist/core/workflow-graph.js +247 -0
- package/dist/daemon.d.ts +3 -0
- package/dist/daemon.js +134 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +183 -0
- package/dist/eval/index.d.ts +65 -0
- package/dist/eval/index.js +191 -0
- package/dist/index.d.ts +37 -6
- package/dist/index.js +75 -3
- package/dist/plugins/content-filter.d.ts +7 -0
- package/dist/plugins/content-filter.js +25 -0
- package/dist/plugins/index.d.ts +42 -0
- package/dist/plugins/index.js +108 -2
- package/dist/plugins/logger.d.ts +6 -0
- package/dist/plugins/logger.js +20 -0
- package/dist/plugins/rate-limiter.d.ts +7 -0
- package/dist/plugins/rate-limiter.js +35 -0
- package/dist/protocols/a2a/client.d.ts +25 -0
- package/dist/protocols/a2a/client.js +115 -0
- package/dist/protocols/a2a/index.d.ts +6 -0
- package/dist/protocols/a2a/index.js +12 -0
- package/dist/protocols/a2a/server.d.ts +41 -0
- package/dist/protocols/a2a/server.js +295 -0
- package/dist/protocols/a2a/types.d.ts +91 -0
- package/dist/protocols/a2a/types.js +15 -0
- package/dist/protocols/a2a/utils.d.ts +6 -0
- package/dist/protocols/a2a/utils.js +47 -0
- package/dist/protocols/agui/client.d.ts +10 -0
- package/dist/protocols/agui/client.js +75 -0
- package/dist/protocols/agui/index.d.ts +4 -0
- package/dist/protocols/agui/index.js +25 -0
- package/dist/protocols/agui/server.d.ts +37 -0
- package/dist/protocols/agui/server.js +191 -0
- package/dist/protocols/agui/types.d.ts +107 -0
- package/dist/protocols/agui/types.js +17 -0
- package/dist/protocols/index.d.ts +2 -0
- package/dist/protocols/index.js +19 -0
- package/dist/protocols/mcp/agent-tools.d.ts +11 -0
- package/dist/protocols/mcp/agent-tools.js +129 -0
- package/dist/protocols/mcp/index.d.ts +5 -0
- package/dist/protocols/mcp/index.js +11 -0
- package/dist/protocols/mcp/server.d.ts +31 -0
- package/dist/protocols/mcp/server.js +248 -0
- package/dist/protocols/mcp/types.d.ts +92 -0
- package/dist/protocols/mcp/types.js +17 -0
- package/dist/providers/index.d.ts +5 -1
- package/dist/providers/index.js +16 -9
- package/dist/publish/index.d.ts +45 -0
- package/dist/publish/index.js +350 -0
- package/dist/schema/oad.d.ts +859 -67
- package/dist/schema/oad.js +47 -3
- package/dist/security/approval.d.ts +36 -0
- package/dist/security/approval.js +113 -0
- package/dist/security/index.d.ts +4 -0
- package/dist/security/index.js +8 -0
- package/dist/security/keys.d.ts +16 -0
- package/dist/security/keys.js +117 -0
- package/dist/skills/auto-learn.d.ts +28 -0
- package/dist/skills/auto-learn.js +257 -0
- package/dist/studio/server.d.ts +63 -0
- package/dist/studio/server.js +625 -0
- package/dist/studio-ui/index.html +662 -0
- package/dist/telemetry/index.d.ts +93 -0
- package/dist/telemetry/index.js +285 -0
- package/dist/tools/builtin/datetime.d.ts +3 -0
- package/dist/tools/builtin/datetime.js +44 -0
- package/dist/tools/builtin/file.d.ts +3 -0
- package/dist/tools/builtin/file.js +151 -0
- package/dist/tools/builtin/index.d.ts +15 -0
- package/dist/tools/builtin/index.js +30 -0
- package/dist/tools/builtin/shell.d.ts +3 -0
- package/dist/tools/builtin/shell.js +43 -0
- package/dist/tools/builtin/web.d.ts +3 -0
- package/dist/tools/builtin/web.js +37 -0
- package/dist/tools/mcp-client.d.ts +24 -0
- package/dist/tools/mcp-client.js +119 -0
- package/package.json +5 -3
- package/scripts/install.ps1 +31 -0
- package/scripts/install.sh +40 -0
- package/src/channels/email.ts +351 -177
- package/src/channels/feishu.ts +349 -236
- package/src/channels/telegram.ts +212 -90
- package/src/channels/websocket.ts +399 -87
- package/src/channels/wechat.ts +329 -149
- package/src/cli.ts +1201 -20
- package/src/core/a2a.ts +60 -0
- package/src/core/agent.ts +420 -152
- package/src/core/runtime.ts +174 -0
- package/src/core/sandbox.ts +143 -0
- package/src/core/scheduler.ts +187 -0
- package/src/core/subagent.ts +98 -0
- package/src/core/workflow-graph.ts +365 -0
- package/src/daemon.ts +96 -0
- package/src/doctor.ts +156 -0
- package/src/eval/index.ts +211 -0
- package/src/eval/suites/basic.json +16 -0
- package/src/eval/suites/memory.json +12 -0
- package/src/eval/suites/safety.json +14 -0
- package/src/index.ts +65 -6
- package/src/plugins/content-filter.ts +23 -0
- package/src/plugins/index.ts +133 -2
- package/src/plugins/logger.ts +18 -0
- package/src/plugins/rate-limiter.ts +38 -0
- package/src/protocols/a2a/client.ts +132 -0
- package/src/protocols/a2a/index.ts +8 -0
- package/src/protocols/a2a/server.ts +333 -0
- package/src/protocols/a2a/types.ts +88 -0
- package/src/protocols/a2a/utils.ts +50 -0
- package/src/protocols/agui/client.ts +83 -0
- package/src/protocols/agui/index.ts +4 -0
- package/src/protocols/agui/server.ts +218 -0
- package/src/protocols/agui/types.ts +153 -0
- package/src/protocols/index.ts +2 -0
- package/src/protocols/mcp/agent-tools.ts +134 -0
- package/src/protocols/mcp/index.ts +8 -0
- package/src/protocols/mcp/server.ts +262 -0
- package/src/protocols/mcp/types.ts +69 -0
- package/src/providers/index.ts +354 -339
- package/src/publish/index.ts +376 -0
- package/src/schema/oad.ts +204 -154
- package/src/security/approval.ts +131 -0
- package/src/security/index.ts +3 -0
- package/src/security/keys.ts +87 -0
- package/src/skills/auto-learn.ts +262 -0
- package/src/studio/server.ts +629 -0
- package/src/studio-ui/index.html +662 -0
- package/src/telemetry/index.ts +324 -0
- package/src/tools/builtin/datetime.ts +41 -0
- package/src/tools/builtin/file.ts +107 -0
- package/src/tools/builtin/index.ts +28 -0
- package/src/tools/builtin/shell.ts +43 -0
- package/src/tools/builtin/web.ts +35 -0
- package/src/tools/mcp-client.ts +131 -0
- package/src/types/agent-workstation.d.ts +2 -0
- package/tests/a2a-protocol.test.ts +285 -0
- package/tests/agui-protocol.test.ts +246 -0
- package/tests/auto-learn.test.ts +105 -0
- package/tests/builtin-tools.test.ts +83 -0
- package/tests/channels/discord.test.ts +79 -0
- package/tests/channels/email.test.ts +148 -0
- package/tests/channels/feishu.test.ts +123 -0
- package/tests/channels/telegram.test.ts +129 -0
- package/tests/channels/websocket.test.ts +53 -0
- package/tests/channels/wechat.test.ts +170 -0
- package/tests/chat-cli.test.ts +160 -0
- package/tests/cli.test.ts +46 -0
- package/tests/daemon.test.ts +135 -0
- package/tests/deepbrain-wire.test.ts +234 -0
- package/tests/doctor.test.ts +38 -0
- package/tests/eval.test.ts +173 -0
- package/tests/init-role.test.ts +124 -0
- package/tests/mcp-client.test.ts +92 -0
- package/tests/mcp-server.test.ts +178 -0
- package/tests/plugin-a2a-enhanced.test.ts +230 -0
- package/tests/publish.test.ts +231 -0
- package/tests/scheduler.test.ts +200 -0
- package/tests/security-enhanced.test.ts +233 -0
- package/tests/skill-learner.test.ts +161 -0
- package/tests/studio.test.ts +229 -0
- package/tests/subagent.test.ts +193 -0
- package/tests/telegram-discord.test.ts +60 -0
- package/tests/telemetry.test.ts +186 -0
- package/tests/tools/builtin-extended.test.ts +138 -0
- package/tests/workflow-graph.test.ts +279 -0
- package/tutorial/customer-service-agent/README.md +612 -0
- package/tutorial/customer-service-agent/SOUL.md +26 -0
- package/tutorial/customer-service-agent/agent.yaml +63 -0
- package/tutorial/customer-service-agent/package.json +19 -0
- package/tutorial/customer-service-agent/src/index.ts +69 -0
- package/tutorial/customer-service-agent/src/skills/faq.ts +27 -0
- package/tutorial/customer-service-agent/src/skills/ticket.ts +22 -0
- package/tutorial/customer-service-agent/tsconfig.json +14 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { SkillLearner, skillToMarkdown, parseSkillMarkdown, type LearnedSkill } from '../src/skills/auto-learn';
|
|
6
|
+
|
|
7
|
+
function makeSkill(overrides: Partial<LearnedSkill> = {}): LearnedSkill {
|
|
8
|
+
return {
|
|
9
|
+
name: 'test-skill',
|
|
10
|
+
description: 'A test skill',
|
|
11
|
+
trigger: 'deploy|deployment',
|
|
12
|
+
instructions: '1. Check env\n2. Run deploy\n3. Verify',
|
|
13
|
+
examples: ['deploy to production', 'run deployment'],
|
|
14
|
+
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
15
|
+
usageCount: 0,
|
|
16
|
+
version: 1,
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('SkillLearner', () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
let learner: SkillLearner;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opc-skills-'));
|
|
27
|
+
learner = new SkillLearner(tmpDir);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('saveSkill / loadLearnedSkills', () => {
|
|
35
|
+
it('should save and load a skill', async () => {
|
|
36
|
+
const skill = makeSkill();
|
|
37
|
+
await learner.saveSkill(skill);
|
|
38
|
+
|
|
39
|
+
const loaded = await learner.loadLearnedSkills();
|
|
40
|
+
expect(loaded).toHaveLength(1);
|
|
41
|
+
expect(loaded[0].name).toBe('test-skill');
|
|
42
|
+
expect(loaded[0].description).toBe('A test skill');
|
|
43
|
+
expect(loaded[0].trigger).toBe('deploy|deployment');
|
|
44
|
+
expect(loaded[0].examples).toEqual(['deploy to production', 'run deployment']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should create directory if not exists', async () => {
|
|
48
|
+
const nested = path.join(tmpDir, 'deep', 'nested');
|
|
49
|
+
const l = new SkillLearner(nested);
|
|
50
|
+
await l.saveSkill(makeSkill());
|
|
51
|
+
expect(fs.existsSync(path.join(nested, 'test-skill.md'))).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return empty array for nonexistent dir', async () => {
|
|
55
|
+
const l = new SkillLearner(path.join(tmpDir, 'nope'));
|
|
56
|
+
const skills = await l.loadLearnedSkills();
|
|
57
|
+
expect(skills).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('matchSkill', () => {
|
|
62
|
+
it('should match by regex pattern', async () => {
|
|
63
|
+
await learner.saveSkill(makeSkill({ trigger: 'deploy|deployment' }));
|
|
64
|
+
await learner.loadLearnedSkills();
|
|
65
|
+
|
|
66
|
+
expect(learner.matchSkill('please deploy to production')).not.toBeNull();
|
|
67
|
+
expect(learner.matchSkill('run deployment now')).not.toBeNull();
|
|
68
|
+
expect(learner.matchSkill('hello world')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should match by keyword fallback for invalid regex', async () => {
|
|
72
|
+
// Use an actually invalid regex so the catch branch is triggered
|
|
73
|
+
await learner.saveSkill(makeSkill({ trigger: '(deploy[broken, kubernetes' }));
|
|
74
|
+
await learner.loadLearnedSkills();
|
|
75
|
+
|
|
76
|
+
expect(learner.matchSkill('kubernetes cluster')).not.toBeNull();
|
|
77
|
+
expect(learner.matchSkill('random text')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return null if not loaded', () => {
|
|
81
|
+
expect(learner.matchSkill('deploy')).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('skillToMarkdown / parseSkillMarkdown', () => {
|
|
86
|
+
it('should round-trip a skill through markdown', () => {
|
|
87
|
+
const skill = makeSkill();
|
|
88
|
+
const md = skillToMarkdown(skill);
|
|
89
|
+
const parsed = parseSkillMarkdown(md);
|
|
90
|
+
|
|
91
|
+
expect(parsed).not.toBeNull();
|
|
92
|
+
expect(parsed!.name).toBe(skill.name);
|
|
93
|
+
expect(parsed!.description).toBe(skill.description);
|
|
94
|
+
expect(parsed!.trigger).toBe(skill.trigger);
|
|
95
|
+
expect(parsed!.instructions).toBe(skill.instructions);
|
|
96
|
+
expect(parsed!.examples).toEqual(skill.examples);
|
|
97
|
+
expect(parsed!.version).toBe(1);
|
|
98
|
+
expect(parsed!.usageCount).toBe(0);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should return null for invalid markdown', () => {
|
|
102
|
+
expect(parseSkillMarkdown('just some text')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { getBuiltinTools, getBuiltinToolsByName } from '../src/tools/builtin';
|
|
6
|
+
import { fileTool, shellTool, datetimeTool } from '../src/tools/builtin';
|
|
7
|
+
|
|
8
|
+
describe('getBuiltinTools', () => {
|
|
9
|
+
it('returns 4 tools', () => {
|
|
10
|
+
const tools = getBuiltinTools();
|
|
11
|
+
expect(tools).toHaveLength(4);
|
|
12
|
+
const names = tools.map(t => t.name);
|
|
13
|
+
expect(names).toContain('file_operations');
|
|
14
|
+
expect(names).toContain('web_fetch');
|
|
15
|
+
expect(names).toContain('shell_exec');
|
|
16
|
+
expect(names).toContain('datetime');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('getBuiltinToolsByName filters correctly', () => {
|
|
20
|
+
const tools = getBuiltinToolsByName(['datetime', 'file_operations']);
|
|
21
|
+
expect(tools).toHaveLength(2);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('getBuiltinToolsByName with no args returns all', () => {
|
|
25
|
+
expect(getBuiltinToolsByName()).toHaveLength(4);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('file_operations tool', () => {
|
|
30
|
+
// file tool resolves paths relative to cwd, so use relative paths from a temp dir
|
|
31
|
+
// Actually, it uses process.cwd() as workspace. Let's just test with paths relative to cwd.
|
|
32
|
+
const testFile = `tmp-test-${Date.now()}.txt`;
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
try { require('fs').unlinkSync(testFile); } catch {}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('write and read a file', async () => {
|
|
39
|
+
const writeRes = await fileTool.execute({ action: 'write', path: testFile, content: 'hello' });
|
|
40
|
+
expect(writeRes.isError).toBe(false);
|
|
41
|
+
|
|
42
|
+
const readRes = await fileTool.execute({ action: 'read', path: testFile });
|
|
43
|
+
expect(readRes.isError).toBe(false);
|
|
44
|
+
expect(readRes.content).toBe('hello');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('list files', async () => {
|
|
48
|
+
const res = await fileTool.execute({ action: 'list', path: '.' });
|
|
49
|
+
expect(res.isError).toBe(false);
|
|
50
|
+
expect(res.content).toContain('package.json');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('exists check', async () => {
|
|
54
|
+
const res = await fileTool.execute({ action: 'exists', path: testFile });
|
|
55
|
+
expect(res.content).toBe('true');
|
|
56
|
+
|
|
57
|
+
const res2 = await fileTool.execute({ action: 'exists', path: 'nope-does-not-exist.txt' });
|
|
58
|
+
expect(res2.content).toBe('false');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects path outside workspace', async () => {
|
|
62
|
+
const res = await fileTool.execute({ action: 'read', path: '../../etc/passwd' });
|
|
63
|
+
expect(res.isError).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('datetime tool', () => {
|
|
68
|
+
it('returns valid JSON with iso field', async () => {
|
|
69
|
+
const res = await datetimeTool.execute({});
|
|
70
|
+
expect(res.isError).toBe(false);
|
|
71
|
+
const parsed = JSON.parse(res.content);
|
|
72
|
+
expect(parsed.iso).toBeDefined();
|
|
73
|
+
expect(new Date(parsed.iso).toISOString()).toBe(parsed.iso);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('shell_exec tool', () => {
|
|
78
|
+
it('runs a command', async () => {
|
|
79
|
+
const res = await shellTool.execute({ command: 'echo hello' });
|
|
80
|
+
expect(res.isError).toBe(false);
|
|
81
|
+
expect(res.content).toContain('hello');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TelegramChannel } from '../../src/channels/telegram';
|
|
3
|
+
|
|
4
|
+
describe('TelegramChannel', () => {
|
|
5
|
+
it('constructor sets token from config', () => {
|
|
6
|
+
const ch = new TelegramChannel({ token: 'test-token-123' });
|
|
7
|
+
expect((ch as any).token).toBe('test-token-123');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('constructor defaults to polling mode', () => {
|
|
11
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
12
|
+
expect((ch as any).mode).toBe('polling');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('constructor reads token from env var', () => {
|
|
16
|
+
const orig = process.env.TELEGRAM_BOT_TOKEN;
|
|
17
|
+
process.env.TELEGRAM_BOT_TOKEN = 'env-token';
|
|
18
|
+
const ch = new TelegramChannel({});
|
|
19
|
+
expect((ch as any).token).toBe('env-token');
|
|
20
|
+
if (orig) process.env.TELEGRAM_BOT_TOKEN = orig;
|
|
21
|
+
else delete process.env.TELEGRAM_BOT_TOKEN;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('constructor sets webhook mode', () => {
|
|
25
|
+
const ch = new TelegramChannel({ token: 'tok', mode: 'webhook', webhookUrl: 'https://x.com' });
|
|
26
|
+
expect((ch as any).mode).toBe('webhook');
|
|
27
|
+
expect((ch as any).webhookUrl).toBe('https://x.com');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('constructor defaults port to 3001', () => {
|
|
31
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
32
|
+
expect((ch as any).port).toBe(3001);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('constructor uses custom port', () => {
|
|
36
|
+
const ch = new TelegramChannel({ token: 'tok', port: 8080 });
|
|
37
|
+
expect((ch as any).port).toBe(8080);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('type is telegram', () => {
|
|
41
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
42
|
+
expect(ch.type).toBe('telegram');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('processUpdate extracts message text', async () => {
|
|
46
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
47
|
+
const messages: any[] = [];
|
|
48
|
+
(ch as any).handler = async (msg: any) => { messages.push(msg); return { id: 'r', role: 'assistant', content: 'ok', timestamp: Date.now() }; };
|
|
49
|
+
// Mock sendMessage to avoid actual API call
|
|
50
|
+
(ch as any).sendMessage = vi.fn();
|
|
51
|
+
await (ch as any).processUpdate({
|
|
52
|
+
message: { message_id: 1, text: 'hello', date: 1000, chat: { id: 123 }, from: { id: 456, first_name: 'Test' } },
|
|
53
|
+
});
|
|
54
|
+
expect(messages).toHaveLength(1);
|
|
55
|
+
expect(messages[0].content).toBe('hello');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('processUpdate handles edited_message', async () => {
|
|
59
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
60
|
+
const messages: any[] = [];
|
|
61
|
+
(ch as any).handler = async (msg: any) => { messages.push(msg); return { id: 'r', role: 'assistant', content: 'ok', timestamp: Date.now() }; };
|
|
62
|
+
(ch as any).sendMessage = vi.fn();
|
|
63
|
+
await (ch as any).processUpdate({
|
|
64
|
+
edited_message: { message_id: 2, text: 'edited', date: 1000, chat: { id: 123 }, from: { id: 456, first_name: 'Test' } },
|
|
65
|
+
});
|
|
66
|
+
expect(messages).toHaveLength(1);
|
|
67
|
+
expect(messages[0].content).toBe('edited');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('processUpdate ignores non-text updates', async () => {
|
|
71
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
72
|
+
const messages: any[] = [];
|
|
73
|
+
ch.onMessage(async (msg) => { messages.push(msg); return { id: 'r', role: 'assistant', content: 'ok', timestamp: Date.now() } as any; });
|
|
74
|
+
(ch as any).sendMessage = vi.fn();
|
|
75
|
+
await (ch as any).processUpdate({
|
|
76
|
+
message: { message_id: 3, photo: [{}], date: 1000, chat: { id: 123 }, from: { id: 456 } },
|
|
77
|
+
});
|
|
78
|
+
expect(messages).toHaveLength(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('processUpdate ignores update without handler', async () => {
|
|
82
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
83
|
+
// Should not throw
|
|
84
|
+
await (ch as any).processUpdate({
|
|
85
|
+
message: { message_id: 4, text: 'hello', date: 1000, chat: { id: 123 }, from: { id: 456 } },
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('offset starts at 0', () => {
|
|
90
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
91
|
+
expect((ch as any).offset).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('missing token results in empty string', () => {
|
|
95
|
+
const orig = process.env.TELEGRAM_BOT_TOKEN;
|
|
96
|
+
delete process.env.TELEGRAM_BOT_TOKEN;
|
|
97
|
+
const ch = new TelegramChannel({});
|
|
98
|
+
expect((ch as any).token).toBe('');
|
|
99
|
+
if (orig) process.env.TELEGRAM_BOT_TOKEN = orig;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('start with no token warns but does not throw', async () => {
|
|
103
|
+
const orig = process.env.TELEGRAM_BOT_TOKEN;
|
|
104
|
+
delete process.env.TELEGRAM_BOT_TOKEN;
|
|
105
|
+
const ch = new TelegramChannel({});
|
|
106
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
107
|
+
await ch.start();
|
|
108
|
+
expect(spy).toHaveBeenCalled();
|
|
109
|
+
spy.mockRestore();
|
|
110
|
+
if (orig) process.env.TELEGRAM_BOT_TOKEN = orig;
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('sendMessage is a function', () => {
|
|
114
|
+
const ch = new TelegramChannel({ token: 'tok' });
|
|
115
|
+
expect(typeof (ch as any).sendMessage === 'function' || typeof (ch as any).apiCall === 'function').toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('long message chunking helper if available', () => {
|
|
119
|
+
// Test the concept - messages over 4096 chars should be split
|
|
120
|
+
const longText = 'a'.repeat(5000);
|
|
121
|
+
const chunks: string[] = [];
|
|
122
|
+
for (let i = 0; i < longText.length; i += 4096) {
|
|
123
|
+
chunks.push(longText.slice(i, i + 4096));
|
|
124
|
+
}
|
|
125
|
+
expect(chunks).toHaveLength(2);
|
|
126
|
+
expect(chunks[0].length).toBe(4096);
|
|
127
|
+
expect(chunks[1].length).toBe(904);
|
|
128
|
+
});
|
|
129
|
+
});
|