Architecture¶
This page describes the internal architecture of WeiLink, including module structure, message routing, login flow, media handling, and the optional admin panel.
Package Structure¶
The package is organized into three groups: the core SDK, the admin panel, and the server/MCP layer.
Core SDK Modules¶
graph TB
subgraph "weilink package"
client["client.py<br/><i>WeiLink (public API)</i>"]
models["models.py<br/><i>Message, BotInfo, MediaContent...</i>"]
protocol["_protocol.py<br/><i>iLink HTTP API calls</i>"]
cdn["_cdn.py<br/><i>CDN upload / download</i>"]
crypto["_crypto.py<br/><i>AES encryption dispatch</i>"]
aes_ssl["_aes_openssl.py<br/><i>ctypes + OpenSSL</i>"]
aes_py["_aes.py<br/><i>Pure-Python AES fallback</i>"]
qr["_qr.py<br/><i>QR code generation</i>"]
store["_store.py<br/><i>SQLite message persistence</i>"]
filelock["filelock.py<br/><i>Cross-process file locks</i>"]
end
client --> protocol
client --> cdn
client --> models
client --> store
client --> filelock
cdn --> crypto
crypto --> aes_ssl
crypto -.->|fallback| aes_py
admin[/"weilink.admin"/]
server[/"weilink.server"/]
client --> admin
admin -.-> client
admin -.-> protocol
admin -.-> qr
server -.-> client
classDef ext fill:#f5f5f5,stroke:#999,stroke-dasharray:5 5
class admin,server ext
Admin Panel Modules¶
graph TB
subgraph "weilink.admin"
admin_srv["server.py<br/><i>AdminServer (daemon thread)</i>"]
admin_hdl["handlers.py<br/><i>REST API handlers</i>"]
admin_stc["static.py<br/><i>HTML & locale loading</i>"]
end
admin_srv --> admin_hdl
admin_hdl --> admin_stc
client[/"client.py"/]
protocol[/"_protocol.py"/]
qr[/"_qr.py"/]
client --> admin_srv
admin_hdl --> client
admin_hdl --> protocol
admin_hdl --> qr
classDef ext fill:#f5f5f5,stroke:#999,stroke-dasharray:5 5
class client,protocol,qr ext
Server & MCP Modules¶
graph TB
subgraph "weilink.server"
mcp_srv["app.py<br/><i>Tool definitions</i>"]
end
subgraph "toolregistry-server"
tr["ToolRegistry<br/><i>Single tool registry</i>"]
mcp_out["MCP transport<br/><i>stdio / sse / streamable-http</i>"]
openapi_out["OpenAPI transport<br/><i>REST + Swagger UI</i>"]
end
mcp_srv --> tr
tr --> mcp_out
tr --> openapi_out
client[/"client.py"/]
mcp_srv --> client
classDef ext fill:#f5f5f5,stroke:#999,stroke-dasharray:5 5
class client ext
Multi-Session Architecture¶
WeiLink supports multiple concurrent sessions. Each session represents a separate WeChat account registered with the bot.
graph LR
subgraph "WeiLink Client"
sessions["_sessions dict"]
default["_default_session"]
end
subgraph "Session: default"
s1_info["bot_info<br/>(bot_id, token, base_url)"]
s1_ctx["context_tokens<br/>{user_id → token}"]
s1_disk["token.json + contexts.json"]
end
subgraph "Session: work"
s2_info["bot_info"]
s2_ctx["context_tokens"]
s2_disk["token.json + contexts.json"]
end
sessions --> s1_info
sessions --> s2_info
default --> s1_info
s1_info --- s1_ctx
s1_ctx --- s1_disk
s2_info --- s2_ctx
s2_ctx --- s2_disk
Receive Flow¶
recv() polls all active sessions in parallel using a thread pool, merging results into a single message list. Each Message carries a bot_id field identifying which session it came from.
sequenceDiagram
participant App as Application
participant WL as WeiLink.recv()
participant TP as ThreadPool
participant S1 as Session "default"
participant S2 as Session "work"
participant API as iLink API
App->>WL: recv(timeout=35)
WL->>TP: submit _recv_session(S1)
WL->>TP: submit _recv_session(S2)
par
S1->>API: get_updates(cursor, timeout)
API-->>S1: messages + new cursor
and
S2->>API: get_updates(cursor, timeout)
API-->>S2: messages + new cursor
end
TP-->>WL: merged messages
WL-->>App: List[Message]
Send Routing¶
send() auto-routes to the correct session based on which session most recently held a context_token for the target user. No manual session selection needed.
flowchart TD
A["send(to=user_id)"] --> B{"Session with<br/>context_token?"}
B -->|Found| C["Use that session"]
B -->|Not found| D{"Any connected?"}
D -->|Yes| C2["First connected"]
D -->|No| E["RuntimeError"]
C & C2 --> F["Upload media"]
F --> G["send_message()"]
Cross-Process File Locking¶
When multiple processes share the same data directory (e.g., an SDK script and a stdio MCP server both using ~/.weilink/), WeiLink coordinates access using two advisory file locks (fcntl.flock on Unix, msvcrt.locking on Windows):
flowchart TD
subgraph "Process A (SDK script)"
A_recv["recv()"]
A_send["send()"]
end
subgraph "Process B (MCP stdio)"
B_recv["recv()"]
B_send["send()"]
end
subgraph "~/.weilink/"
poll_lock[".poll.lock<br/><i>non-blocking exclusive</i>"]
data_lock[".data.lock<br/><i>blocking exclusive (brief)</i>"]
files["token.json<br/>contexts.json"]
db["messages.db<br/><i>(SQLite WAL)</i>"]
end
A_recv -->|"try_lock"| poll_lock
B_recv -->|"try_lock (fails → SQLite fallback)"| poll_lock
A_send -->|"lock"| data_lock
B_send -->|"lock"| data_lock
poll_lock -.-> files
data_lock -.-> files
A_recv -.->|"store()"| db
B_recv -.->|"query_messages()"| db
| Lock | Scope | Behavior |
|---|---|---|
.poll.lock |
Entire recv() cycle |
Non-blocking try-lock. If held by another process, recv() falls back to SQLite (when enabled) or returns []. Prevents cursor divergence. |
.data.lock |
File read-modify-write | Blocking, held briefly (~ms). Serializes token.json / contexts.json access for both recv() and send(). |
Key principle: disk is the source of truth. Every recv() and send() re-reads state from disk under the data lock before acting, ensuring changes from other processes are visible.
Atomic file writes: All writes to token.json, contexts.json, and .default_session use a write-to-temp-then-os.replace() pattern, ensuring that a process crash mid-write never produces a corrupted file.
On Windows, msvcrt.locking is used with exponential-backoff polling for blocking semantics. File locking works on all platforms.
Cooperative Polling Fallback¶
When message_store is enabled and the poll lock is held by another process, recv() reads recent messages (last 60 seconds) from the SQLite store instead of returning an empty list. This allows secondary processes to observe messages without conflicting with the primary poller's cursor.
flowchart TD
A["recv()"] --> B{"try_lock<br/>poll_lock"}
B -->|acquired| C["Poll iLink API"]
C --> D["Store messages<br/>to SQLite"]
D --> E["Update cursor<br/>& context_tokens"]
E --> F["Return messages"]
B -->|"failed"| G{"message_store<br/>enabled?"}
G -->|yes| H["Query SQLite<br/>(last 60s, direction=received)"]
H --> I["Return messages<br/>(no state updates)"]
G -->|no| J["Return []"]
No cursor or context-token updates occur during a fallback read — the messages were already fully processed when the primary poller stored them. This makes the fallback purely read-only and safe.
Activation: automatic when both conditions are true: the poll lock is held by another process, and message_store is enabled (message_store=True).
Store Watcher Dispatcher Fallback¶
The cooperative polling fallback above applies to recv() calls. A similar fallback exists for the dispatcher (on_message handlers + run_background()).
When run_background() is called and the poll lock is held by another process, the dispatcher starts a store watcher loop instead of the normal poll loop. The store watcher periodically queries the SQLite message store for new rows (by auto-increment id), dispatches them to registered on_message handlers, and enqueues them for recv().
flowchart TD
A["run_background()"] --> B{"try_lock<br/>poll_lock"}
B -->|acquired| C["Start _poll_loop<br/>(poll iLink API directly)"]
B -->|"failed"| D{"message_store<br/>enabled?"}
D -->|yes| E["Start _store_watch_loop<br/>(poll SQLite every 2s)"]
D -->|no| F["raise RuntimeError"]
E --> G["query_since_rowid(hwm)"]
G --> H["Dispatch to on_message handlers"]
H --> I["Enqueue for recv()"]
I --> G
Typical multi-process scenario:
| Process | Role | Dispatcher mode |
|---|---|---|
MCP server (weilink mcp) |
Primary poller | _poll_loop — polls iLink API, stores messages to SQLite |
SDK script with on_message |
Secondary consumer | _store_watch_loop — reads new messages from SQLite |
Both processes share the same data directory (~/.weilink/) and SQLite database. The SDK script's on_message handlers fire for every new message, with approximately 2-second latency.
High-water mark: The store watcher tracks the highest rowid it has processed. On startup, it initializes to the current max(id) in the store, so existing messages are not dispatched — only new messages arriving after run_background() is called.
Message Persistence (SQLite Store)¶
WeiLink includes an optional SQLite-backed message store that records all received and sent messages with full serialization (preserving CDN references for later media download).
flowchart LR
recv["recv()"] -->|"store()"| db["messages.db<br/>(SQLite WAL)"]
send["send()"] -->|"store_sent()"| db
fallback["SQLite<br/>fallback"] -->|"query_messages()"| db
history["history"] -->|"query()"| db
download["download"] -->|"get_by_id()"| db
| Feature | Description |
|---|---|
| WAL mode | Concurrent readers + single writer. Readers never block writers, and vice versa. |
| Idempotent writes | INSERT OR IGNORE on message_id prevents duplicates. |
| Auto-pruning | Configurable by age (default 30 days) and count (default 100,000). |
| Thread-safe | Internal write lock + SQLite's own locking. |
| Cross-process safe | SQLite WAL handles concurrent access from multiple processes. |
Enabling¶
- Server mode: always enabled (
message_store=Trueby default in server). - SDK mode: opt-in via
WeiLink(message_store=True)orWeiLink(message_store="/path/to/messages.db"). - Disabled (default for SDK): single-client mode, no SQLite dependency at runtime.
Multi-Client Coordination Summary¶
WeiLink supports multiple processes sharing the same data directory through five mechanisms:
- Poll lock (
.poll.lock): ensures only one process polls iLink at a time, preventing cursor divergence. - Data lock (
.data.lock): serializes reads and writes totoken.jsonandcontexts.json. - SQLite fallback (
recv()): when the poll lock is unavailable and SQLite persistence is enabled,recv()reads recent messages from the database. - Store watcher fallback (dispatcher): when the poll lock is unavailable,
run_background()watches SQLite for new rows and dispatches them toon_messagehandlers. - Atomic writes: all file writes use temp-file-then-rename to prevent corruption on crash.
QR Code Login Flow¶
Login uses a QR code scanned by the WeChat mobile app. The flow works the same whether initiated from the terminal or the admin panel.
sequenceDiagram
participant User as User / Browser
participant WL as WeiLink / Admin
participant API as iLink API
participant Phone as WeChat App
User->>WL: login() / POST /api/sessions/login
WL->>API: get_qr_code()
API-->>WL: {qrcode, qrcode_img_content}
WL-->>User: Display QR code (terminal / SVG)
loop Poll every 2-3s
WL->>API: poll_qr_status(qrcode)
API-->>WL: {status: "waiting"}
end
Phone->>API: Scan QR code
WL->>API: poll_qr_status(qrcode)
API-->>WL: {status: "scaned"}
Phone->>API: Confirm login
WL->>API: poll_qr_status(qrcode)
API-->>WL: {status: "confirmed", bot_token, bot_id}
WL->>WL: Store BotInfo, save token.json
WL-->>User: Login success
CDN Media Pipeline¶
Media (images, voice, files, video) is encrypted with AES-128-ECB before upload and decrypted after download. The encryption key is provided by the iLink API.
flowchart TB
subgraph Upload
direction LR
A1[Raw bytes] --> A2["AES-128-ECB<br/>encrypt"]
A2 --> A3["HTTP PUT<br/>CDN URL"]
A3 --> A4["UploadedMedia"]
end
subgraph Download
direction LR
B1["MediaInfo"] --> B2["HTTP GET<br/>CDN URL"]
B2 --> B3["AES-128-ECB<br/>decrypt"]
B3 --> B4[Raw bytes]
end
AES Encryption Strategy¶
flowchart TD
C1["_crypto.py"] --> C2{"OpenSSL available<br/>via ctypes?"}
C2 -->|Yes| C3["_aes_openssl.py<br/>(native performance)"]
C2 -->|No| C4["_aes.py<br/>(pure-Python fallback)"]
The library ships with zero runtime dependencies. AES encryption first tries to load OpenSSL via ctypes for native performance. If unavailable (e.g., on some minimal containers), it falls back to a vendored pure-Python AES implementation.
Admin Panel Architecture¶
The admin panel is an optional web UI for managing sessions without terminal access. It runs as a daemon thread inside the WeiLink process.
graph TB
subgraph "WeiLink Process"
client["WeiLink Client"]
subgraph "Admin Thread (daemon)"
server["AdminServer<br/>(HTTPServer)"]
handler["AdminRequestHandler"]
end
end
browser["Browser"] -->|"HTTP"| server
server --> handler
handler -->|"Read sessions,<br/>status"| client
handler -->|"Login, logout,<br/>rename (with lock)"| client
handler -->|"QR code"| protocol["_protocol.py"]
handler -->|"SVG generation"| qr["_qr.py"]
handler -->|"HTML / locales"| static["static.py"]
Admin API Endpoints¶
| Method | Path | Description |
|---|---|---|
| GET | / |
Serve single-page admin UI |
| GET | /api/status |
Version, connection status, session count |
| GET | /api/sessions |
All sessions with user details |
| POST | /api/sessions/login |
Start QR login flow |
| GET | /api/sessions/login/status |
Poll QR scan status |
| POST | /api/sessions/{name}/logout |
Log out a session |
| POST | /api/sessions/{name}/rename |
Rename a session |
| GET | /locales/{lang}.json |
Serve i18n locale file |
Thread Safety¶
flowchart TD
subgraph "No lock needed"
R1["GET /api/status"]
R2["GET /api/sessions"]
end
subgraph "Protected by threading.Lock"
W1["Login confirmed"]
W2["POST .../logout"]
W3["POST .../rename"]
end
R1 & R2 -.->|"read-only"| sessions["_sessions dict"]
W1 & W2 & W3 -->|"acquire lock"| lock["threading.Lock"]
lock --> sessions
Read-only endpoints (status, sessions) access session data without locking. Write operations (login confirmation, logout, rename) are serialized through a threading.Lock to prevent race conditions.
Dual-Mode Server Architecture¶
WeiLink uses toolregistry-server to expose bot tools via both MCP and OpenAPI protocols from a single set of tool definitions.
flowchart LR
subgraph "weilink.server.app"
tools["Tool functions<br/>(recv, send, download, ...)"]
registry["ToolRegistry"]
end
subgraph "toolregistry-server"
rt["RouteTable"]
mcp["MCP Server<br/>(stdio / sse / streamable-http)"]
openapi["OpenAPI App<br/>(FastAPI + Swagger UI)"]
end
tools --> registry
registry --> rt
rt --> mcp
rt --> openapi
mcp -->|"MCP protocol"| agent["AI Agent"]
openapi -->|"REST API"| client["HTTP Client"]
Tools are defined once as async Python functions in weilink.server.app, registered into a ToolRegistry, and then served via either transport:
weilink mcp— creates an MCP server usingtoolregistry_server.mcpweilink openapi— creates a FastAPI app usingtoolregistry_server.openapi
Both modes share the same global WeiLink client instance and message cache.