wechat-ilink-photon-sdk
v0.1.2
Published
WeChat iLink bot provider for Spectrum (Photon) — a definePlatform provider over the iLink bot protocol, with durable claim-before-emit inbound and per-contact history.
Maintainers
Readme
wechat-ilink-photon-sdk
A Spectrum (Photon) provider for the WeChat iLink bot protocol, with durable, no-loss inbound and a separate conversation per contact.
It wraps wechat-ilink-client
(embedded and patched — see UPSTREAM.md) and exposes it as a
first-class definePlatform provider you can drop into a Spectrum({ providers })
app next to iMessage, Slack, etc.
Why this exists
The iLink client is deliberately stateless — it persists nothing. A naive integration loses messages, because:
- Its long-poll monitor advances the sync cursor before dispatching a batch, so a crash mid-batch drops messages.
- A Spectrum provider's emit is fire-and-forget past the broadcast boundary —
"emitted" never means "processed", and if the app's message loop dies without
app.stop(), messages are silently dropped.
This SDK closes both gaps with a claim-before-emit runtime: every poll commits messages + context tokens + cursor to a caller-supplied store in one step, and only then emits into Spectrum. The app acks each message out-of-band once it is durably handled; unacked messages are re-emitted on restart. The result is at-least-once delivery with dedupe — never silent loss.
Install / consume
This package is consumed as a vendored, prebuilt dependency (the same pattern
emo.studio uses for its other SDKs) — not from npm. Build it and reference the
dist/ as a file: dependency:
// consumer package.json
{ "dependencies": { "wechat-ilink-photon-sdk": "file:vendor/wechat-ilink-photon-sdk" } }@spectrum-ts/core is a peer dependency (^8.2.0), provided by the host app.
See scripts/sync-vendor.sh for the build-and-copy
workflow.
This package is not currently published to npm.
Quick start
import { Spectrum } from "@spectrum-ts/core";
import { wechatIlink, runQRLogin } from "wechat-ilink-photon-sdk";
import { MyStore } from "./my-store"; // implements IlinkStateStore
const store = new MyStore();
// First run, or any time you want another scanner: QR login persists/refreshes
// the scanner's own credential. Existing scanners are not replaced.
await runQRLogin(store, { onQRCode: (url) => console.log("Scan:", url) });
const app = await Spectrum({
providers: [wechatIlink.config({ store, lineRef: "wechat" })],
});
for await (const [space, message] of app.messages) {
if (message.content.type === "text" && /ping/i.test(message.content.text)) {
await space.send("pong");
}
// Durably mark handled — the out-of-band ack the stream contract lacks.
await wechatIlink(app).ackDispatched(message.id);
}The state store
All durability lives behind IlinkStateStore (the SDK ships an in-memory
implementation for examples/tests; production supplies a database-backed one):
interface IlinkStateStore {
getCredentials(): Promise<IlinkCredentials | null>;
listCredentials?(): Promise<IlinkCredentials[]>; // active scanner sessions
saveCredentials(creds: IlinkCredentials): Promise<void>;
getCursor(credentialKey?: string): Promise<string | null>;
claimBatch(batch: ClaimBatch): Promise<ClaimResult>; // messages + tokens + cursor, atomically
listUnacked(limit: number): Promise<ClaimedMessage[]>; // restart-sweep source
ackDispatched(key: string): Promise<void>;
getContextToken(userId: string, credentialKey?: string): Promise<string | null>;
markCredentialExpired?(credentialKey: string, botToken: string): Promise<void>;
acquireExclusive?(): Promise<() => Promise<void>>; // optional single-poller fence
}When listCredentials() is implemented, the runtime starts one poll session per
credential. Each credential gets its own cursor, context tokens, and inbound
dedupe scope. This matters because two scanner accounts can see the same
contact ids or message counters; treating those as global makes the newest QR
scan kick out the previous scanner.
Inbound records use session-scoped space.id / sender.id values of the form
ilink:{credential}:{user} (encoded for Spectrum ids). Reply through the same
space you received and the SDK routes the outbound through the right scanner.
If you call runtime.send("raw-user-id", ...) while multiple sessions are ready,
the SDK refuses the ambiguous send instead of guessing the wrong scanner.
claimBatch must persist atomically and be idempotent on message key — that
idempotency is what absorbs the server re-delivery you get after a stale-cursor
restart.
What the provider handles
- Inbound: deterministic sender-scoped dedupe keys; a filter chain that drops bot echoes, streaming partials, group messages, recalls, and unrenderable types (each counted for observability); lazy, size-capped, timed media downloads.
- Sends: text / markdown / rich link / image / video / file / voice, paced;
server-side rejections (
ret != 0) surface as thrown errors instead of silent success; a missing context token throwsContextTokenMissingError(a plain error, so a queue can defer it —UnsupportedErrorwould be swallowed by core). - Control content: Spectrum
typing("start")/typing("stop")maps to WeChat's native iLinksendtypingindicator. The runtime fetches the requiredtyping_ticketthroughgetconfig, includes the otherwise-undocumentedilink_user_id, and caches each ticket per scanner/contact for 20 hours. Spectrumreadcontent remains a no-op because iLink does not expose it. - Health: derived from poll recency (
healthSnapshotaction); session expiry (-14) marks only that credential down and heals when that scanner refreshes credentials — no redeploy needed. - Session start: never throws on missing credentials (that would kill the whole multi-provider app) — it starts degraded and self-heals after a QR login.
Examples
pnpm add qrcode-terminal # optional, for inline QR rendering
pnpm example:pong # replies "pong" to any "ping"
pnpm example:smoke # sends one of every content type, reports what workedexamples/pong-bot.ts— the smallest complete bot.examples/smoke-bot.ts— exercises every outbound feature (text, markdown, rich link, image, video, file, voice) end-to-end.examples/file-store.ts— a file-backedIlinkStateStoreused by both.
Development
pnpm install
pnpm build # tsup → dist (ESM + d.ts)
pnpm typecheck
pnpm test # vitest — runtime crash-injection against an in-process fake serverLicense
MIT
