Cloudflare Worker + Durable Object session layer for TeamPlus message capture.
src/index.ts: signed HTTP API for cookie upload, status, start, stop, nudge, backfill.src/session.ts: one Durable Object per TeamPlus account; owns cookie state and the TeamPlus WebSocket.src/turso.ts: inserts normalizedIM_CHAT:NEW_MESSAGEevents into Turso.src/contacts.ts: resolves sender/chat names via the TeamPlus REST API.src/attachments.ts: downloads image/file messages and archives them to R2.schema.sql: Turso tables for message history and session events.
cd worker
pnpm install
turso auth login
./scripts/setup_turso.sh teamplus-messages # Turso DB + secrets + .dev.vars
wrangler r2 bucket create teamplus-attachments
# → edit worker/.dev.vars and set TEAMPLUS_BASE
./scripts/push_secrets.sh
wrangler deployAfter deploy, edit ../.cf-worker.env and set CF_TEAMPLUS_WORKER_URL to the deployed workers.dev URL.
Image (msg_type 205) and file (206) messages carry no bytes — only a
FileName reference in Content2. The Durable Object downloads the file from
DownloadFileHandler.ashx with the session cookie, stores it in the
teamplus-attachments R2 bucket under attachments/<batchID>/<fileName>, and
records that key in the attachment_key column. Failures are non-fatal (the
text row is still saved) and can be repaired later:
# archive any messages whose attachment isn't stored yet
./scripts/cf_worker_request.mjs POST /v1/sessions/default/backfill-attachments '{"limit":50}'A read-only log endpoint gated by TEAMPLUS_DB_KEY (a secret separate from the
admin COOKIE_UPLOAD_SECRET, so you can share read access without granting
control). Returns recent messages as JSON, newest first, with a signed viewer
link for any attachment.
# key via header (preferred) or ?key=
curl -H "X-API-Key: $TEAMPLUS_DB_KEY" "https://<worker>/v1/logs" # default: last 24h
curl -H "X-API-Key: $TEAMPLUS_DB_KEY" "https://<worker>/v1/logs?hours=6"
curl -H "X-API-Key: $TEAMPLUS_DB_KEY" "https://<worker>/v1/logs?days=7" # max window
curl "https://<worker>/v1/logs?key=$TEAMPLUS_DB_KEY&days=3"- Window:
?hours=Nor?days=N. Default 24h, hard-capped at 7 days. ?limit=Ncaps the row count (default/max 2000).- Missing/invalid key →
401.
The attachment.url is a signed viewer link valid for one week (see below).
GET /a?key=...&exp=...&sig=... streams an R2 object in the browser. The link
is HMAC-signed with COOKIE_UPLOAD_SECRET and expires within one week — the
worker rejects any link minted to live longer (400) or already past exp (410),
regardless of the secret. Mint one from the repo root:
./scripts/attachment_url.mjs <attachment_key | event_key | batch_id>
# → https://<worker>/a?key=...&exp=...&sig=... (valid 1 week)From repo root:
./scripts/refresh_and_upload_cf.shFor launchd or cron, run the same command every 3 days. The script refreshes cookies.json locally via the existing Patchright/Tesseract flow, then uploads the cookie jar to:
POST /v1/sessions/default/cookies
The upload is HMAC-signed with CF_TEAMPLUS_UPLOAD_SECRET; the Worker stores only the resulting cookie header inside the Durable Object.
All session endpoints require the same HMAC signature headers:
GET /v1/sessions/default/statusPOST /v1/sessions/default/startPOST /v1/sessions/default/stopPOST /v1/sessions/default/nudgePOST /v1/sessions/default/send({"to":<userNo>,"text":"…"}— DM; or{"chatId":"…","channelType":1,"text":"…"})POST /v1/sessions/default/backfill-attachments({"limit":50})
The Durable Object can send outbound messages with its session cookie — so you can reply from anywhere, with no local daemon. Admin-signed; use the helper:
./scripts/send.mjs --to 1344 --text "晚點回你" # DM to a peer userNo
./scripts/send.mjs --chat-id 1049_1344 --text "…" # explicit DM chat id
./scripts/send.mjs --chat-id <group-guid> --text "…" # group (auto-detected)--to is the peer's sender_id from the log. A chat id shaped n_n is a DM;
a GUID is a group (sent with channelType:1 — existing groups resolve from the
chat id, no member list needed). Group chat_id values appear in the log too.
Quote-reply a specific message with --reply <id> (the message id from
/v1/logs, shown by logs.mjs --ids):
./scripts/logs.mjs --from 邱子玲 --ids # find the message id to reply to
./scripts/send.mjs --to 1708 --reply <id> --text "收到"GET /health is unsigned and returns a basic liveness response.
From repo root, use the helper for signed calls:
./scripts/cf_worker_request.mjs
./scripts/cf_worker_request.mjs POST /v1/sessions/default/nudge '{}'
{ "since": "2026-06-18T...Z", "window_hours": 24, "count": 41, "messages": [ { "ts": "2026-06-19T...Z", "direction": "in", "channel_type": 0, "chat_id": "40_1049", "chat_name": "...", "sender_id": 40, "sender_name": "...", "msg_type": 1, "content": "...", "attachment": { "name": "IMG_2087.jpg", "url": "https://<worker>/a?key=...&exp=...&sig=..." } } ] }