mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +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>
This commit is contained in:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user