mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494eab7204 | ||
|
|
b83c3008d0 | ||
|
|
66d2671c98 | ||
|
|
c7bc8c8636 | ||
|
|
673ccd1800 | ||
|
|
d1ab38c089 |
@@ -1,22 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# pre-commit hook: 对暂存的文件运行 Biome 检查
|
|
||||||
# 仅检查 src/ 下的 .ts/.tsx/.js/.jsx 文件
|
|
||||||
|
|
||||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '^src/.*\.(ts|tsx|js|jsx)$')
|
|
||||||
|
|
||||||
if [ -z "$STAGED_FILES" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Running Biome lint on staged files..."
|
|
||||||
|
|
||||||
# 使用 biome lint 对暂存文件进行检查(仅 lint,不格式化,不自动修复)
|
|
||||||
echo "$STAGED_FILES" | xargs bunx biome lint --no-errors-on-unmatched
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo ""
|
|
||||||
echo "Biome lint failed. Fix errors or use --no-verify to bypass."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -6,13 +6,13 @@
|
|||||||
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
[](https://github.com/claude-code-best/claude-code/blob/main/LICENSE)
|
||||||
[](https://github.com/claude-code-best/claude-code/commits/main)
|
[](https://github.com/claude-code-best/claude-code/commits/main)
|
||||||
[](https://bun.sh/)
|
[](https://bun.sh/)
|
||||||
[](https://discord.gg/qZU6zS7Q)
|
[](https://discord.gg/uApuzJWGKX)
|
||||||
|
|
||||||
> Which Claude do you like? The open source one is the best.
|
> Which Claude do you like? The open source one is the best.
|
||||||
|
|
||||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)... 而且, 我们实现了企业版或者需要登陆 Claude 账号才能使用的特性, 实现技术普惠
|
||||||
|
|
||||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/qZU6zS7Q)
|
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/) | [留影文档在这里](./Friends.md) | [Discord 群组](https://discord.gg/uApuzJWGKX)
|
||||||
|
|
||||||
| 特性 | 说明 | 文档 |
|
| 特性 | 说明 | 文档 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
| **Langfuse 监控** | 企业级 Agent 监控, 可以清晰看到每次 agent loop 细节, 可以一键转化为数据集 | [文档](https://ccb.agent-aura.top/docs/features/langfuse-monitoring) |
|
| **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) |
|
| **Web Search** | 内置网页搜索工具, 支持 bing 和 brave 搜索 | [文档](https://ccb.agent-aura.top/docs/features/web-browser-tool) |
|
||||||
| **Poor Mode** | 穷鬼模式,关闭记忆提取和键入建议,大幅度减少并发请求 | /poor 可以开关 |
|
| **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) |
|
| **自定义模型供应商** | 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) |
|
| 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) |
|
| Computer Use | 屏幕截图、键鼠控制 | [文档](https://ccb.agent-aura.top/docs/features/computer-use) |
|
||||||
|
|||||||
68
bun.lock
68
bun.lock
@@ -17,23 +17,24 @@
|
|||||||
"@ant/computer-use-swift": "workspace:*",
|
"@ant/computer-use-swift": "workspace:*",
|
||||||
"@ant/model-provider": "workspace:*",
|
"@ant/model-provider": "workspace:*",
|
||||||
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.114",
|
||||||
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
||||||
"@anthropic-ai/mcpb": "^2.1.2",
|
"@anthropic-ai/mcpb": "^2.1.2",
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
||||||
"@anthropic-ai/sdk": "^0.80.0",
|
"@anthropic-ai/sdk": "^0.80.0",
|
||||||
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
||||||
"@anthropic/ink": "workspace:*",
|
"@anthropic/ink": "workspace:*",
|
||||||
"@aws-sdk/client-bedrock": "^3.1020.0",
|
"@aws-sdk/client-bedrock": "^3.1032.0",
|
||||||
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
"@aws-sdk/client-bedrock-runtime": "^3.1032.0",
|
||||||
"@aws-sdk/client-sts": "^3.1020.0",
|
"@aws-sdk/client-sts": "^3.1032.0",
|
||||||
"@aws-sdk/credential-provider-node": "^3.972.28",
|
"@aws-sdk/credential-provider-node": "^3.972.32",
|
||||||
"@aws-sdk/credential-providers": "^3.1020.0",
|
"@aws-sdk/credential-providers": "^3.1032.0",
|
||||||
"@azure/identity": "^4.13.1",
|
"@azure/identity": "^4.13.1",
|
||||||
"@biomejs/biome": "^2.4.10",
|
"@biomejs/biome": "^2.4.12",
|
||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
"@claude-code-best/builtin-tools": "workspace:*",
|
"@claude-code-best/builtin-tools": "workspace:*",
|
||||||
"@claude-code-best/mcp-client": "workspace:*",
|
"@claude-code-best/mcp-client": "workspace:*",
|
||||||
|
"@claude-code-best/weixin": "workspace:*",
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
"@commander-js/extra-typings": "^14.0.0",
|
||||||
"@growthbook/growthbook": "^1.6.5",
|
"@growthbook/growthbook": "^1.6.5",
|
||||||
"@langfuse/otel": "^5.1.0",
|
"@langfuse/otel": "^5.1.0",
|
||||||
@@ -41,7 +42,7 @@
|
|||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/api-logs": "^0.214.0",
|
"@opentelemetry/api-logs": "^0.214.0",
|
||||||
"@opentelemetry/core": "^2.6.1",
|
"@opentelemetry/core": "^2.7.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||||
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
||||||
@@ -52,14 +53,14 @@
|
|||||||
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
||||||
"@opentelemetry/resources": "^2.6.1",
|
"@opentelemetry/resources": "^2.7.0",
|
||||||
"@opentelemetry/sdk-logs": "^0.214.0",
|
"@opentelemetry/sdk-logs": "^0.214.0",
|
||||||
"@opentelemetry/sdk-metrics": "^2.6.1",
|
"@opentelemetry/sdk-metrics": "^2.7.0",
|
||||||
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
"@opentelemetry/sdk-trace-base": "^2.7.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||||
"@sentry/node": "^10.47.0",
|
"@sentry/node": "^10.49.0",
|
||||||
"@smithy/core": "^3.23.13",
|
"@smithy/core": "^3.23.15",
|
||||||
"@smithy/node-http-handler": "^4.5.1",
|
"@smithy/node-http-handler": "^4.5.3",
|
||||||
"@types/bun": "^1.3.12",
|
"@types/bun": "^1.3.12",
|
||||||
"@types/cacache": "^20.0.1",
|
"@types/cacache": "^20.0.1",
|
||||||
"@types/he": "^1.2.3",
|
"@types/he": "^1.2.3",
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"asciichart": "^1.5.25",
|
"asciichart": "^1.5.25",
|
||||||
"audio-capture-napi": "workspace:*",
|
"audio-capture-napi": "workspace:*",
|
||||||
"auto-bind": "^5.0.1",
|
"auto-bind": "^5.0.1",
|
||||||
"axios": "^1.14.0",
|
"axios": "^1.15.0",
|
||||||
"bidi-js": "^1.0.3",
|
"bidi-js": "^1.0.3",
|
||||||
"cacache": "^20.0.4",
|
"cacache": "^20.0.4",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
@@ -96,7 +97,7 @@
|
|||||||
"execa": "^9.6.1",
|
"execa": "^9.6.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"figures": "^6.1.0",
|
"figures": "^6.1.0",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.3.0",
|
||||||
"get-east-asian-width": "^1.5.0",
|
"get-east-asian-width": "^1.5.0",
|
||||||
"google-auth-library": "^10.6.2",
|
"google-auth-library": "^10.6.2",
|
||||||
"he": "^1.2.0",
|
"he": "^1.2.0",
|
||||||
@@ -106,21 +107,21 @@
|
|||||||
"image-processor-napi": "workspace:*",
|
"image-processor-napi": "workspace:*",
|
||||||
"indent-string": "^5.0.0",
|
"indent-string": "^5.0.0",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"knip": "^6.1.1",
|
"knip": "^6.4.1",
|
||||||
"lodash-es": "^4.17.23",
|
"lodash-es": "^4.18.1",
|
||||||
"lru-cache": "^11.2.7",
|
"lru-cache": "^11.3.5",
|
||||||
"marked": "^17.0.5",
|
"marked": "^17.0.6",
|
||||||
"modifiers-napi": "workspace:*",
|
"modifiers-napi": "workspace:*",
|
||||||
"openai": "^6.33.0",
|
"openai": "^6.34.0",
|
||||||
"p-map": "^7.0.4",
|
"p-map": "^7.0.4",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"plist": "^3.1.0",
|
"plist": "^3.1.0",
|
||||||
"proper-lockfile": "^4.1.2",
|
"proper-lockfile": "^4.1.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.5",
|
||||||
"react-compiler-runtime": "^1.0.0",
|
"react-compiler-runtime": "^1.0.0",
|
||||||
"react-reconciler": "^0.33.0",
|
"react-reconciler": "^0.33.0",
|
||||||
"rollup": "^4.60.1",
|
"rollup": "^4.60.2",
|
||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"shell-quote": "^1.8.3",
|
"shell-quote": "^1.8.3",
|
||||||
@@ -129,10 +130,10 @@
|
|||||||
"strip-ansi": "^7.2.0",
|
"strip-ansi": "^7.2.0",
|
||||||
"supports-hyperlinks": "^4.4.0",
|
"supports-hyperlinks": "^4.4.0",
|
||||||
"tree-kill": "^1.2.2",
|
"tree-kill": "^1.2.2",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.4",
|
||||||
"type-fest": "^5.5.0",
|
"type-fest": "^5.6.0",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.3",
|
||||||
"undici": "^7.24.6",
|
"undici": "^7.25.0",
|
||||||
"url-handler-napi": "workspace:*",
|
"url-handler-napi": "workspace:*",
|
||||||
"usehooks-ts": "^3.1.1",
|
"usehooks-ts": "^3.1.1",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
@@ -194,9 +195,10 @@
|
|||||||
},
|
},
|
||||||
"packages/acp-link": {
|
"packages/acp-link": {
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"acp-link": "dist/cli/bin.js",
|
"acp-link": "dist/cli/bin.js",
|
||||||
|
"acp-manager": "dist/manager/bin.js",
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
@@ -210,6 +212,7 @@
|
|||||||
"selfsigned": "^5.5.0",
|
"selfsigned": "^5.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.12",
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
},
|
},
|
||||||
@@ -320,6 +323,13 @@
|
|||||||
"name": "url-handler-napi",
|
"name": "url-handler-napi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
},
|
},
|
||||||
|
"packages/weixin": {
|
||||||
|
"name": "@claude-code-best/weixin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
||||||
@@ -564,6 +574,8 @@
|
|||||||
|
|
||||||
"@claude-code-best/mcp-client": ["@claude-code-best/mcp-client@workspace:packages/mcp-client"],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
||||||
|
|||||||
@@ -10,12 +10,18 @@ Channel 是一个 MCP 服务器,它将外部事件推送到你运行中的 Cla
|
|||||||
- **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
|
- **官方文档**:[使用 channels 将事件推送到运行中的会话](https://code.claude.com/docs/zh-CN/channels)
|
||||||
- **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
|
- **飞书插件**:[claude-code-feishu-channel](https://github.com/whobot-ai/claude-code-feishu-channel) — 社区首个飞书 Channel 插件,支持双向消息、配对认证、群组聊天、文件附件
|
||||||
|
|
||||||
|
本仓库现在内置了 **微信 WeChat channel**,不需要单独安装外部 marketplace 插件。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启用频道监听(plugin 格式)
|
# 启用频道监听(plugin 格式)
|
||||||
ccb --channels plugin:feishu@claude-code-feishu-channel
|
ccb --channels plugin:feishu@claude-code-feishu-channel
|
||||||
|
|
||||||
|
# 启用内置微信 channel
|
||||||
|
ccb weixin login
|
||||||
|
ccb --channels plugin:weixin@builtin
|
||||||
|
|
||||||
# 启用频道监听(server 格式)
|
# 启用频道监听(server 格式)
|
||||||
ccb --channels server:my-slack-bridge
|
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` |
|
| **Discord** | 官方 Discord Bot 集成 | `/plugin install discord@claude-plugins-official` |
|
||||||
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
|
| **iMessage** | macOS 原生消息 | `/plugin install imessage@claude-plugins-official` |
|
||||||
| **飞书 (Feishu/Lark)** | 双向消息、群组聊天、文件附件 | `/plugin install feishu@claude-code-feishu-channel` |
|
| **飞书 (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 <code>
|
||||||
|
```
|
||||||
|
|
||||||
|
确认后,该微信用户后续消息才会进入 Claude Code 会话。
|
||||||
|
|
||||||
## 相关文件
|
## 相关文件
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-code-best",
|
"name": "claude-code-best",
|
||||||
"version": "1.4.4",
|
"version": "1.5.0",
|
||||||
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "claude-code-best <claude-code-best@proton.me>",
|
"author": "claude-code-best <claude-code-best@proton.me>",
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
"@claude-code-best/agent-tools": "workspace:*",
|
"@claude-code-best/agent-tools": "workspace:*",
|
||||||
"@claude-code-best/builtin-tools": "workspace:*",
|
"@claude-code-best/builtin-tools": "workspace:*",
|
||||||
"@claude-code-best/mcp-client": "workspace:*",
|
"@claude-code-best/mcp-client": "workspace:*",
|
||||||
|
"@claude-code-best/weixin": "workspace:*",
|
||||||
"@commander-js/extra-typings": "^14.0.0",
|
"@commander-js/extra-typings": "^14.0.0",
|
||||||
"@growthbook/growthbook": "^1.6.5",
|
"@growthbook/growthbook": "^1.6.5",
|
||||||
"@langfuse/otel": "^5.1.0",
|
"@langfuse/otel": "^5.1.0",
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ acp-link --https /path/to/agent
|
|||||||
# Disable authentication (dangerous)
|
# Disable authentication (dangerous)
|
||||||
acp-link --no-auth /path/to/agent
|
acp-link --no-auth /path/to/agent
|
||||||
|
|
||||||
|
# Register to RCS with a specific channel group
|
||||||
|
acp-link --group my-team /path/to/agent
|
||||||
|
|
||||||
# Pass arguments to the agent (use -- to separate)
|
# Pass arguments to the agent (use -- to separate)
|
||||||
acp-link /path/to/agent -- --verbose --model gpt-4
|
acp-link /path/to/agent -- --verbose --model gpt-4
|
||||||
```
|
```
|
||||||
@@ -49,7 +52,7 @@ acp-link /path/to/agent -- --verbose --model gpt-4
|
|||||||
|
|
||||||
```
|
```
|
||||||
USAGE
|
USAGE
|
||||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] <command>...
|
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
|
||||||
acp-link --help
|
acp-link --help
|
||||||
acp-link --version
|
acp-link --version
|
||||||
|
|
||||||
@@ -59,6 +62,7 @@ FLAGS
|
|||||||
[--debug] Enable debug logging to file
|
[--debug] Enable debug logging to file
|
||||||
[--no-auth] Disable authentication (dangerous)
|
[--no-auth] Disable authentication (dangerous)
|
||||||
[--https] Enable HTTPS with self-signed cert
|
[--https] Enable HTTPS with self-signed cert
|
||||||
|
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
|
||||||
-h --help Print help information and exit
|
-h --help Print help information and exit
|
||||||
-v --version Print version information and exit
|
-v --version Print version information and exit
|
||||||
|
|
||||||
@@ -84,6 +88,34 @@ ws://localhost:9315/ws?token=<your-token>
|
|||||||
|
|
||||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to disable (not recommended).
|
||||||
|
|
||||||
|
## RCS Upstream
|
||||||
|
|
||||||
|
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
|
||||||
|
| `ACP_RCS_TOKEN` | API token for RCS authentication |
|
||||||
|
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
|
||||||
|
|
||||||
|
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
|
||||||
|
|
||||||
|
## Manager UI
|
||||||
|
|
||||||
|
通过 `--manager` flag 启动独立的管理服务(不启动代理):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Manager(默认端口 9315)
|
||||||
|
acp-link --manager
|
||||||
|
|
||||||
|
# 指定端口
|
||||||
|
acp-link --manager --port 3210
|
||||||
|
```
|
||||||
|
|
||||||
|
在浏览器打开 `http://localhost:<port>` 即可访问管理界面,创建、停止、删除多个 acp-link 子进程实例并实时查看日志。
|
||||||
|
|
||||||
|
通过 Manager UI 创建的子进程会自动跳过 Manager UI。
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "acp-link",
|
"name": "acp-link",
|
||||||
"version": "1.0.1",
|
"version": "2.0.0",
|
||||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||||
"author": "claude-code-best",
|
"author": "claude-code-best",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -14,12 +14,15 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"dev": "bun run src/cli/bin.ts",
|
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
||||||
|
"dev:remote": "ACP_RCS_URL=https://remote-control.claude-code-best.win/ ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
||||||
|
"dev:manager": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts --manager",
|
||||||
"prepublishOnly": "bun run build"
|
"prepublishOnly": "bun run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/selfsigned": "^2.0.4",
|
"@types/selfsigned": "^2.0.4",
|
||||||
"@types/ws": "^8.18.1"
|
"@types/ws": "^8.18.1",
|
||||||
|
"@types/bun": "^1.3.12"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.19.0",
|
"@agentclientprotocol/sdk": "^0.19.0",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const command = buildCommand({
|
|||||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||||
"Use -- to pass arguments to the agent:\n" +
|
"Use -- to pass arguments to the agent:\n" +
|
||||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||||
|
"Use --manager to start the Manager Web UI instead:\n" +
|
||||||
|
" acp-link --manager\n\n" +
|
||||||
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||||
},
|
},
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -40,6 +42,22 @@ export const command = buildCommand({
|
|||||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
manager: {
|
||||||
|
kind: "boolean",
|
||||||
|
brief: "Start Manager Web UI (no proxy)",
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
kind: "parsed",
|
||||||
|
parse: (value: string) => {
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||||
|
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
positional: {
|
positional: {
|
||||||
kind: "array",
|
kind: "array",
|
||||||
@@ -48,12 +66,12 @@ export const command = buildCommand({
|
|||||||
parse: String,
|
parse: String,
|
||||||
placeholder: "command",
|
placeholder: "command",
|
||||||
},
|
},
|
||||||
minimum: 1,
|
minimum: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
func: async function (
|
func: async function (
|
||||||
this: LocalContext,
|
this: LocalContext,
|
||||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean },
|
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
||||||
...args: readonly string[]
|
...args: readonly string[]
|
||||||
) {
|
) {
|
||||||
const port = flags.port;
|
const port = flags.port;
|
||||||
@@ -61,6 +79,21 @@ export const command = buildCommand({
|
|||||||
const debug = flags.debug;
|
const debug = flags.debug;
|
||||||
const noAuth = flags["no-auth"];
|
const noAuth = flags["no-auth"];
|
||||||
const https = flags.https;
|
const https = flags.https;
|
||||||
|
const manager = flags.manager;
|
||||||
|
const group = flags.group;
|
||||||
|
|
||||||
|
// Manager mode: start web UI only, no proxy
|
||||||
|
if (manager) {
|
||||||
|
const { startManager } = await import("../manager/index.js");
|
||||||
|
await startManager(port);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy mode: agent command is required
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Error: agent command is required (or use --manager)");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const [command, ...agentArgs] = args;
|
const [command, ...agentArgs] = args;
|
||||||
const cwd = process.cwd();
|
const cwd = process.cwd();
|
||||||
|
|
||||||
@@ -85,6 +118,6 @@ export const command = buildCommand({
|
|||||||
|
|
||||||
// Import and run the server
|
// Import and run the server
|
||||||
const { startServer } = await import("../server.js");
|
const { startServer } = await import("../server.js");
|
||||||
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https });
|
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
345
packages/acp-link/src/manager/html.ts
Normal file
345
packages/acp-link/src/manager/html.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
export const MANAGER_HTML = `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ACP Manager</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f8f7f5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a1a1a; }
|
||||||
|
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||||
|
.create-form {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e2de;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.form-group label { font-size: 12px; color: #888; }
|
||||||
|
.form-group input {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d5d2ce;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
.form-group input.wide { width: 400px; }
|
||||||
|
button {
|
||||||
|
background: #d77757;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button:hover { background: #c4694b; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
button.danger { background: #a63d3d; }
|
||||||
|
button.danger:hover { background: #c44a4a; }
|
||||||
|
button.small { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.instances { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.instance-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e2de;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.instance-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.instance-header:hover { background: #f5f3f0; }
|
||||||
|
.status-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
|
||||||
|
.status-dot.stopped { background: #aaa; }
|
||||||
|
.status-dot.failed { background: #f87171; box-shadow: 0 0 6px #f8717166; }
|
||||||
|
.instance-info { flex: 1; display: flex; gap: 16px; align-items: center; font-size: 13px; }
|
||||||
|
.instance-info .group { font-weight: 600; color: #d77757; }
|
||||||
|
.instance-info .cmd { color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.instance-info .pid { color: #999; font-size: 12px; }
|
||||||
|
.instance-info .uptime { color: #999; font-size: 12px; }
|
||||||
|
.instance-actions { display: flex; gap: 6px; }
|
||||||
|
.expand-icon { color: #999; font-size: 12px; transition: transform 0.2s; }
|
||||||
|
.expand-icon.open { transform: rotate(90deg); }
|
||||||
|
.log-panel {
|
||||||
|
display: none;
|
||||||
|
border-top: 1px solid #e5e2de;
|
||||||
|
background: #faf9f7;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.log-panel.visible { display: block; }
|
||||||
|
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.log-line.stdout { color: #333; }
|
||||||
|
.log-line.stderr { color: #d94040; }
|
||||||
|
.empty { color: #999; text-align: center; padding: 40px; font-size: 14px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
body { padding: 12px; }
|
||||||
|
.create-form { flex-wrap: wrap; }
|
||||||
|
.form-group input, .form-group input.wide { width: 100%; }
|
||||||
|
.form-group { flex: 1 1 120px; min-width: 0; }
|
||||||
|
.instance-header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
||||||
|
.instance-info { flex-wrap: wrap; gap: 6px; font-size: 12px; }
|
||||||
|
.instance-info .cmd { max-width: 100%; }
|
||||||
|
button.small { padding: 8px 14px; min-height: 44px; font-size: 13px; }
|
||||||
|
.log-panel { max-height: 50vh; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>ACP Manager</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="create-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Group</label>
|
||||||
|
<input type="text" id="inp-group" placeholder="my-group" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ACP Command</label>
|
||||||
|
<input type="text" id="inp-command" class="wide" placeholder="/path/to/agent --verbose" />
|
||||||
|
</div>
|
||||||
|
<button id="btn-create">Create</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instances" id="instance-list"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var listEl = document.getElementById('instance-list');
|
||||||
|
var esMap = {};
|
||||||
|
var instances = [];
|
||||||
|
var inpGroup = document.getElementById('inp-group');
|
||||||
|
var inpCommand = document.getElementById('inp-command');
|
||||||
|
var btnCreate = document.getElementById('btn-create');
|
||||||
|
|
||||||
|
// localStorage persistence
|
||||||
|
function loadForm() {
|
||||||
|
try {
|
||||||
|
inpGroup.value = localStorage.getItem('acp-mgr-group') || '';
|
||||||
|
inpCommand.value = localStorage.getItem('acp-mgr-command') || '';
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
function saveForm() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('acp-mgr-group', inpGroup.value);
|
||||||
|
localStorage.setItem('acp-mgr-command', inpCommand.value);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
inpGroup.addEventListener('input', saveForm);
|
||||||
|
inpCommand.addEventListener('input', saveForm);
|
||||||
|
loadForm();
|
||||||
|
|
||||||
|
btnCreate.addEventListener('click', function() {
|
||||||
|
var group = inpGroup.value.trim();
|
||||||
|
var command = inpCommand.value.trim();
|
||||||
|
if (!group || !command) return alert('Both fields required');
|
||||||
|
btnCreate.disabled = true;
|
||||||
|
fetch('/api/instances', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ group: group, command: command }),
|
||||||
|
}).then(function() { fetchInstances(); })
|
||||||
|
.finally(function() { btnCreate.disabled = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// event delegation for instance actions
|
||||||
|
listEl.addEventListener('click', function(e) {
|
||||||
|
var btn = e.target.closest('[data-action]');
|
||||||
|
if (btn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var action = btn.getAttribute('data-action');
|
||||||
|
if (action === 'stop') stopInstance(id);
|
||||||
|
else if (action === 'delete') deleteInstance(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var header = e.target.closest('.instance-header');
|
||||||
|
if (header) {
|
||||||
|
var cardId = header.closest('.instance-card').getAttribute('data-id');
|
||||||
|
toggleLog(cardId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchInstances() {
|
||||||
|
var res = await fetch('/api/instances');
|
||||||
|
instances = await res.json();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptime(start) {
|
||||||
|
var s = Math.floor((Date.now() - start) / 1000);
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
||||||
|
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (instances.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="empty">No instances. Create one above.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Diff-based update: only rebuild cards whose status changed
|
||||||
|
var existingCards = {};
|
||||||
|
listEl.querySelectorAll('.instance-card').forEach(function(card) {
|
||||||
|
existingCards[card.getAttribute('data-id')] = card;
|
||||||
|
});
|
||||||
|
|
||||||
|
var newIds = new Set(instances.map(function(i) { return i.id; }));
|
||||||
|
|
||||||
|
// Remove cards that no longer exist
|
||||||
|
for (var eid in existingCards) {
|
||||||
|
if (!newIds.has(eid)) {
|
||||||
|
closeLog(eid);
|
||||||
|
existingCards[eid].remove();
|
||||||
|
delete existingCards[eid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create cards in order
|
||||||
|
instances.forEach(function(inst) {
|
||||||
|
var card = existingCards[inst.id];
|
||||||
|
if (!card) {
|
||||||
|
// New instance — create card
|
||||||
|
card = document.createElement('div');
|
||||||
|
card.className = 'instance-card';
|
||||||
|
card.setAttribute('data-id', inst.id);
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="instance-header">' +
|
||||||
|
'<span class="expand-icon">▶</span>' +
|
||||||
|
'<span class="status-dot"></span>' +
|
||||||
|
'<div class="instance-info">' +
|
||||||
|
'<span class="group"></span>' +
|
||||||
|
'<span class="cmd"></span>' +
|
||||||
|
'<span class="pid"></span>' +
|
||||||
|
'<span class="uptime"></span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="instance-actions"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="log-panel" id="log-' + inst.id + '"></div>';
|
||||||
|
listEl.appendChild(card);
|
||||||
|
}
|
||||||
|
// Update card content
|
||||||
|
card.querySelector('.status-dot').className = 'status-dot ' + inst.status;
|
||||||
|
card.querySelector('.group').textContent = inst.group;
|
||||||
|
card.querySelector('.cmd').textContent = inst.command;
|
||||||
|
card.querySelector('.pid').textContent = inst.pid ? 'PID ' + inst.pid : '';
|
||||||
|
card.querySelector('.uptime').textContent = inst.status === 'running' ? uptime(inst.startTime) : '';
|
||||||
|
|
||||||
|
// Update action buttons
|
||||||
|
var actions = card.querySelector('.instance-actions');
|
||||||
|
var prevStatus = card.getAttribute('data-status');
|
||||||
|
if (prevStatus !== inst.status) {
|
||||||
|
card.setAttribute('data-status', inst.status);
|
||||||
|
actions.innerHTML = inst.status === 'running'
|
||||||
|
? '<button class="small danger" data-action="stop" data-id="' + inst.id + '">Stop</button>'
|
||||||
|
: '<button class="small danger" data-action="delete" data-id="' + inst.id + '">Delete</button>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopInstance(id) {
|
||||||
|
var btn = listEl.querySelector('[data-action="stop"][data-id="' + id + '"]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
await fetch('/api/instances/' + id + '/stop', { method: 'POST' });
|
||||||
|
await fetchInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteInstance(id) {
|
||||||
|
var btn = listEl.querySelector('[data-action="delete"][data-id="' + id + '"]');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
await fetch('/api/instances/' + id, { method: 'DELETE' });
|
||||||
|
closeLog(id);
|
||||||
|
await fetchInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLog(id) {
|
||||||
|
var panel = document.getElementById('log-' + id);
|
||||||
|
if (!panel) return;
|
||||||
|
if (panel.classList.contains('visible')) {
|
||||||
|
closeLog(id);
|
||||||
|
} else {
|
||||||
|
openLog(id);
|
||||||
|
}
|
||||||
|
var icon = listEl.querySelector('[data-id="' + id + '"] .expand-icon');
|
||||||
|
if (icon) icon.classList.toggle('open', panel.classList.contains('visible'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLog(id) {
|
||||||
|
var panel = document.getElementById('log-' + id);
|
||||||
|
if (!panel) return;
|
||||||
|
panel.classList.add('visible');
|
||||||
|
panel.innerHTML = '';
|
||||||
|
var es = new EventSource('/api/instances/' + id + '/logs');
|
||||||
|
esMap[id] = es;
|
||||||
|
var scrollPending = false;
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
try {
|
||||||
|
var entry = JSON.parse(e.data);
|
||||||
|
var line = document.createElement('div');
|
||||||
|
line.className = 'log-line ' + entry.stream;
|
||||||
|
var time = new Date(entry.timestamp).toLocaleTimeString();
|
||||||
|
line.textContent = '[' + time + '] ' + entry.text;
|
||||||
|
panel.appendChild(line);
|
||||||
|
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
||||||
|
if (!scrollPending) {
|
||||||
|
scrollPending = true;
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
panel.scrollTop = panel.scrollHeight;
|
||||||
|
scrollPending = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(err) {}
|
||||||
|
};
|
||||||
|
es.onerror = function() {
|
||||||
|
es.close();
|
||||||
|
delete esMap[id];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLog(id) {
|
||||||
|
if (esMap[id]) {
|
||||||
|
esMap[id].close();
|
||||||
|
delete esMap[id];
|
||||||
|
}
|
||||||
|
var panel = document.getElementById('log-' + id);
|
||||||
|
if (panel) panel.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchInstances();
|
||||||
|
setInterval(fetchInstances, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
44
packages/acp-link/src/manager/index.ts
Normal file
44
packages/acp-link/src/manager/index.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
import { ProcessManager } from "./manager.js";
|
||||||
|
import { createApp } from "./routes.js";
|
||||||
|
|
||||||
|
export async function startManager(port: number): Promise<void> {
|
||||||
|
const manager = new ProcessManager();
|
||||||
|
const app = createApp(manager);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log("Shutting down...");
|
||||||
|
await manager.shutdownAll();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
|
||||||
|
const server = serve({ fetch: app.fetch, port });
|
||||||
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "EADDRINUSE") {
|
||||||
|
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
|
||||||
|
} else {
|
||||||
|
console.error(`\n Error: ${err.message}\n`);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log(` 🖥️ ACP Manager`);
|
||||||
|
console.log();
|
||||||
|
console.log(` URL: http://localhost:${port}`);
|
||||||
|
console.log();
|
||||||
|
console.log(` Press Ctrl+C to stop`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
// Keep running
|
||||||
|
await new Promise(() => {});
|
||||||
|
}
|
||||||
233
packages/acp-link/src/manager/manager.ts
Normal file
233
packages/acp-link/src/manager/manager.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
|
||||||
|
|
||||||
|
function log(tag: string, msg: string) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
console.log(`[${ts}] [${tag}] ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_LINES = 2000;
|
||||||
|
const SHUTDOWN_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
export class ProcessManager {
|
||||||
|
private instances = new Map<string, AcpInstance>();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
private processes = new Map<string, any>();
|
||||||
|
|
||||||
|
create(group: string, command: string): AcpInstance {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const instance: AcpInstance = {
|
||||||
|
id,
|
||||||
|
group,
|
||||||
|
command,
|
||||||
|
status: "running",
|
||||||
|
pid: undefined,
|
||||||
|
startTime: Date.now(),
|
||||||
|
exitCode: null,
|
||||||
|
logs: [],
|
||||||
|
subscribers: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const args = this.parseCommand(command);
|
||||||
|
const fullArgs = ["--group", group, ...args];
|
||||||
|
|
||||||
|
const proc = Bun.spawn(["acp-link", ...fullArgs], {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...Bun.env, ACP_CHILD: "1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.pid = proc.pid;
|
||||||
|
this.instances.set(id, instance);
|
||||||
|
this.processes.set(id, proc);
|
||||||
|
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
|
||||||
|
|
||||||
|
this.pipeStream(proc.stdout, id, "stdout");
|
||||||
|
this.pipeStream(proc.stderr, id, "stderr");
|
||||||
|
|
||||||
|
proc.exited.then((code) => {
|
||||||
|
instance.status = code === 0 ? "stopped" : "failed";
|
||||||
|
instance.exitCode = code;
|
||||||
|
instance.pid = undefined;
|
||||||
|
this.processes.delete(id);
|
||||||
|
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
|
||||||
|
this.notifyStatus(instance);
|
||||||
|
});
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(id: string): boolean {
|
||||||
|
const proc = this.processes.get(id);
|
||||||
|
if (!proc) return false;
|
||||||
|
const inst = this.instances.get(id);
|
||||||
|
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
// Immediately mark as stopped to prevent stale state
|
||||||
|
if (inst) {
|
||||||
|
inst.status = "stopped";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): boolean {
|
||||||
|
const instance = this.instances.get(id);
|
||||||
|
if (!instance) return false;
|
||||||
|
if (instance.status === "running") return false;
|
||||||
|
instance.subscribers.clear();
|
||||||
|
this.instances.delete(id);
|
||||||
|
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): InstanceSummary[] {
|
||||||
|
return Array.from(this.instances.values()).map(this.toSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(id: string): AcpInstance | undefined {
|
||||||
|
return this.instances.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
|
||||||
|
const instance = this.instances.get(id);
|
||||||
|
if (!instance) return () => {};
|
||||||
|
instance.subscribers.add(callback);
|
||||||
|
return () => instance.subscribers.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdownAll(): Promise<void> {
|
||||||
|
const running = Array.from(this.processes.entries());
|
||||||
|
if (running.length === 0) return;
|
||||||
|
|
||||||
|
log("manager", `shutting down ${running.length} running instance(s)...`);
|
||||||
|
for (const [id, proc] of running) {
|
||||||
|
try {
|
||||||
|
proc.kill("SIGTERM");
|
||||||
|
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const [id, proc] of running) {
|
||||||
|
try {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
|
||||||
|
} catch {
|
||||||
|
// already dead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("manager", "all instances shut down");
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCommand(command: string): string[] {
|
||||||
|
const args: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inQuote: string | null = null;
|
||||||
|
|
||||||
|
for (const ch of command) {
|
||||||
|
if (inQuote) {
|
||||||
|
if (ch === inQuote) {
|
||||||
|
inQuote = null;
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"' || ch === "'") {
|
||||||
|
inQuote = ch;
|
||||||
|
} else if (ch === " " || ch === "\t") {
|
||||||
|
if (current) {
|
||||||
|
args.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current) args.push(current);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pipeStream(
|
||||||
|
readable: ReadableStream<Uint8Array>,
|
||||||
|
instanceId: string,
|
||||||
|
stream: "stdout" | "stderr",
|
||||||
|
) {
|
||||||
|
const reader = readable.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
const processChunk = () => {
|
||||||
|
reader
|
||||||
|
.read()
|
||||||
|
.then(({ done, value }) => {
|
||||||
|
if (done) {
|
||||||
|
if (buffer) this.appendLog(instanceId, buffer, stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line) this.appendLog(instanceId, line, stream);
|
||||||
|
}
|
||||||
|
processChunk();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// stream ended or error
|
||||||
|
});
|
||||||
|
};
|
||||||
|
processChunk();
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
|
||||||
|
const instance = this.instances.get(instanceId);
|
||||||
|
if (!instance) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = { timestamp: Date.now(), stream, text };
|
||||||
|
instance.logs.push(entry);
|
||||||
|
if (instance.logs.length > MAX_LOG_LINES) {
|
||||||
|
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sub of instance.subscribers) {
|
||||||
|
try {
|
||||||
|
sub(entry);
|
||||||
|
} catch {
|
||||||
|
// subscriber error, remove it
|
||||||
|
instance.subscribers.delete(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyStatus(instance: AcpInstance) {
|
||||||
|
const statusEntry: LogEntry = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
stream: "stderr",
|
||||||
|
text: `[${instance.status}] exit code: ${instance.exitCode}`,
|
||||||
|
};
|
||||||
|
for (const sub of instance.subscribers) {
|
||||||
|
try {
|
||||||
|
sub(statusEntry);
|
||||||
|
} catch {
|
||||||
|
instance.subscribers.delete(sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(inst: AcpInstance): InstanceSummary {
|
||||||
|
return {
|
||||||
|
id: inst.id,
|
||||||
|
group: inst.group,
|
||||||
|
command: inst.command,
|
||||||
|
status: inst.status,
|
||||||
|
pid: inst.pid,
|
||||||
|
startTime: inst.startTime,
|
||||||
|
exitCode: inst.exitCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
153
packages/acp-link/src/manager/routes.ts
Normal file
153
packages/acp-link/src/manager/routes.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type { ProcessManager } from "./manager.js";
|
||||||
|
import { MANAGER_HTML } from "./html.js";
|
||||||
|
|
||||||
|
function logReq(method: string, path: string, status?: number) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const suffix = status != null ? ` -> ${status}` : "";
|
||||||
|
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(manager: ProcessManager): Hono {
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.get("/", (c) => {
|
||||||
|
logReq("GET", "/", 200);
|
||||||
|
return c.html(MANAGER_HTML);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/instances", (c) => {
|
||||||
|
const list = manager.list();
|
||||||
|
logReq("GET", "/api/instances", 200);
|
||||||
|
return c.json(list);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/instances", async (c) => {
|
||||||
|
let body: { group?: string; command?: string };
|
||||||
|
try {
|
||||||
|
body = await c.req.json<{ group?: string; command?: string }>();
|
||||||
|
} catch {
|
||||||
|
logReq("POST", "/api/instances", 400);
|
||||||
|
return c.json({ error: "invalid JSON body" }, 400);
|
||||||
|
}
|
||||||
|
if (!body.group?.trim() || !body.command?.trim()) {
|
||||||
|
logReq("POST", "/api/instances", 400);
|
||||||
|
return c.json({ error: "group and command are required" }, 400);
|
||||||
|
}
|
||||||
|
const instance = manager.create(body.group.trim(), body.command.trim());
|
||||||
|
logReq("POST", `/api/instances group=${body.group}`, 201);
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
id: instance.id,
|
||||||
|
group: instance.group,
|
||||||
|
command: instance.command,
|
||||||
|
status: instance.status,
|
||||||
|
pid: instance.pid,
|
||||||
|
startTime: instance.startTime,
|
||||||
|
exitCode: instance.exitCode,
|
||||||
|
},
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/instances/:id/stop", (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const inst = manager.get(id);
|
||||||
|
if (!inst) {
|
||||||
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
if (inst.status !== "running") {
|
||||||
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
|
||||||
|
return c.json({ error: "not running" }, 400);
|
||||||
|
}
|
||||||
|
manager.stop(inst.id);
|
||||||
|
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/instances/:id", (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const inst = manager.get(id);
|
||||||
|
if (!inst) {
|
||||||
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
if (inst.status === "running") {
|
||||||
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
|
||||||
|
return c.json({ error: "still running" }, 400);
|
||||||
|
}
|
||||||
|
manager.remove(inst.id);
|
||||||
|
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/instances/:id/logs", (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const inst = manager.get(id);
|
||||||
|
if (!inst) {
|
||||||
|
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
|
||||||
|
return c.json({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const send = (data: string) => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(data));
|
||||||
|
} catch {
|
||||||
|
// stream closed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// send historical logs
|
||||||
|
for (const log of inst.logs) {
|
||||||
|
send(`data: ${JSON.stringify(log)}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribe to new logs
|
||||||
|
const unsub = manager.subscribe(inst.id, (entry) => {
|
||||||
|
send(`data: ${JSON.stringify(entry)}\n\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// keepalive every 15s
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
send(": keepalive\n\n");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
unsub();
|
||||||
|
clearInterval(keepalive);
|
||||||
|
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {
|
||||||
|
// already closed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catch-all: log unmatched routes for debugging
|
||||||
|
app.all("*", (c) => {
|
||||||
|
logReq(c.req.method, c.req.path, 404);
|
||||||
|
return c.json({ error: "not found", path: c.req.path }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
34
packages/acp-link/src/manager/types.ts
Normal file
34
packages/acp-link/src/manager/types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type InstanceStatus = "running" | "stopped" | "failed";
|
||||||
|
|
||||||
|
export interface AcpInstance {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
command: string;
|
||||||
|
status: InstanceStatus;
|
||||||
|
pid: number | undefined;
|
||||||
|
startTime: number;
|
||||||
|
exitCode: number | null;
|
||||||
|
logs: LogEntry[];
|
||||||
|
subscribers: Set<(entry: LogEntry) => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
stream: "stdout" | "stderr";
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInstanceRequest {
|
||||||
|
group: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceSummary {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
command: string;
|
||||||
|
status: InstanceStatus;
|
||||||
|
pid: number | undefined;
|
||||||
|
startTime: number;
|
||||||
|
exitCode: number | null;
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ export interface ServerConfig {
|
|||||||
https?: boolean;
|
https?: boolean;
|
||||||
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
/** Default permission mode for new sessions (e.g. "auto", "default", "bypassPermissions") */
|
||||||
permissionMode?: string;
|
permissionMode?: string;
|
||||||
|
/** Channel group ID for RCS registration */
|
||||||
|
group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending permission request
|
// Pending permission request
|
||||||
@@ -608,11 +610,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
// Initialize RCS upstream client if configured
|
// Initialize RCS upstream client if configured
|
||||||
const rcsUrl = process.env.ACP_RCS_URL;
|
const rcsUrl = process.env.ACP_RCS_URL;
|
||||||
const rcsToken = process.env.ACP_RCS_TOKEN;
|
const rcsToken = process.env.ACP_RCS_TOKEN;
|
||||||
|
const rcsGroup = config.group || process.env.ACP_RCS_GROUP;
|
||||||
|
if (rcsGroup && !/^[a-zA-Z0-9_-]+$/.test(rcsGroup)) {
|
||||||
|
throw new Error(`Invalid ACP_RCS_GROUP "${rcsGroup}": only letters, digits, hyphens, and underscores are allowed`);
|
||||||
|
}
|
||||||
if (rcsUrl) {
|
if (rcsUrl) {
|
||||||
rcsUpstream = new RcsUpstreamClient({
|
rcsUpstream = new RcsUpstreamClient({
|
||||||
rcsUrl,
|
rcsUrl,
|
||||||
apiToken: rcsToken || "",
|
apiToken: rcsToken || "",
|
||||||
agentName: command,
|
agentName: command,
|
||||||
|
channelGroupId: rcsGroup || undefined,
|
||||||
maxSessions: 1,
|
maxSessions: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -876,20 +883,16 @@ export async function startServer(config: ServerConfig): Promise<void> {
|
|||||||
authEnabled: !!AUTH_TOKEN,
|
authEnabled: !!AUTH_TOKEN,
|
||||||
}, "started");
|
}, "started");
|
||||||
|
|
||||||
|
// Graceful shutdown — close RCS upstream
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (rcsUpstream) {
|
||||||
|
await rcsUpstream.close();
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
// Keep the server running
|
// Keep the server running
|
||||||
await new Promise(() => {});
|
await new Promise(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown — close RCS upstream on process exit
|
|
||||||
process.on("SIGINT", async () => {
|
|
||||||
if (rcsUpstream) {
|
|
||||||
await rcsUpstream.close();
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
process.on("SIGTERM", async () => {
|
|
||||||
if (rcsUpstream) {
|
|
||||||
await rcsUpstream.close();
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "esnext",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
|
|
||||||
// Node.js module resolution
|
// Node.js module resolution
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "bundler",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
|
|
||||||
// Output
|
// Output
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"types": ["bun"],
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||||
|
|||||||
@@ -90,9 +90,20 @@ export function getUuidFromRequest(c: Context): string | undefined {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* UUID-based auth for Web UI routes (no-login mode).
|
* UUID-based auth for Web UI routes (no-login mode).
|
||||||
* Requires a UUID in query param or header, injects it into context as c.set("uuid").
|
* Accepts UUID in query param/header, OR a valid API key via Authorization header.
|
||||||
*/
|
*/
|
||||||
export async function uuidAuth(c: Context, next: Next) {
|
export async function uuidAuth(c: Context, next: Next) {
|
||||||
|
// Try API key auth via Authorization header
|
||||||
|
const bearer = extractBearerToken(c);
|
||||||
|
if (bearer && validateApiKey(bearer)) {
|
||||||
|
// Valid API key — generate a stable UUID from the key for downstream use
|
||||||
|
const uuid = getUuidFromRequest(c);
|
||||||
|
c.set("uuid", uuid || bearer);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to UUID auth
|
||||||
const uuid = getUuidFromRequest(c);
|
const uuid = getUuidFromRequest(c);
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
||||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||||
|
import { storeBindSession } from "../../store";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -9,6 +10,13 @@ app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
|||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const username = c.get("username");
|
const username = c.get("username");
|
||||||
const result = registerEnvironment({ ...body, username });
|
const result = registerEnvironment({ ...body, username });
|
||||||
|
// Bind ACP session to the group ID so the web UI can find it by group
|
||||||
|
if (result.session_id) {
|
||||||
|
const groupId = body.bridge_id as string | undefined;
|
||||||
|
if (groupId) {
|
||||||
|
storeBindSession(result.session_id, groupId);
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.json(result, 200);
|
return c.json(result, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
storeUpdateEnvironment,
|
storeUpdateEnvironment,
|
||||||
storeListActiveEnvironments,
|
storeListActiveEnvironments,
|
||||||
storeListActiveEnvironmentsByUsername,
|
storeListActiveEnvironmentsByUsername,
|
||||||
|
storeListSessionsByEnvironment,
|
||||||
} from "../store";
|
} from "../store";
|
||||||
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
||||||
import type { EnvironmentRecord } from "../store";
|
import type { EnvironmentRecord } from "../store";
|
||||||
@@ -20,6 +21,7 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
last_poll_at: row.lastPollAt ? row.lastPollAt.getTime() / 1000 : null,
|
||||||
worker_type: row.workerType,
|
worker_type: row.workerType,
|
||||||
|
channel_group_id: row.bridgeId,
|
||||||
capabilities: row.capabilities,
|
capabilities: row.capabilities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -41,14 +43,19 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
|
|||||||
});
|
});
|
||||||
|
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
// ACP agents: auto-create a session so they appear in the dashboard sessions list
|
// ACP agents: reuse existing session or create one
|
||||||
if (workerType === "acp") {
|
if (workerType === "acp") {
|
||||||
const session = storeCreateSession({
|
const existing = storeListSessionsByEnvironment(record.id);
|
||||||
environmentId: record.id,
|
if (existing.length > 0) {
|
||||||
title: req.machine_name || "ACP Agent",
|
sessionId = existing[0].id;
|
||||||
source: "acp",
|
} else {
|
||||||
});
|
const session = storeCreateSession({
|
||||||
sessionId = session.id;
|
environmentId: record.id,
|
||||||
|
title: req.machine_name || "ACP Agent",
|
||||||
|
source: "acp",
|
||||||
|
});
|
||||||
|
sessionId = session.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
||||||
|
|||||||
@@ -98,13 +98,14 @@ export function storeDeleteToken(token: string): boolean {
|
|||||||
|
|
||||||
// ---------- Environment ----------
|
// ---------- Environment ----------
|
||||||
|
|
||||||
/** Find an active environment by machineName (optionally filtered by workerType) */
|
/** Find an active or offline environment by machineName (optionally filtered by workerType).
|
||||||
|
* Includes "offline" so ACP agents can be reused on reconnect. */
|
||||||
export function storeFindEnvironmentByMachineName(
|
export function storeFindEnvironmentByMachineName(
|
||||||
machineName: string,
|
machineName: string,
|
||||||
workerType?: string,
|
workerType?: string,
|
||||||
): EnvironmentRecord | undefined {
|
): EnvironmentRecord | undefined {
|
||||||
for (const rec of environments.values()) {
|
for (const rec of environments.values()) {
|
||||||
if (rec.machineName === machineName && rec.status === "active") {
|
if (rec.machineName === machineName && (rec.status === "active" || rec.status === "offline")) {
|
||||||
if (!workerType || rec.workerType === workerType) {
|
if (!workerType || rec.workerType === workerType) {
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
@@ -313,12 +314,32 @@ export function storeGetSessionOwners(sessionId: string): Set<string> | undefine
|
|||||||
|
|
||||||
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||||
const result: SessionRecord[] = [];
|
const result: SessionRecord[] = [];
|
||||||
|
const resultIds = new Set<string>();
|
||||||
|
|
||||||
|
// Collect sessions already owned by this UUID
|
||||||
for (const [sessionId, owners] of sessionOwners) {
|
for (const [sessionId, owners] of sessionOwners) {
|
||||||
if (owners.has(uuid)) {
|
if (owners.has(uuid)) {
|
||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
if (session) result.push(session);
|
if (session) {
|
||||||
|
result.push(session);
|
||||||
|
resultIds.add(sessionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
|
||||||
|
for (const [sessionId, session] of sessions) {
|
||||||
|
if (resultIds.has(sessionId)) continue;
|
||||||
|
const owners = sessionOwners.get(sessionId);
|
||||||
|
// No owners map entry at all, or empty owners set
|
||||||
|
const isOrphaned = !owners || owners.size === 0;
|
||||||
|
if (isOrphaned) {
|
||||||
|
storeBindSession(sessionId, uuid);
|
||||||
|
result.push(session);
|
||||||
|
resultIds.add(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export interface EnvironmentResponse {
|
|||||||
username: string | null;
|
username: string | null;
|
||||||
last_poll_at: number | null;
|
last_poll_at: number | null;
|
||||||
worker_type?: string;
|
worker_type?: string;
|
||||||
|
channel_group_id?: string | null;
|
||||||
capabilities?: Record<string, unknown> | null;
|
capabilities?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
import { useState, useEffect, useCallback, lazy, Suspense } from "react";
|
||||||
import { Navbar } from "./components/Navbar";
|
import { Navbar } from "./components/Navbar";
|
||||||
import { IdentityPanel } from "./components/IdentityPanel";
|
import { IdentityPanel } from "./components/IdentityPanel";
|
||||||
|
import { TokenManagerDialog } from "./components/TokenManagerDialog";
|
||||||
import { ThemeProvider } from "./lib/theme";
|
import { ThemeProvider } from "./lib/theme";
|
||||||
import { getUuid, setUuid, apiBind } from "./api/client";
|
import { getUuid, setUuid, apiBind, setActiveApiToken } from "./api/client";
|
||||||
import { ACPDirectView } from "./components/ACPDirectView";
|
import { ACPDirectView } from "./components/ACPDirectView";
|
||||||
|
import { useTokens } from "./hooks/useTokens";
|
||||||
|
|
||||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||||
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
|
const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail })));
|
||||||
@@ -11,7 +13,18 @@ const SessionDetail = lazy(() => import("./pages/SessionDetail").then((m) => ({
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||||
const [identityOpen, setIdentityOpen] = useState(false);
|
const [identityOpen, setIdentityOpen] = useState(false);
|
||||||
|
const [tokenDialogOpen, setTokenDialogOpen] = useState(false);
|
||||||
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
|
const [acpDirect, setAcpDirect] = useState<{ url: string; token: string } | null>(null);
|
||||||
|
const { tokens, activeTokenId, activeLabel, activeTokenValue, setActiveTokenId, addToken, removeToken, updateToken } = useTokens();
|
||||||
|
|
||||||
|
// Sync active token to API client
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveApiToken(activeTokenValue);
|
||||||
|
}, [activeTokenValue]);
|
||||||
|
|
||||||
|
const handleSetActiveToken = useCallback((id: string) => {
|
||||||
|
setActiveTokenId(id);
|
||||||
|
}, [setActiveTokenId]);
|
||||||
|
|
||||||
// Simple hash-based router
|
// Simple hash-based router
|
||||||
const parseRoute = useCallback(() => {
|
const parseRoute = useCallback(() => {
|
||||||
@@ -97,6 +110,8 @@ export default function App() {
|
|||||||
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
<div className="flex h-screen flex-col bg-surface-0 text-text-primary">
|
||||||
<Navbar
|
<Navbar
|
||||||
onIdentityClick={() => setIdentityOpen(true)}
|
onIdentityClick={() => setIdentityOpen(true)}
|
||||||
|
onTokenClick={() => setTokenDialogOpen(true)}
|
||||||
|
activeTokenLabel={currentSessionId ? undefined : activeLabel}
|
||||||
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
|
sessionTitle={currentSessionId || (acpDirect ? "ACP" : undefined)}
|
||||||
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
|
onBack={(currentSessionId || acpDirect) ? navigateToDashboard : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -114,6 +129,17 @@ export default function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
<IdentityPanel open={identityOpen} onClose={() => setIdentityOpen(false)} />
|
||||||
|
|
||||||
|
<TokenManagerDialog
|
||||||
|
open={tokenDialogOpen}
|
||||||
|
onClose={() => setTokenDialogOpen(false)}
|
||||||
|
tokens={tokens}
|
||||||
|
activeTokenId={activeTokenId}
|
||||||
|
onSetActive={handleSetActiveToken}
|
||||||
|
onAdd={addToken}
|
||||||
|
onRemove={removeToken}
|
||||||
|
onUpdate={updateToken}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,11 +24,35 @@ export function setUuid(uuid: string): void {
|
|||||||
localStorage.setItem("rcs_uuid", uuid);
|
localStorage.setItem("rcs_uuid", uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Active API token for Authorization header (set by useTokens) */
|
||||||
|
let _activeToken: string | null = null;
|
||||||
|
|
||||||
|
export function setActiveApiToken(token: string | null): void {
|
||||||
|
_activeToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveApiToken(): string | null {
|
||||||
|
return _activeToken;
|
||||||
|
}
|
||||||
|
|
||||||
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
const uuid = getUuid();
|
|
||||||
const sep = path.includes("?") ? "&" : "?";
|
if (_activeToken) {
|
||||||
const url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
headers["Authorization"] = `Bearer ${_activeToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When using Bearer token auth, backend derives UUID from the token — no need to send query param.
|
||||||
|
// Otherwise fall back to UUID auth via query param.
|
||||||
|
let url: string;
|
||||||
|
if (_activeToken) {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(_activeToken)}`;
|
||||||
|
} else {
|
||||||
|
const uuid = getUuid();
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
url = `${BASE}${path}${sep}uuid=${encodeURIComponent(uuid)}`;
|
||||||
|
}
|
||||||
const opts: RequestInit = { method, headers };
|
const opts: RequestInit = { method, headers };
|
||||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { ThemeToggle } from "../../components/ui/theme-toggle";
|
import { ThemeToggle } from "../../components/ui/theme-toggle";
|
||||||
import { ChevronLeft, LayoutGrid, UserPlus } from "lucide-react";
|
import { ChevronLeft, LayoutGrid, UserPlus, KeyRound } from "lucide-react";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
onIdentityClick: () => void;
|
onIdentityClick: () => void;
|
||||||
|
onTokenClick: () => void;
|
||||||
|
activeTokenLabel?: string | null;
|
||||||
sessionTitle?: string;
|
sessionTitle?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
|
export function Navbar({ onIdentityClick, onTokenClick, activeTokenLabel, sessionTitle, onBack }: NavbarProps) {
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
<nav className="sticky top-0 z-40 border-b border-border bg-surface-1/80 backdrop-blur-md">
|
||||||
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
|
<div className="mx-auto flex h-11 sm:h-12 max-w-5xl items-center justify-between px-3 sm:px-4">
|
||||||
@@ -51,6 +53,19 @@ export function Navbar({ onIdentityClick, sessionTitle, onBack }: NavbarProps) {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
<button
|
||||||
|
onClick={onTokenClick}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm transition-colors",
|
||||||
|
activeTokenLabel
|
||||||
|
? "bg-brand/10 text-brand hover:bg-brand/20"
|
||||||
|
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
|
||||||
|
)}
|
||||||
|
title="Token Manager"
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline max-w-24 truncate">{activeTokenLabel || "No Token"}</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onIdentityClick}
|
onClick={onIdentityClick}
|
||||||
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
className="flex items-center gap-1 rounded-md px-2 sm:px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-2 hover:text-text-primary transition-colors"
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { TokenEntry } from "../hooks/useTokens";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { Check, Copy, Eye, EyeOff, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface TokenManagerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
tokens: TokenEntry[];
|
||||||
|
activeTokenId: string | null;
|
||||||
|
onSetActive: (id: string) => void;
|
||||||
|
onAdd: (token: string, label: string) => string | null;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
onUpdate: (id: string, label: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TokenManagerDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
tokens,
|
||||||
|
activeTokenId,
|
||||||
|
onSetActive,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onUpdate,
|
||||||
|
}: TokenManagerDialogProps) {
|
||||||
|
const [newToken, setNewToken] = useState("");
|
||||||
|
const [newLabel, setNewLabel] = useState("");
|
||||||
|
const [addError, setAddError] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editLabel, setEditLabel] = useState("");
|
||||||
|
const [visibleTokenId, setVisibleTokenId] = useState<string | null>(null);
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCopy = (id: string, token: string) => {
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
setCopiedId(id);
|
||||||
|
setTimeout(() => setCopiedId(null), 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
const error = onAdd(newToken, newLabel);
|
||||||
|
if (error) {
|
||||||
|
setAddError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNewToken("");
|
||||||
|
setNewLabel("");
|
||||||
|
setAddError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (entry: TokenEntry) => {
|
||||||
|
setEditingId(entry.id);
|
||||||
|
setEditLabel(entry.label);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = (id: string) => {
|
||||||
|
onUpdate(id, editLabel.trim() || "Unnamed");
|
||||||
|
setEditingId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSwitch = (id: string) => {
|
||||||
|
onSetActive(id);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md rounded-2xl border-border bg-surface-1 p-6 shadow-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="font-display text-lg font-semibold text-text-primary">
|
||||||
|
Token Manager
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm text-text-muted">
|
||||||
|
Manage API tokens for RCS authentication.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Token list */}
|
||||||
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
|
{tokens.map((entry) => (
|
||||||
|
<div key={entry.id} className="group flex items-center gap-1">
|
||||||
|
{editingId === entry.id ? (
|
||||||
|
<div className="flex flex-1 items-center gap-2 rounded-lg bg-surface-2 px-3 py-1.5">
|
||||||
|
<input
|
||||||
|
value={editLabel}
|
||||||
|
onChange={(e) => setEditLabel(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleSaveEdit(entry.id);
|
||||||
|
if (e.key === "Escape") setEditingId(null);
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-text-primary focus:border-brand focus:outline-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveEdit(entry.id)}
|
||||||
|
className="text-brand hover:text-brand-light transition-colors"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
className="text-text-muted hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSwitch(entry.id)}
|
||||||
|
className={`flex flex-1 items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||||
|
activeTokenId === entry.id
|
||||||
|
? "bg-brand/10 text-brand"
|
||||||
|
: "text-text-secondary hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start min-w-0">
|
||||||
|
<span className="font-medium truncate w-full">{entry.label}</span>
|
||||||
|
<span className="text-xs text-text-muted font-mono">
|
||||||
|
{visibleTokenId === entry.id
|
||||||
|
? entry.token
|
||||||
|
: `${entry.token.slice(0, 6)}${"\u2022".repeat(6)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activeTokenId === entry.id && <Check className="h-4 w-4 flex-shrink-0" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setVisibleTokenId(visibleTokenId === entry.id ? null : entry.id)}
|
||||||
|
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
||||||
|
title="Toggle token visibility"
|
||||||
|
>
|
||||||
|
{visibleTokenId === entry.id ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(entry.id, entry.token)}
|
||||||
|
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
||||||
|
title="Copy token"
|
||||||
|
>
|
||||||
|
{copiedId === entry.id ? <Check className="h-3.5 w-3.5 text-status-active" /> : <Copy className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(entry)}
|
||||||
|
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-text-primary transition-all"
|
||||||
|
title="Edit label"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(entry.id)}
|
||||||
|
className="rounded p-1 text-text-muted opacity-0 group-hover:opacity-100 hover:text-status-error transition-all"
|
||||||
|
title="Delete token"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{tokens.length === 0 && (
|
||||||
|
<div className="py-4 text-center text-sm text-text-muted">
|
||||||
|
No tokens saved yet. Add one below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add form */}
|
||||||
|
<div className="border-t border-border pt-4 space-y-3">
|
||||||
|
<div className="text-sm font-medium text-text-secondary">Add Token</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewToken(e.target.value);
|
||||||
|
setAddError("");
|
||||||
|
}}
|
||||||
|
placeholder="API Token"
|
||||||
|
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none font-mono"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAdd();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newLabel}
|
||||||
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
|
placeholder="Label (optional)"
|
||||||
|
className="flex-1 rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-brand focus:outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAdd();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newToken.trim()}
|
||||||
|
className="rounded-lg bg-brand px-3 py-2 text-white hover:bg-brand-light disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{addError && <div className="text-xs text-status-error">{addError}</div>}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
packages/remote-control-server/web/src/hooks/useTokens.ts
Normal file
120
packages/remote-control-server/web/src/hooks/useTokens.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export interface TokenEntry {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKENS_KEY = "rcs_tokens";
|
||||||
|
const ACTIVE_TOKEN_KEY = "rcs_uuid";
|
||||||
|
const DEFAULT_ID = "__default__";
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `tk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the existing rcs_uuid is present as the default token entry */
|
||||||
|
function ensureDefault(tokens: TokenEntry[]): TokenEntry[] {
|
||||||
|
if (tokens.some((t) => t.id === DEFAULT_ID)) return tokens;
|
||||||
|
let uuid: string | null = null;
|
||||||
|
try {
|
||||||
|
uuid = localStorage.getItem("rcs_uuid");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (!uuid) return tokens;
|
||||||
|
return [{ id: DEFAULT_ID, token: uuid, label: "Default" }, ...tokens];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTokens(): TokenEntry[] {
|
||||||
|
let tokens: TokenEntry[] = [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TOKENS_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (Array.isArray(parsed)) tokens = parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return ensureDefault(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadActiveTokenId(tokens: TokenEntry[]): string {
|
||||||
|
// Try saved active token
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(ACTIVE_TOKEN_KEY);
|
||||||
|
if (saved && tokens.some((t) => t.id === saved)) return saved;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
// Fall back to default (rcs_uuid) entry
|
||||||
|
const defaultEntry = tokens.find((t) => t.id === DEFAULT_ID);
|
||||||
|
if (defaultEntry) return defaultEntry.id;
|
||||||
|
// Fall back to first entry
|
||||||
|
return tokens[0]?.id ?? DEFAULT_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTokens() {
|
||||||
|
const [tokens, setTokens] = useState<TokenEntry[]>(loadTokens);
|
||||||
|
const [activeTokenId, setActiveTokenIdState] = useState<string>(() => loadActiveTokenId(loadTokens()));
|
||||||
|
|
||||||
|
const persistTokens = useCallback((next: TokenEntry[]) => {
|
||||||
|
setTokens(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TOKENS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActiveTokenId = useCallback((id: string) => {
|
||||||
|
setActiveTokenIdState(id);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ACTIVE_TOKEN_KEY, id);
|
||||||
|
location.reload(); // Reload to ensure api client picks up new token from localStorage
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addToken = useCallback((token: string, label: string): string | null => {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (!trimmed) return "Token is required";
|
||||||
|
const entry: TokenEntry = { id: generateId(), token: trimmed, label: label.trim() || trimmed.slice(0, 8) };
|
||||||
|
const next = [...tokens, entry];
|
||||||
|
persistTokens(next);
|
||||||
|
return null;
|
||||||
|
}, [tokens, persistTokens]);
|
||||||
|
|
||||||
|
const removeToken = useCallback((id: string) => {
|
||||||
|
if (id === DEFAULT_ID) return; // Cannot remove default
|
||||||
|
const next = tokens.filter((t) => t.id !== id);
|
||||||
|
persistTokens(next);
|
||||||
|
if (activeTokenId === id) {
|
||||||
|
setActiveTokenId(DEFAULT_ID);
|
||||||
|
}
|
||||||
|
}, [tokens, persistTokens, activeTokenId, setActiveTokenId]);
|
||||||
|
|
||||||
|
const updateToken = useCallback((id: string, label: string) => {
|
||||||
|
const next = tokens.map((t) => t.id === id ? { ...t, label } : t);
|
||||||
|
persistTokens(next);
|
||||||
|
}, [tokens, persistTokens]);
|
||||||
|
|
||||||
|
const activeToken = tokens.find((t) => t.id === activeTokenId) ?? tokens[0] ?? null;
|
||||||
|
const activeLabel = activeToken?.label ?? "Default";
|
||||||
|
const activeTokenValue = activeToken?.token ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokens,
|
||||||
|
activeTokenId,
|
||||||
|
activeToken,
|
||||||
|
activeLabel,
|
||||||
|
activeTokenValue,
|
||||||
|
setActiveTokenId,
|
||||||
|
addToken,
|
||||||
|
removeToken,
|
||||||
|
updateToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export interface Environment {
|
|||||||
status: string;
|
status: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
worker_type?: string;
|
worker_type?: string;
|
||||||
|
channel_group_id?: string | null;
|
||||||
capabilities?: Record<string, unknown> | null;
|
capabilities?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
packages/weixin/package.json
Normal file
11
packages/weixin/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
packages/weixin/src/__tests__/accounts.test.ts
Normal file
54
packages/weixin/src/__tests__/accounts.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
90
packages/weixin/src/__tests__/media.test.ts
Normal file
90
packages/weixin/src/__tests__/media.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
22
packages/weixin/src/__tests__/monitor.test.ts
Normal file
22
packages/weixin/src/__tests__/monitor.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
packages/weixin/src/__tests__/pairing.test.ts
Normal file
78
packages/weixin/src/__tests__/pairing.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
43
packages/weixin/src/__tests__/permissions.test.ts
Normal file
43
packages/weixin/src/__tests__/permissions.test.ts
Normal file
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
32
packages/weixin/src/__tests__/send.test.ts
Normal file
32
packages/weixin/src/__tests__/send.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
57
packages/weixin/src/accounts.ts
Normal file
57
packages/weixin/src/accounts.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
148
packages/weixin/src/api.ts
Normal file
148
packages/weixin/src/api.ts
Normal file
@@ -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<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-WECHAT-UIN': randomUin(),
|
||||||
|
}
|
||||||
|
if (token) {
|
||||||
|
headers.AuthorizationType = 'ilink_bot_token'
|
||||||
|
headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post<T>(
|
||||||
|
baseUrl: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
token?: string,
|
||||||
|
timeoutMs = 40_000,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
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<GetUpdatesResp> {
|
||||||
|
const body: GetUpdatesReq = {
|
||||||
|
get_updates_buf: getUpdatesBuf,
|
||||||
|
base_info: baseInfo(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await post<GetUpdatesResp>(
|
||||||
|
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<void> {
|
||||||
|
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<GetUploadUrlReq, 'base_info'>,
|
||||||
|
): Promise<GetUploadUrlResp> {
|
||||||
|
return post<GetUploadUrlResp>(
|
||||||
|
baseUrl,
|
||||||
|
'/ilink/bot/getuploadurl',
|
||||||
|
{ ...params, base_info: baseInfo() },
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(
|
||||||
|
baseUrl: string,
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
contextToken?: string,
|
||||||
|
): Promise<GetConfigResp> {
|
||||||
|
return post<GetConfigResp>(
|
||||||
|
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<SendTypingReq, 'base_info'>,
|
||||||
|
): Promise<SendTypingResp> {
|
||||||
|
return post<SendTypingResp>(
|
||||||
|
baseUrl,
|
||||||
|
'/ilink/bot/sendtyping',
|
||||||
|
{ ...req, base_info: baseInfo() },
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
119
packages/weixin/src/cli.ts
Normal file
119
packages/weixin/src/cli.ts
Normal file
@@ -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 <code>',
|
||||||
|
'',
|
||||||
|
'Session enablement:',
|
||||||
|
' ccb --channels plugin:weixin@builtin',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runLogin(clear = false): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
115
packages/weixin/src/index.ts
Normal file
115
packages/weixin/src/index.ts
Normal file
@@ -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'
|
||||||
134
packages/weixin/src/login.ts
Normal file
134
packages/weixin/src/login.ts
Normal file
@@ -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<void> {
|
||||||
|
const output = await qrToString(qrcodeUrl, {
|
||||||
|
type: 'terminal',
|
||||||
|
errorCorrectionLevel: 'L',
|
||||||
|
small: true,
|
||||||
|
})
|
||||||
|
process.stderr.write(`${output}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startLogin(apiBaseUrl: string): Promise<QRCodeResult> {
|
||||||
|
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<LoginResult> {
|
||||||
|
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.' }
|
||||||
|
}
|
||||||
163
packages/weixin/src/media.ts
Normal file
163
packages/weixin/src/media.ts
Normal file
@@ -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<Buffer> {
|
||||||
|
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<UploadedFileInfo> {
|
||||||
|
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<string> {
|
||||||
|
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
|
||||||
|
}
|
||||||
303
packages/weixin/src/monitor.ts
Normal file
303
packages/weixin/src/monitor.ts
Normal file
@@ -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<string, string>()
|
||||||
|
|
||||||
|
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<void>
|
||||||
|
|
||||||
|
export type PermissionResponse = {
|
||||||
|
requestId: string
|
||||||
|
behavior: 'allow' | 'deny'
|
||||||
|
fromUserId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnPermissionResponseCallback = (
|
||||||
|
response: PermissionResponse,
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
101
packages/weixin/src/pairing.ts
Normal file
101
packages/weixin/src/pairing.ts
Normal file
@@ -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<string, PendingEntry> {
|
||||||
|
const path = pendingPath()
|
||||||
|
if (!existsSync(path)) return {}
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8')) as Record<string, PendingEntry>
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePending(data: Record<string, PendingEntry>): 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
|
||||||
|
}
|
||||||
83
packages/weixin/src/permissions.ts
Normal file
83
packages/weixin/src/permissions.ts
Normal file
@@ -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<string, PendingPermissionRequest>()
|
||||||
|
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
|
||||||
|
}
|
||||||
180
packages/weixin/src/send.ts
Normal file
180
packages/weixin/src/send.ts
Normal file
@@ -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<string> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
353
packages/weixin/src/server.ts
Normal file
353
packages/weixin/src/server.ts
Normal file
@@ -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<void>
|
||||||
|
shutdown1PEventLogging(): Promise<void>
|
||||||
|
logForDebugging(message: string): void
|
||||||
|
registerPermissionHandler(
|
||||||
|
server: Server,
|
||||||
|
handler: (request: ChannelPermissionRequestParams) => Promise<void>,
|
||||||
|
): 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 <channel source="plugin:weixin:weixin" chat_id="..." sender_id="...">. 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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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()
|
||||||
|
}
|
||||||
178
packages/weixin/src/types.ts
Normal file
178
packages/weixin/src/types.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
5
packages/weixin/tsconfig.json
Normal file
5
packages/weixin/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAllowedChannels,
|
getAllowedChannels,
|
||||||
getHasDevChannels,
|
getHasDevChannels,
|
||||||
} from '../../bootstrap/state.js'
|
} from '../../bootstrap/state.js'
|
||||||
|
import { getBuiltinPlugins } from '../../plugins/builtinPlugins.js'
|
||||||
import { Box, Text } from '@anthropic/ink'
|
import { Box, Text } from '@anthropic/ink'
|
||||||
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
import { getMcpConfigsByScope } from '../../services/mcp/config.js'
|
||||||
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'
|
||||||
@@ -75,25 +76,39 @@ function formatEntry(c: ChannelEntry): string {
|
|||||||
|
|
||||||
type Unmatched = { entry: ChannelEntry; why: string }
|
type Unmatched = { entry: ChannelEntry; why: string }
|
||||||
|
|
||||||
function findUnmatched(
|
type FindUnmatchedDeps = {
|
||||||
|
configuredServerNames?: ReadonlySet<string>
|
||||||
|
installedPluginIds?: ReadonlySet<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findUnmatched(
|
||||||
entries: readonly ChannelEntry[],
|
entries: readonly ChannelEntry[],
|
||||||
|
deps?: FindUnmatchedDeps,
|
||||||
): Unmatched[] {
|
): Unmatched[] {
|
||||||
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
// Server-kind: build one Set from all scopes up front. getMcpConfigsByScope
|
||||||
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
// is not cached (project scope walks the dir tree); getMcpConfigByName would
|
||||||
// redo that walk per entry.
|
// redo that walk per entry.
|
||||||
const scopes = ['enterprise', 'user', 'project', 'local'] as const
|
const configured = deps?.configuredServerNames ?? (() => {
|
||||||
const configured = new Set<string>()
|
const scopes = ['enterprise', 'user', 'project', 'local'] as const
|
||||||
for (const scope of scopes) {
|
const names = new Set<string>()
|
||||||
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
for (const scope of scopes) {
|
||||||
configured.add(name)
|
for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) {
|
||||||
|
names.add(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return names
|
||||||
|
})()
|
||||||
|
|
||||||
// Plugin-kind installed check: installed_plugins.json keys are
|
// Plugin-kind installed check: installed_plugins.json keys are
|
||||||
// `name@marketplace`. loadInstalledPluginsV2 is cached.
|
// `name@marketplace`. loadInstalledPluginsV2 is cached.
|
||||||
const installedPluginIds = new Set(
|
const installedPluginIds = deps?.installedPluginIds ?? (() => {
|
||||||
Object.keys(loadInstalledPluginsV2().plugins),
|
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[] = []
|
const out: Unmatched[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|||||||
17
src/components/LogoV2/__tests__/ChannelsNotice.test.ts
Normal file
17
src/components/LogoV2/__tests__/ChannelsNotice.test.ts
Normal file
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -140,6 +140,31 @@ async function main(): Promise<void> {
|
|||||||
return
|
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=<kind>` (internal — supervisor spawns this).
|
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||||
// Must come before the daemon subcommand check: spawned per-worker, so
|
// Must come before the daemon subcommand check: spawned per-worker, so
|
||||||
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
// perf-sensitive. No enableConfigs(), no analytics sinks at this layer —
|
||||||
|
|||||||
@@ -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: '<channel source="plugin:weixin:weixin" chat_id="user-1" sender_id="user-1">\nhello\n</channel>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
import { CHANNEL_TAG } from 'src/constants/xml.js'
|
||||||
import { logForDebugging } from 'src/utils/debug.js'
|
import { logForDebugging } from 'src/utils/debug.js'
|
||||||
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
import { getAllowedChannels } from '../../../bootstrap/state.js'
|
||||||
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
import type { BridgePermissionCallbacks } from '../../../bridge/bridgePermissionCallbacks.js'
|
||||||
@@ -46,6 +47,76 @@ type InteractivePermissionParams = {
|
|||||||
channelCallbacks?: ChannelPermissionCallbacks
|
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.
|
* Handles the interactive (main-agent) permission flow.
|
||||||
*
|
*
|
||||||
@@ -420,6 +491,17 @@ function handleInteractivePermission(
|
|||||||
description,
|
description,
|
||||||
input_preview: truncateForPreview(displayInput),
|
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) {
|
for (const client of channelClients) {
|
||||||
if (client.type !== 'connected') continue // refine for TS
|
if (client.type !== 'connected') continue // refine for TS
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
* 2. Call registerBuiltinPlugin() with the plugin definition here
|
* 2. Call registerBuiltinPlugin() with the plugin definition here
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { registerWeixinBuiltinPlugin } from './weixin.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize built-in plugins. Called during CLI startup.
|
* Initialize built-in plugins. Called during CLI startup.
|
||||||
*/
|
*/
|
||||||
export function initBuiltinPlugins(): void {
|
export function initBuiltinPlugins(): void {
|
||||||
// No built-in plugins registered yet — this is the scaffolding for
|
registerWeixinBuiltinPlugin()
|
||||||
// migrating bundled skills that should be user-toggleable.
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/plugins/bundled/weixin.ts
Normal file
21
src/plugins/bundled/weixin.ts
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
17
src/services/mcp/__tests__/channelAllowlist.test.ts
Normal file
17
src/services/mcp/__tests__/channelAllowlist.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ mock.module("src/services/analytics/growthbook.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
filterPermissionRelayClients,
|
||||||
shortRequestId,
|
shortRequestId,
|
||||||
truncateForPreview,
|
truncateForPreview,
|
||||||
PERMISSION_REPLY_RE,
|
PERMISSION_REPLY_RE,
|
||||||
@@ -160,3 +161,34 @@ describe("createChannelPermissionCallbacks", () => {
|
|||||||
expect(received?.behavior).toBe("deny");
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod/v4'
|
import { z } from 'zod/v4'
|
||||||
|
import { BUILTIN_MARKETPLACE_NAME } from '../../plugins/builtinPlugins.js'
|
||||||
import { lazySchema } from '../../utils/lazySchema.js'
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
||||||
@@ -68,6 +69,9 @@ export function isChannelAllowlisted(
|
|||||||
if (!pluginSource) return false
|
if (!pluginSource) return false
|
||||||
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
||||||
if (!marketplace) return false
|
if (!marketplace) return false
|
||||||
|
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return getChannelAllowlist().some(
|
return getChannelAllowlist().some(
|
||||||
e => e.plugin === name && e.marketplace === marketplace,
|
e => e.plugin === name && e.marketplace === marketplace,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,33 @@ export type ChannelPermissionRequestParams = {
|
|||||||
* input is in the local terminal dialog; this is a phone-sized
|
* input is in the local terminal dialog; this is a phone-sized
|
||||||
* preview. Server decides whether/how to show it. */
|
* preview. Server decides whether/how to show it. */
|
||||||
input_preview: string
|
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
|
* Meta keys become XML attribute NAMES — a crafted key like
|
||||||
* `x="" injected="y` would break out of the attribute structure. Only
|
* `x="" injected="y` would break out of the attribute structure. Only
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js'
|
|||||||
* don't apply until restart.
|
* don't apply until restart.
|
||||||
*/
|
*/
|
||||||
export function isChannelPermissionRelayEnabled(): boolean {
|
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 = {
|
export type ChannelPermissionResponse = {
|
||||||
@@ -188,8 +188,8 @@ export function filterPermissionRelayClients<
|
|||||||
(c): c is T & { type: 'connected' } =>
|
(c): c is T & { type: 'connected' } =>
|
||||||
c.type === 'connected' &&
|
c.type === 'connected' &&
|
||||||
isInAllowlist(c.name) &&
|
isInAllowlist(c.name) &&
|
||||||
c.capabilities?.experimental?.['claude/channel'] !== undefined &&
|
Boolean(c.capabilities?.experimental?.['claude/channel']) &&
|
||||||
c.capabilities?.experimental?.['claude/channel/permission'] !== undefined,
|
Boolean(c.capabilities?.experimental?.['claude/channel/permission']),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ export function useManageMCPConnections(
|
|||||||
if (
|
if (
|
||||||
client.capabilities?.experimental?.[
|
client.capabilities?.experimental?.[
|
||||||
'claude/channel/permission'
|
'claude/channel/permission'
|
||||||
] !== undefined
|
]
|
||||||
) {
|
) {
|
||||||
client.client.setNotificationHandler(
|
client.client.setNotificationHandler(
|
||||||
ChannelPermissionNotificationSchema(),
|
ChannelPermissionNotificationSchema(),
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
|
"@claude-code-best/mcp-client/*": ["./packages/mcp-client/src/*"],
|
||||||
"@claude-code-best/mcp-client": ["./packages/mcp-client/src/index.ts"],
|
"@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/*"],
|
||||||
"@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"],
|
"include": ["src/**/*.ts", "src/**/*.tsx", "packages/**/*.ts", "packages/**/*.tsx"],
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { defineConfig, type Plugin } from "vite";
|
import { defineConfig, type Plugin } from "vite";
|
||||||
import { resolve, dirname } from "path";
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { getMacroDefines } from "./scripts/defines";
|
import { getMacroDefines } from "./scripts/defines";
|
||||||
import featureFlagsPlugin from "./scripts/vite-plugin-feature-flags";
|
import featureFlagsPlugin from "./scripts/vite-plugin-feature-flags";
|
||||||
import importMetaRequirePlugin from "./scripts/vite-plugin-import-meta-require";
|
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).
|
* Plugin to import .md files as raw strings (Bun's text loader behavior).
|
||||||
|
|||||||
Reference in New Issue
Block a user