Skip to content

Commit 494eab7

Browse files
claude-code-best1111sisyphus-dev-aiclaude
authored
feat: 接入内建 weixin channel(同 #301 重构版本) (#303)
* feat: 接入 weixin 服务层与命令入口 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat: 注册内建 weixin channel 插件 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修正 channel permission relay 路由与能力判定 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修复 builtin channel 的 ChannelsNotice 误报 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: 补充内建 weixin channel 使用说明 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * docs: 更新微信 channel 接入计划状态 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 延迟加载 weixin 登录二维码依赖 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 改用 qrcode 生成 weixin 登录二维码 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * fix: 修正 vite 构建的 Windows 路径解析 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * chore: 删除临时规划文档 wx_channel.md 并还原 package.json 排序 wx_channel.md 内容已整合到 docs/features/channels.md,不再需要。 package.json 中 @ant/model-provider 位置从原始位置被无意移动,还原。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 将 weixin 模块从 src/ 迁移至 packages/weixin 工作区包 将 src/services/weixin/ 中的纯业务逻辑迁入 @claude-code-best/weixin workspace 包,降低 src/ 耦合度。仅保留 server.ts 作为薄适配层。 - 迁移 7 个无修改的纯模块 (types/api/accounts/login/pairing/media/send) - monitor.ts 内联 PERMISSION_REPLY_RE 正则,解除对 src/ 的依赖 - permissions.ts 本地定义 ChannelPermissionRequestParams 接口 - cli.ts 拆分:serve 子命令通过回调注入,login/access 保留在包内 - server.ts 重写为从 @claude-code-best/weixin 导入 - 新增 cli-serve.ts 作为 serve 入口薄壳 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修正 weixin barrel export 中 interface 的导出方式 ChannelPermissionRequestParams 是纯类型,必须用 export type 导出, 否则 Bun 运行时会报 "export not found" 错误。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 将 server.ts 迁入 packages/weixin,彻底移除 src/services/weixin/ 通过依赖注入(WeixinServerDeps)解耦 src/ 依赖(analytics、config、 MCP channel schema),server.ts 完全移入包内。cli.tsx 入口处一次性 注入所有依赖。 src/services/weixin/ 目录已完全删除。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 markdownToPlainText 中代码块正则的 ReDoS 风险 用非正则的线性扫描替代 \`\`\`[\s\S]*?\n([\s\S]*?)\`\`\` 匹配, 避免在含有大量重复 \`\`\` 序列的输入上触发多项式回溯。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: 1111 <11111@asd.c> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b83c300 commit 494eab7

39 files changed

Lines changed: 2616 additions & 19 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
2323
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
2424
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
25-
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
25+
| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) |
2626
| **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) |
2727
| Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) |
2828
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |

bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/features/channels.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Cla
1010
- **官方文档**[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
1111
- **飞书插件**[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
1212

13+
本仓库现在内置了 **微信 WeChat channel**,不需要单独安装外部 marketplace 插件。
14+
1315
## 快速开始
1416

1517
```bash
1618
# 启用频道监听(plugin 格式)
1719
ccb --channels plugin:feishu@claude-code-feishu-channel
1820

21+
# 启用内置微信 channel
22+
ccb weixin login
23+
ccb --channels plugin:weixin@builtin
24+
1925
# 启用频道监听(server 格式)
2026
ccb --channels server:my-slack-bridge
2127

@@ -34,6 +40,37 @@ ccb --dangerously-load-development-channels server:my-custom-channel
3440
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
3541
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
3642
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
43+
| **微信 (WeChat)** | 内置 channel,支持扫码登录、双向消息、附件透传 | `ccb weixin login` + `ccb --channels plugin:weixin@builtin` |
44+
45+
## 微信内置 Channel
46+
47+
### 登录
48+
49+
```bash
50+
ccb weixin login
51+
```
52+
53+
已登录状态可清除:
54+
55+
```bash
56+
ccb weixin login clear
57+
```
58+
59+
### 会话启用
60+
61+
```bash
62+
ccb --channels plugin:weixin@builtin
63+
```
64+
65+
### 配对授权
66+
67+
首次收到未授权微信用户消息时,weixin channel 会回一条 6 位 pairing code。运营侧可在终端执行:
68+
69+
```bash
70+
ccb weixin access pair <code>
71+
```
72+
73+
确认后,该微信用户后续消息才会进入 Claude Code 会话。
3774

3875
## 相关文件
3976

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"@claude-code-best/agent-tools": "workspace:*",
9191
"@claude-code-best/builtin-tools": "workspace:*",
9292
"@claude-code-best/mcp-client": "workspace:*",
93+
"@claude-code-best/weixin": "workspace:*",
9394
"@commander-js/extra-typings": "^14.0.0",
9495
"@growthbook/growthbook": "^1.6.5",
9596
"@langfuse/otel": "^5.1.0",

packages/weixin/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "@claude-code-best/weixin",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "./src/index.ts",
7+
"types": "./src/index.ts",
8+
"dependencies": {
9+
"qrcode": "^1.5.4"
10+
}
11+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
import { mkdtempSync, rmSync, statSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
6+
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-accounts-'))
7+
process.env.WEIXIN_STATE_DIR = testDir
8+
9+
import { clearAccount, loadAccount, saveAccount } from '../accounts.js'
10+
11+
afterEach(() => {
12+
rmSync(testDir, { recursive: true, force: true })
13+
})
14+
15+
describe('account storage', () => {
16+
test('loadAccount returns null when no account exists', () => {
17+
expect(loadAccount()).toBeNull()
18+
})
19+
20+
test('saveAccount and loadAccount round-trip', () => {
21+
const data = {
22+
token: 'test-token',
23+
baseUrl: 'https://example.com',
24+
userId: 'user1',
25+
savedAt: '2025-01-01T00:00:00.000Z',
26+
}
27+
saveAccount(data)
28+
expect(loadAccount()).toEqual(data)
29+
})
30+
31+
test('saveAccount sets file permissions to 0600', () => {
32+
saveAccount({
33+
token: 'test',
34+
baseUrl: 'https://example.com',
35+
savedAt: new Date().toISOString(),
36+
})
37+
const stats = statSync(join(testDir, 'account.json'))
38+
if (process.platform === 'win32') {
39+
expect(stats.isFile()).toBe(true)
40+
return
41+
}
42+
expect(stats.mode & 0o777).toBe(0o600)
43+
})
44+
45+
test('clearAccount removes the file', () => {
46+
saveAccount({
47+
token: 'test',
48+
baseUrl: 'https://example.com',
49+
savedAt: new Date().toISOString(),
50+
})
51+
clearAccount()
52+
expect(loadAccount()).toBeNull()
53+
})
54+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { randomBytes } from 'node:crypto'
3+
import {
4+
aesEcbPaddedSize,
5+
buildCdnDownloadUrl,
6+
buildCdnUploadUrl,
7+
decryptAesEcb,
8+
encryptAesEcb,
9+
guessMediaType,
10+
parseAesKey,
11+
} from '../media.js'
12+
import { UploadMediaType } from '../types.js'
13+
14+
describe('AES-128-ECB', () => {
15+
test('encrypt then decrypt returns original data', () => {
16+
const key = randomBytes(16)
17+
const plaintext = Buffer.from('hello world test data!!')
18+
const ciphertext = encryptAesEcb(plaintext, key)
19+
expect(decryptAesEcb(ciphertext, key)).toEqual(plaintext)
20+
})
21+
22+
test('different keys produce different ciphertext', () => {
23+
const plaintext = Buffer.from('test data')
24+
expect(
25+
encryptAesEcb(plaintext, randomBytes(16)),
26+
).not.toEqual(encryptAesEcb(plaintext, randomBytes(16)))
27+
})
28+
})
29+
30+
describe('aesEcbPaddedSize', () => {
31+
test('pads to next 16-byte boundary', () => {
32+
expect(aesEcbPaddedSize(1)).toBe(16)
33+
expect(aesEcbPaddedSize(16)).toBe(32)
34+
expect(aesEcbPaddedSize(17)).toBe(32)
35+
expect(aesEcbPaddedSize(32)).toBe(48)
36+
})
37+
})
38+
39+
describe('parseAesKey', () => {
40+
test('parses 16 raw bytes from base64', () => {
41+
const raw = randomBytes(16)
42+
expect(parseAesKey(raw.toString('base64'))).toEqual(raw)
43+
})
44+
45+
test('parses hex-encoded key from base64', () => {
46+
const raw = randomBytes(16)
47+
const b64 = Buffer.from(raw.toString('hex'), 'ascii').toString('base64')
48+
expect(parseAesKey(b64)).toEqual(raw)
49+
})
50+
51+
test('throws on invalid key length', () => {
52+
expect(() => parseAesKey(Buffer.from('short').toString('base64'))).toThrow(
53+
'Invalid aes_key',
54+
)
55+
})
56+
})
57+
58+
describe('CDN URL builders', () => {
59+
test('buildCdnDownloadUrl encodes param', () => {
60+
expect(buildCdnDownloadUrl('abc=123', 'https://cdn.example.com')).toBe(
61+
'https://cdn.example.com/download?encrypted_query_param=abc%3D123',
62+
)
63+
})
64+
65+
test('buildCdnUploadUrl encodes params', () => {
66+
expect(
67+
buildCdnUploadUrl('https://cdn.example.com', 'param1', 'key1'),
68+
).toBe(
69+
'https://cdn.example.com/upload?encrypted_query_param=param1&filekey=key1',
70+
)
71+
})
72+
})
73+
74+
describe('guessMediaType', () => {
75+
test('detects image extensions', () => {
76+
expect(guessMediaType('photo.jpg')).toBe(UploadMediaType.IMAGE)
77+
expect(guessMediaType('photo.png')).toBe(UploadMediaType.IMAGE)
78+
expect(guessMediaType('photo.webp')).toBe(UploadMediaType.IMAGE)
79+
})
80+
81+
test('detects video extensions', () => {
82+
expect(guessMediaType('video.mp4')).toBe(UploadMediaType.VIDEO)
83+
expect(guessMediaType('video.mov')).toBe(UploadMediaType.VIDEO)
84+
})
85+
86+
test('defaults to FILE for unknown extensions', () => {
87+
expect(guessMediaType('doc.pdf')).toBe(UploadMediaType.FILE)
88+
expect(guessMediaType('archive.zip')).toBe(UploadMediaType.FILE)
89+
})
90+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { extractPermissionReply } from '../monitor.js'
3+
4+
describe('extractPermissionReply', () => {
5+
test('parses allow replies', () => {
6+
expect(extractPermissionReply('yes abcde')).toEqual({
7+
requestId: 'abcde',
8+
behavior: 'allow',
9+
})
10+
})
11+
12+
test('parses deny replies', () => {
13+
expect(extractPermissionReply('No abcde')).toEqual({
14+
requestId: 'abcde',
15+
behavior: 'deny',
16+
})
17+
})
18+
19+
test('ignores unrelated text', () => {
20+
expect(extractPermissionReply('yes please do it')).toBeNull()
21+
})
22+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
import { mkdtempSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
6+
const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-pairing-'))
7+
process.env.WEIXIN_STATE_DIR = testDir
8+
9+
import {
10+
addPendingPairing,
11+
confirmPairing,
12+
isAllowed,
13+
loadAccessConfig,
14+
saveAccessConfig,
15+
} from '../pairing.js'
16+
17+
afterEach(() => {
18+
rmSync(testDir, { recursive: true, force: true })
19+
})
20+
21+
describe('loadAccessConfig', () => {
22+
test('returns default config when no file exists', () => {
23+
const config = loadAccessConfig()
24+
expect(config.policy).toBe('pairing')
25+
expect(config.allowFrom).toEqual([])
26+
})
27+
28+
test('round-trips saved config', () => {
29+
saveAccessConfig({ policy: 'allowlist', allowFrom: ['user1'] })
30+
const config = loadAccessConfig()
31+
expect(config.policy).toBe('allowlist')
32+
expect(config.allowFrom).toEqual(['user1'])
33+
})
34+
})
35+
36+
describe('isAllowed', () => {
37+
test('returns false for unknown user under pairing policy', () => {
38+
expect(isAllowed('unknown')).toBe(false)
39+
})
40+
41+
test('returns true for allowed user', () => {
42+
saveAccessConfig({ policy: 'pairing', allowFrom: ['user1'] })
43+
expect(isAllowed('user1')).toBe(true)
44+
})
45+
46+
test('returns true for any user under disabled policy', () => {
47+
saveAccessConfig({ policy: 'disabled', allowFrom: [] })
48+
expect(isAllowed('anyone')).toBe(true)
49+
})
50+
})
51+
52+
describe('pairing flow', () => {
53+
test('generates 6-digit code', () => {
54+
expect(addPendingPairing('user1')).toMatch(/^\d{6}$/)
55+
})
56+
57+
test('returns same code for same user', () => {
58+
const code1 = addPendingPairing('user1')
59+
const code2 = addPendingPairing('user1')
60+
expect(code1).toBe(code2)
61+
})
62+
63+
test('confirm adds user to allowlist', () => {
64+
const code = addPendingPairing('user1')
65+
expect(confirmPairing(code)).toBe('user1')
66+
expect(isAllowed('user1')).toBe(true)
67+
})
68+
69+
test('confirm returns null for invalid code', () => {
70+
expect(confirmPairing('000000')).toBeNull()
71+
})
72+
73+
test('code cannot be reused after confirmation', () => {
74+
const code = addPendingPairing('user1')
75+
confirmPairing(code)
76+
expect(confirmPairing(code)).toBeNull()
77+
})
78+
})

0 commit comments

Comments
 (0)