From 494eab72040669f24edc6aa8caab6937fe98306e Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sun, 19 Apr 2026 21:33:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E5=86=85=E5=BB=BA=20?= =?UTF-8?q?weixin=20channel(=E5=90=8C=20#301=20=E9=87=8D=E6=9E=84=E7=89=88?= =?UTF-8?q?=E6=9C=AC)=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 接入 weixin 服务层与命令入口 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * feat: 注册内建 weixin channel 插件 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix: 修正 channel permission relay 路由与能力判定 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix: 修复 builtin channel 的 ChannelsNotice 误报 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: 补充内建 weixin channel 使用说明 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * docs: 更新微信 channel 接入计划状态 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix: 延迟加载 weixin 登录二维码依赖 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix: 改用 qrcode 生成 weixin 登录二维码 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * fix: 修正 vite 构建的 Windows 路径解析 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus * 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 * 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 * fix: 修正 weixin barrel export 中 interface 的导出方式 ChannelPermissionRequestParams 是纯类型,必须用 export type 导出, 否则 Bun 运行时会报 "export not found" 错误。 Co-Authored-By: Claude Opus 4.6 * 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 * fix: 修复 markdownToPlainText 中代码块正则的 ReDoS 风险 用非正则的线性扫描替代 \`\`\`[\s\S]*?\n([\s\S]*?)\`\`\` 匹配, 避免在含有大量重复 \`\`\` 序列的输入上触发多项式回溯。 Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: 1111 <11111@asd.c> Co-authored-by: Sisyphus Co-authored-by: Claude Opus 4.6 --- README.md | 2 +- bun.lock | 10 + docs/features/channels.md | 37 ++ package.json | 1 + packages/weixin/package.json | 11 + .../weixin/src/__tests__/accounts.test.ts | 54 +++ packages/weixin/src/__tests__/media.test.ts | 90 +++++ packages/weixin/src/__tests__/monitor.test.ts | 22 ++ packages/weixin/src/__tests__/pairing.test.ts | 78 ++++ .../weixin/src/__tests__/permissions.test.ts | 43 +++ packages/weixin/src/__tests__/send.test.ts | 32 ++ packages/weixin/src/accounts.ts | 57 +++ packages/weixin/src/api.ts | 148 ++++++++ packages/weixin/src/cli.ts | 119 ++++++ packages/weixin/src/index.ts | 115 ++++++ packages/weixin/src/login.ts | 134 +++++++ packages/weixin/src/media.ts | 163 ++++++++ packages/weixin/src/monitor.ts | 303 +++++++++++++++ packages/weixin/src/pairing.ts | 101 +++++ packages/weixin/src/permissions.ts | 83 ++++ packages/weixin/src/send.ts | 180 +++++++++ packages/weixin/src/server.ts | 353 ++++++++++++++++++ packages/weixin/src/types.ts | 178 +++++++++ packages/weixin/tsconfig.json | 5 + src/components/LogoV2/ChannelsNotice.tsx | 35 +- .../LogoV2/__tests__/ChannelsNotice.test.ts | 17 + src/entrypoints/cli.tsx | 25 ++ .../__tests__/interactiveHandler.test.ts | 38 ++ .../handlers/interactiveHandler.ts | 82 ++++ src/plugins/bundled/index.ts | 5 +- src/plugins/bundled/weixin.ts | 21 ++ .../mcp/__tests__/channelAllowlist.test.ts | 17 + .../mcp/__tests__/channelPermissions.test.ts | 32 ++ src/services/mcp/channelAllowlist.ts | 4 + src/services/mcp/channelNotification.ts | 25 ++ src/services/mcp/channelPermissions.ts | 6 +- src/services/mcp/useManageMCPConnections.ts | 2 +- tsconfig.json | 4 +- vite.config.ts | 3 +- 39 files changed, 2616 insertions(+), 19 deletions(-) create mode 100644 packages/weixin/package.json create mode 100644 packages/weixin/src/__tests__/accounts.test.ts create mode 100644 packages/weixin/src/__tests__/media.test.ts create mode 100644 packages/weixin/src/__tests__/monitor.test.ts create mode 100644 packages/weixin/src/__tests__/pairing.test.ts create mode 100644 packages/weixin/src/__tests__/permissions.test.ts create mode 100644 packages/weixin/src/__tests__/send.test.ts create mode 100644 packages/weixin/src/accounts.ts create mode 100644 packages/weixin/src/api.ts create mode 100644 packages/weixin/src/cli.ts create mode 100644 packages/weixin/src/index.ts create mode 100644 packages/weixin/src/login.ts create mode 100644 packages/weixin/src/media.ts create mode 100644 packages/weixin/src/monitor.ts create mode 100644 packages/weixin/src/pairing.ts create mode 100644 packages/weixin/src/permissions.ts create mode 100644 packages/weixin/src/send.ts create mode 100644 packages/weixin/src/server.ts create mode 100644 packages/weixin/src/types.ts create mode 100644 packages/weixin/tsconfig.json create mode 100644 src/components/LogoV2/__tests__/ChannelsNotice.test.ts create mode 100644 src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts create mode 100644 src/plugins/bundled/weixin.ts create mode 100644 src/services/mcp/__tests__/channelAllowlist.test.ts diff --git a/README.md b/README.md index 95264d1e7..abcc2e569 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ | **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) | | **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) | | **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 | -| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord 等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) | +| **Channels 频道通知** | MCP 服务器推送外部消息到会话(飞书/Slack/Discord/微信等),`--channels plugin:name@marketplace` 启用 | [文档](https://ccb.agent-aura.top/docs/features/channels) | | **自定义模型供应商** | OpenAI/Anthropic/Gemini/Grok 兼容 | [文档](https://ccb.agent-aura.top/docs/features/custom-platform-login) | | Voice Mode | Push-to-Talk 语音输入 | [文档](https://ccb.agent-aura.top/docs/features/voice-mode) | | Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) | diff --git a/bun.lock b/bun.lock index 1c29f78c8..afb20fc09 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,7 @@ "@claude-code-best/agent-tools": "workspace:*", "@claude-code-best/builtin-tools": "workspace:*", "@claude-code-best/mcp-client": "workspace:*", + "@claude-code-best/weixin": "workspace:*", "@commander-js/extra-typings": "^14.0.0", "@growthbook/growthbook": "^1.6.5", "@langfuse/otel": "^5.1.0", @@ -322,6 +323,13 @@ "name": "url-handler-napi", "version": "1.0.0", }, + "packages/weixin": { + "name": "@claude-code-best/weixin", + "version": "1.0.0", + "dependencies": { + "qrcode": "^1.5.4", + }, + }, }, "packages": { "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.19.0", "https://registry.npmmirror.com/@agentclientprotocol/sdk/-/sdk-0.19.0.tgz", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-U9I8ws9WTOk6jCBAWpXefGSDgVXn14/kV6HFzwWGcstQ02mOQgClMAROHmoIn9GqZbDBDEOkdIbP4P4TEMQdug=="], @@ -566,6 +574,8 @@ "@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"], + "@claude-code-best/weixin": ["@claude-code-best/weixin@workspace:packages/weixin"], + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], "@emnapi/core": ["@emnapi/core@1.9.2", "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], diff --git a/docs/features/channels.md b/docs/features/channels.md index 5ad48a454..03e389228 100644 --- a/docs/features/channels.md +++ b/docs/features/channels.md @@ -10,12 +10,18 @@ Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Cla - **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels) - **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件 +本仓库现在内置了 **微信 WeChat channel**,不需要单独安装外部 marketplace 插件。 + ## 快速开始 ```bash # 启用频道监听(plugin 格式) ccb --channels plugin:feishu@claude-code-feishu-channel +# 启用内置微信 channel +ccb weixin login +ccb --channels plugin:weixin@builtin + # 启用频道监听(server 格式) ccb --channels server:my-slack-bridge @@ -34,6 +40,37 @@ ccb --dangerously-load-development-channels server:my-custom-channel | **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` | | **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` | | **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` | +| **微信 (WeChat)** | 内置 channel,支持扫码登录、双向消息、附件透传 | `ccb weixin login` + `ccb --channels plugin:weixin@builtin` | + +## 微信内置 Channel + +### 登录 + +```bash +ccb weixin login +``` + +已登录状态可清除: + +```bash +ccb weixin login clear +``` + +### 会话启用 + +```bash +ccb --channels plugin:weixin@builtin +``` + +### 配对授权 + +首次收到未授权微信用户消息时,weixin channel 会回一条 6 位 pairing code。运营侧可在终端执行: + +```bash +ccb weixin access pair +``` + +确认后,该微信用户后续消息才会进入 Claude Code 会话。 ## 相关文件 diff --git a/package.json b/package.json index e3c961e8e..95a9181e3 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@claude-code-best/agent-tools": "workspace:*", "@claude-code-best/builtin-tools": "workspace:*", "@claude-code-best/mcp-client": "workspace:*", + "@claude-code-best/weixin": "workspace:*", "@commander-js/extra-typings": "^14.0.0", "@growthbook/growthbook": "^1.6.5", "@langfuse/otel": "^5.1.0", diff --git a/packages/weixin/package.json b/packages/weixin/package.json new file mode 100644 index 000000000..f57d36295 --- /dev/null +++ b/packages/weixin/package.json @@ -0,0 +1,11 @@ +{ + "name": "@claude-code-best/weixin", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "qrcode": "^1.5.4" + } +} diff --git a/packages/weixin/src/__tests__/accounts.test.ts b/packages/weixin/src/__tests__/accounts.test.ts new file mode 100644 index 000000000..2c1b2a0ce --- /dev/null +++ b/packages/weixin/src/__tests__/accounts.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-accounts-')) +process.env.WEIXIN_STATE_DIR = testDir + +import { clearAccount, loadAccount, saveAccount } from '../accounts.js' + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +describe('account storage', () => { + test('loadAccount returns null when no account exists', () => { + expect(loadAccount()).toBeNull() + }) + + test('saveAccount and loadAccount round-trip', () => { + const data = { + token: 'test-token', + baseUrl: 'https://example.com', + userId: 'user1', + savedAt: '2025-01-01T00:00:00.000Z', + } + saveAccount(data) + expect(loadAccount()).toEqual(data) + }) + + test('saveAccount sets file permissions to 0600', () => { + saveAccount({ + token: 'test', + baseUrl: 'https://example.com', + savedAt: new Date().toISOString(), + }) + const stats = statSync(join(testDir, 'account.json')) + if (process.platform === 'win32') { + expect(stats.isFile()).toBe(true) + return + } + expect(stats.mode & 0o777).toBe(0o600) + }) + + test('clearAccount removes the file', () => { + saveAccount({ + token: 'test', + baseUrl: 'https://example.com', + savedAt: new Date().toISOString(), + }) + clearAccount() + expect(loadAccount()).toBeNull() + }) +}) diff --git a/packages/weixin/src/__tests__/media.test.ts b/packages/weixin/src/__tests__/media.test.ts new file mode 100644 index 000000000..ed77405d1 --- /dev/null +++ b/packages/weixin/src/__tests__/media.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, test } from 'bun:test' +import { randomBytes } from 'node:crypto' +import { + aesEcbPaddedSize, + buildCdnDownloadUrl, + buildCdnUploadUrl, + decryptAesEcb, + encryptAesEcb, + guessMediaType, + parseAesKey, +} from '../media.js' +import { UploadMediaType } from '../types.js' + +describe('AES-128-ECB', () => { + test('encrypt then decrypt returns original data', () => { + const key = randomBytes(16) + const plaintext = Buffer.from('hello world test data!!') + const ciphertext = encryptAesEcb(plaintext, key) + expect(decryptAesEcb(ciphertext, key)).toEqual(plaintext) + }) + + test('different keys produce different ciphertext', () => { + const plaintext = Buffer.from('test data') + expect( + encryptAesEcb(plaintext, randomBytes(16)), + ).not.toEqual(encryptAesEcb(plaintext, randomBytes(16))) + }) +}) + +describe('aesEcbPaddedSize', () => { + test('pads to next 16-byte boundary', () => { + expect(aesEcbPaddedSize(1)).toBe(16) + expect(aesEcbPaddedSize(16)).toBe(32) + expect(aesEcbPaddedSize(17)).toBe(32) + expect(aesEcbPaddedSize(32)).toBe(48) + }) +}) + +describe('parseAesKey', () => { + test('parses 16 raw bytes from base64', () => { + const raw = randomBytes(16) + expect(parseAesKey(raw.toString('base64'))).toEqual(raw) + }) + + test('parses hex-encoded key from base64', () => { + const raw = randomBytes(16) + const b64 = Buffer.from(raw.toString('hex'), 'ascii').toString('base64') + expect(parseAesKey(b64)).toEqual(raw) + }) + + test('throws on invalid key length', () => { + expect(() => parseAesKey(Buffer.from('short').toString('base64'))).toThrow( + 'Invalid aes_key', + ) + }) +}) + +describe('CDN URL builders', () => { + test('buildCdnDownloadUrl encodes param', () => { + expect(buildCdnDownloadUrl('abc=123', 'https://cdn.example.com')).toBe( + 'https://cdn.example.com/download?encrypted_query_param=abc%3D123', + ) + }) + + test('buildCdnUploadUrl encodes params', () => { + expect( + buildCdnUploadUrl('https://cdn.example.com', 'param1', 'key1'), + ).toBe( + 'https://cdn.example.com/upload?encrypted_query_param=param1&filekey=key1', + ) + }) +}) + +describe('guessMediaType', () => { + test('detects image extensions', () => { + expect(guessMediaType('photo.jpg')).toBe(UploadMediaType.IMAGE) + expect(guessMediaType('photo.png')).toBe(UploadMediaType.IMAGE) + expect(guessMediaType('photo.webp')).toBe(UploadMediaType.IMAGE) + }) + + test('detects video extensions', () => { + expect(guessMediaType('video.mp4')).toBe(UploadMediaType.VIDEO) + expect(guessMediaType('video.mov')).toBe(UploadMediaType.VIDEO) + }) + + test('defaults to FILE for unknown extensions', () => { + expect(guessMediaType('doc.pdf')).toBe(UploadMediaType.FILE) + expect(guessMediaType('archive.zip')).toBe(UploadMediaType.FILE) + }) +}) diff --git a/packages/weixin/src/__tests__/monitor.test.ts b/packages/weixin/src/__tests__/monitor.test.ts new file mode 100644 index 000000000..0c8296a38 --- /dev/null +++ b/packages/weixin/src/__tests__/monitor.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { extractPermissionReply } from '../monitor.js' + +describe('extractPermissionReply', () => { + test('parses allow replies', () => { + expect(extractPermissionReply('yes abcde')).toEqual({ + requestId: 'abcde', + behavior: 'allow', + }) + }) + + test('parses deny replies', () => { + expect(extractPermissionReply('No abcde')).toEqual({ + requestId: 'abcde', + behavior: 'deny', + }) + }) + + test('ignores unrelated text', () => { + expect(extractPermissionReply('yes please do it')).toBeNull() + }) +}) diff --git a/packages/weixin/src/__tests__/pairing.test.ts b/packages/weixin/src/__tests__/pairing.test.ts new file mode 100644 index 000000000..3ae717789 --- /dev/null +++ b/packages/weixin/src/__tests__/pairing.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const testDir = mkdtempSync(join(tmpdir(), 'weixin-test-pairing-')) +process.env.WEIXIN_STATE_DIR = testDir + +import { + addPendingPairing, + confirmPairing, + isAllowed, + loadAccessConfig, + saveAccessConfig, +} from '../pairing.js' + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }) +}) + +describe('loadAccessConfig', () => { + test('returns default config when no file exists', () => { + const config = loadAccessConfig() + expect(config.policy).toBe('pairing') + expect(config.allowFrom).toEqual([]) + }) + + test('round-trips saved config', () => { + saveAccessConfig({ policy: 'allowlist', allowFrom: ['user1'] }) + const config = loadAccessConfig() + expect(config.policy).toBe('allowlist') + expect(config.allowFrom).toEqual(['user1']) + }) +}) + +describe('isAllowed', () => { + test('returns false for unknown user under pairing policy', () => { + expect(isAllowed('unknown')).toBe(false) + }) + + test('returns true for allowed user', () => { + saveAccessConfig({ policy: 'pairing', allowFrom: ['user1'] }) + expect(isAllowed('user1')).toBe(true) + }) + + test('returns true for any user under disabled policy', () => { + saveAccessConfig({ policy: 'disabled', allowFrom: [] }) + expect(isAllowed('anyone')).toBe(true) + }) +}) + +describe('pairing flow', () => { + test('generates 6-digit code', () => { + expect(addPendingPairing('user1')).toMatch(/^\d{6}$/) + }) + + test('returns same code for same user', () => { + const code1 = addPendingPairing('user1') + const code2 = addPendingPairing('user1') + expect(code1).toBe(code2) + }) + + test('confirm adds user to allowlist', () => { + const code = addPendingPairing('user1') + expect(confirmPairing(code)).toBe('user1') + expect(isAllowed('user1')).toBe(true) + }) + + test('confirm returns null for invalid code', () => { + expect(confirmPairing('000000')).toBeNull() + }) + + test('code cannot be reused after confirmation', () => { + const code = addPendingPairing('user1') + confirmPairing(code) + expect(confirmPairing(code)).toBeNull() + }) +}) diff --git a/packages/weixin/src/__tests__/permissions.test.ts b/packages/weixin/src/__tests__/permissions.test.ts new file mode 100644 index 000000000..065376d56 --- /dev/null +++ b/packages/weixin/src/__tests__/permissions.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { + clearPermissionStateForTests, + consumePendingPermission, + getActivePermissionChat, + savePendingPermission, + setActivePermissionChat, +} from '../permissions.js' + +afterEach(() => { + clearPermissionStateForTests() +}) + +describe('permission state', () => { + test('tracks active permission chat', () => { + setActivePermissionChat('user-1', 'ctx-1') + expect(getActivePermissionChat()).toEqual({ + chatId: 'user-1', + contextToken: 'ctx-1', + updatedAt: expect.any(Number), + }) + }) + + test('consumes pending permission only for matching user', () => { + savePendingPermission( + { + request_id: 'abcde', + tool_name: 'Bash', + description: 'Run a command', + input_preview: '{"command":"pwd"}', + }, + 'user-1', + 'ctx-1', + ) + + expect(consumePendingPermission('abcde', 'user-2')).toBeNull() + expect(consumePendingPermission('ABCDE', 'user-1')).toMatchObject({ + request_id: 'abcde', + chatId: 'user-1', + }) + expect(consumePendingPermission('abcde', 'user-1')).toBeNull() + }) +}) diff --git a/packages/weixin/src/__tests__/send.test.ts b/packages/weixin/src/__tests__/send.test.ts new file mode 100644 index 000000000..76785a43d --- /dev/null +++ b/packages/weixin/src/__tests__/send.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'bun:test' +import { markdownToPlainText } from '../send.js' + +describe('markdownToPlainText', () => { + test('removes bold markers', () => { + expect(markdownToPlainText('**bold**')).toBe('bold') + }) + + test('removes italic markers', () => { + expect(markdownToPlainText('*italic*')).toBe('italic') + }) + + test('removes inline code backticks', () => { + expect(markdownToPlainText('`code`')).toBe('code') + }) + + test('removes code block fences', () => { + expect(markdownToPlainText("```js\nconsole.log('hi');\n```")) + .toBe("console.log('hi');") + }) + + test('converts links to text with URL', () => { + expect(markdownToPlainText('[click](https://example.com)')).toBe( + 'click (https://example.com)', + ) + }) + + test('handles mixed markdown', () => { + expect(markdownToPlainText('# Hello\n\n**bold** and *italic* with `code`')) + .toBe('Hello\n\nbold and italic with code') + }) +}) diff --git a/packages/weixin/src/accounts.ts b/packages/weixin/src/accounts.ts new file mode 100644 index 000000000..7cf08942f --- /dev/null +++ b/packages/weixin/src/accounts.ts @@ -0,0 +1,57 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' +export const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c' + +export interface AccountData { + token: string + baseUrl: string + userId?: string + savedAt: string +} + +export function getStateDir(): string { + const dir = + process.env.WEIXIN_STATE_DIR || + join(homedir(), '.claude', 'channels', 'weixin') + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + return dir +} + +function accountPath(): string { + return join(getStateDir(), 'account.json') +} + +export function loadAccount(): AccountData | null { + const path = accountPath() + if (!existsSync(path)) return null + try { + return JSON.parse(readFileSync(path, 'utf-8')) as AccountData + } catch { + return null + } +} + +export function saveAccount(data: AccountData): void { + const path = accountPath() + writeFileSync(path, JSON.stringify(data, null, 2), 'utf-8') + chmodSync(path, 0o600) +} + +export function clearAccount(): void { + const path = accountPath() + if (existsSync(path)) { + unlinkSync(path) + } +} diff --git a/packages/weixin/src/api.ts b/packages/weixin/src/api.ts new file mode 100644 index 000000000..033d643b6 --- /dev/null +++ b/packages/weixin/src/api.ts @@ -0,0 +1,148 @@ +import { randomBytes } from 'node:crypto' +import type { + BaseInfo, + GetConfigResp, + GetUpdatesReq, + GetUpdatesResp, + GetUploadUrlReq, + GetUploadUrlResp, + SendMessageReq, + SendTypingReq, + SendTypingResp, +} from './types.js' + +const CHANNEL_VERSION = '0.1.0' + +function baseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION } +} + +function randomUin(): string { + return randomBytes(4).toString('base64') +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-WECHAT-UIN': randomUin(), + } + if (token) { + headers.AuthorizationType = 'ilink_bot_token' + headers.Authorization = `Bearer ${token}` + } + return headers +} + +async function post( + baseUrl: string, + path: string, + body: unknown, + token?: string, + timeoutMs = 40_000, + signal?: AbortSignal, +): Promise { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + + try { + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return (await response.json()) as T + } finally { + clearTimeout(timeout) + } +} + +export async function getUpdates( + baseUrl: string, + token: string, + getUpdatesBuf: string, + signal?: AbortSignal, +): Promise { + const body: GetUpdatesReq = { + get_updates_buf: getUpdatesBuf, + base_info: baseInfo(), + } + + try { + return await post( + baseUrl, + '/ilink/bot/getupdates', + body, + token, + 40_000, + signal, + ) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf } + } + throw error + } +} + +export async function sendMessage( + baseUrl: string, + token: string, + msg: SendMessageReq['msg'], +): Promise { + const body: SendMessageReq = { msg, base_info: baseInfo() } + await post(baseUrl, '/ilink/bot/sendmessage', body, token) +} + +export async function getUploadUrl( + baseUrl: string, + token: string, + params: Omit, +): Promise { + return post( + baseUrl, + '/ilink/bot/getuploadurl', + { ...params, base_info: baseInfo() }, + token, + ) +} + +export async function getConfig( + baseUrl: string, + token: string, + userId: string, + contextToken?: string, +): Promise { + return post( + baseUrl, + '/ilink/bot/getconfig', + { + ilink_user_id: userId, + context_token: contextToken, + base_info: baseInfo(), + }, + token, + ) +} + +export async function sendTyping( + baseUrl: string, + token: string, + req: Omit, +): Promise { + return post( + baseUrl, + '/ilink/bot/sendtyping', + { ...req, base_info: baseInfo() }, + token, + ) +} diff --git a/packages/weixin/src/cli.ts b/packages/weixin/src/cli.ts new file mode 100644 index 000000000..e038b13cf --- /dev/null +++ b/packages/weixin/src/cli.ts @@ -0,0 +1,119 @@ +import { clearAccount, DEFAULT_BASE_URL, loadAccount, saveAccount } from './accounts.js' +import { startLogin, waitForLogin } from './login.js' +import { confirmPairing } from './pairing.js' +import { runWeixinMcpServer } from './server.js' +import type { WeixinServerDeps } from './server.js' + +function printUsage(): void { + process.stdout.write( + [ + 'Usage:', + ' ccb weixin serve', + ' ccb weixin login', + ' ccb weixin login clear', + ' ccb weixin access pair ', + '', + 'Session enablement:', + ' ccb --channels plugin:weixin@builtin', + ].join('\n') + '\n', + ) +} + +async function runLogin(clear = false): Promise { + if (clear) { + clearAccount() + process.stdout.write('WeChat account cleared.\n') + return + } + + const existing = loadAccount() + if (existing) { + process.stdout.write( + [ + 'Already connected:', + ` User ID: ${existing.userId || 'unknown'}`, + ` Connected since: ${existing.savedAt}`, + '', + 'Run `ccb weixin login clear` to disconnect.', + 'Restart Claude Code with:', + ' ccb --channels plugin:weixin@builtin', + ].join('\n') + '\n', + ) + return + } + + process.stdout.write('Starting WeChat QR login...\n\n') + const qr = await startLogin(DEFAULT_BASE_URL) + process.stdout.write( + `\nScan the QR code above with WeChat, or open this URL:\n${qr.qrcodeUrl || ''}\n\n`, + ) + + const result = await waitForLogin({ + qrcodeId: qr.qrcodeId, + apiBaseUrl: DEFAULT_BASE_URL, + }) + + if (!result.connected || !result.token) { + process.stderr.write(`Login failed: ${result.message}\n`) + process.exit(1) + } + + saveAccount({ + token: result.token, + baseUrl: result.baseUrl || DEFAULT_BASE_URL, + userId: result.userId, + savedAt: new Date().toISOString(), + }) + + process.stdout.write( + [ + 'Connected successfully!', + ` User ID: ${result.userId || 'unknown'}`, + ` Base URL: ${result.baseUrl || DEFAULT_BASE_URL}`, + '', + 'Restart Claude Code with:', + ' ccb --channels plugin:weixin@builtin', + ].join('\n') + '\n', + ) +} + +function runAccess(args: string[]): void { + if (args[0] !== 'pair' || !args[1]) { + printUsage() + process.exit(1) + } + + const userId = confirmPairing(args[1]) + if (!userId) { + process.stderr.write('Invalid or expired pairing code.\n') + process.exit(1) + } + + process.stdout.write(`Paired successfully: ${userId}\n`) +} + +export async function handleWeixinCli( + args: string[], + serverDeps?: WeixinServerDeps, + version?: string, +): Promise { + const [subcommand, ...rest] = args + + switch (subcommand) { + case 'serve': + if (!serverDeps) { + process.stderr.write('[weixin] serve handler not available in this context.\n') + process.exit(1) + } + await runWeixinMcpServer(version ?? '0.0.0', serverDeps) + return + case 'login': + await runLogin(rest[0] === 'clear') + return + case 'access': + runAccess(rest) + return + default: + printUsage() + } +} diff --git a/packages/weixin/src/index.ts b/packages/weixin/src/index.ts new file mode 100644 index 000000000..1e697bc2a --- /dev/null +++ b/packages/weixin/src/index.ts @@ -0,0 +1,115 @@ +// @claude-code-best/weixin — WeChat channel integration + +// Types +export { + MessageType, + MessageItemType, + MessageState, + UploadMediaType, + TypingStatus, +} from './types.js' +export type { + BaseInfo, + CDNMedia, + TextItem, + ImageItem, + VoiceItem, + FileItem, + VideoItem, + RefMessage, + MessageItem, + WeixinMessage, + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + GetUploadUrlReq, + GetUploadUrlResp, + GetConfigResp, + SendTypingReq, + SendTypingResp, +} from './types.js' + +// API client +export { + getUpdates, + sendMessage, + getUploadUrl, + getConfig, + sendTyping, +} from './api.js' + +// Account management +export { + DEFAULT_BASE_URL, + CDN_BASE_URL, + getStateDir, + loadAccount, + saveAccount, + clearAccount, +} from './accounts.js' +export type { AccountData } from './accounts.js' + +// Login +export { startLogin, waitForLogin } from './login.js' +export type { QRCodeResult, LoginResult } from './login.js' + +// Pairing / access control +export { + loadAccessConfig, + saveAccessConfig, + isAllowed, + addPendingPairing, + confirmPairing, +} from './pairing.js' +export type { AccessConfig } from './pairing.js' + +// Media encryption / upload +export { + encryptAesEcb, + decryptAesEcb, + aesEcbPaddedSize, + buildCdnDownloadUrl, + buildCdnUploadUrl, + parseAesKey, + downloadAndDecrypt, + uploadFile, + guessMediaType, + downloadRemoteToTemp, +} from './media.js' +export type { UploadedFileInfo } from './media.js' + +// Message sending +export { markdownToPlainText, sendText, sendMediaFile } from './send.js' + +// Monitor (message polling) +export { + getContextToken, + extractPermissionReply, + startPollLoop, +} from './monitor.js' +export type { + ParsedMessage, + OnMessageCallback, + PermissionResponse, + OnPermissionResponseCallback, +} from './monitor.js' + +// Permission state +export { + setActivePermissionChat, + getActivePermissionChat, + savePendingPermission, + consumePendingPermission, +} from './permissions.js' +export type { + ChannelPermissionRequestParams, + PendingPermissionRequest, + ActivePermissionChat, +} from './permissions.js' + +// Server (MCP) +export { createWeixinMcpServer, runWeixinMcpServer } from './server.js' +export type { WeixinServerDeps } from './server.js' + +// CLI +export { handleWeixinCli } from './cli.js' diff --git a/packages/weixin/src/login.ts b/packages/weixin/src/login.ts new file mode 100644 index 000000000..fd5c68c4c --- /dev/null +++ b/packages/weixin/src/login.ts @@ -0,0 +1,134 @@ +import { toString as qrToString } from 'qrcode' + +export interface QRCodeResult { + qrcodeUrl?: string + qrcodeId: string + message: string +} + +export interface LoginResult { + connected: boolean + token?: string + accountId?: string + baseUrl?: string + userId?: string + message: string +} + +async function renderQrCodeToTerminal(qrcodeUrl: string): Promise { + const output = await qrToString(qrcodeUrl, { + type: 'terminal', + errorCorrectionLevel: 'L', + small: true, + }) + process.stderr.write(`${output}\n`) +} + +export async function startLogin(apiBaseUrl: string): Promise { + const response = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`) + if (!response.ok) { + throw new Error(`Failed to get QR code: HTTP ${response.status}`) + } + + const data = (await response.json()) as { + qrcode?: string + qrcode_img_content?: string + } + + if (!data.qrcode) { + throw new Error('No qrcode in response') + } + + const qrcodeUrl = data.qrcode_img_content || '' + if (qrcodeUrl) { + await renderQrCodeToTerminal(qrcodeUrl) + } + + return { + qrcodeUrl, + qrcodeId: data.qrcode, + message: 'Scan the QR code with WeChat to connect.', + } +} + +export async function waitForLogin(params: { + qrcodeId: string + apiBaseUrl: string + timeoutMs?: number + maxRetries?: number +}): Promise { + const { qrcodeId, apiBaseUrl, timeoutMs = 480_000, maxRetries = 3 } = params + const deadline = Date.now() + timeoutMs + let currentQrcodeId = qrcodeId + let retryCount = 0 + + while (Date.now() < deadline) { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 60_000) + + const response = await fetch( + `${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`, + { + headers: { 'iLink-App-ClientVersion': '1' }, + signal: controller.signal, + }, + ) + clearTimeout(timeout) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const data = (await response.json()) as { + status?: string + bot_token?: string + ilink_bot_id?: string + baseurl?: string + ilink_user_id?: string + } + + switch (data.status) { + case 'confirmed': + return { + connected: true, + token: data.bot_token, + accountId: data.ilink_bot_id, + baseUrl: data.baseurl, + userId: data.ilink_user_id, + message: 'Connected to WeChat successfully!', + } + case 'scaned': + process.stderr.write( + 'QR code scanned, waiting for confirmation...\n', + ) + break + case 'expired': { + retryCount += 1 + if (retryCount >= maxRetries) { + return { + connected: false, + message: 'QR code expired after maximum retries.', + } + } + process.stderr.write('QR code expired, refreshing...\n') + const refreshed = await startLogin(apiBaseUrl) + currentQrcodeId = refreshed.qrcodeId + break + } + case 'wait': + default: + break + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + continue + } + throw error + } + + await new Promise(resolve => setTimeout(resolve, 1000)) + } + + return { connected: false, message: 'Login timed out.' } +} diff --git a/packages/weixin/src/media.ts b/packages/weixin/src/media.ts new file mode 100644 index 000000000..c82aec7a9 --- /dev/null +++ b/packages/weixin/src/media.ts @@ -0,0 +1,163 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto' +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, extname, join } from 'node:path' +import { getUploadUrl } from './api.js' +import { UploadMediaType } from './types.js' + +export function encryptAesEcb(plaintext: Buffer, key: Buffer): Buffer { + const cipher = createCipheriv('aes-128-ecb', key, null) + return Buffer.concat([cipher.update(plaintext), cipher.final()]) +} + +export function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null) + return Buffer.concat([decipher.update(ciphertext), decipher.final()]) +} + +export function aesEcbPaddedSize(size: number): number { + return size + (16 - (size % 16)) +} + +export function buildCdnDownloadUrl( + encryptedQueryParam: string, + cdnBaseUrl: string, +): string { + return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}` +} + +export function buildCdnUploadUrl( + cdnBaseUrl: string, + uploadParam: string, + filekey: string, +): string { + return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}` +} + +export function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64') + if (decoded.length === 16) { + return decoded + } + if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii'))) { + return Buffer.from(decoded.toString('ascii'), 'hex') + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ) +} + +export async function downloadAndDecrypt(params: { + encryptQueryParam: string + aesKey: string + cdnBaseUrl: string +}): Promise { + const url = buildCdnDownloadUrl(params.encryptQueryParam, params.cdnBaseUrl) + const response = await fetch(url) + if (!response.ok) { + throw new Error(`CDN download failed: HTTP ${response.status}`) + } + const ciphertext = Buffer.from(await response.arrayBuffer()) + return decryptAesEcb(ciphertext, parseAesKey(params.aesKey)) +} + +export interface UploadedFileInfo { + encryptQueryParam: string + aesKey: string + fileSize: number + rawSize: number + fileName: string +} + +export async function uploadFile(params: { + filePath: string + toUserId: string + mediaType: number + apiBaseUrl: string + token: string + cdnBaseUrl: string +}): Promise { + const plaintext = readFileSync(params.filePath) + const rawSize = plaintext.length + const rawMd5 = createHash('md5').update(plaintext).digest('hex') + const aesKey = randomBytes(16) + const filekey = randomBytes(16).toString('hex') + const ciphertext = encryptAesEcb(plaintext, aesKey) + const fileSize = ciphertext.length + + const uploadResp = await getUploadUrl(params.apiBaseUrl, params.token, { + filekey, + media_type: params.mediaType, + to_user_id: params.toUserId, + rawsize: rawSize, + rawfilemd5: rawMd5, + filesize: fileSize, + no_need_thumb: true, + aeskey: aesKey.toString('hex'), + }) + + if (!uploadResp.upload_param) { + throw new Error('No upload_param in response') + } + + const uploadUrl = buildCdnUploadUrl( + params.cdnBaseUrl, + uploadResp.upload_param, + filekey, + ) + const uploadResult = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: new Uint8Array(ciphertext), + }) + + if (!uploadResult.ok) { + throw new Error(`CDN upload failed: HTTP ${uploadResult.status}`) + } + + return { + encryptQueryParam: uploadResult.headers.get('x-encrypted-param') || '', + aesKey: Buffer.from(aesKey.toString('hex')).toString('base64'), + fileSize, + rawSize, + fileName: basename(params.filePath), + } +} + +export function guessMediaType(filePath: string): number { + const ext = extname(filePath).toLowerCase() + const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.heic'] + const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.webm'] + + if (imageExts.includes(ext)) return UploadMediaType.IMAGE + if (videoExts.includes(ext)) return UploadMediaType.VIDEO + return UploadMediaType.FILE +} + +export async function downloadRemoteToTemp( + url: string, + destDir?: string, +): Promise { + const dir = destDir || join(tmpdir(), 'weixin-downloads') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + + const response = await fetch(url) + if (!response.ok) throw new Error(`Download failed: HTTP ${response.status}`) + + const buffer = Buffer.from(await response.arrayBuffer()) + const urlPath = new URL(url).pathname + const name = basename(urlPath) || `file_${Date.now()}` + const dest = join(dir, name) + writeFileSync(dest, buffer) + return dest +} diff --git a/packages/weixin/src/monitor.ts b/packages/weixin/src/monitor.ts new file mode 100644 index 000000000..2b2db0307 --- /dev/null +++ b/packages/weixin/src/monitor.ts @@ -0,0 +1,303 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +// Matches the canonical definition in src/services/mcp/channelPermissions.ts +const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i +import { getUpdates } from './api.js' +import { getStateDir } from './accounts.js' +import { downloadAndDecrypt } from './media.js' +import { addPendingPairing, isAllowed } from './pairing.js' +import { consumePendingPermission, setActivePermissionChat } from './permissions.js' +import { sendText } from './send.js' +import { MessageItemType, MessageType, type MessageItem, type WeixinMessage } from './types.js' + +const contextTokens = new Map() + +export function getContextToken(userId: string): string | undefined { + return contextTokens.get(userId) +} + +function cursorPath(): string { + return join(getStateDir(), 'cursor.txt') +} + +function loadCursor(): string { + const path = cursorPath() + if (existsSync(path)) return readFileSync(path, 'utf-8').trim() + return '' +} + +function saveCursor(cursor: string): void { + writeFileSync(cursorPath(), cursor, 'utf-8') +} + +async function downloadMedia( + item: MessageItem, + cdnBaseUrl: string, +): Promise<{ path: string; type: string } | null> { + let encryptQueryParam: string | undefined + let aesKey: string | undefined + let ext = '' + let mediaType = '' + + switch (item.type) { + case MessageItemType.IMAGE: + encryptQueryParam = item.image_item?.media?.encrypt_query_param + aesKey = item.image_item?.aeskey + ? Buffer.from(item.image_item.aeskey, 'hex').toString('base64') + : item.image_item?.media?.aes_key + ext = '.jpg' + mediaType = 'image' + break + case MessageItemType.VOICE: + encryptQueryParam = item.voice_item?.media?.encrypt_query_param + aesKey = item.voice_item?.media?.aes_key + ext = '.silk' + mediaType = 'voice' + break + case MessageItemType.FILE: + encryptQueryParam = item.file_item?.media?.encrypt_query_param + aesKey = item.file_item?.media?.aes_key + ext = item.file_item?.file_name + ? `.${item.file_item.file_name.split('.').pop()}` + : '' + mediaType = 'file' + break + case MessageItemType.VIDEO: + encryptQueryParam = item.video_item?.media?.encrypt_query_param + aesKey = item.video_item?.media?.aes_key + ext = '.mp4' + mediaType = 'video' + break + default: + return null + } + + if (!encryptQueryParam || !aesKey) return null + + try { + const data = await downloadAndDecrypt({ + encryptQueryParam, + aesKey, + cdnBaseUrl, + }) + const dir = join(tmpdir(), 'weixin-media') + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + const rawFileName = item.file_item?.file_name || `${Date.now()}${ext}` + const fileName = basename(rawFileName) + const filePath = join(dir, fileName) + writeFileSync(filePath, data) + return { path: filePath, type: mediaType } + } catch (error) { + process.stderr.write(`[weixin] Failed to download media: ${error}\n`) + return null + } +} + +export interface ParsedMessage { + fromUserId: string + messageId: string + text: string + attachmentPath?: string + attachmentType?: string +} + +export type OnMessageCallback = (msg: ParsedMessage) => Promise + +export type PermissionResponse = { + requestId: string + behavior: 'allow' | 'deny' + fromUserId: string +} + +export type OnPermissionResponseCallback = ( + response: PermissionResponse, +) => Promise + +export function extractPermissionReply( + text: string, +): { requestId: string; behavior: 'allow' | 'deny' } | null { + const match = text.match(PERMISSION_REPLY_RE) + if (!match) return null + const behavior = + match[1]?.toLowerCase().startsWith('y') ? 'allow' : 'deny' + const requestId = match[2]?.toLowerCase() + if (!requestId) return null + return { requestId, behavior } +} + +export async function startPollLoop(params: { + baseUrl: string + cdnBaseUrl: string + token: string + onMessage: OnMessageCallback + onPermissionResponse?: OnPermissionResponseCallback + abortSignal: AbortSignal +}): Promise { + const { + baseUrl, + cdnBaseUrl, + token, + onMessage, + onPermissionResponse, + abortSignal, + } = params + let cursor = loadCursor() + let consecutiveErrors = 0 + + process.stderr.write('[weixin] Starting message poll loop...\n') + + while (!abortSignal.aborted) { + try { + const response = await getUpdates(baseUrl, token, cursor, abortSignal) + + if (response.errcode === -14) { + process.stderr.write( + '[weixin] Session expired (errcode -14). Pausing for 30s...\n', + ) + await new Promise(resolve => setTimeout(resolve, 30_000)) + continue + } + + if (response.ret !== 0 && response.ret !== undefined) { + throw new Error( + `getUpdates error: ret=${response.ret} errcode=${response.errcode} ${response.errmsg}`, + ) + } + + consecutiveErrors = 0 + + if (response.get_updates_buf) { + cursor = response.get_updates_buf + saveCursor(cursor) + } + + if (response.msgs && response.msgs.length > 0) { + for (const msg of response.msgs) { + await processMessage(msg, { + baseUrl, + cdnBaseUrl, + token, + onMessage, + onPermissionResponse, + }) + } + } + } catch (error) { + if (abortSignal.aborted) break + + consecutiveErrors += 1 + process.stderr.write( + `[weixin] Poll error (${consecutiveErrors}): ${error instanceof Error ? error.message : String(error)}\n`, + ) + + if (consecutiveErrors >= 3) { + process.stderr.write( + '[weixin] Too many consecutive errors, backing off 30s...\n', + ) + await new Promise(resolve => setTimeout(resolve, 30_000)) + consecutiveErrors = 0 + } else { + await new Promise(resolve => setTimeout(resolve, 2000)) + } + } + } + + process.stderr.write('[weixin] Poll loop stopped.\n') +} + +async function processMessage( + msg: WeixinMessage, + ctx: { + baseUrl: string + cdnBaseUrl: string + token: string + onMessage: OnMessageCallback + onPermissionResponse?: OnPermissionResponseCallback + }, +): Promise { + if (msg.message_type !== MessageType.USER) return + const fromUserId = msg.from_user_id + if (!fromUserId) return + + if (msg.context_token) { + contextTokens.set(fromUserId, msg.context_token) + } + + if (!isAllowed(fromUserId)) { + const code = addPendingPairing(fromUserId) + try { + await sendText({ + to: fromUserId, + text: `Your pairing code is: ${code}\n\nAsk the operator to confirm:\nccb weixin access pair ${code}`, + baseUrl: ctx.baseUrl, + token: ctx.token, + contextToken: msg.context_token || '', + }) + } catch (error) { + process.stderr.write(`[weixin] Failed to send pairing code: ${error}\n`) + } + return + } + + setActivePermissionChat(fromUserId, msg.context_token) + + let textContent = '' + let mediaPath: string | undefined + let mediaType: string | undefined + + if (msg.item_list) { + for (const item of msg.item_list) { + if (item.type === MessageItemType.TEXT && item.text_item?.text) { + textContent += `${textContent ? '\n' : ''}${item.text_item.text}` + } else if ( + item.type === MessageItemType.IMAGE || + item.type === MessageItemType.VOICE || + item.type === MessageItemType.FILE || + item.type === MessageItemType.VIDEO + ) { + const downloaded = await downloadMedia(item, ctx.cdnBaseUrl) + if (downloaded) { + mediaPath = downloaded.path + mediaType = downloaded.type + } + if (item.type === MessageItemType.VOICE && item.voice_item?.text) { + textContent += `${textContent ? '\n' : ''}[Voice transcription]: ${item.voice_item.text}` + } + } + } + } + + if (!textContent && !mediaPath) return + + if (textContent && ctx.onPermissionResponse) { + const permissionReply = extractPermissionReply(textContent) + if (permissionReply) { + const pending = consumePendingPermission( + permissionReply.requestId, + fromUserId, + ) + if (pending) { + await ctx.onPermissionResponse({ + requestId: pending.request_id, + behavior: permissionReply.behavior, + fromUserId, + }) + return + } + } + } + + await ctx.onMessage({ + fromUserId, + messageId: String(msg.message_id || ''), + text: textContent || '(media attachment)', + attachmentPath: mediaPath, + attachmentType: mediaType, + }) +} diff --git a/packages/weixin/src/pairing.ts b/packages/weixin/src/pairing.ts new file mode 100644 index 000000000..1382c4fcb --- /dev/null +++ b/packages/weixin/src/pairing.ts @@ -0,0 +1,101 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { getStateDir } from './accounts.js' + +export interface AccessConfig { + policy: 'pairing' | 'allowlist' | 'disabled' + allowFrom: string[] +} + +interface PendingEntry { + userId: string + expiresAt: number +} + +function configPath(): string { + return join(getStateDir(), 'access.json') +} + +function pendingPath(): string { + return join(getStateDir(), 'pending-pairings.json') +} + +function loadPending(): Record { + const path = pendingPath() + if (!existsSync(path)) return {} + try { + return JSON.parse(readFileSync(path, 'utf-8')) as Record + } catch { + return {} + } +} + +function savePending(data: Record): void { + writeFileSync(pendingPath(), JSON.stringify(data, null, 2), 'utf-8') +} + +export function loadAccessConfig(): AccessConfig { + const path = configPath() + if (!existsSync(path)) { + return { policy: 'pairing', allowFrom: [] } + } + try { + return JSON.parse(readFileSync(path, 'utf-8')) as AccessConfig + } catch { + return { policy: 'pairing', allowFrom: [] } + } +} + +export function saveAccessConfig(config: AccessConfig): void { + writeFileSync(configPath(), JSON.stringify(config, null, 2), 'utf-8') +} + +export function isAllowed(userId: string): boolean { + const config = loadAccessConfig() + if (config.policy === 'disabled') return true + return config.allowFrom.includes(userId) +} + +export function addPendingPairing(userId: string): string { + const pending = loadPending() + const now = Date.now() + + for (const code of Object.keys(pending)) { + if (pending[code]!.expiresAt < now) { + delete pending[code] + } + } + + for (const [code, entry] of Object.entries(pending)) { + if (entry.userId === userId) { + savePending(pending) + return code + } + } + + const code = String(Math.floor(100000 + Math.random() * 900000)) + pending[code] = { userId, expiresAt: now + 10 * 60 * 1000 } + savePending(pending) + return code +} + +export function confirmPairing(code: string): string | null { + const pending = loadPending() + const entry = pending[code] + if (!entry || entry.expiresAt < Date.now()) { + delete pending[code] + savePending(pending) + return null + } + + delete pending[code] + savePending(pending) + + const config = loadAccessConfig() + if (!config.allowFrom.includes(entry.userId)) { + config.allowFrom.push(entry.userId) + saveAccessConfig(config) + } + + return entry.userId +} diff --git a/packages/weixin/src/permissions.ts b/packages/weixin/src/permissions.ts new file mode 100644 index 000000000..9d4480c8e --- /dev/null +++ b/packages/weixin/src/permissions.ts @@ -0,0 +1,83 @@ +/** Mirrors ChannelPermissionRequestParams from src/services/mcp/channelNotification.ts */ +export interface ChannelPermissionRequestParams { + request_id: string + tool_name: string + description: string + input_preview: string + channel_context?: { + source_server?: string + chat_id?: string + } +} + +export type PendingPermissionRequest = ChannelPermissionRequestParams & { + chatId: string + contextToken?: string + createdAt: number + expiresAt: number +} + +export type ActivePermissionChat = { + chatId: string + contextToken?: string + updatedAt: number +} + +const PENDING_PERMISSION_TTL_MS = 15 * 60 * 1000 + +const pendingPermissions = new Map() +let activePermissionChat: ActivePermissionChat | null = null + +function pruneExpiredPendingPermissions(now = Date.now()): void { + for (const [requestId, entry] of pendingPermissions.entries()) { + if (entry.expiresAt <= now) { + pendingPermissions.delete(requestId) + } + } +} + +export function setActivePermissionChat( + chatId: string, + contextToken?: string, +): void { + activePermissionChat = { chatId, contextToken, updatedAt: Date.now() } +} + +export function getActivePermissionChat(): ActivePermissionChat | null { + return activePermissionChat +} + +export function savePendingPermission( + request: ChannelPermissionRequestParams, + chatId: string, + contextToken?: string, +): PendingPermissionRequest { + pruneExpiredPendingPermissions() + const entry: PendingPermissionRequest = { + ...request, + chatId, + contextToken, + createdAt: Date.now(), + expiresAt: Date.now() + PENDING_PERMISSION_TTL_MS, + } + pendingPermissions.set(request.request_id.toLowerCase(), entry) + return entry +} + +export function consumePendingPermission( + requestId: string, + fromUserId: string, +): PendingPermissionRequest | null { + pruneExpiredPendingPermissions() + const key = requestId.toLowerCase() + const entry = pendingPermissions.get(key) + if (!entry) return null + if (entry.chatId !== fromUserId) return null + pendingPermissions.delete(key) + return entry +} + +export function clearPermissionStateForTests(): void { + pendingPermissions.clear() + activePermissionChat = null +} diff --git a/packages/weixin/src/send.ts b/packages/weixin/src/send.ts new file mode 100644 index 000000000..24d223143 --- /dev/null +++ b/packages/weixin/src/send.ts @@ -0,0 +1,180 @@ +import { randomUUID } from 'node:crypto' +import type { CDNMedia, MessageItem } from './types.js' +import { sendMessage } from './api.js' +import { guessMediaType, uploadFile } from './media.js' +import { MessageItemType, MessageState, MessageType } from './types.js' + +function stripCodeBlocks(text: string): string { + // Non-regex approach to avoid ReDoS on inputs with many ``` sequences. + let result = '' + let i = 0 + while (i < text.length) { + if (text.startsWith('```', i)) { + // Skip the opening fence (including optional language tag on same line) + let j = i + 3 + // skip to end of first line (the fence line itself) + while (j < text.length && text[j] !== '\n') j++ + if (j < text.length) j++ // skip the \n + // Collect content until closing ``` + const contentStart = j + while (j < text.length) { + if (text.startsWith('```', j)) { + result += text.slice(contentStart, j) + // skip closing fence and its trailing newline + j += 3 + while (j < text.length && text[j] !== '\n') j++ + if (j < text.length) j++ // skip \n + break + } + j++ + } + // If no closing fence found, include rest as-is + if (j >= text.length && !text.startsWith('```', j - 3)) { + result += text.slice(i) + } + i = j + } else { + result += text[i] + i++ + } + } + return result +} + +export function markdownToPlainText(text: string): string { + return stripCodeBlocks(text) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]') + .replace(/^>\s+/gm, '') + .replace(/^[-*_]{3,}$/gm, '---') + .replace(/^[\s]*[-*+]\s+/gm, '- ') + .replace(/^[\s]*(\d+)\.\s+/gm, '$1. ') + .replace(/\n{3,}/g, '\n\n') + .trim() +} + +export async function sendText(params: { + to: string + text: string + baseUrl: string + token: string + contextToken: string +}): Promise<{ messageId: string }> { + const clientId = randomUUID() + await sendMessage(params.baseUrl, params.token, { + to_user_id: params.to, + from_user_id: '', + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: params.contextToken, + item_list: [ + { + type: MessageItemType.TEXT, + text_item: { text: markdownToPlainText(params.text) }, + }, + ], + }) + + return { messageId: clientId } +} + +async function sendItems(params: { + items: MessageItem[] + to: string + baseUrl: string + token: string + contextToken: string +}): Promise { + let lastClientId = '' + for (const item of params.items) { + lastClientId = randomUUID() + await sendMessage(params.baseUrl, params.token, { + to_user_id: params.to, + from_user_id: '', + client_id: lastClientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: params.contextToken, + item_list: [item], + }) + } + return lastClientId +} + +export async function sendMediaFile(params: { + filePath: string + to: string + text: string + baseUrl: string + token: string + contextToken: string + cdnBaseUrl: string +}): Promise<{ messageId: string }> { + const mediaType = guessMediaType(params.filePath) + const uploaded = await uploadFile({ + filePath: params.filePath, + toUserId: params.to, + mediaType, + apiBaseUrl: params.baseUrl, + token: params.token, + cdnBaseUrl: params.cdnBaseUrl, + }) + + const cdnMedia: CDNMedia = { + encrypt_query_param: uploaded.encryptQueryParam, + aes_key: uploaded.aesKey, + encrypt_type: 1, + } + + const items: MessageItem[] = [] + if (params.text) { + items.push({ + type: MessageItemType.TEXT, + text_item: { text: markdownToPlainText(params.text) }, + }) + } + + switch (mediaType) { + case 1: + items.push({ + type: MessageItemType.IMAGE, + image_item: { media: cdnMedia, mid_size: uploaded.fileSize }, + }) + break + case 2: + items.push({ + type: MessageItemType.VIDEO, + video_item: { media: cdnMedia, video_size: uploaded.fileSize }, + }) + break + default: + items.push({ + type: MessageItemType.FILE, + file_item: { + media: cdnMedia, + file_name: uploaded.fileName, + len: String(uploaded.rawSize), + }, + }) + break + } + + const messageId = await sendItems({ + items, + to: params.to, + baseUrl: params.baseUrl, + token: params.token, + contextToken: params.contextToken, + }) + return { messageId } +} diff --git a/packages/weixin/src/server.ts b/packages/weixin/src/server.ts new file mode 100644 index 000000000..aed6ec376 --- /dev/null +++ b/packages/weixin/src/server.ts @@ -0,0 +1,353 @@ +import { existsSync } from 'node:fs' +import { Server } from '@modelcontextprotocol/sdk/server/index.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js' +import { + CDN_BASE_URL, + DEFAULT_BASE_URL, + loadAccount, + getConfig, + sendTyping, + getContextToken, + startPollLoop, + getActivePermissionChat, + savePendingPermission, + sendMediaFile, + sendText, + TypingStatus, +} from './index.js' +import type { ParsedMessage } from './monitor.js' +import type { ChannelPermissionRequestParams } from './permissions.js' + +export interface WeixinServerDeps { + enableConfigs(): void + initializeAnalyticsSink(): void + shutdownDatadog(): Promise + shutdown1PEventLogging(): Promise + logForDebugging(message: string): void + registerPermissionHandler( + server: Server, + handler: (request: ChannelPermissionRequestParams) => Promise, + ): void +} + +function formatPermissionRequestMessage( + request: ChannelPermissionRequestParams, +): string { + return [ + 'Claude Code needs your approval.', + '', + `Tool: ${request.tool_name}`, + `Reason: ${request.description}`, + `Input: ${request.input_preview}`, + '', + `Reply with: yes ${request.request_id}`, + `Or deny with: no ${request.request_id}`, + ].join('\n') +} + +export function createWeixinMcpServer(version: string): Server { + const server = new Server( + { name: 'weixin', version }, + { + capabilities: { + experimental: { + 'claude/channel': {}, + 'claude/channel/permission': {}, + }, + tools: {}, + }, + instructions: + 'Messages from WeChat arrive as . Reply using the reply tool with the chat_id from the channel tag. Use absolute paths for file attachments.', + }, + ) + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'reply', + description: + 'Reply to a WeChat message. Pass the chat_id from the channel tag.', + inputSchema: { + type: 'object' as const, + properties: { + chat_id: { + type: 'string', + description: 'The chat_id from the channel notification', + }, + text: { type: 'string', description: 'The reply text' }, + files: { + type: 'array', + items: { type: 'string' }, + description: 'Optional absolute file paths to attach', + }, + }, + required: ['chat_id', 'text'], + }, + }, + { + name: 'send_typing', + description: 'Send a typing indicator to a WeChat user.', + inputSchema: { + type: 'object' as const, + properties: { + chat_id: { type: 'string', description: 'The chat_id (user ID)' }, + }, + required: ['chat_id'], + }, + }, + ], + })) + + server.setRequestHandler(CallToolRequestSchema, async request => { + const { name, arguments: args } = request.params + const account = loadAccount() + if (!account) { + return { + content: [ + { + type: 'text', + text: 'WeChat not connected. Run `ccb weixin login` first.', + }, + ], + isError: true, + } + } + + const baseUrl = account.baseUrl || DEFAULT_BASE_URL + const cdnBaseUrl = CDN_BASE_URL + + switch (name) { + case 'reply': { + const chatId = typeof args?.chat_id === 'string' ? args.chat_id : '' + const text = typeof args?.text === 'string' ? args.text : '' + const files = Array.isArray(args?.files) + ? args.files.filter((value): value is string => typeof value === 'string') + : undefined + + if (!chatId || !text) { + return { + content: [ + { type: 'text', text: 'Missing chat_id or text parameter.' }, + ], + isError: true, + } + } + + const contextToken = getContextToken(chatId) || '' + + try { + if (files && files.length > 0) { + for (const [index, filePath] of files.entries()) { + if (!existsSync(filePath)) { + return { + content: [ + { type: 'text', text: `File not found: ${filePath}` }, + ], + isError: true, + } + } + await sendMediaFile({ + filePath, + to: chatId, + text: index === 0 ? text : '', + baseUrl, + token: account.token, + contextToken, + cdnBaseUrl, + }) + } + + return { + content: [{ type: 'text', text: 'Message sent with attachments.' }], + } + } + + await sendText({ + to: chatId, + text, + baseUrl, + token: account.token, + contextToken, + }) + return { content: [{ type: 'text', text: 'Message sent.' }] } + } catch (error) { + return { + content: [{ type: 'text', text: `Failed to send: ${error}` }], + isError: true, + } + } + } + + case 'send_typing': { + const chatId = typeof args?.chat_id === 'string' ? args.chat_id : '' + if (!chatId) { + return { + content: [{ type: 'text', text: 'Missing chat_id parameter.' }], + isError: true, + } + } + + try { + const contextToken = getContextToken(chatId) + const config = await getConfig( + baseUrl, + account.token, + chatId, + contextToken, + ) + if (config.typing_ticket) { + await sendTyping(baseUrl, account.token, { + ilink_user_id: chatId, + typing_ticket: config.typing_ticket, + status: TypingStatus.TYPING, + }) + } + return { + content: [{ type: 'text', text: 'Typing indicator sent.' }], + } + } catch (error) { + return { + content: [{ type: 'text', text: `Failed to send typing: ${error}` }], + isError: true, + } + } + } + + default: + return { + content: [{ type: 'text', text: `Unknown tool: ${name}` }], + isError: true, + } + } + }) + + return server +} + +export async function runWeixinMcpServer( + version: string, + deps: WeixinServerDeps, +): Promise { + deps.enableConfigs() + deps.initializeAnalyticsSink() + + const account = loadAccount() + if (!account) { + process.stderr.write( + '[weixin] No account configured. Run `ccb weixin login` to connect your WeChat account.\n', + ) + await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()]) + process.exit(1) + } + + const server = createWeixinMcpServer(version) + const transport = new StdioServerTransport() + + deps.registerPermissionHandler(server, async request => { + const targetChatId = request.channel_context?.chat_id + const targetChat = targetChatId + ? { + chatId: targetChatId, + contextToken: getContextToken(targetChatId), + } + : getActivePermissionChat() + + if (!targetChat) { + deps.logForDebugging( + `[Weixin MCP] No active chat available for permission request ${request.request_id}`, + ) + return + } + + try { + savePendingPermission( + request, + targetChat.chatId, + targetChat.contextToken, + ) + await sendText({ + to: targetChat.chatId, + text: formatPermissionRequestMessage(request), + baseUrl, + token: account.token, + contextToken: targetChat.contextToken || '', + }) + } catch (error) { + process.stderr.write( + `[weixin] Failed to relay permission request ${request.request_id}: ${error}\n`, + ) + } + }) + + await server.connect(transport) + + const baseUrl = account.baseUrl || DEFAULT_BASE_URL + const controller = new AbortController() + + let exiting = false + const shutdownAndExit = async (): Promise => { + if (exiting) return + exiting = true + if (!controller.signal.aborted) { + controller.abort() + } + await Promise.all([deps.shutdown1PEventLogging(), deps.shutdownDatadog()]) + process.exit(0) + } + + process.stdin.on('end', () => void shutdownAndExit()) + process.stdin.on('error', () => void shutdownAndExit()) + process.on('SIGINT', () => void shutdownAndExit()) + process.on('SIGTERM', () => void shutdownAndExit()) + process.on('SIGHUP', () => void shutdownAndExit()) + + const ppid = process.ppid + const parentCheck = setInterval(() => { + try { + process.kill(ppid, 0) + } catch { + process.stderr.write('[weixin] Parent process exited, shutting down...\n') + clearInterval(parentCheck) + void shutdownAndExit() + } + }, 5000) + + deps.logForDebugging('[Weixin MCP] Starting poll loop') + await startPollLoop({ + baseUrl, + cdnBaseUrl: CDN_BASE_URL, + token: account.token, + onMessage: async (msg: ParsedMessage) => { + await server.notification({ + method: 'notifications/claude/channel', + params: { + content: msg.text, + meta: { + chat_id: msg.fromUserId, + sender_id: msg.fromUserId, + message_id: msg.messageId, + ...(msg.attachmentPath && { attachment_path: msg.attachmentPath }), + ...(msg.attachmentType && { attachment_type: msg.attachmentType }), + }, + }, + }) + }, + onPermissionResponse: async response => { + await server.notification({ + method: 'notifications/claude/channel/permission', + params: { + request_id: response.requestId, + behavior: response.behavior, + }, + }) + }, + abortSignal: controller.signal, + }) + + clearInterval(parentCheck) + await shutdownAndExit() +} diff --git a/packages/weixin/src/types.ts b/packages/weixin/src/types.ts new file mode 100644 index 000000000..8ceca09c2 --- /dev/null +++ b/packages/weixin/src/types.ts @@ -0,0 +1,178 @@ +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const + +export const UploadMediaType = { + IMAGE: 1, + VIDEO: 2, + FILE: 3, + VOICE: 4, +} as const + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const + +export interface BaseInfo { + channel_version?: string +} + +export interface CDNMedia { + encrypt_query_param?: string + aes_key?: string + encrypt_type?: number +} + +export interface TextItem { + text?: string +} + +export interface ImageItem { + media?: CDNMedia + thumb_media?: CDNMedia + aeskey?: string + url?: string + mid_size?: number + thumb_size?: number + thumb_height?: number + thumb_width?: number + hd_size?: number +} + +export interface VoiceItem { + media?: CDNMedia + encode_type?: number + bits_per_sample?: number + sample_rate?: number + playtime?: number + text?: string +} + +export interface FileItem { + media?: CDNMedia + file_name?: string + md5?: string + len?: string +} + +export interface VideoItem { + media?: CDNMedia + video_size?: number + play_length?: number + video_md5?: string + thumb_media?: CDNMedia + thumb_size?: number + thumb_height?: number + thumb_width?: number +} + +export interface RefMessage { + message_item?: MessageItem + title?: string +} + +export interface MessageItem { + type?: number + create_time_ms?: number + update_time_ms?: number + is_completed?: boolean + msg_id?: string + ref_msg?: RefMessage + text_item?: TextItem + image_item?: ImageItem + voice_item?: VoiceItem + file_item?: FileItem + video_item?: VideoItem +} + +export interface WeixinMessage { + seq?: number + message_id?: number + from_user_id?: string + to_user_id?: string + client_id?: string + create_time_ms?: number + update_time_ms?: number + delete_time_ms?: number + session_id?: string + group_id?: string + message_type?: number + message_state?: number + item_list?: MessageItem[] + context_token?: string +} + +export interface GetUpdatesReq { + get_updates_buf?: string + base_info?: BaseInfo +} + +export interface GetUpdatesResp { + ret?: number + errcode?: number + errmsg?: string + msgs?: WeixinMessage[] + get_updates_buf?: string + longpolling_timeout_ms?: number +} + +export interface SendMessageReq { + msg?: WeixinMessage + base_info?: BaseInfo +} + +export interface GetUploadUrlReq { + filekey?: string + media_type?: number + to_user_id?: string + rawsize?: number + rawfilemd5?: string + filesize?: number + thumb_rawsize?: number + thumb_rawfilemd5?: string + thumb_filesize?: number + no_need_thumb?: boolean + aeskey?: string + base_info?: BaseInfo +} + +export interface GetUploadUrlResp { + upload_param?: string + thumb_upload_param?: string +} + +export interface GetConfigResp { + ret?: number + errmsg?: string + typing_ticket?: string +} + +export interface SendTypingReq { + ilink_user_id?: string + typing_ticket?: string + status?: number + base_info?: BaseInfo +} + +export interface SendTypingResp { + ret?: number + errmsg?: string +} diff --git a/packages/weixin/tsconfig.json b/packages/weixin/tsconfig.json new file mode 100644 index 000000000..af2850cc4 --- /dev/null +++ b/packages/weixin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/components/LogoV2/ChannelsNotice.tsx b/src/components/LogoV2/ChannelsNotice.tsx index 25d81fa88..f4151ba74 100644 --- a/src/components/LogoV2/ChannelsNotice.tsx +++ b/src/components/LogoV2/ChannelsNotice.tsx @@ -11,6 +11,7 @@ import { getAllowedChannels, getHasDevChannels, } from '../../bootstrap/state.js' +import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js' import { Box, Text } from '@anthropic/ink' import { getMcpConfigsByScope } from '../../services/mcp/config.js' import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' @@ -75,25 +76,39 @@ function formatEntry(c: ChannelEntry): string { type Unmatched = { entry: ChannelEntry; why: string } -function findUnmatched( +type FindUnmatchedDeps = { + configuredServerNames?: ReadonlySet + installedPluginIds?: ReadonlySet +} + +export function findUnmatched( entries: readonly ChannelEntry[], + deps?: FindUnmatchedDeps, ): Unmatched[] { // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope // is not cached (project scope walks the dir tree); getMcpConfigByName would // redo that walk per entry. - const scopes = ['enterprise', 'user', 'project', 'local'] as const - const configured = new Set() - for (const scope of scopes) { - for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) { - configured.add(name) + const configured = deps?.configuredServerNames ?? (() => { + const scopes = ['enterprise', 'user', 'project', 'local'] as const + const names = new Set() + for (const scope of scopes) { + for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) { + names.add(name) + } } - } + return names + })() // Plugin-kind installed check: installed_plugins.json keys are // `name@marketplace`. loadInstalledPluginsV2 is cached. - const installedPluginIds = new Set( - Object.keys(loadInstalledPluginsV2().plugins), - ) + const installedPluginIds = deps?.installedPluginIds ?? (() => { + const ids = new Set(Object.keys(loadInstalledPluginsV2().plugins)) + const builtinPlugins = getBuiltinPlugins() + for (const plugin of [...builtinPlugins.enabled, ...builtinPlugins.disabled]) { + ids.add(plugin.source) + } + return ids + })() const out: Unmatched[] = [] for (const entry of entries) { diff --git a/src/components/LogoV2/__tests__/ChannelsNotice.test.ts b/src/components/LogoV2/__tests__/ChannelsNotice.test.ts new file mode 100644 index 000000000..24ec014c8 --- /dev/null +++ b/src/components/LogoV2/__tests__/ChannelsNotice.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test' + +import { findUnmatched } from '../ChannelsNotice.js' + +describe('findUnmatched', () => { + test('does not flag builtin weixin as plugin not installed', () => { + expect( + findUnmatched( + [{ kind: 'plugin', name: 'weixin', marketplace: 'builtin' }], + { + configuredServerNames: new Set(), + installedPluginIds: new Set(['weixin@builtin']), + }, + ), + ).toEqual([]) + }) +}) diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 384848cf7..c519e6572 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -140,6 +140,31 @@ async function main(): Promise { return } + if (args[0] === 'weixin') { + profileCheckpoint('cli_weixin_path') + const { handleWeixinCli } = await import('@claude-code-best/weixin') + const { enableConfigs } = await import('../utils/config.js') + const { initializeAnalyticsSink } = await import('../services/analytics/sink.js') + const { shutdownDatadog } = await import('../services/analytics/datadog.js') + const { shutdown1PEventLogging } = await import('../services/analytics/firstPartyEventLogger.js') + const { logForDebugging } = await import('../utils/debug.js') + const { ChannelPermissionRequestNotificationSchema } = await import('../services/mcp/channelNotification.js') + await handleWeixinCli(args.slice(1), { + enableConfigs, + initializeAnalyticsSink, + shutdownDatadog, + shutdown1PEventLogging, + logForDebugging, + registerPermissionHandler(server, handler) { + server.setNotificationHandler( + ChannelPermissionRequestNotificationSchema(), + async notification => handler(notification.params), + ) + }, + }, MACRO.VERSION) + return + } + // Fast-path for `--daemon-worker=` (internal — supervisor spawns this). // Must come before the daemon subcommand check: spawned per-worker, so // perf-sensitive. No enableConfigs(), no analytics sinks at this layer — diff --git a/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts b/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts new file mode 100644 index 000000000..69693ad73 --- /dev/null +++ b/src/hooks/toolPermission/handlers/__tests__/interactiveHandler.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test' +import { getLatestChannelContextHint } from '../interactiveHandler.js' + +describe('getLatestChannelContextHint', () => { + test('extracts source server and chat id from latest channel user message', () => { + expect( + getLatestChannelContextHint([ + { + type: 'user', + origin: { kind: 'channel', server: 'plugin:weixin:weixin' }, + message: { + content: [ + { + type: 'text', + text: '\nhello\n', + }, + ], + }, + }, + ]), + ).toEqual({ + sourceServer: 'plugin:weixin:weixin', + chatId: 'user-1', + }) + }) + + test('returns null when there is no channel-origin user message', () => { + expect( + getLatestChannelContextHint([ + { + type: 'user', + origin: { kind: 'manual' }, + message: { content: [{ type: 'text', text: 'hello' }] }, + }, + ]), + ).toBeNull() + }) +}) diff --git a/src/hooks/toolPermission/handlers/interactiveHandler.ts b/src/hooks/toolPermission/handlers/interactiveHandler.ts index 8255d5d87..e7cb7a85d 100644 --- a/src/hooks/toolPermission/handlers/interactiveHandler.ts +++ b/src/hooks/toolPermission/handlers/interactiveHandler.ts @@ -1,6 +1,7 @@ import { feature } from 'bun:bundle' import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' import { randomUUID } from 'crypto' +import { CHANNEL_TAG } from 'src/constants/xml.js' import { logForDebugging } from 'src/utils/debug.js' import { getAllowedChannels } from '../../../bootstrap/state.js' import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js' @@ -46,6 +47,76 @@ type InteractivePermissionParams = { channelCallbacks?: ChannelPermissionCallbacks } +type ChannelContextHint = { + sourceServer?: string + chatId?: string +} + +function getTextBlocksText(content: unknown): string { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter( + (block): block is { type: 'text'; text: string } => + typeof block === 'object' && + block !== null && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string', + ) + .map(block => block.text) + .join('\n') +} + +function parseChannelContextHintFromText(text: string): ChannelContextHint | null { + const tagMatch = text.match(new RegExp(`<${CHANNEL_TAG}\\b([^>]*)>`)) + if (!tagMatch?.[1]) { + return null + } + + const attrs = tagMatch[1] + const sourceServer = attrs.match(/\bsource="([^"]+)"/)?.[1] + const chatId = attrs.match(/\bchat_id="([^"]+)"/)?.[1] + + if (!sourceServer && !chatId) { + return null + } + + return { sourceServer, chatId } +} + +export function getLatestChannelContextHint(messages: readonly unknown[]): ChannelContextHint | null { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] as { + type?: unknown + origin?: { kind?: unknown; server?: unknown } + message?: { content?: unknown } + } + + if (message?.type !== 'user' || message?.origin?.kind !== 'channel') { + continue + } + + const text = getTextBlocksText(message.message?.content) + const parsed = parseChannelContextHintFromText(text) + if (parsed) { + return { + sourceServer: + parsed.sourceServer || + (typeof message.origin.server === 'string' + ? message.origin.server + : undefined), + chatId: parsed.chatId, + } + } + } + + return null +} + /** * Handles the interactive (main-agent) permission flow. * @@ -420,6 +491,17 @@ function handleInteractivePermission( description, input_preview: truncateForPreview(displayInput), } + const channelContext = getLatestChannelContextHint( + ctx.toolUseContext.messages, + ) + if (channelContext?.sourceServer || channelContext?.chatId) { + params.channel_context = { + ...(channelContext.sourceServer && { + source_server: channelContext.sourceServer, + }), + ...(channelContext.chatId && { chat_id: channelContext.chatId }), + } + } for (const client of channelClients) { if (client.type !== 'connected') continue // refine for TS diff --git a/src/plugins/bundled/index.ts b/src/plugins/bundled/index.ts index 85dda6519..ab8e2f77d 100644 --- a/src/plugins/bundled/index.ts +++ b/src/plugins/bundled/index.ts @@ -14,10 +14,11 @@ * 2. Call registerBuiltinPlugin() with the plugin definition here */ +import { registerWeixinBuiltinPlugin } from './weixin.js' + /** * Initialize built-in plugins. Called during CLI startup. */ export function initBuiltinPlugins(): void { - // No built-in plugins registered yet — this is the scaffolding for - // migrating bundled skills that should be user-toggleable. + registerWeixinBuiltinPlugin() } diff --git a/src/plugins/bundled/weixin.ts b/src/plugins/bundled/weixin.ts new file mode 100644 index 000000000..2a37b86ab --- /dev/null +++ b/src/plugins/bundled/weixin.ts @@ -0,0 +1,21 @@ +import { registerBuiltinPlugin } from '../builtinPlugins.js' +import { buildCliLaunch } from '../../utils/cliLaunch.js' + +export function registerWeixinBuiltinPlugin(): void { + const launch = buildCliLaunch(['weixin', 'serve']) + + registerBuiltinPlugin({ + name: 'weixin', + description: + 'WeChat channel integration. Enables inbound WeChat messages via channels and provides reply/send_typing MCP tools. Configure with `ccb weixin login` and enable for a session with `--channels plugin:weixin@builtin`.', + version: MACRO.VERSION, + defaultEnabled: true, + mcpServers: { + weixin: { + type: 'stdio', + command: launch.execPath, + args: launch.args, + }, + }, + }) +} diff --git a/src/services/mcp/__tests__/channelAllowlist.test.ts b/src/services/mcp/__tests__/channelAllowlist.test.ts new file mode 100644 index 000000000..ed2907204 --- /dev/null +++ b/src/services/mcp/__tests__/channelAllowlist.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, mock, test } from 'bun:test' + +mock.module('../../analytics/growthbook.js', () => ({ + getFeatureValue_CACHED_MAY_BE_STALE: () => [], +})) + +import { isChannelAllowlisted } from '../channelAllowlist.js' + +describe('isChannelAllowlisted', () => { + test('allows builtin weixin plugin', () => { + expect(isChannelAllowlisted('weixin@builtin')).toBe(true) + }) + + test('rejects undefined plugin source', () => { + expect(isChannelAllowlisted(undefined)).toBe(false) + }) +}) diff --git a/src/services/mcp/__tests__/channelPermissions.test.ts b/src/services/mcp/__tests__/channelPermissions.test.ts index fcdba4d8c..552eb776f 100644 --- a/src/services/mcp/__tests__/channelPermissions.test.ts +++ b/src/services/mcp/__tests__/channelPermissions.test.ts @@ -5,6 +5,7 @@ mock.module("src/services/analytics/growthbook.js", () => ({ })); const { + filterPermissionRelayClients, shortRequestId, truncateForPreview, PERMISSION_REPLY_RE, @@ -160,3 +161,34 @@ describe("createChannelPermissionCallbacks", () => { expect(received?.behavior).toBe("deny"); }); }); + +describe("filterPermissionRelayClients", () => { + test("requires truthy permission capability", () => { + const clients = [ + { + type: "connected", + name: "plugin:weixin:weixin", + capabilities: { + experimental: { + "claude/channel": {}, + "claude/channel/permission": false, + }, + }, + }, + { + type: "connected", + name: "plugin:telegram:telegram", + capabilities: { + experimental: { + "claude/channel": {}, + "claude/channel/permission": {}, + }, + }, + }, + ]; + + expect( + filterPermissionRelayClients(clients, () => true).map(client => client.name), + ).toEqual(["plugin:telegram:telegram"]); + }); +}); diff --git a/src/services/mcp/channelAllowlist.ts b/src/services/mcp/channelAllowlist.ts index 11a48a519..9a5e25f92 100644 --- a/src/services/mcp/channelAllowlist.ts +++ b/src/services/mcp/channelAllowlist.ts @@ -16,6 +16,7 @@ */ import { z } from 'zod/v4' +import { BUILTIN_MARKETPLACE_NAME } from '../../plugins/builtinPlugins.js' import { lazySchema } from '../../utils/lazySchema.js' import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' @@ -68,6 +69,9 @@ export function isChannelAllowlisted( if (!pluginSource) return false const { name, marketplace } = parsePluginIdentifier(pluginSource) if (!marketplace) return false + if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') { + return true + } return getChannelAllowlist().some( e => e.plugin === name && e.marketplace === marketplace, ) diff --git a/src/services/mcp/channelNotification.ts b/src/services/mcp/channelNotification.ts index 5e7ede9ee..975bca285 100644 --- a/src/services/mcp/channelNotification.ts +++ b/src/services/mcp/channelNotification.ts @@ -91,8 +91,33 @@ export type ChannelPermissionRequestParams = { * input is in the local terminal dialog; this is a phone-sized * preview. Server decides whether/how to show it. */ input_preview: string + /** Optional source-channel routing hint for servers that support + * multi-chat routing. Backwards compatible: servers that don't care can + * ignore it and keep their existing fallback behavior. */ + channel_context?: { + source_server?: string + chat_id?: string + } } +export const ChannelPermissionRequestNotificationSchema = lazySchema(() => + z.object({ + method: z.literal(CHANNEL_PERMISSION_REQUEST_METHOD), + params: z.object({ + request_id: z.string(), + tool_name: z.string(), + description: z.string(), + input_preview: z.string(), + channel_context: z + .object({ + source_server: z.string().optional(), + chat_id: z.string().optional(), + }) + .optional(), + }), + }), +) + /** * Meta keys become XML attribute NAMES — a crafted key like * `x="" injected="y` would break out of the attribute structure. Only diff --git a/src/services/mcp/channelPermissions.ts b/src/services/mcp/channelPermissions.ts index 1a2a65f00..de84fbc12 100644 --- a/src/services/mcp/channelPermissions.ts +++ b/src/services/mcp/channelPermissions.ts @@ -34,7 +34,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' * don't apply until restart. */ export function isChannelPermissionRelayEnabled(): boolean { - return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false) + return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', true) } export type ChannelPermissionResponse = { @@ -188,8 +188,8 @@ export function filterPermissionRelayClients< (c): c is T & { type: 'connected' } => c.type === 'connected' && isInAllowlist(c.name) && - c.capabilities?.experimental?.['claude/channel'] !== undefined && - c.capabilities?.experimental?.['claude/channel/permission'] !== undefined, + Boolean(c.capabilities?.experimental?.['claude/channel']) && + Boolean(c.capabilities?.experimental?.['claude/channel/permission']), ) } diff --git a/src/services/mcp/useManageMCPConnections.ts b/src/services/mcp/useManageMCPConnections.ts index 070a8b09f..c7124c63d 100644 --- a/src/services/mcp/useManageMCPConnections.ts +++ b/src/services/mcp/useManageMCPConnections.ts @@ -538,7 +538,7 @@ export function useManageMCPConnections( if ( client.capabilities?.experimental?.[ 'claude/channel/permission' - ] !== undefined + ] ) { client.client.setNotificationHandler( ChannelPermissionNotificationSchema(), diff --git a/tsconfig.json b/tsconfig.json index 4c5d39ab8..9472b1e18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,7 +19,9 @@ "@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"], "@claude-code-best/mcp-client": ["./packages/mcp-client/src/index.ts"], "@claude-code-best/agent-tools/*": ["./packages/agent-tools/src/*"], - "@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"] + "@claude-code-best/agent-tools": ["./packages/agent-tools/src/index.ts"], + "@claude-code-best/weixin/*": ["./packages/weixin/src/*"], + "@claude-code-best/weixin": ["./packages/weixin/src/index.ts"] } }, "include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"], diff --git a/vite.config.ts b/vite.config.ts index fec927ae5..5dd994f29 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,12 @@ import { defineConfig, type Plugin } from "vite"; import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; import { readFileSync } from "fs"; import { getMacroDefines } from "./scripts/defines"; import featureFlagsPlugin from "./scripts/vite-plugin-feature-flags"; import importMetaRequirePlugin from "./scripts/vite-plugin-import-meta-require"; -const projectRoot = dirname(new URL(import.meta.url).pathname); +const projectRoot = dirname(fileURLToPath(import.meta.url)); /** * Plugin to import .md files as raw strings (Bun's text loader behavior).