Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 122 additions & 166 deletions README.md
Original file line number Diff line number Diff line change
@@ -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": "<user-id>@im.wechat",
"to_user_id": "<bot-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)
Loading
Loading