Files
claude-code/packages/weixin/src/server.ts
claude-code-best 494eab7204 feat: 接入内建 weixin channel(同 #301 重构版本) (#303)
* feat: 接入 weixin 服务层与命令入口

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: 注册内建 weixin channel 插件

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修正 channel permission relay 路由与能力判定

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修复 builtin channel 的 ChannelsNotice 误报

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* docs: 补充内建 weixin channel 使用说明

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* docs: 更新微信 channel 接入计划状态

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 延迟加载 weixin 登录二维码依赖

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 改用 qrcode 生成 weixin 登录二维码

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: 修正 vite 构建的 Windows 路径解析

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* chore: 删除临时规划文档 wx_channel.md 并还原 package.json 排序

wx_channel.md 内容已整合到 docs/features/channels.md,不再需要。
package.json 中 @ant/model-provider 位置从原始位置被无意移动,还原。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 将 weixin 模块从 src/ 迁移至 packages/weixin 工作区包

将 src/services/weixin/ 中的纯业务逻辑迁入 @claude-code-best/weixin
workspace 包,降低 src/ 耦合度。仅保留 server.ts 作为薄适配层。

- 迁移 7 个无修改的纯模块 (types/api/accounts/login/pairing/media/send)
- monitor.ts 内联 PERMISSION_REPLY_RE 正则,解除对 src/ 的依赖
- permissions.ts 本地定义 ChannelPermissionRequestParams 接口
- cli.ts 拆分:serve 子命令通过回调注入,login/access 保留在包内
- server.ts 重写为从 @claude-code-best/weixin 导入
- 新增 cli-serve.ts 作为 serve 入口薄壳

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修正 weixin barrel export 中 interface 的导出方式

ChannelPermissionRequestParams 是纯类型,必须用 export type 导出,
否则 Bun 运行时会报 "export not found" 错误。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 将 server.ts 迁入 packages/weixin,彻底移除 src/services/weixin/

通过依赖注入(WeixinServerDeps)解耦 src/ 依赖(analytics、config、
MCP channel schema),server.ts 完全移入包内。cli.tsx 入口处一次性
注入所有依赖。

src/services/weixin/ 目录已完全删除。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 markdownToPlainText 中代码块正则的 ReDoS 风险

用非正则的线性扫描替代 \`\`\`[\s\S]*?\n([\s\S]*?)\`\`\` 匹配,
避免在含有大量重复 \`\`\` 序列的输入上触发多项式回溯。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 1111 <11111@asd.c>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 21:33:27 +08:00

354 lines
9.8 KiB
TypeScript

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()
}