diff --git a/README.md b/README.md index 06ad077..4ee3a99 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,203 @@ # wechat-relay -> Bridge **WeChat personal account** messages to any webhook, script, or pipeline. -> Zero dependencies beyond .NET 10. Native AOT binaries for Linux, macOS & Windows. +> Your personal WeChat account, exposed as a tiny event pipe. +> Scan QR. Listen forever. Ship payloads anywhere. [![CI](https://github.com/slaveoftime/wechat-relay/actions/workflows/ci.yml/badge.svg)](https://github.com/slaveoftime/wechat-relay/actions/workflows/ci.yml) [![NuGet](https://img.shields.io/nuget/v/wechat-relay.svg?color=blue&logo=nuget)](https://www.nuget.org/packages/wechat-relay) [![MIT](https://img.shields.io/github/license/slaveoftime/wechat-relay)](LICENSE) [![dotnet tool](https://img.shields.io/badge/dotnet--tool-install-512bd4)](#install) -## Features +`wechat-relay` is a minimal CLI that turns a WeChat personal account into a programmable message bridge. -| | | -|---|---| -| πŸ” **QR Login** | Scan once, credentials saved in a local JSON session file. No expiry until the server invalidates the session. | -| πŸ“‘ **Persistent Listener** | Long-poll WeChat messages. Survives restarts via disk-backed queue. | -| πŸ”— **Configurable Hooks** | Every inbound message triggers your command (default: `echo`). Passes JSON metadata. | -| πŸ“€ **Send Messages** | Reply to any user via CLI. Context tokens cached automatically. | -| πŸ—οΈ **Native AOT** | Single-file, self-contained binaries β€” no runtime needed. | -| 🐧 **Cross-platform** | `linux-x64`, `win-x64`, `osx-arm64`. | +- QR login with local session persistence +- Long-poll listener with crash-safe queue replay +- Hook command execution for every inbound message +- Reply from the CLI with text, image, or audio +- NuGet tool, npm wrapper, and native AOT binaries ## Install -### As a .NET global tool - -### As an npm package +### npm wrapper ```bash npm install -g @slaveoftime/wechat-relay -wechat-relay # runs the bundled native binary +wechat-relay ``` -```bash -dotnet tool install -g wechat-relay -wechat-relay # prints usage -``` +The npm package ships a bundled native binary for: -### Or grab a native binary +- `linux-x64` +- `win32-x64` +- `darwin-arm64` -Download from [Releases](https://github.com/slaveoftime/wechat-relay/releases): - -| Platform | Asset | -|----------|-------| -| Linux x64 | `wechat-relay-v{version}-linux-x64.tar.gz` | -| Windows x64 | `wechat-relay-v{version}-win-x64.zip` | -| macOS ARM64 | `wechat-relay-v{version}-osx-arm64.tar.gz` | - -Extract and run β€” **no .NET SDK required**. - -## Quick Start +### .NET global tool ```bash -# 1. Login β€” scan QR with WeChat on your phone -wechat-relay login - -# 2. Start listening β€” messages print to console + trigger your hook -wechat-relay listen - -# 3. Send a message -wechat-relay send --text "Hello from CLI!" +dotnet tool install -g wechat-relay +wechat-relay ``` -## Commands - -### `login` +### Native binary -QR code login. Credentials and reply-session tokens are saved in a local JSON session file under your application data directory. They stay available until the server-side session actually expires. +Grab a release artifact and run it directly: -``` -wechat-relay login # use cached or start QR flow -wechat-relay login --force # force new QR login -``` +- `wechat-relay-{version}-linux-x64.tar.gz` +- `wechat-relay-{version}-win-x64.zip` +- `wechat-relay-{version}-osx-arm64.tar.gz` -No browser auto-open β€” the QR URL is printed directly in your terminal for easy copy/paste. +Native builds are self-contained. No SDK required. -### `listen` - -Runs until `Ctrl+C`. Logs each inbound message to console and fires your configured hook command **asynchronously** (non-blocking). +## Boot Sequence ```bash -wechat-relay listen -``` +# 1. Pair the account +wechat-relay login -**Output:** -``` -=== Listening for WeChat Messages === -Press Ctrl+C to stop. +# 2. Start the stream +wechat-relay listen -[14:23:01] seq=42 from=o9cq8...@im.wechat type=1 text="δ½ ε₯½" -[14:23:15] seq=43 from=o9cq8...@im.wechat type=1 text="εœ¨ε—οΌŸ" -``` +# 3. Reply with text +wechat-relay send --text "hello from the terminal" -**Hook payload** (JSON passed to your command): -```json -{ - "seq": 42, - "message_id": 7447467781622590088, - "from_user_id": "@im.wechat", - "to_user_id": "@im.bot", - "create_time_ms": 1775614686361, - "session_id": "", - "message_type": 1, - "text": "δ½ ε₯½", - "context_token": "AARzJWAFAAABAAAA..." -} +# 4. Reply with media +wechat-relay send friend@im.wechat --image ./cat.jpg +wechat-relay send friend@im.wechat --audio ./reply.silk --audio-playtime-ms 4210 ``` -**Persistent queue:** Messages are written to `~/.wechat-relay/pending-messages.jsonl` before hook execution. If the process crashes, pending messages are replayed on next `listen`. +The first time you reply to a user, WeChat expects a `context_token` from an inbound message. +Translation: run `listen`, let them message you once, then `send` works. -### `list-send-to` - -Show all users you can send messages to. - -> Currently, it only support to send to itself. +## Commands -```bash -wechat-relay list-send-to -``` +| Command | What it does | +|---|---| +| `login` | Starts QR login, or reuses the stored local session | +| `login --force` | Clears stored session state and pairs again | +| `listen` | Long-polls WeChat and fires your hook for each inbound message | +| `listen --hook "..."` | Overrides `Hook:Command` for the current run | +| `list-send-to` | Prints send targets from `WeChat:UserId` and `WeChat:ToUsers` | +| `send [target] --text "..."` | Sends a text message | +| `send [target] --image ./file.jpg` | Uploads and sends an image | +| `send [target] --audio ./file.silk` | Uploads and sends audio or voice | +| `--verbose` | Enables debug logging on any command | -### `send` +`send` accepts exactly one payload mode at a time: `--text`, `--image`, or `--audio`. -Send a text message to a WeChat user. +Audio flags currently supported: ```bash -# Send to default channel (logged-in user) -wechat-relay send --text "Hello!" - -# Send to a specific user -wechat-relay send user@im.wechat --text "Hi there" - -# Pipe from stdin -echo "piped message" | wechat-relay send +wechat-relay send friend@im.wechat \ + --audio ./reply.ogg \ + --audio-format ogg \ + --audio-sample-rate 16000 \ + --audio-bits-per-sample 16 \ + --audio-playtime-ms 4210 ``` -> **Note:** The WeChat ilink API requires a `context_token` from a prior inbound message. Run `listen` first and have the user message you, then `send` will use the cached token automatically. +Supported audio format hints: `pcm`, `wav`, `adpcm`, `feature`, `speex`, `amr`, `silk`, `mp3`, `ogg`. -## Configuration +## Config -Edit `appsettings.json`: +Put `appsettings.json` next to the executable or run from the project directory. ```json { - "AppSettings": { - "LogPath": "logs/wechat-relay.log", - "Verbose": false - }, "WeChat": { "BaseUrl": "https://ilinkai.weixin.qq.com/", "BotType": "3", - "UserId": "your-user@im.wechat", - "ToUsers": "user1@im.wechat,user2@im.wechat" + "UserId": "self@im.wechat", + "ToUsers": "friend1@im.wechat,friend2@im.wechat" }, "Hook": { - "Command": "node /path/to/your/hook.js {payload}", - "WorkingDirectory": "/path/to/your/project" + "Command": "echo {payload}", + "WorkingDirectory": null } } ``` -### Hook Examples +Notes: -**Echo (default):** -```json -{ "Command": "echo" } -``` +- `Hook:Command` defaults to `echo` +- `listen --hook` overrides `Hook:Command` without editing config +- `UserId` is the default `send` target +- `ToUsers` adds extra IDs for `list-send-to` -**Call a Node.js script:** -```json -{ "Command": "node /opt/hooks/wechat.js {payload}" } -``` +## Hook Mode -**Curl to a webhook:** -```json -{ "Command": "curl -X POST https://hooks.example.com/wechat -H Content-Type:application/json -d '{payload}'" } -``` +Every inbound message is queued, persisted, and then passed to your hook command as JSON. + +Minimal example: -**Run a Python script:** ```json -{ "Command": "python3 /opt/hooks/handler.py" } +{ + "seq": 42, + "message_id": 7447467781622590088, + "from_user_id": "friend@im.wechat", + "to_user_id": "bot@im.bot", + "create_time_ms": 1775614686361, + "message_type": 1, + "text": "hello", + "summary": "text=\"hello\" [image] [audio 4210ms]", + "items": [ + { + "item_type": 1, + "kind": "text", + "text": "hello" + }, + { + "item_type": 2, + "kind": "image", + "local_path": "C:\\Users\\you\\AppData\\Roaming\\wechat-relay\\inbound-media\\20260409\\msg-7447467781622590088-42\\00-image-image.jpg" + } + ], + "context_token": "AARzJWAFAAABAAAA..." +} ``` -The `{payload}` placeholder is replaced with the raw JSON. Omit it to pass the JSON as a quoted argument. +Storage lives under your application data directory: -## Architecture +- `session-state.json` stores login state and cached context tokens +- `pending-messages.jsonl` stores queued hook work for crash recovery +- `inbound-media/` stores downloaded inbound images, audio, files, and video before the hook runs -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ wechat-relay β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ listen │───▢│ Inbound Messages β”‚ β”‚ -β”‚ β”‚ (long- β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ poll) β”‚ β”‚ β”Œβ”€β”€β” β”Œβ”€β”€β” β”Œβ”€β”€β” β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚M1β”‚ β”‚M2β”‚ β”‚M3β”‚ ... β”‚ β”‚ -β”‚ β”‚ β””β”¬β”€β”˜ β””β”¬β”€β”˜ β””β”¬β”€β”˜ β”‚ β”‚ -β”‚ β””β”€β”€β”€β”Όβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ └─────┐ β”‚ -β”‚ β–Ό β–Ό β–Ό β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Persistent Queue (JSONL) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Hook Runner β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ά β”‚ (async) │──────────┐ β”‚ -β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ -β”‚ β”‚ β”‚ β”‚ -β”‚ β”‚ cmd: echo / node / curl / python β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Local Session Store β”‚ β”‚ -β”‚ β”‚ (%APPDATA%/wechat-relay) β”‚ β”‚ -β”‚ β”‚ - login credentials β”‚ β”‚ -β”‚ β”‚ - reply context tokens β”‚ β”‚ -β”‚ β”‚ - session-state.json β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +If the process dies after receipt but before hook execution, the next `listen` run drains the queue and replays the pending payloads. + +## Console Vibe + +Typical listener output: + +```text +Listening + +Bridge WeChat messages to any webhook +Press Ctrl+C to stop. + +[14:23:01] 42 friend@im.wechat type=1 text="hello" +[14:23:15] 43 friend@im.wechat type=1 [image] +[14:23:22] 44 friend@im.wechat type=1 text="see attached" [audio 4210ms] ``` -## Build from Source +## Build From Source ```bash git clone https://github.com/slaveoftime/wechat-relay.git cd wechat-relay/WeChatRelay -# Debug build +# local build dotnet build -# Release build -dotnet build -c Release - -# Pack as NuGet tool +# package as a dotnet tool dotnet pack -c Release -# Install from local package -dotnet tool install -g --source ./bin/Release/ wechat-relay -# Native AOT (single file, no runtime needed) -dotnet publish -c Release -r win-x64 --self-contained -p:PublishAot=true -dotnet publish -c Release -r linux-x64 --self-contained -p:PublishAot=true -dotnet publish -c Release -r osx-arm64 --self-contained -p:PublishAot=true +# native AOT +dotnet publish -c Release -r win-x64 --self-contained true -p:PublishAot=true +dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishAot=true +dotnet publish -c Release -r osx-arm64 --self-contained true -p:PublishAot=true ``` +Target framework: `.NET 10`. + ## License [MIT](LICENSE) diff --git a/TESTING.md b/TESTING.md index 2be3fcb..97fac65 100644 --- a/TESTING.md +++ b/TESTING.md @@ -37,10 +37,16 @@ wechat-relay send --text "test" # Specific target wechat-relay send o9cq8...@im.wechat --text "test" +# Image +wechat-relay send o9cq8...@im.wechat --image .\sample.jpg + +# Audio / voice +wechat-relay send o9cq8...@im.wechat --audio .\sample.silk --audio-playtime-ms 4210 + # Via stdin echo hello | wechat-relay send ``` -**Expected:** `βœ“ Sent.` or error with hint about context token. +**Expected:** `βœ“ Message sent...`, `βœ“ Image sent...`, or `βœ“ Audio sent...`; otherwise an error with hint about context token. ### 5. Listen + Hook (requires inbound message) @@ -69,11 +75,13 @@ wechat-relay listen **Expected output:** ``` [15:23:01] seq=42 from=o9cq8...@im.wechat type=1 text="hello" +[15:23:15] seq=43 from=o9cq8...@im.wechat type=1 [image] +[15:23:22] seq=44 from=o9cq8...@im.wechat type=1 text="ζˆ‘δΈ‹εˆδΈ‰η‚Ήεˆ°γ€‚" [audio 4210ms] ``` **Hook payload (echoed to console):** ```json -{"seq":42,"message_id":...,"from_user_id":"o9cq8...","to_user_id":"...","create_time_ms":...,"session_id":"","message_type":1,"text":"hello","context_token":"AARz..."} +{"seq":42,"message_id":...,"from_user_id":"o9cq8...","to_user_id":"...","create_time_ms":...,"session_id":"","message_type":1,"text":"hello","summary":"text=\"hello\" [image]","items":[{"item_type":2,"kind":"image","local_path":"C:\\Users\\you\\AppData\\Roaming\\wechat-relay\\inbound-media\\20260409\\msg-7447467781622590088-42\\00-image-image.jpg"}],"context_token":"AARz..."} ``` **Step D β€” Ctrl+C to stop.** diff --git a/WeChatRelay/Commands/ListenCommand.cs b/WeChatRelay/Commands/ListenCommand.cs index df4d840..1d66eeb 100644 --- a/WeChatRelay/Commands/ListenCommand.cs +++ b/WeChatRelay/Commands/ListenCommand.cs @@ -62,9 +62,11 @@ await weChat.StartReceivingAsync(async msg => var seq = msg.Seq?.ToString() ?? "?"; var fromUserId = msg.FromUserId ?? ""; var messageType = msg.MessageType?.ToString() ?? "?"; + var summary = MessageInspector.Describe(msg); + var suffix = string.IsNullOrWhiteSpace(summary) ? string.Empty : $" {Markup.Escape(summary)}"; AnsiConsole.MarkupLine( - $"[grey][[{Markup.Escape(time)}]][/] [bold]{Markup.Escape(seq)}[/] [blue]{Markup.Escape(fromUserId)}[/] [grey]type={Markup.Escape(messageType)}[/]"); + $"[grey][[{Markup.Escape(time)}]][/] [bold]{Markup.Escape(seq)}[/] [blue]{Markup.Escape(fromUserId)}[/] [grey]type={Markup.Escape(messageType)}[/]{suffix}"); if (!string.IsNullOrEmpty(msg.FromUserId) && !string.IsNullOrEmpty(msg.ContextToken)) contextTokenStore.SetContextToken(msg.FromUserId, msg.ContextToken); diff --git a/WeChatRelay/Commands/SendCommand.cs b/WeChatRelay/Commands/SendCommand.cs index 4c4acc8..a9d54de 100644 --- a/WeChatRelay/Commands/SendCommand.cs +++ b/WeChatRelay/Commands/SendCommand.cs @@ -10,6 +10,18 @@ public sealed class SendCommandSettings : VerboseCommandSettings public string? Target { get; init; } public string? Text { get; init; } + + public string? ImagePath { get; init; } + + public string? AudioPath { get; init; } + + public string? AudioFormat { get; init; } + + public int? AudioSampleRate { get; init; } + + public int? AudioBitsPerSample { get; init; } + + public int? AudioPlaytimeMs { get; init; } } public static class SendCommand @@ -50,19 +62,70 @@ private static async Task ExecuteCoreAsync( return 1; } - var message = settings.Text ?? Console.ReadLine() ?? ""; - if (string.IsNullOrEmpty(message)) + var selectedInputCount = + (string.IsNullOrWhiteSpace(settings.Text) ? 0 : 1) + + (string.IsNullOrWhiteSpace(settings.ImagePath) ? 0 : 1) + + (string.IsNullOrWhiteSpace(settings.AudioPath) ? 0 : 1); + if (selectedInputCount > 1) { - AnsiConsole.MarkupLine("[bold red]⚠ No message.[/] Use [cyan]--text [/] or pipe via stdin."); + AnsiConsole.MarkupLine("[bold red]⚠ Pick one payload type.[/] Use only one of [cyan]--text[/], [cyan]--image[/], or [cyan]--audio[/]."); return 1; } var contextToken = contextTokenStore.GetContextToken(toUser); - var result = await weChat.SendTextAsync(toUser, message, contextToken: contextToken); + SendMessageResponse result; + var sentLabel = "Message"; + + if (!string.IsNullOrWhiteSpace(settings.ImagePath)) + { + if (!File.Exists(settings.ImagePath)) + { + AnsiConsole.MarkupLine($"[bold red]⚠ Image file not found:[/] [grey]{Markup.Escape(settings.ImagePath)}[/]"); + return 1; + } + + result = await weChat.SendImageAsync(toUser, settings.ImagePath, contextToken: contextToken); + sentLabel = "Image"; + } + else if (!string.IsNullOrWhiteSpace(settings.AudioPath)) + { + if (!File.Exists(settings.AudioPath)) + { + AnsiConsole.MarkupLine($"[bold red]⚠ Audio file not found:[/] [grey]{Markup.Escape(settings.AudioPath)}[/]"); + return 1; + } + + var encodeType = ResolveAudioEncodeType(settings.AudioPath, settings.AudioFormat, out var encodeTypeError); + if (encodeTypeError is not null) + { + AnsiConsole.MarkupLine($"[bold red]⚠ Invalid audio format:[/] {Markup.Escape(encodeTypeError)}"); + return 1; + } + + result = await weChat.SendAudioAsync(toUser, settings.AudioPath, new AudioSendOptions + { + EncodeType = encodeType, + SampleRate = settings.AudioSampleRate, + BitsPerSample = settings.AudioBitsPerSample, + PlaytimeMs = settings.AudioPlaytimeMs + }, contextToken: contextToken); + sentLabel = "Audio"; + } + else + { + var message = settings.Text ?? Console.ReadLine() ?? ""; + if (string.IsNullOrEmpty(message)) + { + AnsiConsole.MarkupLine("[bold red]⚠ No message.[/] Use [cyan]--text [/], [cyan]--image [/], [cyan]--audio [/], or pipe text via stdin."); + return 1; + } + + result = await weChat.SendTextAsync(toUser, message, contextToken: contextToken); + } if (result.Ret == 0) { - AnsiConsole.MarkupLine($"[bold green]βœ“ Message sent to {toUser}[/]"); + AnsiConsole.MarkupLine($"[bold green]βœ“ {sentLabel} sent to {toUser}[/]"); return 0; } @@ -74,4 +137,44 @@ private static async Task ExecuteCoreAsync( } return 1; } + + private static int? ResolveAudioEncodeType(string audioPath, string? audioFormat, out string? error) + { + error = null; + + var format = audioFormat; + if (string.IsNullOrWhiteSpace(format)) + { + var extension = Path.GetExtension(audioPath); + format = string.IsNullOrWhiteSpace(extension) ? null : extension.TrimStart('.'); + } + + if (string.IsNullOrWhiteSpace(format)) + { + return null; + } + + return format.Trim().ToLowerInvariant() switch + { + "pcm" => 1, + "wav" => 1, + "adpcm" => 2, + "feature" => 3, + "speex" => 4, + "spx" => 4, + "amr" => 5, + "sil" => 6, + "silk" => 6, + "mp3" => 7, + "ogg" => 8, + "ogg-speex" => 8, + _ => SetError($"Unsupported format '{format}'. Use pcm, wav, adpcm, feature, speex, amr, silk, mp3, or ogg.", out error) + }; + } + + private static int? SetError(string message, out string? error) + { + error = message; + return null; + } } diff --git a/WeChatRelay/Models/ApiModels.cs b/WeChatRelay/Models/ApiModels.cs index 6ff6d67..4b487fb 100644 --- a/WeChatRelay/Models/ApiModels.cs +++ b/WeChatRelay/Models/ApiModels.cs @@ -103,6 +103,9 @@ public class MessageItem [JsonPropertyName("image_item")] public ImageItem? ImageItem { get; init; } + [JsonPropertyName("voice_item")] + public VoiceItem? VoiceItem { get; init; } + [JsonPropertyName("file_item")] public FileItem? FileItem { get; init; } @@ -121,39 +124,115 @@ public class TextItem public class ImageItem { - public string? Md5 { get; init; } - public long? Len { get; init; } + [JsonPropertyName("media")] + public CdnMedia? Media { get; init; } + + [JsonPropertyName("thumb_media")] + public CdnMedia? ThumbMedia { get; init; } + + [JsonPropertyName("aeskey")] + public string? AesKey { get; init; } + + [JsonPropertyName("url")] public string? Url { get; init; } - public CdnMedia? CdnMedia { get; init; } + + [JsonPropertyName("mid_size")] + public long? MidSize { get; init; } + + [JsonPropertyName("thumb_size")] + public long? ThumbSize { get; init; } + + [JsonPropertyName("thumb_height")] + public int? ThumbHeight { get; init; } + + [JsonPropertyName("thumb_width")] + public int? ThumbWidth { get; init; } + + [JsonPropertyName("hd_size")] + public long? HdSize { get; init; } +} + +public class VoiceItem +{ + [JsonPropertyName("media")] + public CdnMedia? Media { get; init; } + + [JsonPropertyName("encode_type")] + public int? EncodeType { get; init; } + + [JsonPropertyName("bits_per_sample")] + public int? BitsPerSample { get; init; } + + [JsonPropertyName("sample_rate")] + public int? SampleRate { get; init; } + + [JsonPropertyName("playtime")] + public int? Playtime { get; init; } + + [JsonPropertyName("text")] + public string? Text { get; init; } } public class FileItem { + [JsonPropertyName("file_name")] public string? FileName { get; init; } + + [JsonPropertyName("md5")] public string? Md5 { get; init; } + + [JsonPropertyName("len")] public long? Len { get; init; } - public CdnMedia? CdnMedia { get; init; } + + [JsonPropertyName("media")] + public CdnMedia? Media { get; init; } } public class VideoItem { + [JsonPropertyName("md5")] public string? Md5 { get; init; } + + [JsonPropertyName("len")] public long? Len { get; init; } - public CdnMedia? CdnMedia { get; init; } + + [JsonPropertyName("media")] + public CdnMedia? Media { get; init; } + + [JsonPropertyName("thumb_media")] + public CdnMedia? ThumbMedia { get; init; } + + [JsonPropertyName("video_size")] + public long? VideoSize { get; init; } } public class CdnMedia { + [JsonPropertyName("encrypt_query_param")] public string? EncryptQueryParam { get; init; } + + [JsonPropertyName("aes_key")] public string? AesKey { get; init; } + + [JsonPropertyName("encrypt_type")] + public int? EncryptType { get; init; } } public class RefMessage { + [JsonPropertyName("from_user_id")] public string? FromUserId { get; init; } + + [JsonPropertyName("to_user_id")] public string? ToUserId { get; init; } + + [JsonPropertyName("create_time_ms")] public long? CreateTimeMs { get; init; } + + [JsonPropertyName("content")] public string? Content { get; init; } + + [JsonPropertyName("type")] public int? Type { get; init; } } @@ -196,6 +275,12 @@ public class OutboundItem [JsonPropertyName("text_item")] public TextItemOut? TextItem { get; init; } + + [JsonPropertyName("image_item")] + public ImageItemOut? ImageItem { get; init; } + + [JsonPropertyName("voice_item")] + public VoiceItemOut? VoiceItem { get; init; } } public class TextItemOut @@ -204,6 +289,92 @@ public class TextItemOut public string Text { get; init; } = string.Empty; } +public class ImageItemOut +{ + [JsonPropertyName("media")] + public CdnMedia Media { get; init; } = new(); + + [JsonPropertyName("mid_size")] + public long MidSize { get; init; } + + [JsonPropertyName("hd_size")] + public long? HdSize { get; init; } +} + +public class VoiceItemOut +{ + [JsonPropertyName("media")] + public CdnMedia Media { get; init; } = new(); + + [JsonPropertyName("encode_type")] + public int? EncodeType { get; init; } + + [JsonPropertyName("bits_per_sample")] + public int? BitsPerSample { get; init; } + + [JsonPropertyName("sample_rate")] + public int? SampleRate { get; init; } + + [JsonPropertyName("playtime")] + public int? Playtime { get; init; } + + [JsonPropertyName("text")] + public string? Text { get; init; } +} + +public sealed class AudioSendOptions +{ + public int? EncodeType { get; init; } + public int? BitsPerSample { get; init; } + public int? SampleRate { get; init; } + public int? PlaytimeMs { get; init; } +} + +public sealed class GetUploadUrlRequest +{ + [JsonPropertyName("aeskey")] + public string AesKey { get; init; } = string.Empty; + + [JsonPropertyName("base_info")] + public UploadBaseInfo BaseInfo { get; init; } = new(); + + [JsonPropertyName("filekey")] + public string FileKey { get; init; } = string.Empty; + + [JsonPropertyName("filesize")] + public long FileSize { get; init; } + + [JsonPropertyName("media_type")] + public int MediaType { get; init; } + + [JsonPropertyName("no_need_thumb")] + public bool NoNeedThumb { get; init; } = true; + + [JsonPropertyName("rawfilemd5")] + public string RawFileMd5 { get; init; } = string.Empty; + + [JsonPropertyName("rawsize")] + public long RawSize { get; init; } + + [JsonPropertyName("to_user_id")] + public string ToUserId { get; init; } = string.Empty; +} + +public sealed class UploadBaseInfo +{ + [JsonPropertyName("channel_version")] + public string ChannelVersion { get; init; } = "1.0.0"; +} + +public sealed class GetUploadUrlResponse +{ + [JsonPropertyName("upload_param")] + public string? UploadParam { get; init; } + + [JsonPropertyName("thumb_upload_param")] + public string? ThumbUploadParam { get; init; } +} + public class SendMessageResponse { [JsonPropertyName("ret")] diff --git a/WeChatRelay/Models/JsonContracts.cs b/WeChatRelay/Models/JsonContracts.cs index eb9100a..8403a93 100644 --- a/WeChatRelay/Models/JsonContracts.cs +++ b/WeChatRelay/Models/JsonContracts.cs @@ -8,7 +8,7 @@ internal sealed class GetUpdatesRequest public string GetUpdatesBuf { get; init; } = string.Empty; } -internal sealed class HookPayload +public sealed class HookPayload { [JsonPropertyName("seq")] public long? Seq { get; init; } @@ -34,6 +34,39 @@ internal sealed class HookPayload [JsonPropertyName("text")] public string Text { get; init; } = string.Empty; + [JsonPropertyName("summary")] + public string Summary { get; init; } = string.Empty; + + [JsonPropertyName("items")] + public List Items { get; init; } = []; + [JsonPropertyName("context_token")] public string? ContextToken { get; init; } } + +public sealed class HookPayloadItem +{ + [JsonPropertyName("item_type")] + public int ItemType { get; init; } + + [JsonPropertyName("kind")] + public string Kind { get; init; } = string.Empty; + + [JsonPropertyName("text")] + public string? Text { get; init; } + + [JsonPropertyName("local_path")] + public string? LocalPath { get; init; } + + [JsonPropertyName("encode_type")] + public int? EncodeType { get; init; } + + [JsonPropertyName("sample_rate")] + public int? SampleRate { get; init; } + + [JsonPropertyName("bits_per_sample")] + public int? BitsPerSample { get; init; } + + [JsonPropertyName("playtime_ms")] + public int? PlaytimeMs { get; init; } +} diff --git a/WeChatRelay/Program.cs b/WeChatRelay/Program.cs index 79bd46a..e8f1ffd 100644 --- a/WeChatRelay/Program.cs +++ b/WeChatRelay/Program.cs @@ -50,9 +50,11 @@ public static void ConfigureServices(IServiceCollection services, bool verbose = services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHttpClient(); services.AddHttpClient(); services.AddSingleton(BuildHookConfig(config.GetSection("Hook"), hookCommandOverride)); + services.AddSingleton(); services.AddSingleton(); } @@ -134,11 +136,42 @@ private static RootCommand BuildCommandLine() { Description = "Message text. If omitted, reads from stdin" }; + var imageOption = new Option("--image") + { + Description = "Send an image from a local file path" + }; + var audioOption = new Option("--audio") + { + Description = "Send a voice/audio message from a local file path" + }; + audioOption.Aliases.Add("--voice"); + var audioFormatOption = new Option("--audio-format") + { + Description = "Optional audio encoding hint: pcm, wav, adpcm, feature, speex, amr, silk, mp3, or ogg" + }; + var audioSampleRateOption = new Option("--audio-sample-rate") + { + Description = "Optional audio sample rate in Hz" + }; + var audioBitsPerSampleOption = new Option("--audio-bits-per-sample") + { + Description = "Optional audio bit depth" + }; + var audioPlaytimeOption = new Option("--audio-playtime-ms") + { + Description = "Optional audio duration in milliseconds" + }; var sendVerboseOption = CreateVerboseOption(); - var sendCommand = new Command("send", "Send a text message") + var sendCommand = new Command("send", "Send a text, image, or audio message") { targetArgument, textOption, + imageOption, + audioOption, + audioFormatOption, + audioSampleRateOption, + audioBitsPerSampleOption, + audioPlaytimeOption, sendVerboseOption }; sendCommand.SetAction(parseResult => @@ -146,6 +179,12 @@ private static RootCommand BuildCommandLine() { Target = parseResult.GetValue(targetArgument), Text = parseResult.GetValue(textOption), + ImagePath = parseResult.GetValue(imageOption), + AudioPath = parseResult.GetValue(audioOption), + AudioFormat = parseResult.GetValue(audioFormatOption), + AudioSampleRate = parseResult.GetValue(audioSampleRateOption), + AudioBitsPerSample = parseResult.GetValue(audioBitsPerSampleOption), + AudioPlaytimeMs = parseResult.GetValue(audioPlaytimeOption), Verbose = parseResult.GetValue(sendVerboseOption) }, CancellationToken.None).GetAwaiter().GetResult()); diff --git a/WeChatRelay/Serialization/WeChatJsonContext.cs b/WeChatRelay/Serialization/WeChatJsonContext.cs index f910753..52532ed 100644 --- a/WeChatRelay/Serialization/WeChatJsonContext.cs +++ b/WeChatRelay/Serialization/WeChatJsonContext.cs @@ -13,6 +13,8 @@ namespace WeChatRelay.Serialization; [JsonSerializable(typeof(InboundMessage))] [JsonSerializable(typeof(QrStartResponse))] [JsonSerializable(typeof(QrStatusResponse))] +[JsonSerializable(typeof(GetUploadUrlRequest))] +[JsonSerializable(typeof(GetUploadUrlResponse))] [JsonSerializable(typeof(SendMessageRequest))] [JsonSerializable(typeof(SendMessageResponse))] internal partial class WeChatJsonContext : JsonSerializerContext diff --git a/WeChatRelay/Services/HookRunner.cs b/WeChatRelay/Services/HookRunner.cs index 8e52f0b..54ac0be 100644 --- a/WeChatRelay/Services/HookRunner.cs +++ b/WeChatRelay/Services/HookRunner.cs @@ -23,7 +23,7 @@ public class HookConfig public string? WorkingDirectory { get; init; } } -public class HookRunner(HookConfig hookCfg, ILogger log) : IHookRunner +public class HookRunner(HookConfig hookCfg, IInboundMediaStore inboundMediaStore, ILogger log) : IHookRunner { private readonly ConcurrentQueue _queue = new(); private readonly string _queueFile = Path.Combine( @@ -91,6 +91,8 @@ private void DrainPersisted() private async Task InvokeHookAsync(InboundMessage msg, CancellationToken ct) { + var items = await inboundMediaStore.BuildHookItemsAsync(msg, ct); + // Build the hook payload JSON var json = JsonSerializer.Serialize(new HookPayload { @@ -101,7 +103,9 @@ private async Task InvokeHookAsync(InboundMessage msg, CancellationToken ct) CreateTimeMs = msg.CreateTimeMs, SessionId = msg.SessionId, MessageType = msg.MessageType, - Text = ExtractText(msg), + Text = MessageInspector.ExtractText(msg), + Summary = MessageInspector.Describe(msg), + Items = items, ContextToken = msg.ContextToken }, WeChatJsonContext.Default.HookPayload); var escaped = json.Replace("\"", "\\\""); @@ -137,10 +141,4 @@ private async Task InvokeHookAsync(InboundMessage msg, CancellationToken ct) else log.LogWarning("Hook exited {Code} for seq={Seq}: {Error}", process.ExitCode, msg.Seq, error.Trim()); } - - private static string ExtractText(InboundMessage msg) - { - var texts = msg.ItemList.Where(i => i.Type == 1 && i.TextItem != null).Select(i => i.TextItem!.Text); - return string.Join(" ", texts); - } } diff --git a/WeChatRelay/Services/InboundMediaStore.cs b/WeChatRelay/Services/InboundMediaStore.cs new file mode 100644 index 0000000..f7b42c3 --- /dev/null +++ b/WeChatRelay/Services/InboundMediaStore.cs @@ -0,0 +1,313 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Logging; +using WeChatRelay.Models; + +namespace WeChatRelay.Services; + +public interface IInboundMediaStore +{ + Task> BuildHookItemsAsync(InboundMessage msg, CancellationToken ct = default); +} + +public sealed class InboundMediaStore(IHttpClientFactory httpClientFactory, ILogger log) : IInboundMediaStore +{ + private const string CdnDownloadBaseUrl = "https://novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param="; + private readonly string _mediaRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "wechat-relay", + "inbound-media"); + + public async Task> BuildHookItemsAsync(InboundMessage msg, CancellationToken ct = default) + { + var items = new List(); + + for (var index = 0; index < msg.ItemList.Count; index++) + { + var item = msg.ItemList[index]; + + switch (item.Type) + { + case 1 when item.TextItem is not null: + items.Add(new HookPayloadItem + { + ItemType = item.Type, + Kind = "text", + Text = item.TextItem.Text + }); + break; + case 2 when item.ImageItem is not null: + items.Add(new HookPayloadItem + { + ItemType = item.Type, + Kind = "image", + LocalPath = await TrySaveImageAsync(msg, item.ImageItem, index, ct) + }); + break; + case 3 when item.VoiceItem is not null: + items.Add(new HookPayloadItem + { + ItemType = item.Type, + Kind = "audio", + Text = item.VoiceItem.Text, + LocalPath = await TrySaveVoiceAsync(msg, item.VoiceItem, index, ct), + EncodeType = item.VoiceItem.EncodeType, + SampleRate = item.VoiceItem.SampleRate, + BitsPerSample = item.VoiceItem.BitsPerSample, + PlaytimeMs = item.VoiceItem.Playtime + }); + break; + case 4 when item.FileItem is not null: + items.Add(new HookPayloadItem + { + ItemType = item.Type, + Kind = "file", + LocalPath = await TrySaveFileAsync(msg, item.FileItem, index, ct) + }); + break; + case 5 when item.VideoItem is not null: + items.Add(new HookPayloadItem + { + ItemType = item.Type, + Kind = "video", + LocalPath = await TrySaveVideoAsync(msg, item.VideoItem, index, ct) + }); + break; + } + } + + return items; + } + + private async Task TrySaveImageAsync(InboundMessage msg, ImageItem item, int index, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(item.Media?.EncryptQueryParam)) + { + return null; + } + + try + { + byte[] payload; + if (!string.IsNullOrWhiteSpace(item.AesKey)) + { + payload = await DownloadAndDecryptAsync(item.Media.EncryptQueryParam, Convert.FromHexString(item.AesKey), ct); + } + else if (!string.IsNullOrWhiteSpace(item.Media.AesKey)) + { + payload = await DownloadAndDecryptAsync(item.Media.EncryptQueryParam, ParseAesKey(item.Media.AesKey), ct); + } + else + { + payload = await DownloadPlainAsync(item.Media.EncryptQueryParam, ct); + } + + var extension = DetectImageExtension(payload); + return await SaveBufferAsync(msg, index, "image", $"image{extension}", payload, ct); + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to save inbound image for seq={Seq} item={ItemIndex}", msg.Seq, index); + return null; + } + } + + private async Task TrySaveVoiceAsync(InboundMessage msg, VoiceItem item, int index, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(item.Media?.EncryptQueryParam) || string.IsNullOrWhiteSpace(item.Media.AesKey)) + { + return null; + } + + try + { + var payload = await DownloadAndDecryptAsync(item.Media.EncryptQueryParam, ParseAesKey(item.Media.AesKey), ct); + var extension = ResolveAudioExtension(item.EncodeType); + return await SaveBufferAsync(msg, index, "audio", $"audio{extension}", payload, ct); + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to save inbound audio for seq={Seq} item={ItemIndex}", msg.Seq, index); + return null; + } + } + + private async Task TrySaveFileAsync(InboundMessage msg, FileItem item, int index, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(item.Media?.EncryptQueryParam) || string.IsNullOrWhiteSpace(item.Media.AesKey)) + { + return null; + } + + try + { + var payload = await DownloadAndDecryptAsync(item.Media.EncryptQueryParam, ParseAesKey(item.Media.AesKey), ct); + var fileName = SanitizeFileName(item.FileName, "file.bin"); + return await SaveBufferAsync(msg, index, "file", fileName, payload, ct); + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to save inbound file for seq={Seq} item={ItemIndex}", msg.Seq, index); + return null; + } + } + + private async Task TrySaveVideoAsync(InboundMessage msg, VideoItem item, int index, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(item.Media?.EncryptQueryParam) || string.IsNullOrWhiteSpace(item.Media.AesKey)) + { + return null; + } + + try + { + var payload = await DownloadAndDecryptAsync(item.Media.EncryptQueryParam, ParseAesKey(item.Media.AesKey), ct); + return await SaveBufferAsync(msg, index, "video", "video.mp4", payload, ct); + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to save inbound video for seq={Seq} item={ItemIndex}", msg.Seq, index); + return null; + } + } + + private async Task DownloadPlainAsync(string encryptQueryParam, CancellationToken ct) + { + using var client = httpClientFactory.CreateClient(); + var url = BuildDownloadUrl(encryptQueryParam); + return await client.GetByteArrayAsync(url, ct); + } + + private async Task DownloadAndDecryptAsync(string encryptQueryParam, byte[] key, CancellationToken ct) + { + var encrypted = await DownloadPlainAsync(encryptQueryParam, ct); + using var aes = Aes.Create(); + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.PKCS7; + aes.Key = key; + + using var decryptor = aes.CreateDecryptor(); + return decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length); + } + + private async Task SaveBufferAsync(InboundMessage msg, int index, string kind, string fileName, byte[] payload, CancellationToken ct) + { + var targetPath = BuildTargetPath(msg, index, kind, fileName); + if (File.Exists(targetPath) && new FileInfo(targetPath).Length > 0) + { + return targetPath; + } + + var directory = Path.GetDirectoryName(targetPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = $"{targetPath}.{Guid.NewGuid():N}.tmp"; + await File.WriteAllBytesAsync(tempPath, payload, ct); + + try + { + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + + File.Move(tempPath, targetPath); + return targetPath; + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } + + private string BuildTargetPath(InboundMessage msg, int index, string kind, string fileName) + { + var timestamp = msg.CreateTimeMs.HasValue + ? DateTimeOffset.FromUnixTimeMilliseconds(msg.CreateTimeMs.Value) + : DateTimeOffset.UtcNow; + var messageKey = $"msg-{msg.MessageId?.ToString() ?? "unknown"}-{msg.Seq?.ToString() ?? "unknown"}"; + var directory = Path.Combine(_mediaRoot, timestamp.ToString("yyyyMMdd"), SanitizeFileName(messageKey, "message")); + var indexedFileName = $"{index:D2}-{kind}-{SanitizeFileName(fileName, $"{kind}.bin")}"; + return Path.GetFullPath(Path.Combine(directory, indexedFileName)); + } + + private static string BuildDownloadUrl(string encryptQueryParam) + => $"{CdnDownloadBaseUrl}{Uri.EscapeDataString(encryptQueryParam)}"; + + private static byte[] ParseAesKey(string aesKeyBase64) + { + var decoded = Convert.FromBase64String(aesKeyBase64); + if (decoded.Length == 16) + { + return decoded; + } + + if (decoded.Length == 32) + { + var ascii = Encoding.ASCII.GetString(decoded); + if (ascii.All(Uri.IsHexDigit)) + { + return Convert.FromHexString(ascii); + } + } + + throw new InvalidOperationException($"Unsupported aes_key format. Expected 16 raw bytes or 32 ASCII hex bytes, got {decoded.Length} bytes."); + } + + private static string ResolveAudioExtension(int? encodeType) => encodeType switch + { + 1 => ".wav", + 2 => ".adpcm", + 3 => ".feature", + 4 => ".spx", + 5 => ".amr", + 6 => ".silk", + 7 => ".mp3", + 8 => ".ogg", + _ => ".bin" + }; + + private static string DetectImageExtension(byte[] payload) + { + if (payload.Length >= 3 && payload[0] == 0xFF && payload[1] == 0xD8 && payload[2] == 0xFF) + { + return ".jpg"; + } + + if (payload.Length >= 8 && payload[0] == 0x89 && payload[1] == 0x50 && payload[2] == 0x4E && payload[3] == 0x47) + { + return ".png"; + } + + if (payload.Length >= 6 && payload[0] == 0x47 && payload[1] == 0x49 && payload[2] == 0x46) + { + return ".gif"; + } + + if (payload.Length >= 12 && payload[0] == 0x52 && payload[1] == 0x49 && payload[2] == 0x46 && payload[3] == 0x46 && payload[8] == 0x57 && payload[9] == 0x45 && payload[10] == 0x42 && payload[11] == 0x50) + { + return ".webp"; + } + + if (payload.Length >= 2 && payload[0] == 0x42 && payload[1] == 0x4D) + { + return ".bmp"; + } + + return ".bin"; + } + + private static string SanitizeFileName(string? value, string fallback) + { + var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(candidate.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()).Trim(); + return string.IsNullOrWhiteSpace(sanitized) ? fallback : sanitized; + } +} \ No newline at end of file diff --git a/WeChatRelay/Services/MessageInspector.cs b/WeChatRelay/Services/MessageInspector.cs new file mode 100644 index 0000000..68110d9 --- /dev/null +++ b/WeChatRelay/Services/MessageInspector.cs @@ -0,0 +1,74 @@ +using WeChatRelay.Models; + +namespace WeChatRelay.Services; + +internal static class MessageInspector +{ + public static string ExtractText(InboundMessage msg) + { + var fragments = new List(); + + foreach (var item in msg.ItemList) + { + if (item.Type == 1 && !string.IsNullOrWhiteSpace(item.TextItem?.Text)) + { + fragments.Add(item.TextItem.Text); + } + else if (item.Type == 3 && !string.IsNullOrWhiteSpace(item.VoiceItem?.Text)) + { + fragments.Add(item.VoiceItem.Text); + } + } + + return string.Join(" ", fragments); + } + + public static string Describe(InboundMessage msg) + { + var parts = new List(); + var text = ExtractText(msg); + + if (!string.IsNullOrWhiteSpace(text)) + { + parts.Add($"text=\"{text}\""); + } + + var imageCount = msg.ItemList.Count(i => i.Type == 2 && i.ImageItem is not null); + if (imageCount == 1) + { + parts.Add("[image]"); + } + else if (imageCount > 1) + { + parts.Add($"[{imageCount} images]"); + } + + var videoCount = msg.ItemList.Count(i => i.Type == 5 && i.VideoItem is not null); + if (videoCount == 1) + { + parts.Add("[video]"); + } + else if (videoCount > 1) + { + parts.Add($"[{videoCount} videos]"); + } + + var fileCount = msg.ItemList.Count(i => i.Type == 4 && i.FileItem is not null); + if (fileCount == 1) + { + parts.Add("[file]"); + } + else if (fileCount > 1) + { + parts.Add($"[{fileCount} files]"); + } + + foreach (var voiceItem in msg.ItemList.Where(i => i.Type == 3).Select(i => i.VoiceItem).Where(i => i is not null)) + { + var duration = voiceItem!.Playtime.HasValue ? $" {voiceItem.Playtime.Value}ms" : string.Empty; + parts.Add($"[audio{duration}]"); + } + + return string.Join(" ", parts); + } +} diff --git a/WeChatRelay/Services/WeChatService.cs b/WeChatRelay/Services/WeChatService.cs index 81b814c..2b4bfd0 100644 --- a/WeChatRelay/Services/WeChatService.cs +++ b/WeChatRelay/Services/WeChatService.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.Logging; +using System.Net.Http.Headers; +using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using WeChatRelay.Models; using WeChatRelay.Serialization; @@ -12,6 +15,8 @@ public interface IWeChatService Task StartQrLoginAsync(CancellationToken ct = default); Task<(bool Connected, string? BotToken, string? AccountId, string? BaseUrl, string? UserId, string Message)> WaitForQrConfirmAsync(string sessionKey, CancellationToken ct = default); Task SendTextAsync(string toUserId, string content, CancellationToken ct = default, string? contextToken = null); + Task SendImageAsync(string toUserId, string filePath, CancellationToken ct = default, string? contextToken = null); + Task SendAudioAsync(string toUserId, string filePath, AudioSendOptions options, CancellationToken ct = default, string? contextToken = null); Task StartReceivingAsync(Func onMessage, CancellationToken ct = default); List GetSendToCandidates(); } @@ -24,6 +29,7 @@ public class WeChatService( ILogger log) : IWeChatService { private const string DefaultBaseUrl = "https://ilinkai.weixin.qq.com/"; + private const string CdnBaseUrl = "https://novac2c.cdn.weixin.qq.com/c2c/"; private const int SessionExpiredErrCode = -14; private static readonly TimeSpan QrPollTimeout = TimeSpan.FromSeconds(35); private static readonly TimeSpan QrLoginTimeout = TimeSpan.FromMinutes(8); @@ -79,6 +85,60 @@ public async Task StartQrLoginAsync(CancellationToken ct = defa } public async Task SendTextAsync(string toUserId, string content, CancellationToken ct = default, string? contextToken = null) + => await SendItemsAsync(toUserId, + [ + new OutboundItem + { + Type = 1, + TextItem = new TextItemOut { Text = content } + } + ], ct, contextToken); + + public async Task SendImageAsync(string toUserId, string filePath, CancellationToken ct = default, string? contextToken = null) + { + var payload = await File.ReadAllBytesAsync(filePath, ct); + var uploaded = await UploadMediaAsync(toUserId, payload, mediaType: 1, ct); + + return await SendItemsAsync(toUserId, + [ + new OutboundItem + { + Type = 2, + ImageItem = new ImageItemOut + { + Media = uploaded.Media, + MidSize = uploaded.CiphertextSize, + HdSize = uploaded.CiphertextSize + } + } + ], ct, contextToken); + } + + public async Task SendAudioAsync(string toUserId, string filePath, AudioSendOptions options, CancellationToken ct = default, string? contextToken = null) + { + ArgumentNullException.ThrowIfNull(options); + + var payload = await File.ReadAllBytesAsync(filePath, ct); + var uploaded = await UploadMediaAsync(toUserId, payload, mediaType: 4, ct); + + return await SendItemsAsync(toUserId, + [ + new OutboundItem + { + Type = 3, + VoiceItem = new VoiceItemOut + { + Media = uploaded.Media, + EncodeType = options.EncodeType, + BitsPerSample = options.BitsPerSample, + SampleRate = options.SampleRate, + Playtime = options.PlaytimeMs + } + } + ], ct, contextToken); + } + + private async Task SendItemsAsync(string toUserId, IEnumerable itemList, CancellationToken ct, string? contextToken) { if (!IsLoggedIn) return new SendMessageResponse { Ret = -1, ErrMsg = "Not logged in" }; @@ -93,20 +153,16 @@ public async Task SendTextAsync(string toUserId, string con MessageType = 2, MessageState = 2, ContextToken = contextToken, - ItemList = [new OutboundItem { Type = 1, TextItem = new TextItemOut { Text = content } }] + ItemList = itemList.ToList() } }; - var json = JsonSerializer.Serialize(req, WeChatJsonContext.Default.SendMessageRequest); - var url = new Uri(new Uri(NormalizeBaseUrl(cfg.BaseUrl!)), "ilink/bot/sendmessage"); - - ApplyHeaders(); - var resp = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"), ct); - resp.EnsureSuccessStatusCode(); - - var respJson = await resp.Content.ReadAsStringAsync(ct); - var result = JsonSerializer.Deserialize(respJson, WeChatJsonContext.Default.SendMessageResponse) - ?? new SendMessageResponse { Ret = -1, ErrMsg = "Failed to parse response" }; + var result = await PostJsonAsync( + new Uri(new Uri(NormalizeBaseUrl(cfg.BaseUrl!)), "ilink/bot/sendmessage"), + req, + WeChatJsonContext.Default.SendMessageRequest, + WeChatJsonContext.Default.SendMessageResponse, + ct) ?? new SendMessageResponse { Ret = -1, ErrMsg = "Failed to parse response" }; if (result.Ret == SessionExpiredErrCode) { @@ -118,6 +174,46 @@ public async Task SendTextAsync(string toUserId, string con return result; } + private async Task UploadMediaAsync(string toUserId, byte[] payload, int mediaType, CancellationToken ct) + { + var aesKey = RandomNumberGenerator.GetBytes(16); + var aesKeyHex = Convert.ToHexString(aesKey).ToLowerInvariant(); + var fileKey = Guid.NewGuid().ToString("N"); + var ciphertext = EncryptAesEcb(payload, aesKey); + + var uploadParams = await PostJsonAsync( + new Uri(new Uri(NormalizeBaseUrl(cfg.BaseUrl!)), "ilink/bot/getuploadurl"), + new GetUploadUrlRequest + { + AesKey = aesKeyHex, + FileKey = fileKey, + FileSize = ciphertext.Length, + MediaType = mediaType, + RawFileMd5 = ComputeMd5Hex(payload), + RawSize = payload.Length, + ToUserId = toUserId + }, + WeChatJsonContext.Default.GetUploadUrlRequest, + WeChatJsonContext.Default.GetUploadUrlResponse, + ct); + + var uploadParam = uploadParams?.UploadParam; + if (string.IsNullOrWhiteSpace(uploadParam)) + throw new InvalidOperationException("Upload endpoint did not return upload parameters."); + + var encryptedParam = await UploadToCdnAsync(uploadParam, fileKey, ciphertext, ct); + var encodedAesKey = Convert.ToBase64String(Encoding.ASCII.GetBytes(aesKeyHex)); + + return new UploadedMedia( + new CdnMedia + { + EncryptQueryParam = encryptedParam, + AesKey = encodedAesKey, + EncryptType = 1 + }, + ciphertext.Length); + } + public async Task StartReceivingAsync(Func onMessage, CancellationToken ct = default) { if (!IsLoggedIn) { log.LogWarning("Not logged in"); return; } @@ -200,6 +296,71 @@ private void ApplyHeaders() http.DefaultRequestHeaders.Add("X-WECHAT-UIN", GenerateRandomUin()); } + private async Task PostJsonAsync( + Uri url, + TRequest payload, + JsonTypeInfo requestTypeInfo, + JsonTypeInfo responseTypeInfo, + CancellationToken ct) + { + var json = JsonSerializer.Serialize(payload, requestTypeInfo); + ApplyHeaders(); + + var resp = await http.PostAsync(url, new StringContent(json, Encoding.UTF8, "application/json"), ct); + resp.EnsureSuccessStatusCode(); + + var respJson = await resp.Content.ReadAsStringAsync(ct); + return JsonSerializer.Deserialize(respJson, responseTypeInfo); + } + + private async Task UploadToCdnAsync(string uploadParam, string fileKey, byte[] ciphertext, CancellationToken ct) + { + http.DefaultRequestHeaders.Clear(); + + var uploadUrl = new Uri(new Uri(CdnBaseUrl), + $"upload?encrypted_query_param={Uri.EscapeDataString(uploadParam)}&filekey={Uri.EscapeDataString(fileKey)}"); + using var request = new HttpRequestMessage(HttpMethod.Post, uploadUrl) + { + Content = new ByteArrayContent(ciphertext) + }; + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + + var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var detail = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"CDN upload failed: {(int)response.StatusCode} {detail}"); + } + + if (response.Headers.TryGetValues("x-encrypted-param", out var values)) + { + var headerValue = values.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(headerValue)) + { + return headerValue; + } + } + + return uploadParam; + } + + private static byte[] EncryptAesEcb(byte[] payload, byte[] key) + { + using var aes = Aes.Create(); + aes.Mode = CipherMode.ECB; + aes.Padding = PaddingMode.PKCS7; + aes.Key = key; + + using var encryptor = aes.CreateEncryptor(); + return encryptor.TransformFinalBlock(payload, 0, payload.Length); + } + + private static string ComputeMd5Hex(byte[] payload) + { + var hash = MD5.HashData(payload); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + private async Task PollQrStatusAsync(string baseUrl, string qrcode, CancellationToken ct) { var url = new Uri(new Uri(baseUrl), $"ilink/bot/get_qrcode_status?qrcode={Uri.EscapeDataString(qrcode)}"); @@ -232,4 +393,6 @@ private void InvalidateSession(string message) contextTokenStore.Clear(); log.LogError(message); } + + private sealed record UploadedMedia(CdnMedia Media, long CiphertextSize); }