diff --git a/SKILL.md b/SKILL.md index 714aaf1..5026539 100644 --- a/SKILL.md +++ b/SKILL.md @@ -10,7 +10,7 @@ description: 微信消息桥接 - 在微信中与 Claude Code 聊天。支持文 ## 前置条件 - Node.js >= 18 -- macOS(daemon 使用 launchd 管理) +- macOS / Linux / Windows(Git Bash) - 个人微信账号(需扫码绑定) - 已安装 Claude Code(`@anthropic-ai/claude-agent-sdk`) diff --git a/package-lock.json b/package-lock.json index c697f98..8619fcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0" }, @@ -43,6 +44,132 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "node_modules/@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "cpu": [ + "ia32" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "cpu": [ + "x64" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", diff --git a/package.json b/package.json index 1ec8c88..1ab6ec8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.1.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0" }, @@ -24,7 +25,14 @@ "@types/qrcode-terminal": "^0.12.0", "typescript": "^5.7.0" }, - "keywords": ["wechat", "claude-code", "claude", "bridge", "chat", "skill"], + "keywords": [ + "wechat", + "claude-code", + "claude", + "bridge", + "chat", + "skill" + ], "author": "Wechat-ggGitHub", "license": "MIT", "repository": { diff --git a/scripts/daemon.sh b/scripts/daemon.sh index acb05fe..f04a90a 100755 --- a/scripts/daemon.sh +++ b/scripts/daemon.sh @@ -375,48 +375,210 @@ linux_logs() { fi } +# ============================================================================= +# Windows (Git Bash / MINGW / MSYS) functions +# ============================================================================= + +win32_pid_file() { + echo "${DATA_DIR}/${SERVICE_NAME}.pid" +} + +win32_is_process_running() { + local pid="$1" + # Git Bash on Windows supports /proc/$PID + [ -d "/proc/$pid" ] 2>/dev/null +} + +win32_find_daemon_pids() { + # Find all node processes running our daemon entry point + ps -W 2>/dev/null | grep -i "node" | grep "dist/main.js" | awk '{print $1}' | head -20 + # Also try pgrep-style via /proc + for p in /proc/[0-9]*/cmdline; do + if [ -f "$p" ] && grep -ql "dist/main.js" "$p" 2>/dev/null; then + basename "$(dirname "$p")" + fi + done 2>/dev/null +} + +win32_start() { + local pid_file="$(win32_pid_file)" + local node_bin="$(command -v node 2>/dev/null || echo 'node')" + + # Check if already running via PID file + if [ -f "$pid_file" ]; then + local old_pid=$(cat "$pid_file" 2>/dev/null) + if [ -n "$old_pid" ] && win32_is_process_running "$old_pid"; then + echo "Already running (PID: $old_pid)" + exit 0 + fi + rm -f "$pid_file" + fi + + mkdir -p "$DATA_DIR/logs" + + echo "Starting wechat-claude-code daemon (Windows)..." + + # Start daemon in background + "$node_bin" "${PROJECT_DIR}/dist/main.js" start \ + >> "$DATA_DIR/logs/stdout.log" \ + 2>> "$DATA_DIR/logs/stderr.log" & + local pid=$! + echo "$pid" > "$pid_file" + + # Give it a moment to start (or fail immediately) + sleep 2 + + if win32_is_process_running "$pid"; then + echo "Started (PID: $pid)" + echo "Logs: $DATA_DIR/logs/stdout.log" + else + echo "ERROR: Process exited immediately. Check logs:" + tail -20 "$DATA_DIR/logs/stderr.log" 2>/dev/null + rm -f "$pid_file" + exit 1 + fi +} + +win32_stop() { + local pid_file="$(win32_pid_file)" + + if [ ! -f "$pid_file" ]; then + echo "Not running (no PID file)" + exit 0 + fi + + local pid=$(cat "$pid_file" 2>/dev/null) + if [ -z "$pid" ]; then + rm -f "$pid_file" + echo "Stopped" + exit 0 + fi + + if win32_is_process_running "$pid"; then + kill "$pid" 2>/dev/null || taskkill //PID "$pid" //F 2>/dev/null || true + local count=0 + while win32_is_process_running "$pid" && [ $count -lt 10 ]; do + sleep 1 + count=$((count + 1)) + done + # Force kill if still running + if win32_is_process_running "$pid"; then + taskkill //PID "$pid" //F 2>/dev/null || true + fi + echo "Stopped (PID: $pid)" + else + echo "Process not running (cleaning up PID file)" + fi + + rm -f "$pid_file" +} + +win32_status() { + local pid_file="$(win32_pid_file)" + + if [ ! -f "$pid_file" ]; then + echo "Not running" + exit 0 + fi + + local pid=$(cat "$pid_file" 2>/dev/null) + if [ -z "$pid" ]; then + echo "Not running (invalid PID file)" + exit 0 + fi + + if win32_is_process_running "$pid"; then + echo "Running (PID: $pid)" + else + echo "Not running (stale PID file)" + fi +} + +win32_logs() { + local log_dir="${DATA_DIR}/logs" + if [ -d "$log_dir" ]; then + local latest=$(ls -t "${log_dir}"/bridge-*.log 2>/dev/null | head -1) + if [ -n "$latest" ]; then + echo "=== Bridge log: $(basename "$latest") (last 100 lines) ===" + tail -100 "$latest" + fi + for f in "${log_dir}"/stdout.log "${log_dir}"/stderr.log; do + if [ -f "$f" ]; then + echo "" + echo "=== $(basename "$f") (last 30 lines) ===" + tail -30 "$f" + fi + done + else + echo "No logs found" + fi +} + # ============================================================================= # Main dispatcher # ============================================================================= +# Detect Windows: MINGW, MSYS, or CYGWIN +is_windows() { + case "$OS_TYPE" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + main() { local command="${1:-}" - case "$OS_TYPE" in - Darwin) - case "$command" in - start) macos_start ;; - stop) macos_stop ;; - restart) macos_stop; sleep 1; macos_start ;; - status) macos_status ;; - logs) macos_logs ;; - *) - echo "Usage: daemon.sh {start|stop|restart|status|logs}" - echo "Platform: macOS (launchd)" - exit 1 - ;; - esac - ;; - Linux) - case "$command" in - start) linux_start ;; - stop) linux_stop ;; - restart) linux_restart ;; - status) linux_status ;; - logs) linux_logs ;; - *) - echo "Usage: daemon.sh {start|stop|restart|status|logs}" - echo "Platform: Linux (systemd)" - exit 1 - ;; - esac - ;; - *) - echo "Error: Unsupported platform '$OS_TYPE'" - echo "Supported platforms: macOS (Darwin), Linux" - exit 1 - ;; - esac + if is_windows; then + case "$command" in + start) win32_start ;; + stop) win32_stop ;; + restart) win32_stop; sleep 1; win32_start ;; + status) win32_status ;; + logs) win32_logs ;; + *) + echo "Usage: daemon.sh {start|stop|restart|status|logs}" + echo "Platform: Windows (Git Bash)" + exit 1 + ;; + esac + else + case "$OS_TYPE" in + Darwin) + case "$command" in + start) macos_start ;; + stop) macos_stop ;; + restart) macos_stop; sleep 1; macos_start ;; + status) macos_status ;; + logs) macos_logs ;; + *) + echo "Usage: daemon.sh {start|stop|restart|status|logs}" + echo "Platform: macOS (launchd)" + exit 1 + ;; + esac + ;; + Linux) + case "$command" in + start) linux_start ;; + stop) linux_stop ;; + restart) linux_restart ;; + status) linux_status ;; + logs) linux_logs ;; + *) + echo "Usage: daemon.sh {start|stop|restart|status|logs}" + echo "Platform: Linux (systemd)" + exit 1 + ;; + esac + ;; + *) + echo "Error: Unsupported platform '$OS_TYPE'" + echo "Supported platforms: macOS (Darwin), Linux, Windows (Git Bash)" + exit 1 + ;; + esac + fi } main "$@" diff --git a/src/claude/provider.ts b/src/claude/provider.ts index b0fae60..a36c4ac 100644 --- a/src/claude/provider.ts +++ b/src/claude/provider.ts @@ -128,18 +128,50 @@ async function* singleUserMessage( function resolveGlobalClaudeCliPath(): string | undefined { try { - const claudeBin = execSync("which claude", { encoding: "utf8" }).trim(); - // Resolve symlinks to get the actual file - const realBin = execSync(`readlink -f "${claudeBin}" 2>/dev/null || realpath "${claudeBin}" 2>/dev/null || echo "${claudeBin}"`, { encoding: "utf8" }).trim(); - // On npm global installs, the binary itself is cli.js - if (realBin.endsWith(".js") && existsSync(realBin)) return realBin; - // Otherwise look for cli.js next to the binary - const cliJs = join(dirname(realBin), "cli.js"); - if (existsSync(cliJs)) return cliJs; - // Try npm global prefix - const npmPrefix = execSync("npm config get prefix", { encoding: "utf8" }).trim(); - const npmCli = join(npmPrefix, "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js"); - if (existsSync(npmCli)) return npmCli; + if (process.platform === 'win32') { + // Windows: use `where claude` instead of `which` + try { + const claudeBin = execSync("where claude", { encoding: "utf8" }).trim().split(/\r?\n/)[0]; + if (claudeBin) { + // On Windows, no symlinks to resolve — check directly + if (claudeBin.endsWith(".js") && existsSync(claudeBin)) return claudeBin; + const cliJs = join(dirname(claudeBin), "cli.js"); + if (existsSync(cliJs)) return cliJs; + } + } catch { + // `where` failed, fall through + } + + // Check well-known Windows npm global path + const appData = process.env.APPDATA; + if (appData) { + const knownPath = join(appData, "npm", "node_modules", "@anthropic-ai", "claude-code", "cli.js"); + if (existsSync(knownPath)) return knownPath; + } + + // Try npm global prefix + try { + const npmPrefix = execSync("npm config get prefix", { encoding: "utf8" }).trim(); + const npmCli = join(npmPrefix, "node_modules", "@anthropic-ai", "claude-code", "cli.js"); + if (existsSync(npmCli)) return npmCli; + } catch { + // ignore + } + } else { + // macOS / Linux: use `which` and resolve symlinks + const claudeBin = execSync("which claude", { encoding: "utf8" }).trim(); + // Resolve symlinks to get the actual file + const realBin = execSync(`readlink -f "${claudeBin}" 2>/dev/null || realpath "${claudeBin}" 2>/dev/null || echo "${claudeBin}"`, { encoding: "utf8" }).trim(); + // On npm global installs, the binary itself is cli.js + if (realBin.endsWith(".js") && existsSync(realBin)) return realBin; + // Otherwise look for cli.js next to the binary + const cliJs = join(dirname(realBin), "cli.js"); + if (existsSync(cliJs)) return cliJs; + // Try npm global prefix + const npmPrefix = execSync("npm config get prefix", { encoding: "utf8" }).trim(); + const npmCli = join(npmPrefix, "lib", "node_modules", "@anthropic-ai", "claude-code", "cli.js"); + if (existsSync(npmCli)) return npmCli; + } } catch { // ignore } @@ -175,12 +207,9 @@ export async function claudeQuery(options: QueryOptions): Promise { hasImages: !!images?.length, }); - // When images are present we use the multi-content AsyncIterable path; - // otherwise a plain string is simpler and sufficient. - const hasImages = images && images.length > 0; - const promptParam: string | AsyncIterable = hasImages - ? singleUserMessage(prompt, images) - : prompt; + // Always use the streaming-input path so text-only turns and image turns + // reuse the same SDK input mode across a resumed session. + const promptParam: AsyncIterable = singleUserMessage(prompt, images); // --- Build SDK options --- const sdkOptions: Options = { @@ -235,23 +264,13 @@ export async function claudeQuery(options: QueryOptions): Promise { } // --- Execute query & accumulate output --- - const MAX_THINKING_PREVIEW = 300; // max chars per thinking block shown to user let sessionId = ""; const textParts: string[] = []; let errorMessage: string | undefined; - let thinkingBuf = ""; // accumulates current thinking block - let thinkingCapped = false; // true once we've emitted the preview for this block - - const QUERY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes try { const result = query({ prompt: promptParam, options: sdkOptions }); - let timeoutId: ReturnType; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error('Claude query timed out after 5 minutes')), QUERY_TIMEOUT_MS); - }); - const iterateResult = async () => { for await (const message of result) { const sid = getSessionId(message); @@ -285,33 +304,12 @@ export async function claudeQuery(options: QueryOptions): Promise { } case "stream_event": { const evt = (message as any).event; - if (evt?.type === "content_block_start") { - // Reset thinking state at the start of each new block - if (evt?.content_block?.type === "thinking") { - thinkingBuf = ""; - thinkingCapped = false; - } - } else if (evt?.type === "content_block_delta") { + if (evt?.type === "content_block_delta") { const deltaType: string = evt.delta?.type ?? ""; if (deltaType === "text_delta" && onText) { const delta: string = evt.delta.text; if (delta) await onText(delta); - } else if (deltaType === "thinking_delta" && onText && !thinkingCapped) { - // Accumulate thinking text; emit a short preview once we hit the cap - thinkingBuf += (evt.delta.thinking as string) ?? ""; - if (thinkingBuf.length >= MAX_THINKING_PREVIEW) { - thinkingCapped = true; - await onText("💭 " + thinkingBuf.slice(0, MAX_THINKING_PREVIEW).trim() + "…\n"); - thinkingBuf = ""; - } - } - } else if (evt?.type === "content_block_stop") { - // Block ended before hitting the cap — emit what we have (if non-empty) - if (thinkingBuf.trim() && onText && !thinkingCapped) { - await onText("💭 " + thinkingBuf.trim() + "\n"); } - thinkingBuf = ""; - thinkingCapped = false; } break; } @@ -346,9 +344,9 @@ export async function claudeQuery(options: QueryOptions): Promise { }; try { - await Promise.race([iterateResult(), timeoutPromise]); + await iterateResult(); } finally { - clearTimeout(timeoutId!); + // no timeout — wait indefinitely } } catch (err: unknown) { errorMessage = err instanceof Error ? err.message : String(err); diff --git a/src/main.ts b/src/main.ts index 6dedca0..0b0c74b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,20 +2,23 @@ import { createInterface } from 'node:readline'; import process from 'node:process'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; +import { unlinkSync, writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { WeChatApi } from './wechat/api.js'; import { saveAccount, loadLatestAccount, type AccountData } from './wechat/accounts.js'; import { startQrLogin, waitForQrScan } from './wechat/login.js'; import { createMonitor, type MonitorCallbacks } from './wechat/monitor.js'; import { createSender } from './wechat/send.js'; -import { downloadImage, extractText, extractFirstImageUrl } from './wechat/media.js'; +import { downloadImage, extractText, extractAllImageUrls, extractFileItem, extractVideoItem, extractVoiceItem, downloadFileToLocal, downloadVideoToLocal, downloadVoiceToLocal, type DownloadedMedia } from './wechat/media.js'; + import { createSessionStore, type Session } from './session.js'; import { createPermissionBroker } from './permission.js'; import { routeCommand, type CommandContext, type CommandResult } from './commands/router.js'; import { claudeQuery, type QueryOptions } from './claude/provider.js'; import { loadConfig, saveConfig } from './config.js'; import { logger } from './logger.js'; +import { processVoiceMessage } from './media/voice.js'; import { DATA_DIR } from './constants.js'; import { MessageType, type WeixinMessage } from './wechat/types.js'; @@ -23,7 +26,104 @@ import { MessageType, type WeixinMessage } from './wechat/types.js'; // Helpers // --------------------------------------------------------------------------- -const MAX_MESSAGE_LENGTH = 2048; +const MAX_MESSAGE_LENGTH = 800; // WeChat reads best under 800 chars per message +const FINAL_REPLY_TARGET_LENGTH = 280; +const STREAMING_MODE_ERROR_PATTERNS = [ + /only prompt commands are supported in streaming mode/i, +]; +const ABORT_ERROR_PATTERNS = [ + /Claude Code process aborted by user/i, + /operation aborted/i, + /\babort(ed)?\b/i, + /\binterrupted\b/i, +]; +const EARLY_RESPONSE_MIN_CHARS = 8; +const EARLY_RESPONSE_MAX_CHARS = 140; +const EARLY_RESPONSE_WAIT_MS = 2800; +const RESUME_RECOVERY_ERROR_PATTERNS = [ + ...STREAMING_MODE_ERROR_PATTERNS, + /cannot resume/i, + /\bresume\b.*(failed|invalid|unsupported|not supported|not found|expired|missing)/i, + /\bsession\b.*(invalid|not found|expired|missing|unknown|closed|ended)/i, + /(invalid|not found|expired|missing|unknown).*(resume|session)/i, +]; +const HISTORY_FALLBACK_MESSAGE_LIMIT = 12; +const HISTORY_FALLBACK_CHAR_LIMIT = 6000; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function shouldRetryWithoutResume(error?: string, text?: string): boolean { + if (!error || text?.trim()) { + return false; + } + return RESUME_RECOVERY_ERROR_PATTERNS.some((pattern) => pattern.test(error)); +} + +function hasStreamingModeCompatibilityError(error?: string): boolean { + if (!error) { + return false; + } + return STREAMING_MODE_ERROR_PATTERNS.some((pattern) => pattern.test(error)); +} + +function isAbortLikeError(error?: string): boolean { + if (!error) { + return false; + } + return ABORT_ERROR_PATTERNS.some((pattern) => pattern.test(error)); +} + +function buildPromptWithHistory(basePrompt: string, session: Session): string { + const history = session.chatHistory || []; + const previousMessages = history.slice(0, -1); + if (previousMessages.length === 0) { + return basePrompt; + } + + const recentMessages = previousMessages.slice(-HISTORY_FALLBACK_MESSAGE_LIMIT); + const contextLines: string[] = []; + let usedChars = 0; + + for (let i = recentMessages.length - 1; i >= 0; i--) { + const msg = recentMessages[i]; + const content = msg.content.replace(/\n{3,}/g, '\n\n').trim(); + if (!content) { + continue; + } + + const role = msg.role === 'user' ? '用户' : 'Claude'; + const line = `${role}: ${content}`; + if (usedChars + line.length > HISTORY_FALLBACK_CHAR_LIMIT && contextLines.length > 0) { + break; + } + + contextLines.unshift(line); + usedChars += line.length; + } + + if (contextLines.length === 0) { + return basePrompt; + } + + return [ + ...contextLines, + '', + `用户: ${basePrompt}`, + ].join('\n'); +} + +// ── File sending via [SEND_FILE: path] markers ────────────────────────────── +// When Claude outputs [SEND_FILE: /path/to/file], we detect it, upload the +// file to WeChat CDN, and send it as a media message. The marker is stripped +// from the text before forwarding to the user. + +const SEND_FILE_RE = /\[SEND_FILE:\s*([^\]]+)\]/g; +const SEND_FILE_MARKER_PREFIX = '[SEND_FILE:'; function splitMessage(text: string, maxLen: number = MAX_MESSAGE_LENGTH): string[] { if (text.length <= maxLen) return [text]; @@ -34,9 +134,21 @@ function splitMessage(text: string, maxLen: number = MAX_MESSAGE_LENGTH): string chunks.push(remaining); break; } - // Try to split at a newline near the limit - let splitIdx = remaining.lastIndexOf('\n', maxLen); - if (splitIdx < maxLen * 0.3) { + // Try to split at double newline (paragraph break) first + let splitIdx = -1; + const searchStart = Math.max(maxLen * 0.3, maxLen - 200); + for (let i = maxLen; i >= searchStart; i--) { + if (remaining[i] === '\n' && remaining[i - 1] === '\n') { + splitIdx = i; + break; + } + } + // Fallback: single newline + if (splitIdx < 0) { + splitIdx = remaining.lastIndexOf('\n', maxLen); + } + // Last resort: hard cut + if (splitIdx < searchStart) { splitIdx = maxLen; } chunks.push(remaining.slice(0, splitIdx)); @@ -45,6 +157,245 @@ function splitMessage(text: string, maxLen: number = MAX_MESSAGE_LENGTH): string return chunks; } +function cleanupFinalWechatText(text: string, sentEarlyResponse: boolean): string { + const lines = text + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => line.trimEnd()); + + const cleaned: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed) { + if (cleaned.length > 0 && cleaned[cleaned.length - 1] !== '') { + cleaned.push(''); + } + continue; + } + + cleaned.push(trimmed); + } + + while (cleaned.length > 0 && cleaned[0] === '') { + cleaned.shift(); + } + + while (cleaned.length > 0 && cleaned[cleaned.length - 1] === '') { + cleaned.pop(); + } + + if (sentEarlyResponse) { + while (cleaned.length > 0) { + const firstLine = cleaned[0]?.trim(); + if (!firstLine) { + cleaned.shift(); + continue; + } + if (isGoodEarlyResponse(firstLine)) { + cleaned.shift(); + while (cleaned.length > 0 && cleaned[0] === '') { + cleaned.shift(); + } + continue; + } + break; + } + } + + return cleaned.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +function splitWechatReply(text: string, targetLen: number = FINAL_REPLY_TARGET_LENGTH): string[] { + const normalized = text.trim(); + if (!normalized) { + return []; + } + + if (normalized.length <= targetLen) { + return [normalized]; + } + + const chunks: string[] = []; + const paragraphs = normalized.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean); + let current = ''; + + const flush = (): void => { + const trimmed = current.trim(); + if (trimmed) { + chunks.push(trimmed); + } + current = ''; + }; + + const appendParagraph = (paragraph: string): void => { + const candidate = current ? `${current}\n\n${paragraph}` : paragraph; + if (candidate.length <= targetLen) { + current = candidate; + return; + } + + if (current) { + flush(); + } + + if (paragraph.length <= targetLen) { + current = paragraph; + return; + } + + const lines = paragraph.split('\n').map((line) => line.trim()).filter(Boolean); + let local = ''; + + for (const line of lines) { + const localCandidate = local ? `${local}\n${line}` : line; + if (localCandidate.length <= targetLen) { + local = localCandidate; + continue; + } + + if (local) { + chunks.push(local); + local = ''; + } + + if (line.length <= targetLen) { + local = line; + } else { + chunks.push(...splitMessage(line, targetLen)); + } + } + + if (local) { + current = local; + } + }; + + for (const paragraph of paragraphs) { + appendParagraph(paragraph); + } + + flush(); + return chunks; +} + +function isGoodEarlyResponse(text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) { + return false; + } + + if (/[`#]/.test(trimmed) || /^\s*[-*0-9]+\./m.test(trimmed) || trimmed.includes('\n- ')) { + return false; + } + + const compact = trimmed.replace(/\s+/g, ''); + + if (/^(好的?|行|可以|收到|明白|嗯|好嘞|没问题|稍等|稍等下|马上)[,。!?!?~… ]*$/i.test(compact)) { + return false; + } + + if (/^(找到了|结果是|看完了|查完了|已经(处理|完成|删完|清空)|搞定了|删除成功|清理完了)/.test(compact)) { + return false; + } + + const actionLeadPatterns = [ + /我先/i, + /我这就/i, + /我先去/i, + /我先看/i, + /我先查/i, + /我先核对/i, + /我先确认/i, + /我先检查/i, + /我先拉/i, + /我先搜/i, + /我拉出来看看/i, + /我来先/i, + /我来看看/i, + /我来看下/i, + /我来查/i, + /我来查下/i, + /我来确认/i, + /我来确认下/i, + /我来检查/i, + /我来处理/i, + /我来清/i, + /我帮你/i, + /我去看/i, + /我去查/i, + /我看一下/i, + /我看下/i, + /我查一下/i, + /我查下/i, + /我确认一下/i, + /我确认下/i, + /我检查一下/i, + /我检查下/i, + /我过一遍/i, + /我翻一下/i, + /我先过一遍/i, + /我先翻一下/i, + /让我先/i, + /先把/i, + /先看下/i, + /先查下/i, + /继续处理/i, + /好[的呀啊吧]*[,, ]*我/i, + /行[,, ]*我/i, + /收到[,, ]*我/i, + ]; + + if (actionLeadPatterns.some((pattern) => pattern.test(trimmed))) { + return true; + } + + return /(看|查|核对|确认|检查|处理|清理|清空|删除|扫描|拉取|翻|整理|筛|对比|排查|验证|汇总|过一遍|搜|分析)/.test(trimmed) + && /(我|先|这就|马上|继续)/.test(trimmed) + && !/(总结|如下|共计|一共|清单|名单|累计)/.test(trimmed); +} + +function findEarlyFlushIndex(text: string): number { + if (!text) { + return 0; + } + + const strongBoundary = /\n{2,}|[。!?!?;;](?:\s|$)?/g; + let match: RegExpExecArray | null; + while ((match = strongBoundary.exec(text)) !== null) { + const boundary = match.index + match[0].length; + if (boundary > EARLY_RESPONSE_MAX_CHARS) { + break; + } + if (boundary >= EARLY_RESPONSE_MIN_CHARS) { + const candidate = text.slice(0, boundary).trim(); + if (isGoodEarlyResponse(candidate)) { + return boundary; + } + } + } + + return 0; +} + +function findSoftEarlyFlushIndex(text: string): number { + if (!text) { + return 0; + } + + const trimmed = text.trim(); + if (!trimmed || trimmed.length < EARLY_RESPONSE_MIN_CHARS || trimmed.length > EARLY_RESPONSE_MAX_CHARS) { + return 0; + } + + if (trimmed.includes('\n') || trimmed.includes(SEND_FILE_MARKER_PREFIX)) { + return 0; + } + + return isGoodEarlyResponse(trimmed) ? text.length : 0; +} + function promptUser(question: string, defaultValue?: string): Promise { return new Promise((resolve) => { const rl = createInterface({ input: process.stdin, output: process.stdout }); @@ -151,16 +502,68 @@ async function runSetup(): Promise { console.log('运行 npm run daemon -- start 启动服务'); } +// --------------------------------------------------------------------------- +// Singleton lock (prevents multiple daemon instances) +// --------------------------------------------------------------------------- + +const LOCK_FILE = join(DATA_DIR, 'daemon.lock'); + +function isPM2(): boolean { + return !!process.env.pm_id || !!process.env.pm2_exec_path; +} + +function acquireLock(): void { + mkdirSync(DATA_DIR, { recursive: true }); + + // PM2 manages process lifecycle itself - skip lock to avoid restart loops + if (isPM2()) { + logger.info('Running under PM2, skipping singleton lock'); + return; + } + + if (existsSync(LOCK_FILE)) { + try { + const lockData = JSON.parse(readFileSync(LOCK_FILE, 'utf8')); + try { + process.kill(lockData.pid, 0); + console.error(`❌ 已有 daemon 进程运行中 (PID: ${lockData.pid}, started: ${lockData.started})`); + console.error(' 如需重启,请先运行: npm run daemon -- stop'); + process.exit(1); + } catch { + // Process is dead, steal the lock + logger.warn('Stale lock found, stealing', { oldPid: lockData.pid }); + } + } catch { + // Corrupted lock file, remove it + logger.warn('Corrupted lock file, removing'); + } + } + + // Write our lock + writeFileSync(LOCK_FILE, JSON.stringify({ + pid: process.pid, + started: new Date().toISOString(), + })); + logger.info('Acquired daemon lock', { pid: process.pid }); +} + +function releaseLock(): void { + try { unlinkSync(LOCK_FILE); } catch { /* ignore */ } +} + // --------------------------------------------------------------------------- // Daemon // --------------------------------------------------------------------------- async function runDaemon(): Promise { + acquireLock(); + const config = loadConfig(); const account = loadLatestAccount(); if (!account) { console.error('未找到账号,请先运行 node dist/main.js setup'); + releaseLock(); process.exit(1); } @@ -184,6 +587,10 @@ async function runDaemon(): Promise { const sender = createSender(api, account.accountId); const sharedCtx = { lastContextToken: '' }; const activeControllers = new Map(); + + // Pending media: stores downloaded image local paths from aborted queries + // so that the next message can reference them + const pendingMediaPaths: string[] = []; const permissionBroker = createPermissionBroker(async () => { try { await sender.sendText(account.userId ?? '', sharedCtx.lastContextToken, '⏰ 权限请求超时,已自动拒绝。'); @@ -194,9 +601,12 @@ async function runDaemon(): Promise { // -- Wire the monitor callbacks -- + // Typing indicator: cache typing_ticket per userId + const typingTicketCache = new Map(); + const callbacks: MonitorCallbacks = { onMessage: async (msg: WeixinMessage) => { - await handleMessage(msg, account, session, sessionStore, permissionBroker, sender, config, sharedCtx, activeControllers); + await handleMessage(msg, account, session, sessionStore, permissionBroker, sender, config, sharedCtx, activeControllers, pendingMediaPaths, api, typingTicketCache); }, onSessionExpired: () => { logger.warn('Session expired, will keep retrying...'); @@ -211,6 +621,7 @@ async function runDaemon(): Promise { function shutdown(): void { logger.info('Shutting down...'); monitor.stop(); + releaseLock(); process.exit(0); } @@ -237,6 +648,9 @@ async function handleMessage( config: ReturnType, sharedCtx: { lastContextToken: string }, activeControllers: Map, + pendingMediaPaths: string[], + api: WeChatApi, + typingTicketCache: Map, ): Promise { // Filter: only user messages with required fields if (msg.message_type !== MessageType.USER) return; @@ -248,7 +662,10 @@ async function handleMessage( // Extract text from items const userText = extractTextFromItems(msg.item_list); - const imageItem = extractFirstImageUrl(msg.item_list); + const imageItems = extractAllImageUrls(msg.item_list); + const fileItem = extractFileItem(msg.item_list); + const videoItem = extractVideoItem(msg.item_list); + const voiceItem = extractVoiceItem(msg.item_list); // Concurrency guard: abort current query when new message arrives if (session.state === 'processing') { @@ -257,6 +674,7 @@ async function handleMessage( const ctrl = activeControllers.get(account.accountId); if (ctrl) { ctrl.abort(); activeControllers.delete(account.accountId); } session.state = 'idle'; + pendingMediaPaths.length = 0; sessionStore.save(account.accountId, session); // Fall through to command routing so /clear executes normally } else if (!userText.startsWith('/')) { @@ -265,6 +683,7 @@ async function handleMessage( if (ctrl) { ctrl.abort(); activeControllers.delete(account.accountId); } session.state = 'idle'; sessionStore.save(account.accountId, session); + // pendingMediaPaths retains any downloaded image paths from aborted query // Fall through to send new message to Claude } else if (!userText.startsWith('/status') && !userText.startsWith('/help')) { return; @@ -336,7 +755,7 @@ async function handleMessage( // Fall through to send the claudePrompt to Claude await sendToClaude( result.claudePrompt, - imageItem, + imageItems, fromUserId, contextToken, account, @@ -346,6 +765,9 @@ async function handleMessage( sender, config, activeControllers, + pendingMediaPaths, + api, + typingTicketCache, ); return; } @@ -360,14 +782,66 @@ async function handleMessage( // -- Normal message -> Claude -- - if (!userText && !imageItem) { - await sender.sendText(fromUserId, contextToken, '暂不支持此类型消息,请发送文字或图片'); + if (!userText && imageItems.length === 0 && !fileItem && !videoItem && !voiceItem) { + await sender.sendText(fromUserId, contextToken, '暂不支持此类型消息,请发送文字、图片、文件、视频或语音'); return; } + // Build context description for non-text media + let mediaContext = ''; + + // If there are pending media paths from a previously aborted query, attach them + if (pendingMediaPaths.length > 0) { + mediaContext += `\n[用户之前发送了以下图片,已保存到本地,请一并处理: ${pendingMediaPaths.join(', ')}]`; + pendingMediaPaths.length = 0; + } + + // Handle file: download to local and describe to Claude + if (fileItem) { + const downloaded = await downloadFileToLocal(fileItem); + if (downloaded) { + mediaContext += `\n[用户发送了一个文件: ${downloaded.fileName} (${formatSize(downloaded.size)}),已保存到 ${downloaded.localPath}]`; + } else { + mediaContext += '\n[用户发送了一个文件,但下载失败]'; + } + } + + // Handle video: download and pass path to Claude + if (videoItem) { + const downloaded = await downloadVideoToLocal(videoItem); + if (downloaded) { + mediaContext += `\n[用户发送了一个视频: ${downloaded.fileName} (${formatSize(downloaded.size)}),已保存到 ${downloaded.localPath}。你可以使用工具读取和分析这个视频文件。]`; + } else { + mediaContext += '\n[用户发送了一个视频,但下载失败]'; + } + } + + // Handle voice: download, transcribe, and include text if available + if (voiceItem) { + const downloaded = await downloadVoiceToLocal(voiceItem); + if (downloaded) { + const serverVoiceText = downloaded.voiceText?.trim(); + if (serverVoiceText) { + mediaContext += `\n用户发送了一条语音,语音识别文本: ${serverVoiceText}`; + } else { + // Fall back to local transcription only when WeChat did not already provide text + const transcription = await processVoiceMessage(downloaded.localPath); + if (transcription) { + mediaContext += `\n用户发送了一条语音,语音识别文本: ${transcription}`; + } else { + mediaContext += `\n用户发送了一条语音,已保存到 ${downloaded.localPath}(语音识别暂不可用)`; + } + } + } else { + mediaContext += '\n[用户发送了一条语音,但下载失败]'; + } + } + + const effectivePrompt = (userText || '请处理用户发送的文件') + mediaContext; + await sendToClaude( - userText, - imageItem, + effectivePrompt, + imageItems, fromUserId, contextToken, account, @@ -377,6 +851,9 @@ async function handleMessage( sender, config, activeControllers, + pendingMediaPaths, + api, + typingTicketCache, ); } @@ -386,7 +863,7 @@ function extractTextFromItems(items: NonNullable): s async function sendToClaude( userText: string, - imageItem: ReturnType, + imageItems: ReturnType, fromUserId: string, contextToken: string, account: AccountData, @@ -396,6 +873,9 @@ async function sendToClaude( sender: ReturnType, config: ReturnType, activeControllers: Map, + pendingMediaPaths: string[], + api: WeChatApi, + typingTicketCache: Map, ): Promise { // Set state to processing session.state = 'processing'; @@ -408,27 +888,167 @@ async function sendToClaude( // Record user message in chat history sessionStore.addChatMessage(session, 'user', userText || '(图片)'); + // -- Typing indicator -- + const TYPING_TICKET_TTL = 20 * 60 * 1000; // cache ticket for 20 minutes + const TYPING_REFRESH_INTERVAL = 5 * 1000; // refresh every 5s + let typingTimer: ReturnType | null = null; + + async function getTypingTicket(): Promise { + const cached = typingTicketCache.get(fromUserId); + if (cached && cached.expires > Date.now()) return cached.ticket; + const ticket = await api.getConfig(fromUserId, contextToken); + typingTicketCache.set(fromUserId, { ticket, expires: Date.now() + TYPING_TICKET_TTL }); + return ticket; + } + + async function startTyping(): Promise { + try { + const ticket = await getTypingTicket(); + await api.sendTyping(fromUserId, ticket, 1); + typingTimer = setInterval(async () => { + try { + const t = await getTypingTicket(); + await api.sendTyping(fromUserId, t, 1); + } catch { /* ignore typing refresh errors */ } + }, TYPING_REFRESH_INTERVAL); + } catch (err) { + logger.warn('Failed to start typing indicator', { error: err instanceof Error ? err.message : String(err) }); + } + } + + async function stopTyping(): Promise { + if (typingTimer) { clearInterval(typingTimer); typingTimer = null; } + try { + const cached = typingTicketCache.get(fromUserId); + if (cached) await api.sendTyping(fromUserId, cached.ticket, 2); + } catch { /* ignore */ } + } + + let partialTextBuffer = ''; + let sentAssistantPrefix = ''; + let sentEarlyResponse = false; + let partialFlushTimer: ReturnType | null = null; + let partialFlushChain = Promise.resolve(); + + const isCurrentRun = (): boolean => activeControllers.get(account.accountId) === abortController; + + function clearPartialFlushTimer(): void { + if (partialFlushTimer) { + clearTimeout(partialFlushTimer); + partialFlushTimer = null; + } + } + + function queueEarlyResponse(allowSoftFlush = false): Promise { + partialFlushChain = partialFlushChain + .then(async () => { + clearPartialFlushTimer(); + + if (sentEarlyResponse || !isCurrentRun()) { + return; + } + + let flushIndex = findEarlyFlushIndex(partialTextBuffer); + if (flushIndex <= 0 && allowSoftFlush) { + flushIndex = findSoftEarlyFlushIndex(partialTextBuffer); + } + if (flushIndex <= 0) { + if (partialTextBuffer.trim()) { + scheduleEarlyResponse(); + } + return; + } + + const markerIndex = partialTextBuffer.indexOf(SEND_FILE_MARKER_PREFIX); + if (markerIndex >= 0 && markerIndex < flushIndex) { + flushIndex = markerIndex; + } + + if (flushIndex <= 0) { + return; + } + + const rawChunk = partialTextBuffer.slice(0, flushIndex); + const displayChunk = rawChunk.trim(); + partialTextBuffer = partialTextBuffer.slice(flushIndex).replace(/^\n+/, ''); + + if (!displayChunk) { + if (partialTextBuffer.trim()) { + scheduleEarlyResponse(); + } + return; + } + + if (!isCurrentRun()) { + return; + } + + for (const chunk of splitMessage(displayChunk)) { + await sender.sendText(fromUserId, contextToken, chunk); + } + + sentAssistantPrefix += rawChunk; + sentEarlyResponse = true; + }) + .catch((err) => { + logger.warn('Failed to flush partial assistant text', { + error: err instanceof Error ? err.message : String(err), + }); + }); + + return partialFlushChain; + } + + function scheduleEarlyResponse(): void { + if (sentEarlyResponse || !isCurrentRun()) { + return; + } + clearPartialFlushTimer(); + if (!partialTextBuffer.trim()) { + return; + } + + partialFlushTimer = setTimeout(() => { + void queueEarlyResponse(true); + }, EARLY_RESPONSE_WAIT_MS); + } + + // Start typing indicator (fire and forget, don't block query) + startTyping(); + try { - // Download image if present + // Download images if present let images: QueryOptions['images']; - if (imageItem) { - const base64DataUri = await downloadImage(imageItem); - if (base64DataUri) { - // Convert data URI to the format Claude expects - const matches = base64DataUri.match(/^data:([^;]+);base64,(.+)$/); - if (matches) { - images = [ - { + if (imageItems.length > 0) { + const imageEntries: NonNullable = []; + for (const imgItem of imageItems) { + const imgResult = await downloadImage(imgItem); + if (imgResult) { + // Notify user that image was received and saved + try { + await sender.sendText(fromUserId, contextToken, `📷 收到图片 (${formatSize(imgResult.size)}),正在处理...`); + } catch { /* ignore send errors */ } + + // Convert data URI to the format Claude expects + const matches = imgResult.dataUri.match(/^data:([^;]+);base64,(.+)$/); + if (matches) { + imageEntries.push({ type: 'image', source: { type: 'base64', media_type: matches[1], data: matches[2], }, - }, - ]; + }); + } + + // Save path to pendingMediaPaths so it survives abort + pendingMediaPaths.push(imgResult.localPath); } } + if (imageEntries.length > 0) { + images = imageEntries; + } } const effectivePermissionMode = session.permissionMode ?? config.permissionMode; @@ -437,43 +1057,102 @@ async function sendToClaude( // Map 'auto' to bypassPermissions — skips all permission checks in the SDK const sdkPermissionMode = isAutoPermission ? 'bypassPermissions' : effectivePermissionMode; - // Unified buffer: text deltas and tool summaries all go here - let pendingBuffer = ''; - let anySent = false; - let lastSendTime = Date.now(); // start the clock now, so first delta doesn't fire immediately - const SEND_INTERVAL_MS = 36_000; - - // Send everything in pendingBuffer. force=true ignores rate limit. - async function trySend(force = false): Promise { - if (!pendingBuffer.trim()) return; - const now = Date.now(); - if (!force && now - lastSendTime < SEND_INTERVAL_MS) return; - const toSend = pendingBuffer.trim(); - pendingBuffer = ''; - const chunks = splitMessage(toSend); - for (const chunk of chunks) { - lastSendTime = Date.now(); - anySent = true; - await sender.sendText(fromUserId, contextToken, chunk); + // Extract [SEND_FILE: path] markers from text. + // Returns { filePaths, cleanedText } — does NOT send files. + function extractSendFileMarkers(text: string): { filePaths: string[]; cleanedText: string } { + const filePaths: string[] = []; + let match: RegExpExecArray | null; + const re = new RegExp(SEND_FILE_RE.source, 'g'); + while ((match = re.exec(text)) !== null) { + filePaths.push(match[1].trim()); } + const cleaned = text.replace(new RegExp(SEND_FILE_RE.source, 'g'), '').trim(); + return { filePaths, cleanedText: cleaned }; } + const SEND_FILE_SYSTEM_HINT = ` + +[WeChat Bridge - Communication Protocol] +You are communicating with the user through WeChat. Follow these rules strictly: + +## Message Format +- Keep responses SHORT - WeChat messages work best under 500 characters +- Break long responses into short paragraphs separated by newlines +- Use bullet points or numbered lists for multiple items +- Do NOT use markdown headers (##, ###) or code blocks with triple backticks - they render poorly in WeChat +- Use plain text formatting only +- Be direct - lead with the answer, not the reasoning + +## File & Media Support +You can send files (Excel, Word, PDF, images, videos, etc.) to the user via WeChat. +When you need to send a file, output a marker in your response: [SEND_FILE: /absolute/path/to/file] +The system will automatically upload and send the file to the user's WeChat, and strip the marker from the text. +You can include multiple markers to send multiple files. Examples: +- [SEND_FILE: C:\\Users\\user\\Desktop\\report.xlsx] +- [SEND_FILE: /home/user/photo.jpg] +Always verify the file exists before including the marker. You can use Bash (ls) to check. + +## Media from User +The user can send you files, images, videos, and voice messages. When they do: +- Files are downloaded to ~/.wechat-claude-code/downloads/ and the path is included in the prompt +- Videos are downloaded to ~/.wechat-claude-code/downloads/ and the path is included in the prompt. Use tools to analyze video files. +- Voice messages are downloaded to ~/.wechat-claude-code/downloads/ (speech-to-text transcription included when available) +- Images are sent as base64 data for direct analysis +You can read and process these files from their local paths. + +## File Management +You can manage files on this computer. The user may ask you to: +- Search for files by name, type, or content +- Read, create, edit, or delete files +- Organize files (move, rename, copy) +- Convert file formats (e.g., CSV to Excel, JSON to CSV) +- Compress or extract archives +Always confirm before deleting files. Show file paths so the user can verify. + +## Behavior Guidelines +- When the user sends a file/image, always acknowledge it first, then process +- Before using tools for a multi-step, destructive, or time-consuming task, first send one short sentence telling the user what you are about to do +- That first sentence should feel like a natural chat reply, not a robotic status label +- A brief acknowledgment is fine, but it must quickly transition into the immediate next action +- Prefer one complete natural sentence of about 10-30 Chinese characters before you start working +- Avoid repetitive honorifics or exaggerated phrases like "老板说得对" unless the user is clearly using that tone on purpose +- Do not mention hidden context management or say things like "我是依据上面的会话历史继续的" unless the user explicitly asks how context or memory works +- Do not narrate every internal step or stream token-by-token +- Do not expose tool failures, retries, debugging chatter, or implementation steps unless the user explicitly asks for them +- After the initial short sentence, keep silent until you have a real result to report +- When done, summarize the result concisely +- If something fails, explain what happened and suggest next steps +- Use Chinese (中文) by default unless the user writes in English`; + + const effectiveSystemPrompt = (config.systemPrompt ?? '') + SEND_FILE_SYSTEM_HINT; + + const basePrompt = userText || '请分析这张图片'; + const buildClaudePrompt = (useHistoryFallback: boolean): string => + useHistoryFallback ? buildPromptWithHistory(basePrompt, session) : basePrompt; + const queryOptions: QueryOptions = { - prompt: userText || '请分析这张图片', + prompt: buildClaudePrompt(!session.sdkSessionId), cwd: (session.workingDirectory || config.workingDirectory).replace(/^~/, process.env.HOME || ''), resume: session.sdkSessionId, model: session.model, - systemPrompt: config.systemPrompt, + systemPrompt: effectiveSystemPrompt, permissionMode: sdkPermissionMode, abortController, images, - onText: async (delta: string) => { - pendingBuffer += delta; - await trySend(); - }, - onThinking: async (summary: string) => { - pendingBuffer += (pendingBuffer ? '\n' : '') + summary; - await trySend(); + onText: async (text: string) => { + if (!text || !isCurrentRun()) { + return; + } + + partialTextBuffer += text; + + if (!sentEarlyResponse) { + if (findEarlyFlushIndex(partialTextBuffer) > 0) { + await queueEarlyResponse(); + } else { + scheduleEarlyResponse(); + } + } }, onPermissionRequest: isAutoPermission ? async () => true // auto-approve all tools, skip broker @@ -508,58 +1187,156 @@ async function sendToClaude( let result = await claudeQuery(queryOptions); - // If resume failed (e.g. corrupted session), retry without resume - if (result.error && queryOptions.resume) { - logger.warn('Resume failed, retrying without resume', { error: result.error, sessionId: queryOptions.resume }); + // Retry without resume only for errors that clearly indicate resume/session reuse is unavailable. + if (queryOptions.resume && shouldRetryWithoutResume(result.error, result.text)) { + logger.warn('Resume unavailable, retrying with local history fallback', { + error: result.error, + sessionId: queryOptions.resume, + }); + // Save the previous session ID for debugging before clearing + session.previousSdkSessionId = queryOptions.resume; queryOptions.resume = undefined; session.sdkSessionId = undefined; + queryOptions.prompt = buildClaudePrompt(true); sessionStore.save(account.accountId, session); const retryResult = await claudeQuery(queryOptions); Object.assign(result, retryResult); } - // Flush any remaining buffered content - await trySend(true); + if (hasStreamingModeCompatibilityError(result.error)) { + const incompatibleSessionId = result.sessionId || queryOptions.resume || session.sdkSessionId; + logger.warn('Streaming mode compatibility issue detected, rotating Claude session for next turn', { + sessionId: incompatibleSessionId, + error: result.error, + }); + if (incompatibleSessionId) { + session.previousSdkSessionId = incompatibleSessionId; + } + result.sessionId = ''; + } + + clearPartialFlushTimer(); - // Send result back to WeChat + if (!isCurrentRun()) { + logger.info('Skipping stale Claude result because a newer query is active', { + sessionId: result.sessionId, + hasText: !!result.text, + hasError: !!result.error, + }); + return; + } + + if (isAbortLikeError(result.error)) { + logger.info('Skipping aborted Claude output', { + sessionId: result.sessionId, + hasText: !!result.text, + error: result.error, + }); + session.state = 'idle'; + sessionStore.save(account.accountId, session); + return; + } + + // Send result back to WeChat — use the streamed prefix when available, + // then fall back to the final result for anything not yet delivered. if (result.text) { if (result.error) { logger.warn('Claude query had error but returned text, using text', { error: result.error }); } sessionStore.addChatMessage(session, 'assistant', result.text); - // If nothing was streamed at all (e.g. streaming not supported), send full text now - if (!anySent) { - const chunks = splitMessage(result.text); + const { filePaths } = extractSendFileMarkers(result.text); + let remainingText = result.text; + + if (sentAssistantPrefix) { + if (result.text.startsWith(sentAssistantPrefix)) { + remainingText = result.text.slice(sentAssistantPrefix.length); + } else { + logger.warn('Streamed assistant prefix did not match final result, falling back to full response', { + streamedLength: sentAssistantPrefix.length, + finalLength: result.text.length, + }); + } + } + + const { cleanedText } = extractSendFileMarkers(remainingText); + const textNotes: string[] = []; + + // Send files first + for (const fp of filePaths) { + try { + if (!existsSync(fp)) { + logger.warn('SEND_FILE: file not found', { path: fp }); + textNotes.push(`⚠️ 文件发送失败: ${fp}(文件不存在)`); + continue; + } + logger.info('SEND_FILE: uploading', { path: fp }); + await sender.sendMedia(fromUserId, contextToken, fp); + logger.info('SEND_FILE: sent successfully', { path: fp }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error('SEND_FILE: failed', { path: fp, error: msg }); + textNotes.push(`⚠️ 文件发送失败: ${fp}`); + } + } + + // Then send the final text that has not already been streamed. + const finalText = cleanupFinalWechatText( + [cleanedText.trim(), ...textNotes].filter(Boolean).join('\n'), + sentEarlyResponse, + ); + if (finalText) { + const chunks = splitWechatReply(finalText); for (const chunk of chunks) { await sender.sendText(fromUserId, contextToken, chunk); } } } else if (result.error) { logger.error('Claude query error', { error: result.error }); - await sender.sendText(fromUserId, contextToken, '⚠️ Claude 处理请求时出错,请稍后重试。'); - } else if (!anySent) { + try { + await sender.sendText(fromUserId, contextToken, '❌ 处理出错,请重试'); + } catch { /* ignore send errors */ } + } else { await sender.sendText(fromUserId, contextToken, 'ℹ️ Claude 无返回内容(可能因权限被拒而终止)'); } // Update session with new SDK session ID session.sdkSessionId = result.sessionId || undefined; session.state = 'idle'; + // Clear pending media paths on successful completion (they've been processed) + pendingMediaPaths.length = 0; sessionStore.save(account.accountId, session); } catch (err) { + clearPartialFlushTimer(); const isAbort = err instanceof Error && (err.name === 'AbortError' || err.message.includes('abort')); if (isAbort) { - // Query was cancelled by a new incoming message — exit silently - logger.info('Claude query aborted by new message'); + // Query was cancelled by a new incoming message — keep pendingMediaPaths intact + // so the next message can reference the downloaded images + logger.info('Claude query aborted by new message, pending media preserved', { pendingMediaPaths }); } else { + if (!isCurrentRun()) { + logger.info('Suppressing error from stale Claude query', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } const errorMsg = err instanceof Error ? err.message : String(err); - logger.error('Error in sendToClaude', { error: errorMsg }); - await sender.sendText(fromUserId, contextToken, '⚠️ 处理消息时出错,请稍后重试。'); + logger.error('Error in sendToClaude (silenced)', { error: errorMsg }); + try { + await sender.sendText(fromUserId, contextToken, '❌ 处理出错,请重试'); + } catch { /* ignore send errors */ } + } + // Note: do NOT clear sdkSessionId on abort — the aborted query may have + // returned a valid sessionId in provider.ts which was already captured. + // Only clear it if we know the session is genuinely lost. + if (isCurrentRun()) { + session.state = 'idle'; + sessionStore.save(account.accountId, session); } - session.state = 'idle'; - sessionStore.save(account.accountId, session); } finally { - // Clean up the abort controller if it's still ours - if (activeControllers.get(account.accountId) === abortController) { + clearPartialFlushTimer(); + // Stop typing indicator + if (isCurrentRun()) { + await stopTyping(); activeControllers.delete(account.accountId); } } diff --git a/src/media/ffmpeg.ts b/src/media/ffmpeg.ts new file mode 100644 index 0000000..63423eb --- /dev/null +++ b/src/media/ffmpeg.ts @@ -0,0 +1,39 @@ +import { existsSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { logger } from '../logger.js'; + +let cachedFfmpegPath: string | null | undefined = undefined; +const require = createRequire(import.meta.url); + +/** + * Resolve the ffmpeg binary path. + * Prefers the bundled npm package (@ffmpeg-installer/ffmpeg), + * falls back to system PATH lookup. + * Returns null if neither is available. + */ +export function getFfmpegPath(): string | null { + if (cachedFfmpegPath !== undefined) { + return cachedFfmpegPath; + } + + // Try bundled ffmpeg from npm package + try { + const bundled = require('@ffmpeg-installer/ffmpeg').path as string; + if (bundled && existsSync(bundled)) { + cachedFfmpegPath = bundled; + logger.info(`Using bundled ffmpeg: ${bundled}`); + return bundled; + } + } catch (error) { + logger.warn('Bundled ffmpeg unavailable, falling back to system PATH', { + error: error instanceof Error ? error.message : String(error), + }); + } + + // Fall back to system ffmpeg in PATH + // execFile will search PATH, so just use the binary name + const sysFfmpeg = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'; + cachedFfmpegPath = sysFfmpeg; + logger.info(`Using system ffmpeg: ${sysFfmpeg}`); + return sysFfmpeg; +} diff --git a/src/media/video.ts b/src/media/video.ts new file mode 100644 index 0000000..6ab5112 --- /dev/null +++ b/src/media/video.ts @@ -0,0 +1,118 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdirSync, readdirSync, unlinkSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { logger } from '../logger.js'; +import { getFfmpegPath } from './ffmpeg.js'; + +const execFileAsync = promisify(execFile); + +/** + * Check if ffmpeg is available (bundled or system) + */ +export function isFfmpegAvailable(): boolean { + return getFfmpegPath() !== null; +} + +/** + * Extract key frames from a video file using ffmpeg + * Returns an array of image file paths (JPEG frames) + * + * @param videoPath - Path to the video file + * @param maxFrames - Maximum number of frames to extract (default: 4) + * @returns Array of extracted frame image paths + */ +export async function extractVideoFrames( + videoPath: string, + maxFrames: number = 4 +): Promise { + // Verify ffmpeg exists + const ffmpegPath = getFfmpegPath(); + if (!ffmpegPath) { + logger.warn('ffmpeg not available, cannot extract video frames'); + return []; + } + + // Verify video file exists + if (!existsSync(videoPath)) { + logger.error(`Video file not found: ${videoPath}`); + return []; + } + + // Create temp directory for frames + const frameDir = join(tmpdir(), `wechat-frames-${Date.now()}`); + mkdirSync(frameDir, { recursive: true }); + + const framePattern = join(frameDir, 'frame_%03d.jpg'); + + try { + // Extract frames evenly distributed across the video duration + await execFileAsync(ffmpegPath, [ + '-i', videoPath, + '-vf', `fps=1/${maxFrames},select='not(mod(n\\,1))'`, + '-frames:v', String(maxFrames), + '-q:v', '2', // Good quality JPEG + '-y', // Overwrite output + framePattern + ], { timeout: 30000 }); + + // Collect extracted frame files + const frames = readdirSync(frameDir) + .filter(f => f.endsWith('.jpg')) + .sort() + .map(f => join(frameDir, f)); + + if (frames.length === 0) { + // Fallback: try simpler extraction (first few seconds) + await execFileAsync(ffmpegPath, [ + '-i', videoPath, + '-vf', `select='gte(n\\,0)'`, + '-frames:v', String(maxFrames), + '-q:v', '2', + '-y', + framePattern + ], { timeout: 30000 }); + + const fallbackFrames = readdirSync(frameDir) + .filter(f => f.endsWith('.jpg')) + .sort() + .map(f => join(frameDir, f)); + + return fallbackFrames; + } + + return frames; + } catch (error) { + logger.error('Failed to extract video frames:', error); + // Cleanup on error + try { + rmSync(frameDir, { recursive: true, force: true }); + } catch {} + return []; + } +} + +/** + * Clean up extracted frame files and their temp directory + */ +export function cleanupFrames(framePaths: string[]): void { + if (framePaths.length === 0) return; + + // Collect unique directories + const dirs = new Set(); + for (const path of framePaths) { + try { + if (existsSync(path)) { + dirs.add(join(path, '..')); + unlinkSync(path); + } + } catch {} + } + // Try to remove the temp directories + for (const dir of dirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} + } +} diff --git a/src/media/voice.ts b/src/media/voice.ts new file mode 100644 index 0000000..465bdb7 --- /dev/null +++ b/src/media/voice.ts @@ -0,0 +1,96 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'util'; +import { existsSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { logger } from '../logger.js'; +import { getFfmpegPath } from './ffmpeg.js'; + +const execFileAsync = promisify(execFile); + +/** + * Convert AMR audio file to WAV using ffmpeg + */ +export async function convertAmrToWav(amrPath: string): Promise { + const ffmpegPath = getFfmpegPath(); + if (!ffmpegPath) { + logger.warn('ffmpeg not available for voice conversion'); + return null; + } + + const wavPath = join(tmpdir(), `voice_${Date.now()}.wav`); + + try { + await execFileAsync(ffmpegPath, [ + '-i', amrPath, + '-ar', '16000', // 16kHz sample rate (good for speech recognition) + '-ac', '1', // Mono + '-sample_fmt', 's16', // 16-bit + '-y', + wavPath + ], { timeout: 15000 }); + + if (existsSync(wavPath)) { + return wavPath; + } + return null; + } catch (error) { + logger.error('AMR to WAV conversion failed:', error); + return null; + } +} + +/** + * Transcribe audio file to text using Windows Speech Recognition (PowerShell) + * Falls back to a simple description if recognition fails + */ +export async function transcribeAudio(audioPath: string): Promise { + if (!existsSync(audioPath)) { + return null; + } + + // Try Windows Speech Recognition via PowerShell + try { + const escapedPath = audioPath.replace(/\\/g, '\\\\'); + const psScript = ` +Add-Type -AssemblyName System.Speech +$recog = New-Object System.Speech.Recognition.SpeechRecognitionEngine +$recog.SetInputToWaveFile("${escapedPath}") +$result = $recog.Recognize() +if ($result) { $result.Text } else { "" } +`; + const { stdout } = await execFileAsync('powershell', [ + '-NoProfile', '-NonInteractive', '-Command', psScript + ], { timeout: 15000 }); + + const text = stdout.trim(); + if (text && text.length > 0) { + return text; + } + } catch (error) { + logger.warn('Windows Speech Recognition failed:', error); + } + + return null; +} + +/** + * Process voice message: convert + transcribe + * Returns transcription text or null + */ +export async function processVoiceMessage(amrPath: string): Promise { + // Step 1: Convert AMR to WAV + const wavPath = await convertAmrToWav(amrPath); + if (!wavPath) { + logger.warn('Could not convert voice message'); + return null; + } + + // Step 2: Transcribe + const text = await transcribeAudio(wavPath); + + // Cleanup WAV file + try { unlinkSync(wavPath); } catch {} + + return text; +} diff --git a/src/permission.ts b/src/permission.ts index c5c1836..dae8f50 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -1,7 +1,7 @@ import { logger } from './logger.js'; import type { PendingPermission } from './session.js'; -const PERMISSION_TIMEOUT = 120_000; +// No timeout — wait indefinitely for user response const GRACE_PERIOD = 15_000; export type OnPermissionTimeout = () => void; @@ -14,7 +14,7 @@ export function createPermissionBroker(onTimeout?: OnPermissionTimeout) { // Clear any existing pending permission for this account to prevent timer leak const existing = pending.get(accountId); if (existing) { - clearTimeout(existing.timer); + if (existing.timer) clearTimeout(existing.timer); pending.delete(accountId); existing.resolve(false); logger.warn('Replaced existing pending permission', { accountId, toolName: existing.toolName }); @@ -22,15 +22,8 @@ export function createPermissionBroker(onTimeout?: OnPermissionTimeout) { timedOut.delete(accountId); // clear any previous timeout flag return new Promise((resolve) => { - const timer = setTimeout(() => { - logger.warn('Permission timeout, auto-denied', { accountId, toolName }); - pending.delete(accountId); - timedOut.set(accountId, Date.now()); - // Clean up grace period entry after GRACE_PERIOD - setTimeout(() => timedOut.delete(accountId), GRACE_PERIOD); - resolve(false); - onTimeout?.(); - }, PERMISSION_TIMEOUT); + // No timeout timer — wait forever until user responds y/n + const timer: ReturnType | null = null; pending.set(accountId, { toolName, toolInput, resolve, timer }); }); @@ -39,7 +32,7 @@ export function createPermissionBroker(onTimeout?: OnPermissionTimeout) { function resolvePermission(accountId: string, allowed: boolean): boolean { const perm = pending.get(accountId); if (!perm) return false; - clearTimeout(perm.timer); + if (perm.timer) clearTimeout(perm.timer); pending.delete(accountId); perm.resolve(allowed); logger.info('Permission resolved', { accountId, toolName: perm.toolName, allowed }); @@ -66,14 +59,13 @@ export function createPermissionBroker(onTimeout?: OnPermissionTimeout) { `\u8F93\u5165: ${perm.toolInput.slice(0, 500)}`, '', '\u56DE\u590D y \u5141\u8BB8\uFF0Cn \u62D2\u7EDD', - '(120\u79D2\u672A\u56DE\u590D\u81EA\u52A8\u62D2\u7EDD)', ].join('\n'); } function rejectPending(accountId: string): boolean { const perm = pending.get(accountId); if (!perm) return false; - clearTimeout(perm.timer); + if (perm.timer) clearTimeout(perm.timer); pending.delete(accountId); perm.resolve(false); logger.info('Permission auto-rejected (session cleared)', { accountId, toolName: perm.toolName }); diff --git a/src/session.ts b/src/session.ts index 336fc6f..c57e4a4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -35,7 +35,7 @@ export interface PendingPermission { toolName: string; toolInput: string; resolve: (allowed: boolean) => void; - timer: NodeJS.Timeout; + timer: NodeJS.Timeout | null; } const DEFAULT_MAX_HISTORY = 100; diff --git a/src/wechat/api.ts b/src/wechat/api.ts index d375256..2baae1b 100644 --- a/src/wechat/api.ts +++ b/src/wechat/api.ts @@ -1,23 +1,20 @@ import type { GetUpdatesResp, SendMessageReq, - GetUploadUrlResp, } from './types.js'; import { logger } from '../logger.js'; -/** Generate a random uint32 and return its base64 representation. */ +/** Generate a random uint32 decimal string, then base64 encode it. */ function generateUin(): string { const buf = new Uint8Array(4); crypto.getRandomValues(buf); - const view = new DataView(buf.buffer); - const uint32 = view.getUint32(0, false); // big-endian - return Buffer.from(buf).toString('base64'); + const uint32 = new DataView(buf.buffer).getUint32(0, false); + return Buffer.from(String(uint32), 'utf-8').toString('base64'); } export class WeChatApi { private readonly token: string; private readonly baseUrl: string; - private readonly uin: string; constructor(token: string, baseUrl: string = 'https://ilinkai.weixin.qq.com') { if (baseUrl) { @@ -36,18 +33,25 @@ export class WeChatApi { } this.token = token; this.baseUrl = baseUrl.replace(/\/+$/, ''); - this.uin = generateUin(); } private headers(): Record { return { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.token}`, 'AuthorizationType': 'ilink_bot_token', - 'X-WECHAT-UIN': this.uin, + 'Authorization': `Bearer ${this.token}`, + 'X-WECHAT-UIN': generateUin(), // regenerate per request }; } + private assertRetSuccess(action: string, response: { ret?: number; retmsg?: string }): void { + if (response.ret === undefined || response.ret === 0) { + return; + } + const suffix = response.retmsg ? ` (${response.retmsg})` : ''; + throw new Error(`${action} failed with ret=${response.ret}${suffix}`); + } + private async request>( path: string, body: unknown, @@ -58,13 +62,16 @@ export class WeChatApi { const url = `${this.baseUrl}/${path}`; - logger.debug('API request', { url, body }); + // All requests must include base_info + const fullBody = { ...(body as Record), base_info: { channel_version: '1.0.0' } }; + + logger.debug('API request', { url, body: fullBody }); try { const res = await fetch(url, { method: 'POST', headers: this.headers(), - body: JSON.stringify(body), + body: JSON.stringify(fullBody), signal: controller.signal, }); @@ -98,32 +105,82 @@ export class WeChatApi { /** Send a message to a user. Retries up to 3 times on rate-limit (ret: -2). */ async sendMessage(req: SendMessageReq): Promise { const MAX_RETRIES = 3; - let delay = 10_000; // start with 10s backoff on rate-limit + let delay = 10_000; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - const res = await this.request<{ ret?: number }>('ilink/bot/sendmessage', req); + const res = await this.request<{ ret?: number; retmsg?: string }>('ilink/bot/sendmessage', req); if ((res as any)?.ret === -2) { if (attempt === MAX_RETRIES) { logger.warn('sendMessage rate-limited after max retries', { attempts: MAX_RETRIES }); - return; // give up silently rather than crash + return; } logger.warn('sendMessage rate-limited (ret:-2), retrying', { attempt, delayMs: delay }); await new Promise(r => setTimeout(r, delay)); - delay = Math.min(delay * 2, 60_000); // exponential backoff, cap at 60s + delay = Math.min(delay * 2, 60_000); continue; } + this.assertRetSuccess('sendMessage', res); return; } } - /** Get a presigned upload URL for media files. */ - async getUploadUrl( - fileType: string, - fileSize: number, - fileName: string, - ): Promise { - return this.request( + /** + * Get CDN upload parameters for a media file. + * Returns upload_param to use as CDN upload query parameter. + */ + async getUploadUrl(params: { + filekey: string; + media_type: number; + to_user_id: string; + rawsize: number; + rawfilemd5: string; + filesize: number; + aeskey: string; + no_need_thumb?: boolean; + }): Promise<{ upload_param: string; thumb_upload_param?: string }> { + const res = await this.request<{ ret?: number; upload_param?: string; thumb_upload_param?: string }>( 'ilink/bot/getuploadurl', - { file_type: fileType, file_size: fileSize, file_name: fileName }, + { + filekey: params.filekey, + media_type: params.media_type, + to_user_id: params.to_user_id, + rawsize: params.rawsize, + rawfilemd5: params.rawfilemd5, + filesize: params.filesize, + aeskey: params.aeskey, + no_need_thumb: params.no_need_thumb ?? true, + }, ); + + if (res.ret !== undefined && res.ret !== 0) { + throw new Error(`getUploadUrl failed with ret=${res.ret}`); + } + if (!res.upload_param) { + throw new Error(`getUploadUrl returned no upload_param: ${JSON.stringify(res)}`); + } + return res as { upload_param: string; thumb_upload_param?: string }; + } + + /** Get typing_ticket for sendtyping. Cached per userId for ~24h. */ + async getConfig(ilinkUserId: string, contextToken?: string): Promise { + const body: Record = { ilink_user_id: ilinkUserId }; + if (contextToken) body.context_token = contextToken; + const res = await this.request<{ ret?: number; retmsg?: string; typing_ticket?: string }>('ilink/bot/getconfig', body); + this.assertRetSuccess('getconfig', res); + if (!res.typing_ticket) { + throw new Error(`getconfig returned no typing_ticket: ${JSON.stringify(res)}`); + } + logger.info('Got typing_ticket', { ilinkUserId }); + return res.typing_ticket; + } + + /** Send or cancel typing indicator. status: 1=typing, 2=cancel. */ + async sendTyping(ilinkUserId: string, typingTicket: string, status: 1 | 2): Promise { + const res = await this.request<{ ret?: number; retmsg?: string }>('ilink/bot/sendtyping', { + ilink_user_id: ilinkUserId, + typing_ticket: typingTicket, + status, + }); + this.assertRetSuccess('sendtyping', res); } + } diff --git a/src/wechat/cdn.ts b/src/wechat/cdn.ts index db67a1b..ed9867a 100644 --- a/src/wechat/cdn.ts +++ b/src/wechat/cdn.ts @@ -1,8 +1,15 @@ -import { decryptAesEcb } from "./crypto.js"; +import { decryptAesEcb, encryptAesEcb } from "./crypto.js"; import { logger } from "../logger.js"; import { CDN_BASE_URL } from "./accounts.js"; +import { readFile } from "node:fs/promises"; +import { basename, extname } from "node:path"; +import { randomBytes, createHash } from "node:crypto"; -export function buildCdnDownloadUrl(encryptQueryParam: string): string { +export function buildCdnDownloadUrl(encryptQueryParam: string, fullUrl?: string): string { + // Prefer full_url (contains taskid) when available — required for re-sent files + if (fullUrl && fullUrl.startsWith('https://')) { + return fullUrl; + } if (!/^[A-Za-z0-9%=&+._~\-/]+$/.test(encryptQueryParam)) { throw new Error('Invalid CDN query parameter'); } @@ -12,8 +19,9 @@ export function buildCdnDownloadUrl(encryptQueryParam: string): string { export async function downloadAndDecrypt( encryptQueryParam: string, aesKeyBase64: string, + fullUrl?: string, ): Promise { - const url = buildCdnDownloadUrl(encryptQueryParam); + const url = buildCdnDownloadUrl(encryptQueryParam, fullUrl); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30_000); let response: Response; @@ -38,10 +46,8 @@ export async function downloadAndDecrypt( const raw = Buffer.from(aesKeyBase64, "base64"); if (raw.length === 16) { - // base64-of-raw-16-bytes aesKey = raw; } else { - // base64-of-hex-string: decode the string as hex to get the 16-byte key const hexStr = raw.toString("utf-8"); aesKey = Buffer.from(hexStr, "hex"); } @@ -51,3 +57,149 @@ export async function downloadAndDecrypt( return decrypted; } + +// ── Upload helpers ────────────────────────────────────────────────────────── + +/** Map file extension to WeChat media_type parameter */ +function mediaTypeFromExt(ext: string): number { + const lower = ext.toLowerCase(); + const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']; + const videoExts = ['.mp4', '.mov', '.avi', '.mkv', '.m4v', '.3gp']; + + if (imageExts.includes(lower)) return 1; // IMAGE + if (videoExts.includes(lower)) return 2; // VIDEO + return 3; // FILE +} + +export interface UploadResult { + mediaType: number; + aesKeyHex: string; + aesKeyBase64: string; + encryptQueryParam: string; + fileName: string; + fileSize: number; + encryptedSize: number; + md5: string; +} + +/** Upload encrypted data to WeChat CDN. Returns x-encrypted-param from response header. */ +async function uploadToCdn(uploadParam: string, filekey: string, encryptedData: Buffer): Promise { + const url = `${CDN_BASE_URL}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`; + + logger.info('Uploading to CDN', { filekey, encryptedSize: encryptedData.length }); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 120_000); + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + }, + body: new Uint8Array(encryptedData), + signal: controller.signal, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`CDN upload failed: ${res.status} ${text}`); + } + + const eqp = res.headers.get('x-encrypted-param'); + if (!eqp) { + throw new Error('CDN upload succeeded but no x-encrypted-param in response headers'); + } + + logger.info('CDN upload succeeded', { encryptedQueryParamLength: eqp.length }); + return eqp; + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + throw new Error('CDN upload timed out after 120s'); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +/** + * Read a local file, encrypt it with AES-128-ECB, and upload to WeChat CDN. + * + * Upload flow per the iLink protocol: + * 1. Generate random AES-128 key and filekey + * 2. Compute raw file MD5 and encrypted size + * 3. Call getUploadUrl to get upload_param + * 4. POST encrypted data to CDN + * 5. Get x-encrypted-param from CDN response header + */ +export async function uploadFile( + filePath: string, + toUserId: string, + getUploadUrl: (params: { + filekey: string; + media_type: number; + to_user_id: string; + rawsize: number; + rawfilemd5: string; + filesize: number; + aeskey: string; + no_need_thumb: boolean; + }) => Promise<{ upload_param: string }>, +): Promise { + const ext = extname(filePath); + const fileName = basename(filePath); + const mediaType = mediaTypeFromExt(ext); + + const fileData = await readFile(filePath); + const fileSize = fileData.length; + + // Generate AES-128 key (16 random bytes) + const aesKeyRaw = randomBytes(16); + const aesKeyHex = aesKeyRaw.toString('hex'); + + // Compute raw file MD5 + const md5 = createHash('md5').update(fileData).digest('hex'); + + // Encrypt file with AES-128-ECB + const encrypted = encryptAesEcb(aesKeyRaw, fileData); + const encryptedSize = encrypted.length; + + // Generate filekey (random 16 bytes hex) + const filekey = randomBytes(16).toString('hex'); + + logger.info('Uploading file', { filePath, fileName, mediaType, fileSize, encryptedSize, md5 }); + + // Step 1: Get upload parameters from API + const uploadInfo = await getUploadUrl({ + filekey, + media_type: mediaType, + to_user_id: toUserId, + rawsize: fileSize, + rawfilemd5: md5, + filesize: encryptedSize, + aeskey: aesKeyHex, + no_need_thumb: true, + }); + + logger.info('Got upload_param from API'); + + // Step 2: Upload encrypted data to CDN + const encryptQueryParam = await uploadToCdn(uploadInfo.upload_param, filekey, encrypted); + + // Prepare aes_key for sendmessage: base64(hex string) + const aesKeyBase64 = Buffer.from(aesKeyHex, 'utf-8').toString('base64'); + + logger.info('File upload complete', { fileName, fileSize, encryptedSize }); + + return { + mediaType, + aesKeyHex, + aesKeyBase64, + encryptQueryParam, + fileName, + fileSize, + encryptedSize, + md5, + }; +} diff --git a/src/wechat/media.ts b/src/wechat/media.ts index fe053b4..41957b2 100644 --- a/src/wechat/media.ts +++ b/src/wechat/media.ts @@ -2,6 +2,42 @@ import type { MessageItem, ImageItem } from './types.js'; import { MessageItemType } from './types.js'; import { downloadAndDecrypt } from './cdn.js'; import { logger } from '../logger.js'; +import { writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +const DOWNLOADS_DIR = join(homedir(), '.wechat-claude-code', 'downloads'); + +export interface DownloadedMedia { + fileName: string; + localPath: string; + size: number; +} + +function getCdnData(item: MessageItem): { aesKey: string; encryptQueryParam: string; fullUrl?: string } | null { + const fileItem = item.file_item ?? item.video_item ?? item.voice_item; + if (!fileItem) return null; + + // media format + if (fileItem.media?.encrypt_query_param && (fileItem.media.aes_key)) { + return { + aesKey: fileItem.media.aes_key, + encryptQueryParam: fileItem.media.encrypt_query_param, + fullUrl: fileItem.media.full_url, + }; + } + + // cdn_media format + if (fileItem.cdn_media?.encrypt_query_param && fileItem.cdn_media?.aes_key) { + return { + aesKey: fileItem.cdn_media.aes_key, + encryptQueryParam: fileItem.cdn_media.encrypt_query_param, + fullUrl: fileItem.cdn_media.full_url, + }; + } + + return null; +} function detectMimeType(data: Buffer): string { if (data[0] === 0x89 && data[1] === 0x50) return 'image/png'; @@ -14,23 +50,24 @@ function detectMimeType(data: Buffer): string { /** * Extract AES key and encrypt_query_param from an ImageItem, - * supporting both the old cdn_media format and the newer flat format. + * prioritizing the original/full-resolution image (media) over cdn_media. */ -function getImageCdnData(imageItem: ImageItem): { aesKey: string; encryptQueryParam: string } | null { - // Old format: cdn_media.aes_key + cdn_media.encrypt_query_param - if (imageItem.cdn_media?.aes_key && imageItem.cdn_media?.encrypt_query_param) { +function getImageCdnData(imageItem: ImageItem): { aesKey: string; encryptQueryParam: string; fullUrl?: string } | null { + // Prefer media (original/full image) — this is the HD version + if (imageItem.media?.encrypt_query_param && (imageItem.media.aes_key || imageItem.aeskey)) { return { - aesKey: imageItem.cdn_media.aes_key, - encryptQueryParam: imageItem.cdn_media.encrypt_query_param, + aesKey: imageItem.media.aes_key ?? imageItem.aeskey!, + encryptQueryParam: imageItem.media.encrypt_query_param, + fullUrl: imageItem.media.full_url, }; } - // New format: aeskey + media.encrypt_query_param - // Use media.aes_key (base64-of-hex) over aeskey (raw hex) since downloadAndDecrypt expects base64 - if (imageItem.media?.encrypt_query_param && (imageItem.media.aes_key || imageItem.aeskey)) { + // Fallback: old cdn_media format + if (imageItem.cdn_media?.aes_key && imageItem.cdn_media?.encrypt_query_param) { return { - aesKey: imageItem.media.aes_key ?? imageItem.aeskey!, - encryptQueryParam: imageItem.media.encrypt_query_param, + aesKey: imageItem.cdn_media.aes_key, + encryptQueryParam: imageItem.cdn_media.encrypt_query_param, + fullUrl: imageItem.cdn_media.full_url, }; } @@ -43,10 +80,10 @@ function getImageCdnData(imageItem: ImageItem): { aesKey: string; encryptQueryPa } /** - * Download a CDN image, decrypt it, and return a base64 data URI. + * Download a CDN image, decrypt it, save to disk, and return a base64 data URI + local path. * Returns null on failure. */ -export async function downloadImage(item: MessageItem): Promise { +export async function downloadImage(item: MessageItem): Promise<{ dataUri: string; localPath: string; size: number } | null> { const imageItem = item.image_item; if (!imageItem) { return null; @@ -57,13 +94,28 @@ export async function downloadImage(item: MessageItem): Promise { return null; } + // Log image size info for debugging + logger.info('Image item size info', { + midSize: imageItem.mid_size, + hdSize: imageItem.hd_size, + thumbSize: imageItem.thumb_size, + thumbWidth: imageItem.thumb_width, + thumbHeight: imageItem.thumb_height, + }); + try { - const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey); + const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey, cdnData.fullUrl); const mimeType = detectMimeType(decrypted); + const ext = mimeType.split('/')[1] || 'jpg'; + const fileName = `image_${Date.now()}.${ext}`; + await mkdir(DOWNLOADS_DIR, { recursive: true }); + const localPath = join(DOWNLOADS_DIR, fileName); + await writeFile(localPath, decrypted); + const base64 = decrypted.toString('base64'); const dataUri = `data:${mimeType};base64,${base64}`; - logger.info('Image downloaded and decrypted', { size: decrypted.length }); - return dataUri; + logger.info('Image downloaded, decrypted and saved', { localPath, size: decrypted.length }); + return { dataUri, localPath, size: decrypted.length }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); logger.warn('Failed to download image', { error: msg }); @@ -80,8 +132,139 @@ export function extractText(item: MessageItem): string { } /** - * Find the first IMAGE type item in a list. + * Find all IMAGE type items in a list. + */ +export function extractAllImageUrls(items?: MessageItem[]): MessageItem[] { + if (!items) return []; + return items.filter((item) => item.type === MessageItemType.IMAGE); +} + +/** + * Find the first FILE type item in a list. + */ +export function extractFileItem(items?: MessageItem[]): MessageItem | undefined { + return items?.find((item) => item.type === MessageItemType.FILE); +} + +/** + * Find the first VIDEO type item in a list. + */ +export function extractVideoItem(items?: MessageItem[]): MessageItem | undefined { + return items?.find((item) => item.type === MessageItemType.VIDEO); +} + +/** + * Find the first VOICE type item in a list. + */ +export function extractVoiceItem(items?: MessageItem[]): MessageItem | undefined { + return items?.find((item) => item.type === MessageItemType.VOICE); +} + +/** + * Sanitize a file name: strip path components, replace special chars, fallback to timestamp. */ -export function extractFirstImageUrl(items?: MessageItem[]): MessageItem | undefined { - return items?.find((item) => item.type === MessageItemType.IMAGE); +export function sanitizeFileName(name: string): string { + // Take only the last segment (strip any path separators) + let base = name.split(/[/\\]/).pop() || ''; + // Replace special characters with underscore + base = base.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + // Collapse multiple underscores + base = base.replace(/_+/g, '_').replace(/^_|_$/g, ''); + // Fallback to timestamp if empty + if (!base) { + base = `file_${Date.now()}`; + } + return base; +} + +/** + * Download a file from CDN and save to local disk. + */ +export async function downloadFileToLocal(item: MessageItem): Promise { + const fileItem = item.file_item; + if (!fileItem) return null; + + const cdnData = getCdnData(item); + if (!cdnData) { + logger.warn('File item has no usable CDN data'); + return null; + } + + const rawFileName = fileItem.file_name || ''; + const fileName = sanitizeFileName(rawFileName) || `file_${Date.now()}`; + + try { + const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey, cdnData.fullUrl); + await mkdir(DOWNLOADS_DIR, { recursive: true }); + const localPath = join(DOWNLOADS_DIR, fileName); + await writeFile(localPath, decrypted); + logger.info('File downloaded and saved', { localPath, size: decrypted.length }); + return { fileName, localPath, size: decrypted.length }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('Failed to download file', { error: msg }); + return null; + } +} + +/** + * Download a video from CDN and save to local disk. + */ +export async function downloadVideoToLocal(item: MessageItem): Promise { + const videoItem = item.video_item; + if (!videoItem) return null; + + const cdnData = getCdnData(item); + if (!cdnData) { + logger.warn('Video item has no usable CDN data'); + return null; + } + + const fileName = `video_${Date.now()}.mp4`; + + try { + const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey, cdnData.fullUrl); + await mkdir(DOWNLOADS_DIR, { recursive: true }); + const localPath = join(DOWNLOADS_DIR, fileName); + await writeFile(localPath, decrypted); + logger.info('Video downloaded and saved', { localPath, size: decrypted.length }); + return { fileName, localPath, size: decrypted.length }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('Failed to download video', { error: msg }); + return null; + } +} + +/** + * Download a voice message from CDN and save to local disk. + */ +export async function downloadVoiceToLocal(item: MessageItem): Promise { + const voiceItem = item.voice_item; + if (!voiceItem) return null; + + const cdnData = getCdnData(item); + if (!cdnData) { + logger.warn('Voice item has no usable CDN data'); + return null; + } + + const fileName = `voice_${Date.now()}.amr`; + + try { + const decrypted = await downloadAndDecrypt(cdnData.encryptQueryParam, cdnData.aesKey, cdnData.fullUrl); + await mkdir(DOWNLOADS_DIR, { recursive: true }); + const localPath = join(DOWNLOADS_DIR, fileName); + await writeFile(localPath, decrypted); + logger.info('Voice downloaded and saved', { localPath, size: decrypted.length }); + const result: DownloadedMedia & { voiceText?: string } = { fileName, localPath, size: decrypted.length }; + if (voiceItem.text) { + result.voiceText = voiceItem.text; + } + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.warn('Failed to download voice', { error: msg }); + return null; + } } diff --git a/src/wechat/send.ts b/src/wechat/send.ts index 80134bd..7a40fb2 100644 --- a/src/wechat/send.ts +++ b/src/wechat/send.ts @@ -1,5 +1,6 @@ import { WeChatApi } from './api.js'; import { MessageItemType, MessageType, MessageState, type MessageItem, type OutboundMessage } from './types.js'; +import { uploadFile, type UploadResult } from './cdn.js'; import { logger } from '../logger.js'; export function createSender(api: WeChatApi, botAccountId: string) { @@ -34,5 +35,149 @@ export function createSender(api: WeChatApi, botAccountId: string) { logger.info('Text message sent', { toUserId, clientId }); } - return { sendText }; + /** + * Upload a local file to WeChat CDN and send it as a file message. + */ + async function sendFile(toUserId: string, contextToken: string, filePath: string): Promise { + const clientId = generateClientId(); + + const result: UploadResult = await uploadFile( + filePath, + toUserId, + (params) => api.getUploadUrl(params), + ); + + const items: MessageItem[] = [ + { + type: MessageItemType.FILE, + file_item: { + media: { + encrypt_query_param: result.encryptQueryParam, + aes_key: result.aesKeyBase64, + encrypt_type: 1, + }, + file_name: result.fileName, + md5: result.md5, + len: String(result.fileSize), + }, + }, + ]; + + const msg: OutboundMessage = { + from_user_id: botAccountId, + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: items, + }; + + logger.info('Sending file message', { toUserId, clientId, fileName: result.fileName, fileSize: result.fileSize }); + await api.sendMessage({ msg }); + logger.info('File message sent', { toUserId, clientId }); + } + + /** + * Upload a local image to WeChat CDN and send it as an image message. + */ + async function sendImage(toUserId: string, contextToken: string, filePath: string): Promise { + const clientId = generateClientId(); + + const result: UploadResult = await uploadFile( + filePath, + toUserId, + (params) => api.getUploadUrl(params), + ); + + const items: MessageItem[] = [ + { + type: MessageItemType.IMAGE, + image_item: { + media: { + encrypt_query_param: result.encryptQueryParam, + aes_key: result.aesKeyBase64, + encrypt_type: 1, + }, + mid_size: result.encryptedSize, + hd_size: result.encryptedSize, + }, + }, + ]; + + const msg: OutboundMessage = { + from_user_id: botAccountId, + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: items, + }; + + logger.info('Sending image message', { toUserId, clientId, fileName: result.fileName, fileSize: result.fileSize }); + await api.sendMessage({ msg }); + logger.info('Image message sent', { toUserId, clientId }); + } + + /** + * Upload a local video to WeChat CDN and send it as a video message. + */ + async function sendVideo(toUserId: string, contextToken: string, filePath: string): Promise { + const clientId = generateClientId(); + + const result: UploadResult = await uploadFile( + filePath, + toUserId, + (params) => api.getUploadUrl(params), + ); + + const items: MessageItem[] = [ + { + type: MessageItemType.VIDEO, + video_item: { + media: { + encrypt_query_param: result.encryptQueryParam, + aes_key: result.aesKeyBase64, + encrypt_type: 1, + }, + video_size: result.encryptedSize, + video_md5: result.md5, + }, + }, + ]; + + const msg: OutboundMessage = { + from_user_id: botAccountId, + to_user_id: toUserId, + client_id: clientId, + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: items, + }; + + logger.info('Sending video message', { toUserId, clientId, fileName: result.fileName, fileSize: result.fileSize }); + await api.sendMessage({ msg }); + logger.info('Video message sent', { toUserId, clientId }); + } + + /** + * Auto-detect file type and send using the appropriate method. + */ + async function sendMedia(toUserId: string, contextToken: string, filePath: string): Promise { + const ext = filePath.split('.').pop()?.toLowerCase() ?? ''; + const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; + const videoExts = ['mp4', 'mov', 'avi', 'mkv', 'm4v', '3gp']; + + if (imageExts.includes(ext)) { + await sendImage(toUserId, contextToken, filePath); + } else if (videoExts.includes(ext)) { + await sendVideo(toUserId, contextToken, filePath); + } else { + await sendFile(toUserId, contextToken, filePath); + } + } + + return { sendText, sendFile, sendImage, sendVideo, sendMedia }; } diff --git a/src/wechat/types.ts b/src/wechat/types.ts index df135fe..da1d71b 100644 --- a/src/wechat/types.ts +++ b/src/wechat/types.ts @@ -1,5 +1,5 @@ -// WeChat Work (企业微信) protocol type definitions -// Extracted from the ClawBot WeChat plugin API +// WeChat iLink Bot protocol type definitions +// Based on: https://github.com/epiral/weixin-bot/blob/main/docs/protocol-spec.md // ── Enums ────────────────────────────────────────────────────────────────── @@ -25,9 +25,10 @@ export enum MessageState { // ── Media ────────────────────────────────────────────────────────────────── export interface CDNMedia { - aes_key: string; encrypt_query_param: string; - cdn_url?: string; + aes_key: string; + full_url?: string; // Complete CDN URL including taskid — needed for re-sent files + encrypt_type?: number; // 0 = only file id encrypted, 1 = packed with thumb etc. } // ── Message Items ─────────────────────────────────────────────────────────── @@ -38,26 +39,45 @@ export interface TextItem { export interface ImageItem { cdn_media?: CDNMedia; - /** Alternative field name used by some API versions */ + media?: CDNMedia; + thumb_media?: CDNMedia; aeskey?: string; - media?: { encrypt_query_param: string; aes_key?: string }; url?: string; mid_size?: number; hd_size?: number; + thumb_size?: number; + thumb_width?: number; + thumb_height?: number; } export interface VoiceItem { - cdn_media: CDNMedia; - voice_text?: string; + cdn_media?: CDNMedia; + media?: CDNMedia; + encode_type?: number; + bits_per_sample?: number; + sample_rate?: number; + playtime?: number; + text?: string; } export interface FileItem { - cdn_media: CDNMedia; + cdn_media?: CDNMedia; + media?: CDNMedia; file_name?: string; + md5?: string; + len?: string; } export interface VideoItem { - cdn_media: CDNMedia; + cdn_media?: CDNMedia; + media?: CDNMedia; + video_size?: number; + play_length?: number; + video_md5?: string; + thumb_media?: CDNMedia; + thumb_size?: number; + thumb_width?: number; + thumb_height?: number; } export interface MessageItem { @@ -115,15 +135,31 @@ export interface SendMessageReq { // ── GetUploadUrl API ──────────────────────────────────────────────────────── +/** media_type values for getuploadurl */ +export enum MediaType { + IMAGE = 1, + VIDEO = 2, + FILE = 3, + VOICE = 4, +} + export interface GetUploadUrlReq { - file_type: string; - file_size: number; - file_name: string; + filekey: string; + media_type: MediaType; + to_user_id: string; + rawsize: number; + rawfilemd5: string; + filesize: number; + no_need_thumb: boolean; + aeskey: string; + base_info: { + channel_version: string; + }; } export interface GetUploadUrlResp { - errcode: number; - url: string; - aes_key: string; - encrypt_query_param: string; + ret?: number; + errcode?: number; + upload_param?: string; + thumb_upload_param?: string; }