mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
* 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>
354 lines
9.8 KiB
TypeScript
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()
|
|
}
|