mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 05:45:51 +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>
304 lines
8.4 KiB
TypeScript
304 lines
8.4 KiB
TypeScript
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,
|
|
})
|
|
}
|