chore: initial public release

This commit is contained in:
SMNET Studio
2026-08-10 17:22:45 +08:00
commit c000c31c22
186 changed files with 71441 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
node_modules
**/node_modules
**/dist
.git
.github
.vscode
.idea
.env
.env.*
!.env.example
data
**/data
**/*.db
**/*.db-wal
**/*.db-shm
**/*.log
coverage
.turbo
**/.DS_Store
.ilink
tokens
**/*.bak
apps/api/data
packages/db/data
*.md
!docs/docker.md
!docs/upstash-redis.md
!docs/oauth-linuxdo.md
+282
View File
@@ -0,0 +1,282 @@
# ─────────────────────────────────────────────────────────────
# 这里的值是「默认值」。除 REDIS_URL / LLM_* / LLM_PROVIDER_SECRET /
# WECHAT_AI_TOKEN / LINUXDO_ADMIN_IDS / COOKIE 与 URL 相关项之外,
# 绝大多数都可以在 /admin → 设置 页直接改,覆盖存在 Redis,
# 各节点 5 秒内生效,不必改这个文件再重启。
# 详见 docs/runtime-settings.md
# ─────────────────────────────────────────────────────────────
# Serverenv-only:监听地址在 listen() 时固定)
# 本地开发用 127.0.0.1Docker / 公网部署用 0.0.0.0
WECHAT_AI_HOST=127.0.0.1
WECHAT_AI_PORT=8787
# 公网地址(OAuth 回调与文档用),Docker 请改成 https://你的域名
PUBLIC_BASE_URL=http://127.0.0.1:8787
# 跨域白名单(逗号分隔 Origin)。默认已含 PUBLIC_BASE_URL 的 origin
# CORS_ORIGINS=https://your.domain.com
# optional legacy/script token (OAuth is primary)
WECHAT_AI_TOKEN=change-me-long-random
COOKIE_SECURE=false
SESSION_COOKIE_NAME=wa_session
# Process L1 cache over Redis (session/user/prompt). Set false to disable.
# REDIS_L1_CACHE=true
# REDIS_L1_SESSION_MS=45000
# REDIS_L1_USER_MS=30000
# REDIS_L1_PROMPT_MS=60000
# 默认人设(每次开场都要读,改人设后最多滞后这个时间生效)
# REDIS_L1_DEFAULT_PERSONA_MS=60000
# 广场列表快照(人设/表情包)。翻页、搜索、排序共用一份,免去每次 SMEMBERS+MGET 全量。
# 多节点下,别的节点发布的新内容最多滞后这个时间才出现在广场。
# REDIS_L1_SQUARE_MS=10000
# 管理台仪表盘统计快照(doctorSnapshot 会扫全量 bot/peer/persona
# REDIS_L1_DOCTOR_MS=15000
# 仪表盘的 assignments / messages / memories 三项每个 peer 约多读 4 个 key。
# peer 数超过这个上限就整体跳过,快照返回 deepStats=false(诊断里显示「未统计」
# 而不是 0)。设 0 表示永不统计。
# DOCTOR_DEEP_STATS_MAX_PEERS=5000
# 超管身份缓存(解析一次要扫全部用户;授权/删号会立即失效)
# REDIS_L1_SUPERADMIN_MS=120000
# Remote Redis (required) — Upstash example (TLS = rediss://)
# Console → your DB → Connect → copy "Redis URL" for ioredis / Node
REDIS_URL=rediss://default:YOUR_UPSTASH_PASSWORD@YOUR_ENDPOINT.upstash.io:6379
# REDIS_CONNECT_TIMEOUT_MS=15000
# REDIS_TLS=true
# 并发命令合并进同一次往返(远端 Redis 必开)。REDIS_AUTO_PIPELINE=false 可关闭
# REDIS_AUTO_PIPELINE=true
# REDIS_KEEPALIVE_MS=10000
# 单命令重试次数。宁可快速失败让上层恢复,也不要盲目重试堆积
# REDIS_MAX_RETRIES=2
# 4=IPv4 / 6=IPv6 / 0=系统默认。不设时 Upstash 自动用 4(双栈 DNS 会多一跳)
# REDIS_FAMILY=4
# 静态页(app/admin/docs 外壳)启动时预压缩 br+gzip,避免每次请求现压。
# STATIC_PRECOMPRESS=true
# 出站超时(毫秒)。没有它,卡死的上游会一直占着一个 reply consumer
# 和该 bot:peer 的串行链,直到 OpenAI SDK 默认的 600s × 3 次重试跑完。
# LLM_TIMEOUT_MS=120000
# CHATFLOW_HTTP_TIMEOUT_MS=15000
# Multi-node labels (optional)
# WORKER_ID=node-01
# NODE_LABEL=cn-east-1a
# NODE_REGION=cn-east
# APP_VERSION=0.2.0
# OTA fleet update (file-diff + auto restart; default on)
# OTA_ENABLED=true
# OTA_ALLOW_INSTALL=true
# OTA_STAGING_DIR=.wa-update-staging
# Bot tokens: Redis key wa:bot:{id}:creds (no local token files)
# Stickers: meta + image blob in Redis (wa:sticker:* / wa:sticker:{id}:blob)
# STICKER_MAX_BYTES=2097152
# STICKER_SEND_ENABLED=true
# MAX_STICKERS_PER_REPLY=2
# ── Inbound media (入站图片与语音) ──
# WeChat's own speech-to-text. Default ON and deliberately independent of
# VISION_ENABLED: the transcript arrives inside the inbound message, so using it
# costs nothing and needs no model at all. Set false to have voice notes
# answered with "didn't catch that" instead. (The audio itself is SILK/AMR,
# which no OpenAI-compatible endpoint accepts, so there is no ASR path here.)
# VOICE_TRANSCRIPT_ENABLED=true
#
# Image understanding. Master switch, default OFF — off means images are never
# downloaded and the bot simply says it cannot see them.
# VISION_ENABLED=false
#
# How the image reaches the conversation:
# caption (default) — a vision endpoint describes the image, and only that
# TEXT goes to the roleplay model. **The roleplay model needs no vision
# support at all**, so this is the mode to use with deepseek and friends.
# Bonus: the description is stored in history, so the model can still talk
# about the image several turns later.
# direct — hand the image to the roleplay model as content parts. Requires
# that model to be vision-capable; it errors outright otherwise.
# VISION_MODE=caption
#
# The vision endpoint. Admin-level creds, dialed directly like the platform LLM
# (a captioner is not user-supplied, so it does not need the tools gateway).
# Leave base/key empty to reuse LLM_BASE_URL / LLM_API_KEY — that works when the
# provider hosts a vision model alongside the chat model.
# VISION_MODEL is REQUIRED; without it every image is reported as unreadable.
# VISION_BASE_URL=
# VISION_API_KEY=
# VISION_MODEL=
# Caption length cap — a description, not an essay.
# VISION_CAPTION_MAX_TOKENS=300
# Each image costs real tokens, so cap how many one message may spend.
# VISION_MAX_IMAGES=2
# Per-attachment download cap (decrypted bytes; base64 adds ~1/3 on top).
# INBOUND_MEDIA_MAX_BYTES=4194304
# ── Platform LLM (admin) — main site connects DIRECTLY ──
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o-mini
# ── Tools gateway (HF wechat-ai-tools) ─────────────────
# User custom OpenAI-compatible APIs + web search egress ONLY via this service.
# Main site never dials user base_url or search engines.
# Local: cd huggingface/wechat-ai-tools && docker build -t wechat-ai-tools . && docker run -p 7860:7860 --env-file .env wechat-ai-tools
# TOOLS_BASE_URL=http://127.0.0.1:7860
# TOOLS_API_KEY=change-me-shared-with-tools-service
# TOOLS_TIMEOUT_MS=60000
# WEB_SEARCH_ENABLED=true
# WEB_SEARCH_MAX_RESULTS=5
# Encrypt user-stored custom API keys (required when users add providers)
# LLM_PROVIDER_SECRET=long-random-secret-at-least-16-chars
# Chatflow: http nodes only hit tools host + this allowlist (comma hosts)
# Exact hostnames, no wildcards — api.example.com does not cover sub.api.example.com.
# Set to a single `*` to allow any PUBLIC host instead. `*` is not a blanket
# bypass: loopback, RFC1918, link-local (169.254.169.254), CGNAT
# (100.100.100.200), unique-local IPv6 and single-label/intranet names stay
# blocked, and every redirect hop is re-checked. Graphs are authored by any
# persona owner, so `*` hands every registered user a server-side fetch.
# CHATFLOW_HTTP_ALLOWLIST=
# CHATFLOW_MAX_STEPS=32
# CHATFLOW_MAX_NODES=40
# LINUX DO OAuth (https://connect.linux.do)
LINUXDO_CLIENT_ID=
LINUXDO_CLIENT_SECRET=
# must match app registration callback, e.g. http://127.0.0.1:8787/api/v1/auth/callback
LINUXDO_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/callback
# Comma-separated LINUX DO user ids and/or usernames who become admins
LINUXDO_ADMIN_IDS=12345,your_username
# Optional overrides:
# LINUXDO_AUTHORIZE_URL=https://connect.linux.do/oauth2/authorize
# LINUXDO_TOKEN_URL=https://connect.linux.do/oauth2/token
# LINUXDO_USERINFO_URL=https://connect.linux.do/api/user
# LINUXDO_SCOPE=openid profile
# First successful signup becomes admin when LINUXDO_ADMIN_IDS is empty
# FIRST_USER_IS_ADMIN=true
# Local username+password auth (+ invite-only local register)
# LOCAL_AUTH_ENABLED=true
# PASSWORD_MIN_LENGTH=8
# INVITE_REQUIRED_FOR_LOCAL=true
# INVITE_CODE_TTL_SEC=604800
# INVITE_CODE_LENGTH=10
# INVITE_MAX_PENDING_PER_USER=20
# Per user: max INVITE_QUOTA_MAX codes every INVITE_QUOTA_WINDOW_HOURS hours
# INVITE_QUOTA_WINDOW_HOURS=24
# INVITE_QUOTA_MAX=3
# Product
DEFAULT_PERSONA_SLUG=catgirl
ALLOW_UNAPPROVED_USERS=false
SHORT_HISTORY_LIMIT=20
MEMORY_EXTRACT_EVERY_N=8
# Memory retrieval (no embedding API): text overlap top-K when over FULL_INJECT_MAX
# MEMORY_TOP_K=12
# MEMORY_FULL_INJECT_MAX=20
# MEMORY_MAX_ITEMS=100
# get_current_time tool (no external key). Set TIME_TOOL_ENABLED=false to disable.
# TIME_TOOL_ENABLED=true
# TIME_TOOL_TIMEZONE=Asia/Shanghai
PEER_RATE_PER_MINUTE=20
# Human-like multi-bubble replies
# MULTI_BUBBLE_JSON: primary model must return {"messages":["..."]} (and sticker objects when catalog is present)
# REPLY_FILTER_ENABLED: optional second-pass AI that reformats primary reply into JSON (extra latency/cost; default off)
# SPLIT_REPLY: if JSON parse fails, fallback to punctuation/paragraph split
REPLY_FILTER_ENABLED=false
MULTI_BUBBLE_JSON=true
SPLIT_REPLY=true
MAX_REPLY_CHUNKS=5
MAX_CHUNK_CHARS=72
# Delay between bubbles (ms) — higher = more human typing feel
REPLY_DELAY_MS_PER_CHAR=90
REPLY_DELAY_MIN_MS=1400
REPLY_DELAY_MAX_MS=5500
REPLY_DELAY_FIRST_MIN_MS=900
REPLY_DELAY_FIRST_MAX_MS=2200
REPLY_DELAY_THINK_EXTRA_MS=400
# User-to-user chat via @{LINUX DO username} (bind code in /app first)
# P2P_ENABLED=true
# P2P_BIND_CODE_TTL_SEC=600
# P2P_REQUEST_TTL_SEC=300
# P2P_SESSION_IDLE_SEC=1800
# P2P_RELAY_MAX_CHARS=500
# P2P_MAX_REQUESTS_PER_DAY=20
# Proactive outreach (idle → LLM message). Global hard off by default.
# Requires: PROACTIVE_ENABLED=true + bot switch + per-peer switch + prior context_token
# PROACTIVE_ENABLED=false
# PROACTIVE_IDLE_HOURS=12
# PROACTIVE_MIN_INTERVAL_HOURS=24
# PROACTIVE_MAX_PER_DAY=1
# PROACTIVE_QUIET_HOURS=0-8
# PROACTIVE_SCAN_INTERVAL_SEC=300
# PROACTIVE_MAX_PER_SCAN=10
# PROACTIVE_LOCK_TTL_SEC=180
# PROACTIVE_ATTEMPT_COOLDOWN_HOURS=1
# Admin broadcast (plain text to WeChat peers via /admin). Requires WORKER_ENABLED.
# Interval between messages (ms). Lower = faster; raise if iLink rate-limits.
# BROADCAST_INTERVAL_MS=200
# BROADCAST_MAX_TEXT=2000
# BROADCAST_HISTORY=100
# Web try-chat (persona preview in /app, no WeChat). Set TRY_CHAT_ENABLED=false to disable.
# TRY_CHAT_ENABLED=true
# TRY_CHAT_MAX_USER_MSGS_PER_DAY=40
# TRY_CHAT_MAX_USER_MSGS_PER_SESSION=20
# TRY_CHAT_SESSION_TTL_SEC=3600
# TRY_CHAT_MAX_HISTORY=40
# Persona fork from square (default on)
# PERSONA_FORK_ENABLED=true
# Worker (same process as API — one image / one container)
WORKER_ENABLED=true
# Cap concurrent iLink long-polls in this process (raise for more bots)
MAX_BOTS_PER_WORKER=500
# Lease TTL for multi-replica of the same image (optional scale-out)
LEASE_TTL_SEC=45
LEASE_RENEW_SEC=15
# Multi-node: overloaded workers release excess leases so idle nodes can claim.
# Default is an even split; per-node load weights (0-500%, default 100) are set
# at runtime in /admin -> 节点 -> 负载权重 and stored in Redis, not here.
# REBALANCE_ENABLED=false freezes existing leases, so weights then only affect
# newly claimed bots.
# REBALANCE_ENABLED=true
# REBALANCE_INTERVAL_SEC=60
# REBALANCE_SLACK=2
# REBALANCE_MAX_PER_TICK=50
# How long a node's load weight survives after its heartbeat stops, before the
# fleet deletes it automatically. Must outlast a restart / OTA apply.
# WORKER_WEIGHT_TTL_SEC=3600
# Pin WORKER_ID per node to keep its load weight across restarts — without it
# every restart gets a fresh random id and starts back at the default 100%.
# WORKER_ID=node-a
# Concurrent LLM + WeChat send jobs (poll is decoupled from reply)
REPLY_CONCURRENCY=16
INBOX_MAX_LEN=20000
# silent | fatal | error | warn | info | debug | trace (anything else → info).
# Drives Fastify's logger, which emits one line per request plus framework
# errors. Successful /health probes are skipped so the container healthcheck
# does not bury everything else.
LOG_LEVEL=info
# Successful requests slower than this are logged at warn.
# LOG_SLOW_REQUEST_MS=1000
# ── Admin live activity stream (SSE, /admin) ──
# DATA_STREAM_ENABLED=true
# Redis command sample rate (01) — every command would flood the stream
# DATA_STREAM_REDIS_SAMPLE=0.08
# Process-local cap on stream events per second
# DATA_STREAM_MAX_EPS=80
# Legacy sqlite path — read only by the db migrate CLI, not at runtime
# WECHAT_AI_DB_PATH=data/wechat-ai.db
# ── Multi-node (homogeneous replicas + Cloudflare Worker LB) ──
# Every node shares the same REDIS_URL and PUBLIC_BASE_URL (main domain).
# Unique per process (recommended on multi-node): node-01, node-02, …
# WORKER_ID=node-01
# Optional ops labels shown in admin「节点」(not public URLs)
# NODE_LABEL=cn-east-1a
# NODE_REGION=cn-east
# APP_VERSION=0.2.0
# Source node IPs live only in cloudflare-worker ORIGINS — see cloudflare-worker/README.md
+43
View File
@@ -0,0 +1,43 @@
node_modules/
**/node_modules/
dist/
.wa-version
.wa-update-staging/
.wa-backup/
data/
**/data/
.env
.env.local
*.db
*.db-journal
*.db-shm
*.db-wal
.DS_Store
*.log
.turbo/
coverage/
apps/web/dist/
packages/*/dist/
.ilink/
tokens/
*.bak
# Claude / editor
.claude/
.idea/
.vscode/
*.swp
# Python (huggingface/wechat-ai-tools)
__pycache__/
*.pyc
.venv/
.venv
.pytest_cache/
.hf/
# Wrangler (Cloudflare Worker)
cloudflare-worker/.wrangler/
# Local UI preview shots
scripts/ui-shots/
+75
View File
@@ -0,0 +1,75 @@
# syntax=docker/dockerfile:1
# WeChat-AI (iLink + Redis + LINUX DO OAuth)
#
# Prefer host wrappers (bump + OTA pack + docker):
# pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
# pnpm docker:up
# Publish channel: /admin → 上传通道包 (dist/release/<ver>/files.json)
# Plain `docker build` does NOT bump or pack.
#
# 必填环境变量见 .env.example / docs/docker.md
FROM node:22-bookworm-slim
LABEL org.opencontainers.image.title="wechat-ai" \
org.opencontainers.image.description="WeChat roleplay bots via iLink, Redis, LINUX DO OAuth"
# Keep corepack/pnpm under /pnpm so the non-root runtime user can use them
# without writing to /home/appuser/.cache (which caused EACCES).
ENV PNPM_HOME=/pnpm \
COREPACK_HOME=/pnpm/corepack \
PATH=/pnpm:$PATH \
NODE_ENV=production \
WECHAT_AI_HOST=0.0.0.0 \
WECHAT_AI_PORT=8787 \
COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable \
&& corepack prepare [email protected] --activate \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Install deps first (better layer cache)
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/core/package.json ./packages/core/
COPY packages/db/package.json ./packages/db/
COPY packages/ilink/package.json ./packages/ilink/
COPY packages/llm/package.json ./packages/llm/
# tsx is a runtime dependency of @wechat-ai/api; install all workspace packages
RUN pnpm install --frozen-lockfile
# Application source (TypeScript run via tsx)
COPY apps/api ./apps/api
COPY packages ./packages
COPY scripts ./scripts
# Non-root user + writable home for tools
RUN mkdir -p /home/appuser \
&& groupadd --system --gid 1001 nodejs \
&& useradd --system --uid 1001 --gid nodejs --home-dir /home/appuser --no-create-home appuser \
&& chown -R appuser:nodejs /app /pnpm /home/appuser
USER appuser
EXPOSE 8787
# scrypt (login + provider-secret KDF) runs on the libuv pool; the default 4
# threads let a login burst queue behind itself.
ENV UV_THREADPOOL_SIZE=16
HEALTHCHECK --interval=30s --timeout=8s --start-period=50s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.WECHAT_AI_PORT||8787)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
# Run node as PID 1 so SIGTERM reaches the process and the graceful shutdown
# in index.ts actually runs (releasing bot leases). `sh -c` swallowed it, so
# every deploy left up to LEASE_TTL_SEC of bots that no node was polling.
# The separate seed step is gone — index.ts already calls seedPersonas().
# WORKDIR is apps/api because tsx is linked under apps/api/node_modules;
# resolveRepoRoot() still walks up to /app for .env and OTA paths.
WORKDIR /app/apps/api
CMD ["node", "--import", "tsx", "src/index.ts"]
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 WeChat-AI Contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+138
View File
@@ -0,0 +1,138 @@
<div align="center">
# WeChat-AI
**自托管微信角色扮演对话服务** · Self-hosted WeChat roleplay chatbot service
直连腾讯 **iLink**,数据存 **远端 Redis**,登录用 **LINUX DO OAuth**
Connects directly to Tencent **iLink**, stores data in **remote Redis**, and authenticates via **LINUX DO OAuth**.
[功能 Features](#功能-features) · [架构 Architecture](#架构-architecture) · [快速开始 Quick Start](#快速开始-quick-start) · [文档 Docs](#文档-docs) · [许可证 License](#许可证-license)
[![community](https://github.com/user-attachments/assets/653f2b6b-ee32-4f0f-abe0-1ba96e4bb473)](https://linux.do/) [![Telegram Group](https://img.shields.io/badge/Telegram-Group-blue?logo=telegram&style=flat)](https://t.me/smnet_group/105727)
</div>
---
## 功能 Features
| 中文 | English |
|------|---------|
| LINUX DO OAuth 登录(用户 / 管理员) | LINUX DO OAuth login (user / admin) |
| 用户中心:扫码添加/删除微信机器人、批准私聊用户、分配人设 | User center: scan-QR add/remove WeChat bots, approve chat peers, assign personas |
| 用户对话:绑定 LINUX DO 后微信内 `@用户名` 请求对话,对方 `/同意` 后双向中继 | P2P chat: after binding, `@username` in WeChat to request a chat, peer replies `/agree` to relay messages both ways |
| 管理后台:数据面板、Token 用量、用户与机器人、部署节点、表情包审核 | Admin dashboard: stats, token usage, users & bots, deploy nodes, sticker moderation |
| 表情包广场:投稿 / 我的库 / 公开需审核;恶意图过滤 | Sticker square: submit / my library / public needs review; malicious-image filtering |
| 回复文字与图片表情(主人表情库 → 模型引用 slug → iLink CDN 发图) | Text + image sticker replies (owner sticker library → model references slug → sent via iLink CDN) |
| 输入状态指示(getconfig + sendtyping,回复送达即消失) | Typing indicator (getconfig + sendtyping, disappears when reply is delivered) |
| 入站图片理解(`VISION_ENABLED`,默认关闭;caption 模式人设模型无需视觉) | Inbound image understanding (`VISION_ENABLED`, off by default; `caption` mode needs no vision-capable roleplay model) |
| 入站语音转写(微信自带转写,默认开启) | Inbound voice transcription (WeChat built-in STT, on by default) |
| 远端 Redis 存储(bot token 与表情包均在 Redis | Remote Redis storage (bot tokens & stickers in Redis) |
| OpenAI 兼容 LLM + 按日 Token 统计 | OpenAI-compatible LLM + daily token stats |
| 用户自定义模型 + 联网搜索(经 HF 工具网关出站,主站不直连用户 API | User custom models + web search (egress only via HF tools gateway) |
| Chatflow:可视化编排(`/chatflow`),人设可选 prompt / chatflow 模式 | Chatflow: visual orchestration (`/chatflow`), personas support prompt / chatflow mode |
| 多节点同构部署 + Cloudflare Worker 负载均衡 | Multi-node homogeneous deployment + Cloudflare Worker LB |
| OTA 增量更新(文件差量 + 自动重启) | OTA incremental updates (file diff + auto restart) |
## 架构 Architecture
```
微信用户 ──► 腾讯 iLink ──► 本系统多节点 (收消息 / 人设+记忆 / LLM / 回消息)
WeChat user ──► Tencent iLink ──► multi-node system (receive / persona+memory / LLM / reply)
浏览器 ──► 主域名 CF Worker LB ──► Node-1…N (同一镜像, 共享 Redis)
Browser ──► main domain CF Worker LB ──► Node-1…N (same image, shared Redis)
```
## 快速开始 Quick Start
### 本地开发 Local Development
```bash
pnpm install
cp .env.example .env
# 必填 Required: REDIS_URLUpstash 用 rediss://)、LLM_API_KEY(平台)、LINUXDO_* 、LINUXDO_ADMIN_IDS
# 用户自定义 LLM / 联网搜索:部署 huggingface/wechat-ai-tools,配置 TOOLS_BASE_URL + TOOLS_API_KEY
pnpm db:seed
pnpm diag
pnpm dev
```
页面 / Pages
| 路径 Path | 说明 Description |
|-----------|------------------|
| `/` | 功能落地页 + OG 分享图 Landing page |
| `/app` | 用户中心 User center (LINUX DO login) |
| `/docs` | 使用文档 Documentation |
| `/admin` | 管理后台 Admin dashboard |
| `/chatflow` | Chatflow 编辑器 Chatflow editor |
### Docker 一键部署 One-Click Deploy
```bash
# 配置好 .env 后 After configuring .env
docker compose up -d --build
```
详见 / See `docs/docker.md`
### 多节点 Multi-Node
每台服务器运行**同一镜像**,共享同一个 Upstash Redis,用户只访问主域名。Cloudflare Worker 负责健康检查与轮询分流,源站地址只写在 Worker 的 `ORIGINS` 中。详见 / See `cloudflare-worker/README.md`
Each server runs the **same image**, shares one Upstash Redis; users only visit the main domain. A Cloudflare Worker handles health checks and round-robin, origin addresses live only in the Worker's `ORIGINS`.
## 文档 Docs
| 文档 Doc | 内容 Content |
|----------|-------------|
| [docs/upstash-redis.md](docs/upstash-redis.md) | Upstash Redis 配置 Redis setup |
| [docs/oauth-linuxdo.md](docs/oauth-linuxdo.md) | LINUX DO OAuth 配置 |
| [docs/docker.md](docs/docker.md) | Docker / 多节点部署 Multi-node deploy |
| [docs/cloudflare.md](docs/cloudflare.md) | Cloudflare 缓存 Cache |
| [cloudflare-worker/README.md](cloudflare-worker/README.md) | CF Worker 多源站 LB |
| [docs/ai-gateway.md](docs/ai-gateway.md) | AI 网关(主站 ↔ HFAI gateway |
| [docs/chatflow.md](docs/chatflow.md) | Chatflow 编排 |
| [docs/admin-api.md](docs/admin-api.md) | 管理 API |
| [docs/runbook.md](docs/runbook.md) | 运维手册 Ops runbook |
| [docs/e2e-checklist.md](docs/e2e-checklist.md) | 真机验收清单 E2E checklist |
## 仓库结构 Repository Structure
```
apps/api # REST + iLink worker + Admin/App/Chatflow UI (public/*.html)
packages/ilink # iLink HTTP 客户端 iLink HTTP client
packages/db # Redis 仓储 / seed / 人设与模型连接 Redis repos / seed
packages/llm # OpenAI 兼容 chat + tools 网关客户端
packages/core # 会话、人设、记忆、路由、chatflow 引擎
huggingface/ # wechat-ai-tools:唯一外网 AI/搜索出口(可独立部署)
cloudflare-worker # 主域名负载均衡 Cloudflare Worker LB
docs/ # runbook、E2E 清单、ADR
scripts/ # 构建 / 打包 / 验收脚本 build / pack / accept scripts
```
## 验收 Acceptance
```bash
pnpm accept # 离线自动化门禁 offline automated gate
```
真机清单 / Real-device checklist`docs/e2e-checklist.md` · 状态 / Status`docs/ACCEPTANCE.md`
## 合规与风险 Compliance & Risk
- 使用腾讯 **微信 ClawBot / iLink** 能力,须遵守相关使用条款。
Using Tencent **WeChat ClawBot / iLink** capabilities requires compliance with the applicable terms.
- 个人 Bot 存在限流与处置风险;默认 **白名单用户** 才可对话。
Personal bots face rate-limit and takedown risks; by default only **approved users** can chat.
- 角色扮演内容会经 LLM API 出机;请自行评估隐私。
Roleplay content leaves the machine via LLM APIs; assess your own privacy posture.
- iLink 协议以实测为准,字段可能变更;适配层见 `packages/ilink`
The iLink protocol is based on observed behavior and may change; the adapter lives in `packages/ilink`.
## 许可证 License
[Apache-2.0](LICENSE)
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@wechat-ai/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"ilink:login": "tsx src/cli-login.ts",
"diag": "tsx src/cli-doctor.ts",
"test": "node --import tsx --test src/**/*.test.ts"
},
"dependencies": {
"@fastify/compress": "^8.0.1",
"@wechat-ai/core": "workspace:*",
"@wechat-ai/db": "workspace:*",
"@wechat-ai/ilink": "workspace:*",
"@wechat-ai/llm": "workspace:*",
"dotenv": "^16.4.7",
"fastify": "^5.2.1",
"tsx": "^4.19.3",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+987
View File
@@ -0,0 +1,987 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5" />
<meta name="theme-color" content="#f5f5f7" id="themeColor" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
<meta name="description" content="WeChat-AI:自托管微信角色扮演对话服务。LINUX DO 登录、扫码绑定机器人、人设广场、表情包、长期记忆与私聊分配。" />
<!-- Open Graph / Twitter:绝对 URL 由服务端按 PUBLIC_BASE_URL 注入 -->
<meta property="og:title" content="WeChat-AI — 微信角色扮演机器人" />
<meta property="og:description" content="自托管微信角色扮演平台:扫码绑定机器人、人设广场、表情包回图、长期记忆与私聊分配。LINUX DO 登录,一站管理智能体。" />
<meta property="og:image" content="/og.jpg" />
<meta property="og:image:type" content="image/jpeg" />
<meta property="og:image:width" content="1280" />
<meta property="og:image:height" content="720" />
<meta property="og:image:alt" content="WeChat-AI" />
<meta property="og:url" content="/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="WeChat-AI" />
<meta property="og:locale" content="zh_CN" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="WeChat-AI — 微信角色扮演机器人" />
<meta name="twitter:description" content="扫码绑定机器人、人设广场、长期记忆与私聊分配。多用户角色扮演,一站管理智能体。" />
<meta name="twitter:image" content="/og.jpg" />
<link rel="canonical" href="/" />
<!-- Robot favicon (inline SVG) -->
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%235e5ce6'/%3E%3Cstop offset='100%25' stop-color='%23bf5af2'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Cg fill='none' stroke='%23fff' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='14' y='22' width='36' height='28' rx='8'/%3E%3Ccircle cx='26' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Ccircle cx='38' cy='36' r='2.4' fill='%23fff' stroke='none'/%3E%3Cpath d='M32 14v8M24 50v4M40 50v4M14 34h-4M54 34h-4'/%3E%3C/g%3E%3C/svg%3E"
/>
<!-- Icon font: render-blocking cross-origin CSS that then chain-loads a woff2.
preconnect collapses the DNS+TLS round trips; media=print + onload takes it
off the critical path so first paint never waits on jsdelivr. -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css"
media="print" onload="this.media='all';this.onload=null" />
<noscript><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.min.css" /></noscript>
<title>WeChat-AI — 微信角色扮演机器人</title>
<script>
(function () {
try {
var k = "wa_theme";
var t = localStorage.getItem(k);
if (t === "system" || (t !== "light" && t !== "dark")) {
if (localStorage.getItem(k) === "system") {
document.documentElement.setAttribute("data-theme-pref", "system");
}
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
document.documentElement.setAttribute("data-theme", t);
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
<style>
:root {
--bg: #f5f5f7;
--bg-elevated: rgba(255, 255, 255, 0.72);
--surface: #ffffff;
--surface-2: #f5f5f7;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #86868b;
--separator: rgba(0, 0, 0, 0.08);
--blue: #0071e3;
--blue-hover: #0077ed;
--blue-soft: rgba(0, 113, 227, 0.1);
--green: #34c759;
--green-soft: rgba(52, 199, 89, 0.12);
--chip: rgba(0, 0, 0, 0.05);
--glow-a: rgba(0, 113, 227, 0.1);
--glow-b: rgba(175, 82, 222, 0.08);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.08);
--radius: 18px;
--radius-sm: 12px;
--radius-lg: 22px;
--radius-pill: 980px;
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text",
"Helvetica Neue", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
sans-serif;
--ease: cubic-bezier(0.25, 0.1, 0.25, 1);
--theme-icon-sun: none;
--theme-icon-moon: inline;
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-l: env(safe-area-inset-left, 0px);
--safe-r: env(safe-area-inset-right, 0px);
--nav-h: 56px;
/* Fluid page gutter + content width: continuous phone → 4K, no jumps */
--gutter: clamp(16px, 4vw, 28px);
--content-max: 1080px;
/* ---- Liquid glass ----
Layered translucency: tinted base + blurred backdrop + specular rim
(bright top edge, dim bottom edge) that reads as glass thickness
rather than a flat frosted panel. */
--glass-bg: rgba(255, 255, 255, 0.6);
--glass-bg-strong: rgba(255, 255, 255, 0.76);
--glass-blur: 24px;
--glass-hi: rgba(255, 255, 255, 0.95);
--glass-lo: rgba(255, 255, 255, 0.3);
--glass-ring: rgba(0, 0, 0, 0.07);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.05);
--sk-sheen: rgba(255, 255, 255, 0.6);
/* ---- 动态玻璃:滚动深度驱动 ---- */
--glass-rgb: 255, 255, 255;
--glass-a-min: 0.3;
--glass-a-max: 0.76;
--glass-blur-min: 4px;
--glass-blur-max: 24px;
--glass-progress: 0;
--glass-a: calc(var(--glass-a-min) + (var(--glass-a-max) - var(--glass-a-min)) * var(--glass-progress));
--glass-blur-live: calc(var(--glass-blur-min) + (var(--glass-blur-max) - var(--glass-blur-min)) * var(--glass-progress));
}
@media (min-width: 1600px) {
:root { --content-max: 1200px; }
}
html[data-theme="dark"] {
--bg: #000000;
--bg-elevated: rgba(28, 28, 30, 0.72);
--surface: #1c1c1e;
--surface-2: #2c2c2e;
--text: #f5f5f7;
--text-secondary: #a1a1a6;
--text-tertiary: #6e6e73;
--separator: rgba(255, 255, 255, 0.1);
--blue: #0a84ff;
--blue-hover: #409cff;
--blue-soft: rgba(10, 132, 255, 0.18);
--green: #30d158;
--green-soft: rgba(48, 209, 88, 0.16);
--chip: rgba(255, 255, 255, 0.08);
--glow-a: rgba(10, 132, 255, 0.14);
--glow-b: rgba(191, 90, 242, 0.12);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 4px 24px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.55);
--theme-icon-sun: inline;
--theme-icon-moon: none;
--glass-bg: rgba(30, 30, 32, 0.58);
--glass-bg-strong: rgba(32, 32, 34, 0.78);
--glass-hi: rgba(255, 255, 255, 0.16);
--glass-lo: rgba(255, 255, 255, 0.04);
--glass-ring: rgba(255, 255, 255, 0.1);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
--sk-sheen: rgba(255, 255, 255, 0.08);
--glass-rgb: 30, 30, 32;
--glass-a-min: 0.32;
--glass-a-max: 0.78;
}
* { box-sizing: border-box; }
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color-scheme: light;
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
scrollbar-width: thin;
scrollbar-color: var(--chip) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--chip);
border-radius: 99px;
border: 2px solid transparent;
background-clip: padding-box;
}
html[data-theme="dark"] { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
min-height: 100dvh;
font-family: var(--font);
color: var(--text);
background:
radial-gradient(1200px 600px at 10% -10%, var(--glow-a), transparent 50%),
radial-gradient(900px 500px at 100% 0%, var(--glow-b), transparent 45%),
var(--bg);
line-height: 1.47;
overflow-x: hidden;
}
a { color: var(--blue); text-decoration: none; }
a:hover { text-decoration: underline; }
/* ---------- 边缘折射(位移滤镜)---------- */
.wa-lens-defs { position: absolute; width: 0; height: 0; overflow: hidden; }
/* 折射只在 Chromium 桌面开启,见下方网关脚本。降低模糊让折射看得见——
已经糊成一团的内容没法再折射,两者此消彼长。 */
html.wa-lens .glass,
html.wa-lens .glass-strong {
backdrop-filter: blur(14px) saturate(180%) url(#waGlassLens);
-webkit-backdrop-filter: blur(14px) saturate(180%);
}
/* ---------- Liquid glass material ---------- */
.glass {
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(180%);
box-shadow:
inset 0 1px 0 0 var(--glass-hi),
inset 0 -1px 0 0 var(--glass-lo),
var(--glass-shadow);
}
.glass-strong { background: var(--glass-bg-strong); }
/* 吸顶层专用:底下有内容滚过时才逐渐加深,滚动到顶部时近乎透明。
与 iOS 导航栏一致;.glass 保持静态,供不随滚动变化的浮层使用。 */
.glass-dynamic {
background: rgba(var(--glass-rgb), var(--glass-a));
backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
-webkit-backdrop-filter: blur(var(--glass-blur-live)) saturate(180%);
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.95 * var(--glass-progress))),
0 1px 0 0 rgba(0, 0, 0, calc(0.08 * var(--glass-progress)));
}
html[data-theme="dark"] .glass-dynamic {
box-shadow:
inset 0 1px 0 0 rgba(255, 255, 255, calc(0.16 * var(--glass-progress))),
0 1px 0 0 rgba(255, 255, 255, calc(0.1 * var(--glass-progress)));
}
/* Specular rim traced around the shape, masked to a 1px ring so the fill
stays clear. Gives the edge-lit look that separates liquid glass from
plain frosted blur. */
.glass-rim::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(145deg, var(--glass-hi) 0%, transparent 38%, transparent 62%, var(--glass-lo) 100%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 3;
}
.glass-rim > * { position: relative; z-index: 2; }
/* ---------- Staggered entrance (gated on scroll reveal, see script) ---------- */
@keyframes riseIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* fill-mode "backwards" (not "both"): it holds the pre-delay state so the
stagger still reads, but releases the settled state once the animation
ends. With "both" the frozen "transform: none" out-ranked
.feature-card:hover, and cancelling the animation on hover made every
card replay riseIn on hover-out. */
.stagger.in-view > * { animation: riseIn 0.42s var(--ease) backwards; }
.stagger:not(.in-view) > * { opacity: 0; }
html.no-js .stagger > *, .stagger.in-view > * { opacity: 1; }
.stagger.in-view > *:nth-child(1) { animation-delay: 0.02s; }
.stagger.in-view > *:nth-child(2) { animation-delay: 0.06s; }
.stagger.in-view > *:nth-child(3) { animation-delay: 0.1s; }
.stagger.in-view > *:nth-child(4) { animation-delay: 0.14s; }
.stagger.in-view > *:nth-child(5) { animation-delay: 0.18s; }
.stagger.in-view > *:nth-child(6) { animation-delay: 0.22s; }
.stagger.in-view > *:nth-child(7) { animation-delay: 0.26s; }
.stagger.in-view > *:nth-child(8) { animation-delay: 0.3s; }
.stagger.in-view > *:nth-child(n + 9) { animation-delay: 0.34s; }
/* ---------- Button busy spinner ---------- */
.btn.is-busy {
pointer-events: none;
position: relative;
color: transparent !important;
}
.btn.is-busy > * { visibility: hidden; }
.btn.is-busy::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 15px;
height: 15px;
margin: -8px 0 0 -8px;
border-radius: 50%;
border: 2px solid var(--spin-track, rgba(0, 0, 0, 0.2));
border-top-color: var(--spin-head, var(--text));
animation: spin 0.7s linear infinite;
}
.btn-primary.is-busy, .btn-danger.is-busy { --spin-track: rgba(255, 255, 255, 0.35); --spin-head: #fff; }
@keyframes spin { to { transform: rotate(360deg); } }
button, .btn, .theme-toggle {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.btn:focus-visible,
.theme-toggle:focus-visible,
a:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--blue-soft);
}
.nav {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: var(--nav-h);
padding: calc(10px + var(--safe-t)) max(16px, var(--safe-r)) 10px max(16px, var(--safe-l));
/* 动态玻璃:内容真的从下面滚过,所以底色、模糊与边缘阴影
全部交给 .glass-dynamic 按滚动进度连续给出,这里只留布局。 */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
color: var(--text);
text-decoration: none;
}
.brand:hover { text-decoration: none; }
.brand-mark {
width: 28px;
height: 28px;
border-radius: 8px;
flex-shrink: 0;
display: inline-grid;
place-items: center;
background: linear-gradient(145deg, #5e5ce6 0%, #bf5af2 100%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 2px 8px rgba(94, 92, 230, 0.28);
color: #fff;
font-size: 14px;
line-height: 1;
}
.brand-mark .bi {
font-size: 0.95em;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.nav-actions {
display: flex;
align-items: center;
gap: 10px;
}
.theme-toggle {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--chip);
color: var(--text);
cursor: pointer;
display: grid;
place-items: center;
font-size: 16px;
}
.theme-toggle:hover { background: var(--blue-soft); }
.theme-toggle .sun { display: var(--theme-icon-sun); }
.theme-toggle .moon { display: var(--theme-icon-moon); }
.btn {
appearance: none;
border: none;
font-family: inherit;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
border-radius: var(--radius-pill);
padding: 9px 18px;
min-height: 40px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
transition: transform 0.15s var(--ease), background 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { text-decoration: none; }
.btn:active { transform: scale(0.98); }
.btn-primary {
background: var(--blue);
color: #fff;
box-shadow: 0 1px 2px rgba(0, 113, 227, 0.25);
}
.btn-primary:hover { background: var(--blue-hover); color: #fff; }
.btn-secondary {
background: var(--chip);
color: var(--text);
}
.btn-secondary:hover { filter: brightness(0.96); color: var(--text); }
html[data-theme="dark"] .btn-secondary:hover { filter: brightness(1.15); }
.btn-lg { padding: 14px 28px; font-size: 16px; min-height: 48px; }
.btn-ghost {
background: transparent;
color: var(--blue);
padding: 9px 12px;
}
.wrap {
width: min(var(--content-max), 100%);
margin: 0 auto;
padding: 0 max(var(--gutter), var(--safe-r)) 0 max(var(--gutter), var(--safe-l));
}
/* Hero */
.hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 40px;
align-items: center;
padding: 56px 0 48px;
}
.hero-copy h1 {
margin: 0 0 14px;
font-size: clamp(29px, 5.4vw, 48px);
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.08;
}
.hero-copy .lead {
margin: 0 0 28px;
font-size: clamp(15.5px, 2vw, 18px);
color: var(--text-secondary);
line-height: 1.55;
max-width: 36em;
}
.hero-cta {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.hero-meta {
margin-top: 18px;
font-size: 13px;
color: var(--text-tertiary);
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 18px;
}
.pill {
font-size: 12px;
font-weight: 500;
padding: 5px 12px;
border-radius: var(--radius-pill);
background: var(--blue-soft);
color: var(--blue);
}
.hero-visual {
position: relative;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--separator);
box-shadow: var(--shadow-lg);
background: var(--surface);
aspect-ratio: 16 / 9;
}
.hero-visual img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* The screenshot fills the frame, so lift the specular rim above it */
/* Sections */
section.block {
padding: 28px 0 12px;
}
.section-head {
margin-bottom: 22px;
}
.section-head h2 {
margin: 0 0 8px;
font-size: clamp(22px, 3.4vw, 28px);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.2;
}
.section-head p {
margin: 0;
color: var(--text-secondary);
font-size: clamp(14px, 1.6vw, 16px);
}
/* auto-fit: 3 → 2 → 1 columns with no breakpoint steps */
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 16px;
}
.feature-card {
background: var(--surface);
border: 1px solid var(--separator);
border-radius: var(--radius);
padding: 22px 20px;
box-shadow: var(--shadow-sm);
transition: transform 0.18s var(--ease), box-shadow 0.18s;
}
.feature-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.feature-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--blue-soft);
color: var(--blue);
font-size: 18px;
margin-bottom: 14px;
}
.feature-card h3 {
margin: 0 0 8px;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.02em;
}
.feature-card p {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.55;
}
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr));
gap: 14px;
counter-reset: step;
}
.step {
background: var(--surface);
border: 1px solid var(--separator);
border-radius: var(--radius);
padding: 20px 18px;
position: relative;
}
.step::before {
counter-increment: step;
content: counter(step);
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--blue);
color: #fff;
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.step h3 {
margin: 0 0 6px;
font-size: 15px;
font-weight: 600;
}
.step p {
margin: 0;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
.cta-banner {
margin: 40px 0 24px;
padding: 36px 32px;
border-radius: 24px;
background:
linear-gradient(135deg, rgba(0, 113, 227, 0.12), rgba(94, 92, 230, 0.1)),
var(--surface);
border: 1px solid var(--separator);
text-align: center;
box-shadow: var(--shadow-md);
}
html[data-theme="dark"] .cta-banner {
background:
linear-gradient(135deg, rgba(10, 132, 255, 0.16), rgba(94, 92, 230, 0.12)),
var(--surface);
}
.cta-banner h2 {
margin: 0 0 10px;
font-size: clamp(21px, 3.2vw, 26px);
letter-spacing: -0.03em;
line-height: 1.2;
}
.cta-banner p {
margin: 0 0 22px;
color: var(--text-secondary);
font-size: 15px;
}
.cta-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
}
.note {
margin: 8px 0 40px;
padding: 16px 18px;
border-radius: var(--radius-sm);
background: var(--surface-2);
border: 1px solid var(--separator);
font-size: 13px;
color: var(--text-secondary);
line-height: 1.55;
}
.note strong { color: var(--text); font-weight: 600; }
footer {
border-top: 1px solid var(--separator);
padding: 28px 0 calc(36px + var(--safe-b));
color: var(--text-tertiary);
font-size: 13px;
}
footer .foot-inner {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
justify-content: space-between;
align-items: center;
}
footer a { color: var(--text-secondary); }
footer a:hover { color: var(--blue); }
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
gap: 28px;
padding: 36px 0 32px;
}
.hero-visual { order: -1; max-width: 560px; margin: 0 auto; width: 100%; }
}
@media (max-width: 560px) {
:root { --nav-h: 52px; }
.cta-banner { padding: 28px 20px; }
.cta-actions { flex-direction: column; align-items: stretch; }
.cta-actions .btn { width: 100%; min-height: 48px; }
.hero-cta { flex-direction: column; align-items: stretch; }
.hero-cta .btn { width: 100%; min-height: 48px; }
.nav {
padding-left: max(12px, var(--safe-l));
padding-right: max(12px, var(--safe-r));
gap: 8px;
}
.nav-actions { gap: 6px; }
.nav-actions .btn-ghost .btn-label { display: none; }
.nav-actions .btn-ghost {
padding: 0;
min-width: 36px;
min-height: 36px;
width: 36px;
height: 36px;
border-radius: 50%;
}
.brand span {
max-width: 42vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.theme-toggle { width: 36px; height: 36px; flex-shrink: 0; }
}
@media (max-width: 380px) {
.brand span { max-width: 96px; }
.nav-actions .btn-primary { padding: 8px 12px; font-size: 13px; min-height: 36px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
html { scroll-behavior: auto; }
}
@media (prefers-contrast: more) {
:root {
--separator: rgba(0, 0, 0, 0.24);
--text-secondary: #515154;
--text-tertiary: #6e6e73;
}
html[data-theme="dark"] {
--separator: rgba(255, 255, 255, 0.3);
--text-secondary: #c7c7cc;
--text-tertiary: #98989d;
}
}
@media (prefers-reduced-transparency: reduce) {
:root { --bg-elevated: #f5f5f7; }
html[data-theme="dark"] { --bg-elevated: #1c1c1e; }
/* Every glass surface falls back to an opaque fill, no backdrop blur */
.nav,
.glass,
.glass-strong,
.glass-dynamic,
html.wa-lens .glass,
html.wa-lens .glass-strong {
background: var(--bg-elevated);
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* 动态玻璃降级为实心:底色不再随滚动变化,只留一条分隔线 */
.glass-dynamic,
html[data-theme="dark"] .glass-dynamic {
box-shadow: 0 1px 0 0 var(--separator);
}
.glass-rim::before { display: none; }
}
</style>
<noscript>
<style>
/* No script means the reveal observer never adds .in-view, which would
leave the feature grid and steps stuck at opacity 0. Ungate them. */
.stagger > * { opacity: 1 !important; animation: none !important; }
/* 没有脚本就没有滚动进度:--glass-progress 会永远停在 0,吸顶栏只剩
30% 底色 + 4px 模糊、且上下边缘阴影的 alpha 也是 0,正文会直接透过
导航栏。钉到 1,等同于改造前 .nav 的 --glass-bg-strong + 24px 模糊。 */
:root { --glass-progress: 1; }
</style>
</noscript>
</head>
<body>
<svg class="wa-lens-defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>
<!-- 边缘折射位移图:R 通道控制横向取样、G 通道控制纵向。中心为中性值 128 故不位移,
四边渐变让取样点向内偏移,形成玻璃厚度的透镜感。
color-interpolation-filters="sRGB" 必须保留:默认的 linearRGB 会把中性点 128
换算成 ~55,导致整个背景被均匀推歪。 -->
<filter id="waGlassLens" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
<feImage href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' preserveAspectRatio='none'><defs><linearGradient id='x' x1='0' x2='1'><stop offset='0' stop-color='rgb(255,0,0)'/><stop offset='0.25' stop-color='rgb(128,0,0)'/><stop offset='0.75' stop-color='rgb(128,0,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient><linearGradient id='y' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='rgb(0,255,0)'/><stop offset='0.3' stop-color='rgb(0,128,0)'/><stop offset='0.7' stop-color='rgb(0,128,0)'/><stop offset='1' stop-color='rgb(0,0,0)'/></linearGradient></defs><rect width='100' height='100' fill='url(%23x)'/><rect width='100' height='100' fill='url(%23y)' style='mix-blend-mode:plus-lighter'/></svg>" preserveAspectRatio="none" result="wa-map"/>
<feDisplacementMap in="SourceGraphic" in2="wa-map" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs></svg>
<header class="nav glass-dynamic">
<a class="brand" href="/">
<span class="brand-mark" aria-hidden="true"><i class="bi bi-robot"></i></span>
<span>WeChat-AI</span>
</a>
<div class="nav-actions">
<button class="theme-toggle" id="themeToggle" type="button" title="切换主题" aria-label="切换主题">
<i class="bi bi-sun-fill sun" aria-hidden="true"></i>
<i class="bi bi-moon-fill moon" aria-hidden="true"></i>
</button>
<a class="btn btn-ghost" href="/docs"><span class="btn-label">使用文档</span></a>
<a class="btn btn-ghost" href="/app"><span class="btn-label">用户中心</span></a>
<a class="btn btn-primary" href="/app">开始使用</a>
</div>
</header>
<main>
<div class="wrap">
<section class="hero">
<div class="hero-copy">
<div class="pill-row">
<span class="pill">自托管</span>
<span class="pill">LINUX DO 登录</span>
<span class="pill">iLink 直连</span>
</div>
<h1>用微信,跑起你的角色扮演智能体</h1>
<p class="lead">
WeChat-AI 把扫码绑定、人设广场、表情包回图、长期记忆与私聊分配放在一个面板里。
登录后几分钟即可添加机器人,和微信好友开始对话。
</p>
<div class="hero-cta">
<a class="btn btn-primary btn-lg" href="/app">
<i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>
进入用户中心
</a>
<a class="btn btn-secondary btn-lg" href="/docs">
<i class="bi bi-book" aria-hidden="true"></i>
使用文档
</a>
</div>
<p class="hero-meta">支持 LINUX DO OAuth · 多机器人 · 人设 / 表情广场</p>
</div>
<div class="hero-visual glass-rim">
<img src="/og.jpg" width="1280" height="720" alt="WeChat-AI 产品预览" />
</div>
</section>
<section class="block" id="features">
<div class="section-head">
<h2>核心能力</h2>
<p>面向多用户的微信角色扮演服务,从绑定到运营一条链路打通。</p>
</div>
<div class="feature-grid stagger">
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-qr-code-scan" aria-hidden="true"></i></div>
<h3>扫码绑定机器人</h3>
<p>微信扫码添加自己的机器人;token 失效可「重新扫码」,好友、人设与记忆会保留。</p>
</article>
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-person-badge" aria-hidden="true"></i></div>
<h3>人设广场</h3>
<p>浏览、投稿与收藏公开人设;为自己的机器人分配角色,随时切换性格与提示词。</p>
</article>
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-emoji-smile" aria-hidden="true"></i></div>
<h3>表情包广场</h3>
<p>投稿 / 收藏表情;模型可按 slug 引用,机器人通过 iLink 回发图片表情。</p>
</article>
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-chat-heart" aria-hidden="true"></i></div>
<h3>私聊批准与分配</h3>
<p>默认白名单模式:批准微信用户后才可对话,并为每个 peer 指定人设。</p>
</article>
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-brain" aria-hidden="true"></i></div>
<h3>长期记忆</h3>
<p>跨会话记住关键事实与偏好,让角色扮演更连贯;可按人设分组查看与清理。</p>
</article>
<article class="feature-card">
<div class="feature-icon"><i class="bi bi-lightning-charge" aria-hidden="true"></i></div>
<h3>主动联系</h3>
<p>空闲一段时间后,智能体可按配置主动找对方聊天,可设安静时段与每日上限。</p>
</article>
</div>
</section>
<section class="block" id="how">
<div class="section-head">
<h2>如何开始</h2>
<p>四步完成从登录到和微信好友对话。</p>
</div>
<div class="steps stagger">
<div class="step">
<h3>LINUX DO 登录</h3>
<p>在用户中心使用 LINUX DO 账号登录,创建个人空间。</p>
</div>
<div class="step">
<h3>扫码加机器人</h3>
<p>在「机器人」页扫码绑定微信 ClawBot / iLink 账号。</p>
</div>
<div class="step">
<h3>选人设与表情</h3>
<p>从广场添加人设与表情到自己的库,再分配给机器人。</p>
</div>
<div class="step">
<h3>批准好友对话</h3>
<p>批准私聊用户后即可收消息、LLM 回复文字与表情。</p>
</div>
</div>
</section>
<div class="cta-banner">
<h2>准备好了?</h2>
<p>登录用户中心,添加第一台机器人,开始角色扮演。</p>
<div class="cta-actions">
<a class="btn btn-primary btn-lg" href="/app">
<i class="bi bi-box-arrow-in-right" aria-hidden="true"></i>
进入用户中心
</a>
<a class="btn btn-secondary btn-lg" href="/docs">使用文档</a>
<a class="btn btn-secondary btn-lg" href="/admin">管理后台</a>
</div>
</div>
<p class="note">
<strong>合规提示:</strong>使用腾讯微信 ClawBot / iLink 能力须遵守相关条款;个人 Bot 存在限流与处置风险。
默认仅白名单用户可对话。角色扮演内容会经 LLM API 出机,请自行评估隐私与内容安全。
</p>
</div>
</main>
<footer>
<div class="wrap foot-inner">
<span>WeChat-AI · 自托管微信角色扮演</span>
<span>
<a href="/docs">使用文档</a>
·
<a href="/app">用户中心</a>
·
<a href="/admin">管理后台</a>
·
<a href="/health">健康检查</a>
</span>
</div>
</footer>
<script>
(function () {
var root = document.documentElement;
var btn = document.getElementById("themeToggle");
var meta = document.getElementById("themeColor");
function applyThemeColor() {
if (!meta) return;
meta.content = root.getAttribute("data-theme") === "dark" ? "#000000" : "#f5f5f7";
}
applyThemeColor();
if (btn) {
btn.addEventListener("click", function () {
var next = root.getAttribute("data-theme") === "dark" ? "light" : "dark";
root.setAttribute("data-theme", next);
try { localStorage.setItem("wa_theme", next); } catch (e) {}
root.removeAttribute("data-theme-pref");
applyThemeColor();
});
}
// 旧的 .scrolled 开关已移除:吸顶栏改由 --glass-progress 连续驱动,
// 不再需要这个二值状态类。
})();
</script>
<!-- Kept in a separate script element so a throw above can never abort the
reveal below and leave .stagger content permanently at opacity 0. -->
<script>
// Reveal grids as they scroll into view (respects reduced-motion)
(function () {
// Array.prototype.slice.call, not NodeList#forEach: the browsers that
// take the fallback branch below (no IntersectionObserver) are the same
// ones that lack NodeList.prototype.forEach, and a throw there would
// hide the feature grid and steps for good.
var targets = Array.prototype.slice.call(document.querySelectorAll(".stagger"));
function revealAll() {
for (var i = 0; i < targets.length; i++) targets[i].classList.add("in-view");
}
try {
var reduce = false;
try { reduce = matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (e) {}
if (reduce || !("IntersectionObserver" in window)) {
revealAll();
return;
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) { en.target.classList.add("in-view"); io.unobserve(en.target); }
});
}, { rootMargin: "0px 0px -10% 0px", threshold: 0.05 });
for (var j = 0; j < targets.length; j++) io.observe(targets[j]);
} catch (e) {
revealAll();
}
})();
/* 只有 Chromium 会真正渲染 backdrop-filter 里的 SVG 滤镜引用;Safari / Firefox
语法能解析但渲染为空,会把整条 backdrop-filter 作废——必须运行时判定而不是 @supports。
navigator.userAgentData 目前仅 Chromium 实现,用作引擎判定。
再要求 pointer:fine:位移滤镜在滚动时每帧重算,中低端手机上代价太高。 */
(function () {
try {
var fine = matchMedia("(pointer: fine)").matches;
var chromium = !!navigator.userAgentData;
if (chromium && fine && CSS.supports("backdrop-filter", "url(#x)")) {
document.documentElement.classList.add("wa-lens");
}
} catch (e) {}
})();
/* 把滚动深度写成 0..1 的进度值,驱动吸顶层的模糊与底色。rAF 节流,passive 监听。 */
(function () {
var root = document.documentElement;
var ticking = false;
function apply() {
ticking = false;
var y = window.scrollY || root.scrollTop || 0;
root.style.setProperty("--glass-progress", Math.min(1, y / 80).toFixed(3));
}
addEventListener(
"scroll",
function () {
if (!ticking) {
ticking = true;
requestAnimationFrame(apply);
}
},
{ passive: true },
);
apply();
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

+95
View File
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
previewText,
redactRedisKey,
ActivityBus,
} from "./activity-stream.js";
describe("previewText", () => {
it("keeps short text", () => {
const r = previewText("hello", 48);
assert.equal(r.preview, "hello");
assert.equal(r.len, 5);
assert.equal(r.truncated, false);
});
it("truncates long text", () => {
const s = "a".repeat(100);
const r = previewText(s, 48);
assert.equal(r.len, 100);
assert.equal(r.truncated, true);
assert.ok(r.preview.endsWith("…"));
assert.equal(r.preview.length, 49);
});
});
describe("redactRedisKey", () => {
it("redacts creds keys", () => {
assert.equal(
redactRedisKey("wa:bot:abc:creds"),
"wa:bot:abc:*",
);
});
it("passes normal keys", () => {
assert.equal(redactRedisKey("wa:msgs:b1:p1"), "wa:msgs:b1:p1");
});
});
describe("ActivityBus", () => {
it("rings and rate-limits", () => {
const fakeDb = {
redis: {
duplicate: () => ({
on() {},
subscribe: async () => {},
unsubscribe: async () => {},
disconnect() {},
}),
pipeline: () => ({
lpush() {
return this;
},
ltrim() {
return this;
},
publish() {
return this;
},
exec: async () => [],
}),
publish: async () => 0,
lrange: async () => [],
},
} as any;
const bus = new ActivityBus({
db: fakeDb,
source: "test",
enabled: true,
maxEps: 5,
ringSize: 10,
redisSample: 0,
});
const got: string[] = [];
bus.subscribe((ev) => got.push(ev.type));
for (let i = 0; i < 10; i++) {
bus.emit({ type: "worker.job", summary: `n=${i}` }, { fleet: false, persist: false });
}
const jobs = got.filter((t) => t === "worker.job");
assert.equal(jobs.length, 5);
// optional stream.dropped summary when over cap
assert.ok(got.length >= 5 && got.length <= 6);
assert.equal(
bus.recentLocal(20).filter((e) => e.type === "worker.job").length,
5,
);
// redis samples stay local path
bus.noteRedisCmd({ op: "get", key: "wa:user:1", ms: 2, ok: true });
// sample may or may not fire (random); just ensure no throw
});
});
+465
View File
@@ -0,0 +1,465 @@
/**
* Admin live activity bus: in-process listeners + Redis Pub/Sub fan-in.
*
* redis.cmd samples stay local (never LPUSH) to avoid Upstash recursion.
* Important domain events (message / worker / llm) may PUBLISH + optional backlog.
*/
import { randomUUID } from "node:crypto";
import type { Db } from "@wechat-ai/db";
import { K } from "@wechat-ai/db";
export type StreamLevel = "info" | "warn" | "error";
export type StreamEvent = {
id: string;
ts: string;
type: string;
level?: StreamLevel;
source?: string;
summary: string;
data?: Record<string, unknown>;
};
export type EmitInput = {
type: string;
summary: string;
level?: StreamLevel;
source?: string;
data?: Record<string, unknown>;
/** Override auto id */
id?: string;
/** Override ISO ts */
ts?: string;
};
export type EmitOpts = {
/** LPUSH to Redis recent list (default: true for important domain types) */
persist?: boolean;
/** PUBLISH to fleet channel (default: true except redis.cmd / stream.*) */
fleet?: boolean;
};
export type ActivityBusOptions = {
db: Db;
source?: string;
enabled?: boolean;
/** Max events accepted per second (process-local) */
maxEps?: number;
ringSize?: number;
backlogSize?: number;
/** Redis cmd sample rate 0..1 */
redisSample?: number;
/** Max redis.cmd events per second */
redisMaxEps?: number;
};
type Listener = (ev: StreamEvent) => void;
const IMPORTANT_PREFIXES = ["message.", "worker.", "llm."];
function isImportantType(type: string): boolean {
return IMPORTANT_PREFIXES.some((p) => type.startsWith(p));
}
function shouldFleetDefault(type: string): boolean {
if (type.startsWith("redis.")) return false;
if (type.startsWith("stream.")) return false;
return isImportantType(type);
}
function shouldPersistDefault(type: string): boolean {
return shouldFleetDefault(type);
}
/** Truncate message body for stream privacy. */
export function previewText(
text: string | null | undefined,
maxChars: number,
): { preview: string; len: number; truncated: boolean } {
const raw = text ?? "";
const len = raw.length;
if (len <= maxChars) {
return { preview: raw, len, truncated: false };
}
return {
preview: raw.slice(0, Math.max(0, maxChars)) + "…",
len,
truncated: true,
};
}
/** Redact Redis key for stream display (keep pattern, drop secrets-ish tails). */
export function redactRedisKey(key: string | undefined | null): string {
if (!key) return "";
const s = String(key);
// session / creds / blob: keep prefix only
if (/:creds$/i.test(s) || /:blob$/i.test(s) || /:session:/i.test(s)) {
const parts = s.split(":");
return parts.slice(0, Math.min(3, parts.length)).join(":") + ":*";
}
if (s.length > 96) return s.slice(0, 93) + "…";
return s;
}
export class ActivityBus {
private readonly db: Db;
private readonly source: string;
/** Admin-editable at runtime — see applyRuntimeOptions(). */
private enabled: boolean;
private maxEps: number;
private readonly ringSize: number;
private readonly backlogSize: number;
private redisSample: number;
private readonly redisMaxEps: number;
private ring: StreamEvent[] = [];
private listeners = new Set<Listener>();
private sub: ReturnType<Db["redis"]["duplicate"]> | null = null;
private started = false;
private closed = false;
private windowStart = Date.now();
private windowCount = 0;
private dropped = 0;
private lastDropReport = 0;
private redisWindowStart = Date.now();
private redisWindowCount = 0;
/** Dedup fleet + local echoes (id → expiry ms) */
private seen = new Map<string, number>();
private readonly seenTtlMs = 60_000;
constructor(opts: ActivityBusOptions) {
this.db = opts.db;
this.source = opts.source || "api";
this.enabled = opts.enabled !== false;
this.maxEps = Math.max(5, opts.maxEps ?? 80);
this.ringSize = Math.max(50, opts.ringSize ?? 500);
this.backlogSize = Math.max(50, opts.backlogSize ?? 300);
this.redisSample = Math.min(1, Math.max(0, opts.redisSample ?? 0.08));
this.redisMaxEps = Math.max(1, opts.redisMaxEps ?? 15);
}
/**
* Apply admin-editable settings in place (runtime settings reload).
* Turning the stream on lazily opens the Redis subscriber, which start()
* would otherwise only ever do at boot.
*/
applyRuntimeOptions(patch: {
enabled?: boolean;
maxEps?: number;
redisSample?: number;
}): void {
if (patch.maxEps !== undefined) this.maxEps = Math.max(5, patch.maxEps);
if (patch.redisSample !== undefined) {
this.redisSample = Math.min(1, Math.max(0, patch.redisSample));
}
if (patch.enabled === undefined || patch.enabled === this.enabled) return;
this.enabled = patch.enabled;
if (this.enabled && !this.started && !this.closed) {
void this.start().catch(() => undefined);
}
}
isEnabled(): boolean {
return this.enabled && !this.closed;
}
getSource(): string {
return this.source;
}
async start(): Promise<void> {
if (!this.enabled || this.started || this.closed) return;
this.started = true;
try {
const sub = this.db.redis.duplicate();
this.sub = sub;
sub.on("error", (err: Error) => {
if (process.env.LOG_LEVEL === "debug") {
console.error("[stream] sub error", err.message);
}
});
await sub.subscribe(K.streamChannel);
sub.on("message", (_ch: string, raw: string) => {
try {
const ev = JSON.parse(raw) as StreamEvent;
if (!ev?.id || !ev?.type) return;
this.ingestRemote(ev);
} catch {
/* ignore bad payload */
}
});
} catch (err) {
console.warn(
"[stream] fleet subscribe unavailable:",
err instanceof Error ? err.message : err,
);
}
}
async stop(): Promise<void> {
this.closed = true;
this.listeners.clear();
if (this.sub) {
try {
await this.sub.unsubscribe(K.streamChannel);
this.sub.disconnect();
} catch {
/* */
}
this.sub = null;
}
}
subscribe(fn: Listener): () => void {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
}
/** Newest-first local ring snapshot. */
recentLocal(limit = 100): StreamEvent[] {
const n = Math.max(1, Math.min(limit, this.ring.length));
return this.ring.slice(0, n);
}
async recentMerged(limit = 100): Promise<StreamEvent[]> {
const n = Math.max(1, Math.min(limit, 300));
let remote: StreamEvent[] = [];
try {
const raw = await this.db.redis.lrange(K.streamRecent, 0, n - 1);
remote = raw
.map((r) => {
try {
return JSON.parse(r) as StreamEvent;
} catch {
return null;
}
})
.filter((x): x is StreamEvent => !!x?.id);
} catch {
remote = [];
}
const local = this.recentLocal(n);
const map = new Map<string, StreamEvent>();
for (const ev of [...remote, ...local]) {
if (!map.has(ev.id)) map.set(ev.id, ev);
}
return [...map.values()]
.sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0))
.slice(0, n);
}
/**
* Emit a stream event. Rate-limited; may drop under load.
*/
emit(input: EmitInput, opts: EmitOpts = {}): StreamEvent | null {
if (!this.isEnabled()) return null;
const now = Date.now();
if (now - this.windowStart >= 1000) {
this.windowStart = now;
this.windowCount = 0;
}
if (this.windowCount >= this.maxEps) {
this.dropped++;
this.maybeReportDrops(now);
return null;
}
this.windowCount++;
const ev: StreamEvent = {
id: input.id || `sev_${randomUUID().replace(/-/g, "").slice(0, 16)}`,
ts: input.ts || new Date().toISOString(),
type: input.type,
level: input.level || "info",
source: input.source || this.source,
summary: input.summary,
data: input.data,
};
this.deliverLocal(ev);
const fleet = opts.fleet ?? shouldFleetDefault(ev.type);
const persist = opts.persist ?? shouldPersistDefault(ev.type);
if (fleet || persist) {
void this.fanOut(ev, { fleet, persist });
}
return ev;
}
/** Sampled redis command hook (local only). */
noteRedisCmd(info: {
op: string;
key?: string;
keys?: number;
ms?: number;
ok?: boolean;
}): void {
if (!this.isEnabled() || this.redisSample <= 0) return;
const now = Date.now();
if (now - this.redisWindowStart >= 1000) {
this.redisWindowStart = now;
this.redisWindowCount = 0;
}
if (this.redisWindowCount >= this.redisMaxEps) return;
if (Math.random() > this.redisSample) return;
this.redisWindowCount++;
const keyLabel = info.key
? redactRedisKey(info.key)
: info.keys != null
? `${info.keys} key(s)`
: "";
const msPart = info.ms != null ? ` ${Math.round(info.ms)}ms` : "";
const okPart = info.ok === false ? " fail" : "";
this.emit(
{
type: "redis.cmd",
level: info.ok === false ? "warn" : "info",
summary: `${info.op.toUpperCase()}${keyLabel ? " " + keyLabel : ""}${msPart}${okPart}`,
data: {
op: info.op,
key: keyLabel || undefined,
keys: info.keys,
ms: info.ms,
ok: info.ok !== false,
},
},
{ fleet: false, persist: false },
);
}
private maybeReportDrops(now: number): void {
if (this.dropped <= 0) return;
if (now - this.lastDropReport < 5000) return;
const n = this.dropped;
this.dropped = 0;
this.lastDropReport = now;
// Bypass rate limit for meta by delivering directly
const ev: StreamEvent = {
id: `sev_drop_${now.toString(36)}`,
ts: new Date().toISOString(),
type: "stream.dropped",
level: "warn",
source: this.source,
summary: `rate limit: dropped ${n} event(s) in last window`,
data: { dropped: n, maxEps: this.maxEps },
};
this.deliverLocal(ev);
}
private ingestRemote(ev: StreamEvent): void {
if (this.markSeen(ev.id)) return;
// Do not re-publish remote events
this.pushRing(ev);
for (const fn of this.listeners) {
try {
fn(ev);
} catch {
/* listener errors must not break bus */
}
}
}
private deliverLocal(ev: StreamEvent): void {
if (this.markSeen(ev.id)) return;
this.pushRing(ev);
for (const fn of this.listeners) {
try {
fn(ev);
} catch {
/* */
}
}
}
private markSeen(id: string): boolean {
const now = Date.now();
if (this.seen.size > 4000) {
for (const [k, exp] of this.seen) {
if (exp < now) this.seen.delete(k);
}
if (this.seen.size > 4000) {
// drop oldest ~20%
let i = 0;
const n = Math.ceil(this.seen.size * 0.2);
for (const k of this.seen.keys()) {
this.seen.delete(k);
if (++i >= n) break;
}
}
}
if (this.seen.has(id)) return true;
this.seen.set(id, now + this.seenTtlMs);
return false;
}
private pushRing(ev: StreamEvent): void {
this.ring.unshift(ev);
if (this.ring.length > this.ringSize) {
this.ring.length = this.ringSize;
}
}
private async fanOut(
ev: StreamEvent,
opts: { fleet: boolean; persist: boolean },
): Promise<void> {
// Never persist/fleet full message bodies — local ring + SSE keep fullText
const fleetEv = stripSensitiveStreamData(ev);
const raw = JSON.stringify(fleetEv);
try {
if (opts.persist) {
const pipe = this.db.redis.pipeline();
pipe.lpush(K.streamRecent, raw);
pipe.ltrim(K.streamRecent, 0, this.backlogSize - 1);
if (opts.fleet) pipe.publish(K.streamChannel, raw);
await pipe.exec();
} else if (opts.fleet) {
await this.db.redis.publish(K.streamChannel, raw);
}
} catch {
/* non-fatal */
}
}
}
/** Drop fullText/text before Redis pub/backlog (privacy + size). */
function stripSensitiveStreamData(ev: StreamEvent): StreamEvent {
if (!ev.data) return ev;
if (!("fullText" in ev.data) && !("text" in ev.data)) return ev;
const data = { ...ev.data };
delete data.fullText;
delete data.text;
return { ...ev, data };
}
// ── Singleton ──────────────────────────────────────────
let bus: ActivityBus | null = null;
export function initActivityBus(opts: ActivityBusOptions): ActivityBus {
if (bus) {
void bus.stop();
}
bus = new ActivityBus(opts);
return bus;
}
export function getActivityBus(): ActivityBus | null {
return bus;
}
export function emitActivity(
input: EmitInput,
opts?: EmitOpts,
): StreamEvent | null {
return bus?.emit(input, opts) ?? null;
}
+386
View File
@@ -0,0 +1,386 @@
import {
ILinkClient,
ILinkError,
resolveQrOpenUrl,
type QrcodeStatusResponse,
} from "@wechat-ai/ilink";
import {
type Db,
BOT_LOGIN_TTL_SEC,
type BotLoginSessionRecord,
getBotAccount,
getBotLoginSession,
markBotLoginCancelled,
saveBotLoginSession,
upsertBotAccount,
writeAudit,
} from "@wechat-ai/db";
import type { BotWorkerManager } from "./worker.js";
export type LoginSessionStatus = BotLoginSessionRecord["status"];
export type LoginMode = BotLoginSessionRecord["mode"];
export type LoginSessionView = BotLoginSessionRecord;
interface InternalSession {
view: LoginSessionView;
client: ILinkClient;
qrcode: string;
timer?: ReturnType<typeof setTimeout>;
stopped: boolean;
}
/**
* QR bot login: poll loop is process-local (holds ILinkClient),
* but the session **view** is stored in Redis so any replica can
* serve GET status / cancel under load balancing.
*/
export class BotLoginSessionManager {
/** Local poll ownership only — status is authoritative in Redis. */
private sessions = new Map<string, InternalSession>();
constructor(
private db: Db,
private worker: BotWorkerManager,
) {}
/**
* Start QR login.
* - create (default): new bot id
* - rebind: update token on existing botId (peers / memories kept)
*/
async start(
ownerUserId: string,
displayName?: string,
opts?: { rebindBotId?: string },
): Promise<LoginSessionView> {
const rebindBotId = opts?.rebindBotId?.trim();
let mode: LoginMode = "create";
let name =
(displayName?.trim() || "").slice(0, 64) ||
`bot-${Date.now().toString(36).slice(-6)}`;
if (rebindBotId) {
const bot = await getBotAccount(this.db, rebindBotId);
if (!bot) {
throw new Error("bot not found");
}
// Ownership is enforced by the HTTP route before calling start()
mode = "rebind";
name = (displayName?.trim() || bot.display_name || name).slice(0, 64);
}
const client = new ILinkClient({ timeoutMs: 30_000 });
const qr = await client.getBotQrcode(3);
if (!qr.qrcode) {
throw new ILinkError(
qr.errmsg ?? "get_bot_qrcode failed",
qr.ret,
undefined,
qr,
);
}
const sessionId = `login_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const now = new Date().toISOString();
const openUrl = resolveQrOpenUrl(qr);
const view: LoginSessionView = {
sessionId,
displayName: name,
ownerUserId,
status: "wait_scan",
mode,
rebindBotId: rebindBotId || undefined,
qrcode: qr.qrcode,
openUrl,
message:
mode === "rebind"
? `重新绑定「${name}」:请用微信扫码(好友/人设/记忆将保留)`
: "请用微信扫描二维码(或打开下方链接)",
createdAt: now,
updatedAt: now,
};
const session: InternalSession = {
client,
qrcode: qr.qrcode,
stopped: false,
view,
};
this.sessions.set(sessionId, session);
await this.persist(view);
void this.pollLoop(sessionId);
session.timer = setTimeout(() => {
void this.expireLocal(sessionId);
}, BOT_LOGIN_TTL_SEC * 1000);
return { ...view };
}
/** Any node: read shared Redis view (falls back to local if still hot). */
async get(sessionId: string): Promise<LoginSessionView | undefined> {
const remote = await getBotLoginSession(this.db, sessionId);
if (remote) return { ...remote };
const local = this.sessions.get(sessionId);
return local ? { ...local.view } : undefined;
}
/**
* Cancel from any node: mark Redis cancelled so the poller exits;
* drop local resources if this process owns the poll.
*/
async cancel(sessionId: string, ownerUserId?: string): Promise<boolean> {
if (!ownerUserId) {
const cur = await getBotLoginSession(this.db, sessionId);
if (!cur) {
const local = this.sessions.get(sessionId);
if (!local) return false;
local.stopped = true;
this.dropLocal(sessionId);
return true;
}
ownerUserId = cur.ownerUserId;
}
const marked = await markBotLoginCancelled(
this.db,
sessionId,
ownerUserId,
);
if (!marked) {
// Maybe only local (Redis miss) — try local ownership check
const local = this.sessions.get(sessionId);
if (!local) return false;
if (local.view.ownerUserId !== ownerUserId) return false;
local.stopped = true;
this.dropLocal(sessionId);
return true;
}
const local = this.sessions.get(sessionId);
if (local) {
local.stopped = true;
local.view = { ...marked };
this.dropLocal(sessionId);
}
return true;
}
private async persist(view: LoginSessionView): Promise<void> {
try {
await saveBotLoginSession(this.db, view, BOT_LOGIN_TTL_SEC);
} catch (err) {
console.error(
"[bot-login] persist failed:",
err instanceof Error ? err.message : err,
);
}
}
private async expireLocal(sessionId: string): Promise<void> {
const s = this.sessions.get(sessionId);
if (s && s.view.status !== "confirmed") {
s.stopped = true;
s.view.status = "expired";
s.view.message = "登录超时,请重新发起";
s.view.updatedAt = new Date().toISOString();
await this.persist(s.view);
}
this.dropLocal(sessionId);
}
private dropLocal(sessionId: string): void {
const s = this.sessions.get(sessionId);
if (s?.timer) clearTimeout(s.timer);
this.sessions.delete(sessionId);
}
private async pollLoop(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const deadline = Date.now() + 8 * 60_000;
while (!session.stopped && Date.now() < deadline) {
// Cross-node cancel
try {
const remote = await getBotLoginSession(this.db, sessionId);
if (!remote || remote.status === "cancelled") {
session.stopped = true;
if (remote?.status === "cancelled") {
session.view = { ...remote };
}
this.dropLocal(sessionId);
return;
}
} catch {
/* continue poll on redis blip */
}
try {
const status = await session.client.getQrcodeStatus(session.qrcode);
await this.applyStatus(session, status);
await this.persist(session.view);
if (
session.view.status === "confirmed" ||
session.view.status === "expired" ||
session.view.status === "error" ||
session.view.status === "cancelled"
) {
// Keep confirmed/error view in Redis for clients; drop local poll resources
this.dropLocal(sessionId);
return;
}
} catch (err) {
if (
err instanceof ILinkError &&
(err.body as { aborted?: boolean })?.aborted
) {
session.view.status = "wait_scan";
session.view.updatedAt = new Date().toISOString();
await this.persist(session.view);
continue;
}
const msg = err instanceof Error ? err.message : String(err);
if (/timed out|aborted|fetch failed|ECONNRESET|network/i.test(msg)) {
session.view.message = "等待扫码中…(网络重试)";
session.view.updatedAt = new Date().toISOString();
await this.persist(session.view);
await sleep(800);
continue;
}
session.view.status = "error";
session.view.message = msg;
session.view.updatedAt = new Date().toISOString();
await this.persist(session.view);
this.dropLocal(sessionId);
return;
}
await sleep(400);
}
if (session.view.status !== "confirmed") {
session.view.status = "expired";
session.view.message = "登录超时,请重新发起";
session.view.updatedAt = new Date().toISOString();
await this.persist(session.view);
}
this.dropLocal(sessionId);
}
private async applyStatus(
session: InternalSession,
status: QrcodeStatusResponse,
): Promise<void> {
const st = (status.status ?? "").toLowerCase();
session.view.updatedAt = new Date().toISOString();
if (
st === "confirmed" ||
st === "confirmed_login" ||
st === "success" ||
Boolean(status.bot_token)
) {
if (!status.bot_token) {
session.view.status = "error";
session.view.message = "扫码成功但未返回 bot_token";
return;
}
try {
if (session.view.mode === "rebind" && session.view.rebindBotId) {
await this.finishRebind(session, status);
} else {
await this.finishCreate(session, status);
}
} catch (err) {
session.view.status = "error";
session.view.message =
err instanceof Error ? err.message : "保存账号失败";
}
return;
}
if (st === "expired" || st === "cancel" || st === "cancelled") {
session.view.status = "expired";
session.view.message = `二维码已${st === "expired" ? "过期" : "取消"},请重新发起`;
session.stopped = true;
return;
}
if (st.includes("scan") && !st.includes("wait")) {
session.view.status = "scanned";
session.view.message = "已扫码,请在手机上确认登录";
} else {
session.view.status = "wait_scan";
session.view.message =
session.view.mode === "rebind"
? "等待微信扫码以重新绑定…"
: "等待微信扫码…";
}
}
private async finishCreate(
session: InternalSession,
status: QrcodeStatusResponse,
): Promise<void> {
const botId = `bot_${Date.now().toString(36)}`;
const displayName =
session.view.displayName ||
status.account_id ||
status.ilink_bot_id ||
botId;
await upsertBotAccount(this.db, {
id: botId,
ownerUserId: session.view.ownerUserId,
displayName,
accountRef: status.account_id ?? status.ilink_bot_id,
baseUrl: status.baseurl,
botToken: status.bot_token!,
});
await writeAudit(this.db, "bot_login", session.view.ownerUserId, {
botId,
accountRef: status.account_id,
displayName,
mode: "create",
});
this.worker.ensureLoop(botId);
session.view.status = "confirmed";
session.view.botId = botId;
session.view.message = `登录成功:${displayName}`;
session.stopped = true;
}
private async finishRebind(
session: InternalSession,
status: QrcodeStatusResponse,
): Promise<void> {
const botId = session.view.rebindBotId!;
const existing = await getBotAccount(this.db, botId);
if (!existing) {
throw new Error("bot not found during rebind");
}
// Keep display name & ownership; refresh token + optional account refs
const displayName = existing.display_name || session.view.displayName;
await upsertBotAccount(this.db, {
id: botId,
ownerUserId: existing.owner_user_id || session.view.ownerUserId,
displayName,
accountRef:
status.account_id ?? status.ilink_bot_id ?? existing.account_ref ?? undefined,
baseUrl: status.baseurl ?? existing.base_url ?? undefined,
botToken: status.bot_token!,
});
await writeAudit(this.db, "bot_rebind", session.view.ownerUserId, {
botId,
accountRef: status.account_id,
displayName,
});
this.worker.restartBot(botId);
session.view.status = "confirmed";
session.view.botId = botId;
session.view.message = `重新绑定成功:${displayName}(数据已保留)`;
session.stopped = true;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
+355
View File
@@ -0,0 +1,355 @@
import {
type BroadcastJob,
type Db,
cancelBroadcastJob,
findNextPendingBroadcast,
getBroadcastJob,
pushBroadcastFailure,
releaseBroadcastLock,
renewBroadcastLock,
saveBroadcastJob,
setBroadcastActive,
tryAcquireBroadcastLock,
writeAudit,
} from "@wechat-ai/db";
export type AdminSendResult = {
ok: boolean;
reason?:
| "empty"
| "no_context_token"
| "no_credentials"
| "ilink_error"
| "cancelled";
error?: string;
};
export interface BroadcastRunnerOptions {
db: Db;
workerId: string;
/** Interval between send attempts (ms) */
intervalMs: number;
/** Tick interval to look for new jobs (ms) */
pollIntervalMs?: number;
lockTtlSec?: number;
adminSendText: (
botId: string,
peerId: string,
text: string,
) => Promise<AdminSendResult>;
/** Serialize with inbound replies for the same peer when possible */
runOnPeerChain?: (
botId: string,
peerId: string,
fn: () => Promise<void>,
) => Promise<void>;
log?: (msg: string, extra?: unknown) => void;
}
/**
* Serial async processor for admin broadcast jobs stored in Redis.
* Only one job runs at a time fleet-wide (lock + active pointer).
*/
export class BroadcastRunner {
private stopped = true;
private timer: ReturnType<typeof setTimeout> | null = null;
private running = false;
private wakeRequested = false;
constructor(private opts: BroadcastRunnerOptions) {}
/**
* Apply admin-editable settings in place (runtime settings reload).
* Values are re-read on every tick, so a longer pollIntervalMs only takes
* hold after the currently armed timer fires.
*/
applyRuntimeOptions(patch: {
intervalMs?: number;
pollIntervalMs?: number;
lockTtlSec?: number;
}): void {
if (patch.intervalMs !== undefined) {
this.opts.intervalMs = Math.max(50, patch.intervalMs);
}
if (patch.pollIntervalMs !== undefined) {
this.opts.pollIntervalMs = Math.max(250, patch.pollIntervalMs);
}
if (patch.lockTtlSec !== undefined) {
this.opts.lockTtlSec = Math.max(10, patch.lockTtlSec);
}
}
start(): void {
this.stopped = false;
this.opts.log?.(
`[broadcast] runner start interval=${this.opts.intervalMs}ms`,
);
this.scheduleNext(1_500);
}
stop(): void {
this.stopped = true;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
/** Call after creating a job so we don't wait for the next poll. */
wake(): void {
this.wakeRequested = true;
if (this.stopped || this.running) return;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.scheduleNext(50);
}
private scheduleNext(ms: number): void {
if (this.stopped) return;
this.timer = setTimeout(() => {
void this.tick()
.catch((err) => {
this.opts.log?.(
`[broadcast] tick error: ${
err instanceof Error ? err.message : String(err)
}`,
);
})
.finally(() => {
const next = this.wakeRequested
? 200
: (this.opts.pollIntervalMs ?? 2_000);
this.wakeRequested = false;
this.scheduleNext(next);
});
}, ms);
}
/** Exposed for tests */
async tick(): Promise<void> {
if (this.stopped || this.running) return;
this.running = true;
try {
const job = await findNextPendingBroadcast(this.opts.db);
if (!job) return;
await this.processJob(job);
} finally {
this.running = false;
}
}
private async processJob(initial: BroadcastJob): Promise<void> {
const lockTtl = this.opts.lockTtlSec ?? 60;
const got = await tryAcquireBroadcastLock(
this.opts.db,
initial.id,
this.opts.workerId,
lockTtl,
);
if (!got) {
this.opts.log?.(
`[broadcast] job=${initial.id} lock held by another worker`,
);
return;
}
let job = (await getBroadcastJob(this.opts.db, initial.id)) ?? initial;
if (job.status === "cancelled" || job.status === "completed") {
await releaseBroadcastLock(
this.opts.db,
job.id,
this.opts.workerId,
);
return;
}
try {
if (job.status === "pending") {
job.status = "running";
job.startedAt = job.startedAt || new Date().toISOString();
await saveBroadcastJob(this.opts.db, job);
}
await setBroadcastActive(this.opts.db, job.id);
// Keep a local working copy. Never replace it wholesale with a Redis
// re-read — unflushed stats/cursor would be wiped and the job can
// finish as e.g. 1/N after only the last in-batch increment survives.
job.stats = job.stats ?? { total: 0, sent: 0, skipped: 0, failed: 0 };
job.failures = job.failures ?? [];
const recipients = job.recipients ?? [];
const text = job.text;
let cursor = Math.max(0, job.cursor ?? 0);
let sinceFlush = 0;
let sinceCancelCheck = 0;
let lastRenew = Date.now();
// With remote Redis, a GET every message dominates wall time. Check cancel
// every few sends (and always before first / after last).
const cancelCheckEvery = Math.max(
1,
Math.min(5, Math.floor(1000 / Math.max(50, this.opts.intervalMs))),
);
// Flush often enough that the admin UI progress is useful, but not every
// single send on a high-latency Redis.
const flushEvery = Math.max(1, Math.min(5, cancelCheckEvery));
this.opts.log?.(
`[broadcast] start job=${job.id} total=${recipients.length} cursor=${cursor}`,
);
while (cursor < recipients.length) {
if (this.stopped) break;
// Periodic cancel poll — do NOT assign job = live (stale stats).
if (sinceCancelCheck === 0) {
const live = await getBroadcastJob(this.opts.db, job.id);
if (!live || live.status === "cancelled") {
job.status = "cancelled";
job.finishedAt = new Date().toISOString();
job.cursor = cursor;
await saveBroadcastJob(this.opts.db, job);
break;
}
}
sinceCancelCheck =
(sinceCancelCheck + 1) % cancelCheckEvery;
const target = recipients[cursor]!;
const sendOnce = async () => {
const result = await this.opts.adminSendText(
target.botId,
target.peerId,
text,
);
if (result.ok) {
job.stats.sent += 1;
} else if (
result.reason === "no_context_token" ||
result.reason === "no_credentials" ||
result.reason === "empty"
) {
job.stats.skipped += 1;
} else {
job.stats.failed += 1;
pushBroadcastFailure(job, {
botId: target.botId,
peerId: target.peerId,
error: result.error || result.reason || "send_failed",
});
}
};
try {
if (this.opts.runOnPeerChain) {
await this.opts.runOnPeerChain(
target.botId,
target.peerId,
sendOnce,
);
} else {
await sendOnce();
}
} catch (err) {
job.stats.failed += 1;
pushBroadcastFailure(job, {
botId: target.botId,
peerId: target.peerId,
error: err instanceof Error ? err.message : String(err),
});
}
cursor += 1;
job.cursor = cursor;
sinceFlush += 1;
const now = Date.now();
if (now - lastRenew > 15_000) {
await renewBroadcastLock(
this.opts.db,
job.id,
this.opts.workerId,
lockTtl,
);
lastRenew = now;
}
if (sinceFlush >= flushEvery || cursor >= recipients.length) {
await saveBroadcastJob(this.opts.db, job);
sinceFlush = 0;
}
if (cursor < recipients.length && this.opts.intervalMs > 0) {
await sleep(this.opts.intervalMs);
}
}
// Finalize if not cancelled mid-way
const final = await getBroadcastJob(this.opts.db, job.id);
if (final && final.status === "running") {
// Prefer in-memory progress; Redis copy may lag between flushes.
final.cursor = cursor;
final.stats = job.stats;
final.failures = job.failures;
if (cursor >= recipients.length) {
final.status = "completed";
final.finishedAt = new Date().toISOString();
} else if (this.stopped) {
// leave as running for another worker / restart
final.status = "running";
}
await saveBroadcastJob(this.opts.db, final);
job = final;
} else if (
final &&
final.status === "cancelled" &&
job.status !== "cancelled"
) {
// Cancel won the race after the loop; still persist latest counts.
final.cursor = cursor;
final.stats = job.stats;
final.failures = job.failures;
await saveBroadcastJob(this.opts.db, final);
job = final;
}
if (job.status === "completed" || job.status === "cancelled") {
await writeAudit(this.opts.db, "admin_broadcast_finished", "system", {
jobId: job.id,
status: job.status,
stats: job.stats,
});
await setBroadcastActive(this.opts.db, null);
this.opts.log?.(
`[broadcast] done job=${job.id} status=${job.status} ` +
`sent=${job.stats.sent} skipped=${job.stats.skipped} failed=${job.stats.failed}`,
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
job.status = "failed";
job.error = message;
job.finishedAt = new Date().toISOString();
await saveBroadcastJob(this.opts.db, job);
await setBroadcastActive(this.opts.db, null);
await writeAudit(this.opts.db, "admin_broadcast_failed", "system", {
jobId: job.id,
error: message,
});
this.opts.log?.(`[broadcast] job=${job.id} failed: ${message}`);
} finally {
await releaseBroadcastLock(
this.opts.db,
initial.id,
this.opts.workerId,
);
}
}
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// re-export for routes that may cancel via runner-less path
export { cancelBroadcastJob };
+27
View File
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
etagFromHash,
ifNoneMatchHits,
CC_PRIVATE_NO_STORE,
CC_CDN_STICKER,
} from "./cache-headers.js";
test("etagFromHash quotes hash", () => {
assert.equal(etagFromHash("abc123"), '"abc123"');
assert.equal(etagFromHash("abc123", true), 'W/"abc123"');
});
test("ifNoneMatchHits matches strong and weak", () => {
const etag = etagFromHash("deadbeef");
assert.equal(ifNoneMatchHits(etag, etag), true);
assert.equal(ifNoneMatchHits(`W/${etag}`, etag), true);
assert.equal(ifNoneMatchHits('"other"', etag), false);
assert.equal(ifNoneMatchHits(undefined, etag), false);
assert.equal(ifNoneMatchHits("*", etag), true);
});
test("cache constants present", () => {
assert.match(CC_PRIVATE_NO_STORE, /no-store/);
assert.match(CC_CDN_STICKER, /immutable/);
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Shared Cache-Control helpers for Cloudflare-friendly origin responses.
*
* Prefer Cloudflare-CDN-Cache-Control for longer edge TTL than browsers.
* Authenticated JSON stays private, no-store.
*/
import type { FastifyReply } from "fastify";
/** Landing / docs HTML (browser short, edge longer). */
export const CC_HTML_MARKETING =
"public, max-age=300" as const;
export const CDN_HTML_MARKETING =
"public, max-age=3600, stale-while-revalidate=86400" as const;
/** App / admin SPA shells. */
export const CC_HTML_APP = "public, max-age=60" as const;
export const CDN_HTML_APP =
"public, max-age=3600, stale-while-revalidate=86400" as const;
/** OG share image. */
export const CC_OG = "public, max-age=86400, immutable" as const;
export const CDN_OG = "public, max-age=604800" as const;
/** Public approved sticker CDN. */
export const CC_CDN_STICKER =
"public, max-age=31536000, immutable" as const;
export const CDN_CDN_STICKER = "public, max-age=31536000" as const;
/**
* Private sticker bytes (own / pending / admin review). Content-addressed, so
* revalidate cheaply with an ETag instead of re-sending the blob every time.
*/
export const CC_PRIVATE_STICKER =
"private, max-age=300, must-revalidate" as const;
/**
* Login QR image. Locally rendered from a link that embeds a login ticket, so
* it must never be shared or stored by an intermediary.
*/
export const CC_PRIVATE_QR = "private, no-store" as const;
/** Auth config (static-ish). */
export const CC_AUTH_CONFIG = "public, max-age=60" as const;
export const CDN_AUTH_CONFIG = "public, max-age=300" as const;
/** Default for authenticated / dynamic APIs. */
export const CC_PRIVATE_NO_STORE = "private, no-store" as const;
/** Health checks — never cache. */
export const CC_NO_STORE = "no-store" as const;
export function setPublicCache(
reply: FastifyReply,
browser: string,
edge?: string,
extra?: { etag?: string; cacheTag?: string },
): void {
reply.header("Cache-Control", browser);
if (edge) {
reply.header("Cloudflare-CDN-Cache-Control", edge);
}
if (extra?.etag) {
reply.header("ETag", extra.etag);
}
if (extra?.cacheTag) {
reply.header("Cache-Tag", extra.cacheTag);
}
}
export function setPrivateNoStore(reply: FastifyReply): void {
reply.header("Cache-Control", CC_PRIVATE_NO_STORE);
}
/** Quoted weak or strong ETag from a hex hash. */
export function etagFromHash(hash: string, weak = false): string {
const h = hash.replace(/"/g, "");
return weak ? `W/"${h}"` : `"${h}"`;
}
export function ifNoneMatchHits(
ifNoneMatch: string | string[] | undefined,
etag: string,
): boolean {
if (!ifNoneMatch) return false;
const raw = Array.isArray(ifNoneMatch) ? ifNoneMatch.join(",") : ifNoneMatch;
const want = etag.replace(/^W\//, "").replace(/"/g, "");
for (const part of raw.split(",")) {
const t = part.trim();
if (t === "*") return true;
const got = t.replace(/^W\//, "").replace(/"/g, "");
if (got === want) return true;
}
return false;
}
+138
View File
@@ -0,0 +1,138 @@
import {
doctorSnapshot,
getBotCredentials,
listBotAccounts,
openDatabase,
seedPersonas,
} from "@wechat-ai/db";
import { probeToolsHealth } from "@wechat-ai/llm";
import { loadConfig } from "./config.js";
import { isPlaceholderRedisUrl, loadLinuxDoConfig } from "./oauth-linuxdo.js";
function ok(msg: string): void {
console.log(`${msg}`);
}
function warn(msg: string): void {
console.log(` ! ${msg}`);
}
function fail(msg: string): void {
console.log(`${msg}`);
}
async function main(): Promise<void> {
const cfg = loadConfig();
let exitCode = 0;
console.log("WeChat-AI doctor\n");
console.log("Environment");
// Mask password in redis URL for logs
const maskedRedis = cfg.redisUrl.replace(
/:\/\/([^:]+):([^@]+)@/,
"://$1:***@",
);
ok(`REDIS_URL=${maskedRedis}`);
if (isPlaceholderRedisUrl(cfg.redisUrl)) {
fail(
"REDIS_URL 仍是占位符。请到 Upstash Console → Connect 复制 rediss:// 连接串写入 .env",
);
process.exit(1);
}
if (/upstash\.io/i.test(cfg.redisUrl) && cfg.redisUrl.startsWith("redis://")) {
warn("Upstash 建议使用 rediss://TLS),当前是 redis://");
}
if (!cfg.llmApiKey) {
fail("LLM_API_KEY missing (platform / admin LLM)");
exitCode = 1;
} else {
ok(
`platform LLM model=${cfg.llmModel} base=${cfg.llmBaseUrl.replace(/\/\/([^:]+):([^@]+)@/, "//$1:***@")}`,
);
}
if (cfg.toolsBaseUrl) {
ok(`TOOLS_BASE_URL=${cfg.toolsBaseUrl}`);
if (!cfg.toolsApiKey) {
warn("TOOLS_API_KEY empty — tools gateway may reject requests");
}
const probe = await probeToolsHealth(cfg.toolsBaseUrl);
if (probe.ok) {
ok(`tools gateway health: ${probe.detail.slice(0, 80)}`);
} else {
fail(`tools gateway unreachable: ${probe.detail}`);
exitCode = 1;
}
} else {
warn(
"TOOLS_BASE_URL 未配置 — 用户自定义 LLM 与联网搜索不可用(平台 LLM 仍可用)",
);
}
if (cfg.webSearchEnabled && !cfg.toolsBaseUrl) {
fail("WEB_SEARCH_ENABLED=true 但未配置 TOOLS_BASE_URL");
exitCode = 1;
}
const oauth = loadLinuxDoConfig();
if (!oauth) {
fail("LINUX DO OAuth 未配置 LINUXDO_CLIENT_ID/SECRET/REDIRECT_URI");
exitCode = 1;
} else {
ok(`OAuth redirect=${oauth.redirectUri}`);
}
if (cfg.adminIds.size === 0) {
warn("LINUXDO_ADMIN_IDS 为空 — 无人自动成为管理员");
} else {
ok(`admin ids: ${[...cfg.adminIds].join(",")}`);
}
console.log("\nRedis");
const db = openDatabase(cfg.redisUrl);
try {
await db.ping();
ok("PONG");
} catch (err) {
fail(`连接失败: ${(err as Error).message}`);
process.exit(1);
}
await seedPersonas(db);
const snap = await doctorSnapshot(db);
ok(
`users=${snap.users} bots=${snap.bots} personas=${snap.personas} default=${snap.defaultPersona ?? "none"}`,
);
ok(
`peers=${snap.peers} (approved=${snap.approvedPeers} pending=${snap.unapprovedPeers})`,
);
if (snap.deepStats) {
ok(
`assignments=${snap.assignments} messages=${snap.messages} memories=${snap.memories}`,
);
} else {
warn(
`assignments/messages/memories 未统计(peers 超过 DOCTOR_DEEP_STATS_MAX_PEERS`,
);
}
if (!snap.defaultPersona) {
fail("无默认人设");
exitCode = 1;
}
if (snap.activeBots === 0) warn("尚无机器人 — 用户登录后扫码添加");
console.log("\nBot credentials (Redis)");
for (const bot of await listBotAccounts(db)) {
const creds = await getBotCredentials(db, bot.id);
if (!creds?.botToken) {
fail(
`bot ${bot.id}: missing Redis token (wa:bot:${bot.id}:creds) — re-scan login`,
);
exitCode = 1;
} else {
ok(`bot ${bot.id} owner=${bot.owner_user_id} token=redis`);
}
}
console.log(exitCode === 0 ? "\nDoctor: PASS" : "\nDoctor: ISSUES FOUND");
await db.close();
process.exit(exitCode);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+82
View File
@@ -0,0 +1,82 @@
import { loginWithQrcode, ILinkClient } from "@wechat-ai/ilink";
import {
openDatabase,
upsertBotAccount,
writeAudit,
} from "@wechat-ai/db";
import { loadConfig } from "./config.js";
function parseArgs(argv: string[]): { name?: string; owner?: string } {
const out: { name?: string; owner?: string } = {};
const args = argv.filter((a) => a !== "--");
for (let i = 0; i < args.length; i++) {
if (args[i] === "--name" && args[i + 1]) {
out.name = args[++i];
} else if (args[i] === "--owner" && args[i + 1]) {
out.owner = args[++i];
}
}
return out;
}
async function main(): Promise<void> {
const cfg = loadConfig();
const args = parseArgs(process.argv.slice(2));
const db = openDatabase(cfg.redisUrl);
console.log("Requesting iLink QR code…");
console.log("推荐:浏览器打开 /app → 机器人 → 扫码添加(token 写入 Redis)。");
console.log("Open WeChat → scan the QR (ClawBot / 插件扫码).\n");
if (args.name) console.log(`Display name: ${args.name}\n`);
const client = new ILinkClient({ timeoutMs: 30_000 });
const result = await loginWithQrcode({
client,
timeoutMs: 8 * 60_000,
onQrcode: (info) => {
console.log("qrcode id:", info.qrcode);
const openUrl = info.qrcodeUrl || info.qrcodeImgContent;
if (openUrl?.startsWith("http")) {
console.log("\n>>> 请在手机微信中打开此链接完成扫码/授权:\n");
console.log(openUrl);
console.log("\n");
}
console.log("等待扫码(最长约 8 分钟,请勿关闭窗口)…\n");
},
onStatus: (st) => {
process.stdout.write(`\rstatus: ${st.padEnd(16)}`);
},
});
console.log("\n\nLogin confirmed.");
const botId = `bot_${Date.now().toString(36)}`;
const displayName =
args.name ?? result.accountId ?? `bot-${botId.slice(-6)}`;
const ownerUserId = args.owner ?? "cli";
await upsertBotAccount(db, {
id: botId,
ownerUserId,
displayName,
accountRef: result.accountId,
baseUrl: result.baseUrl,
botToken: result.botToken,
});
await writeAudit(db, "bot_login", "cli", {
botId,
accountRef: result.accountId,
displayName,
});
console.log("Saved bot:", botId, `(${displayName})`);
console.log("Token: Redis key wa:bot:" + botId + ":creds");
console.log("多 Bot:再次运行 pnpm ilink:login -- --name 第二个号");
console.log("Start: pnpm dev → http://127.0.0.1:8787/app");
await db.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+498
View File
@@ -0,0 +1,498 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { config as loadDotenv } from "dotenv";
import { parseAdminIds } from "./oauth-linuxdo.js";
/**
* Levels pino accepts. Validated rather than passed through: pino throws at
* construction on an unknown level, so a typo in LOG_LEVEL would take the whole
* process down at boot.
*/
export const LOG_LEVELS = [
"silent",
"fatal",
"error",
"warn",
"info",
"debug",
"trace",
] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export const VISION_MODES = ["caption", "direct"] as const;
export type VisionMode = (typeof VISION_MODES)[number];
export function resolveVisionMode(raw: string | undefined): VisionMode {
const v = (raw ?? "").trim().toLowerCase();
// Default to caption: it is the only mode that works when the roleplay model
// is text-only, which is the common case.
return v === "direct" ? "direct" : "caption";
}
export function resolveLogLevel(raw: string | undefined): LogLevel {
const v = (raw ?? "").trim().toLowerCase();
return (LOG_LEVELS as readonly string[]).includes(v)
? (v as LogLevel)
: "info";
}
export function resolveRepoRoot(start = process.cwd()): string {
let dir = path.resolve(start);
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
const fromFile = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../..",
);
if (fs.existsSync(path.join(fromFile, "pnpm-workspace.yaml"))) {
return fromFile;
}
return path.resolve(start);
}
const repoRootEarly = resolveRepoRoot();
loadDotenv({ path: path.join(repoRootEarly, ".env") });
loadDotenv({ path: path.resolve(process.cwd(), ".env") });
export interface AppConfig {
host: string;
port: number;
/** legacy bearer for scripts; optional when OAuth enabled */
token: string;
redisUrl: string;
/** Admin platform LLM (main site connects directly) */
llmBaseUrl: string;
llmApiKey: string;
llmModel: string;
/**
* HF / tools gateway (user custom LLM egress + web search).
* Main site never dials user custom base_url; only this host.
*/
toolsBaseUrl: string;
toolsApiKey: string;
toolsTimeoutMs: number;
/** Global switch: allow personas to use web_search via tools gateway */
webSearchEnabled: boolean;
webSearchMaxResults: number;
/** Encrypt user-stored custom LLM API keys at rest */
llmProviderSecret: string;
/**
* Extra hosts for chatflow http nodes (comma-separated).
* Tools gateway host is always allowed; private IPs still blocked unless tools itself.
*/
chatflowHttpAllowlist: string[];
chatflowMaxSteps: number;
chatflowMaxNodes: number;
defaultPersonaSlug: string;
allowUnapproved: boolean;
shortHistoryLimit: number;
memoryExtractEveryN: number;
workerEnabled: boolean;
/** Max concurrent iLink long-poll loops in this process */
maxBotsPerWorker: number;
leaseTtlSec: number;
leaseRenewSec: number;
/**
* Multi-node: overloaded workers shed leases so idle nodes can claim.
* Default on. Set REBALANCE_ENABLED=false to keep sticky leases.
*/
rebalanceEnabled: boolean;
/** Min seconds between rebalance shed attempts on this process */
rebalanceIntervalSec: number;
/** Allow this many bots above fair share before shedding */
rebalanceSlack: number;
/** Max leases to release per rebalance tick */
rebalanceMaxPerTick: number;
/**
* Seconds an admin load-weight override survives after its node stops
* heartbeating, before the fleet deletes it automatically. Must outlast a
* restart / OTA apply, or every deploy would reset the tuning.
*/
workerWeightTtlSec: number;
/** Concurrent LLM/reply jobs */
replyConcurrency: number;
inboxMaxLen: number;
logLevel: LogLevel;
/** Requests slower than this are logged at warn even when they succeed */
logSlowRequestMs: number;
peerRatePerMinute: number;
repoRoot: string;
publicBaseUrl: string;
sessionCookieName: string;
adminIds: Set<string>;
cookieSecure: boolean;
/**
* Allowed browser Origins for credentialed CORS on /api/v1.
* Defaults to PUBLIC_BASE_URL origin. Empty list = same-origin only (no ACAO).
*/
corsOrigins: Set<string>;
/** Split AI reply into multiple WeChat bubbles */
splitReply: boolean;
maxReplyChunks: number;
maxChunkChars: number;
/** Ask model to return {"messages":[...]} JSON bubbles */
multiBubbleJson: boolean;
/**
* Second-pass AI filter: reformat primary reply into multi-bubble JSON before send.
* Extra LLM cost/latency. Off by default — primary model emits JSON via MULTI_BUBBLE_JSON.
* Enable with REPLY_FILTER_ENABLED=true only if format quality needs a dedicated pass.
*/
replyFilterEnabled: boolean;
/** Human-like delay between WeChat bubbles */
replyDelayMsPerChar: number;
replyDelayMinMs: number;
replyDelayMaxMs: number;
replyDelayFirstMinMs: number;
replyDelayFirstMaxMs: number;
replyDelayThinkExtraMs: number;
/** Max uploaded sticker size in bytes (blob stored in Redis) */
stickerMaxBytes: number;
/** Body cap for the few upload routes (global default is 1MB) */
uploadBodyLimit: number;
/** Inject sticker catalog + allow sendImage */
stickerSendEnabled: boolean;
maxStickersPerReply: number;
/**
* Master switch for inbound image understanding. Default OFF.
* When off, images are never downloaded and get a canned "can't see it" line.
*/
visionEnabled: boolean;
/**
* How an image reaches the conversation.
*
* `caption` (default): a dedicated vision endpoint describes the image and
* only that TEXT enters the roleplay turn — so the persona's own model needs
* no multimodal support at all. This is the mode that works with a text-only
* platform model such as deepseek.
*
* `direct`: the image is handed to the roleplay model as content parts.
* Requires that model to be vision-capable; it errors outright otherwise.
*/
visionMode: VisionMode;
/**
* Vision endpoint. Admin-level credentials, dialed directly like the platform
* LLM — a captioner is not user-supplied, so it does not need the tools
* gateway. Empty base/key fall back to the platform LLM's.
*/
visionBaseUrl: string;
visionApiKey: string;
/** Vision model id. Required for vision to do anything at all. */
visionModel: string;
/** Cap on caption length — a description, not an essay. */
visionCaptionMaxTokens: number;
/**
* Act on the speech-to-text WeChat/iLink already did for a voice message.
*
* Default ON, and unrelated to VISION_ENABLED: the transcript arrives inside
* the inbound message, so using it costs nothing extra and needs no model.
* Turn off to have voice notes answered with "didn't catch that" instead.
*/
voiceTranscriptEnabled: boolean;
/** Max images forwarded from a single message (each one costs real tokens) */
visionMaxImages: number;
/** Hard cap per downloaded attachment, before base64 inflation */
inboundMediaMaxBytes: number;
/** Global hard switch for idle proactive outreach */
proactiveEnabled: boolean;
proactiveIdleHours: number;
proactiveMinIntervalHours: number;
proactiveMaxPerDay: number;
/** e.g. "0-8"; empty string disables quiet hours */
proactiveQuietHours: string;
proactiveScanIntervalSec: number;
proactiveMaxPerScan: number;
proactiveLockTtlSec: number;
proactiveAttemptCooldownHours: number;
/** Memory: top-K when over fullInjectMax */
memoryTopK: number;
/** Memory: inject all when count ≤ this */
memoryFullInjectMax: number;
/** Memory: hard cap stored facts per peer+persona */
memoryMaxItems: number;
/** LLM tool: get_current_time */
timeToolEnabled: boolean;
timeToolTimeZone: string;
/** WeChat user-to-user relay via @LINUX DO username */
p2pEnabled: boolean;
p2pBindCodeTtlSec: number;
p2pRequestTtlSec: number;
p2pSessionIdleSec: number;
p2pRelayMaxChars: number;
p2pMaxRequestsPerDay: number;
/** Admin broadcast: delay between messages (ms) */
broadcastIntervalMs: number;
/** Admin broadcast: max text length */
broadcastMaxText: number;
/** Admin broadcast: retained job history count */
broadcastHistory: number;
/** Web try-chat (persona preview without WeChat) */
tryChatEnabled: boolean;
tryChatMaxUserMsgsPerDay: number;
tryChatMaxUserMsgsPerSession: number;
tryChatSessionTtlSec: number;
tryChatMaxHistory: number;
/** Server-side persona fork */
personaForkEnabled: boolean;
/** Local username+password auth */
localAuthEnabled: boolean;
passwordMinLength: number;
/** Local register requires invite code */
inviteRequiredForLocal: boolean;
inviteCodeTtlSec: number;
inviteCodeLength: number;
inviteMaxPendingPerUser: number;
/** Sliding window: max invites generated per user per window */
inviteQuotaWindowHours: number;
inviteQuotaMax: number;
firstUserIsAdmin: boolean;
/**
* Optional ops labels for multi-node fleet display (not public URLs).
* WORKER_ID itself is read by BotWorkerManager from process.env.
*/
nodeLabel: string;
nodeRegion: string;
/** App version string for worker heartbeat */
appVersion: string;
/** Consume OTA update jobs from Redis (default true) */
otaEnabled: boolean;
/** Allow pnpm install during OTA when lock/package.json changes */
otaAllowInstall: boolean;
/** Staging dir for OTA writes (absolute or relative to repo root) */
otaStagingDir: string;
/** Admin live activity stream (SSE) */
dataStreamEnabled: boolean;
/** Redis command sample rate for stream (01) */
dataStreamRedisSample: number;
/** Process-local max stream events per second */
dataStreamMaxEps: number;
}
/** Read OTA-written version file (monorepo root `.wa-version`). */
export function readWaVersionFile(repoRoot: string): string | null {
try {
const p = path.join(repoRoot, ".wa-version");
if (!fs.existsSync(p)) return null;
const v = fs.readFileSync(p, "utf8").trim().split(/\r?\n/)[0]?.trim() ?? "";
return v || null;
} catch {
return null;
}
}
export function readPackageJsonVersion(repoRoot: string): string | null {
try {
const p = path.join(repoRoot, "package.json");
if (!fs.existsSync(p)) return null;
const j = JSON.parse(fs.readFileSync(p, "utf8")) as { version?: string };
const v = (j.version || "").trim();
return v || null;
} catch {
return null;
}
}
/**
* Resolve runtime version for heartbeat / admin display.
*
* Order (OTA-friendly — env cannot be changed by OTA):
* 1. `.wa-version` at monorepo root (written by OTA apply)
* 2. `APP_VERSION` env (optional ops pin when no OTA stamp)
* 3. root `package.json` version (not workspace package / npm_package_version;
* pnpm --filter @wechat-ai/api would otherwise report apps/api's 0.1.0)
* 4. fallback
*/
export function resolveAppVersion(
env: NodeJS.ProcessEnv,
repoRoot: string,
fallback = "0.2.0",
): string {
const fromFile = readWaVersionFile(repoRoot);
if (fromFile) return fromFile;
const fromEnv = (env.APP_VERSION ?? "").trim();
if (fromEnv) return fromEnv;
// Prefer monorepo root package.json only — ignore npm_package_version
// (set by pnpm to the filtered workspace package, e.g. apps/api @ 0.1.0).
const fromPkg = readPackageJsonVersion(repoRoot);
if (fromPkg) return fromPkg;
return fallback;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const repoRoot = resolveRepoRoot();
const port = Number(env.WECHAT_AI_PORT ?? "8787");
const host = env.WECHAT_AI_HOST ?? "127.0.0.1";
const publicBaseUrl =
env.PUBLIC_BASE_URL ??
`http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
const corsOrigins = new Set<string>();
try {
corsOrigins.add(new URL(publicBaseUrl).origin);
} catch {
/* ignore bad PUBLIC_BASE_URL */
}
if (env.CORS_ORIGINS) {
for (const part of env.CORS_ORIGINS.split(",")) {
const o = part.trim();
if (!o) continue;
try {
corsOrigins.add(new URL(o).origin);
} catch {
// allow bare origin like https://a.com
if (/^https?:\/\//i.test(o)) corsOrigins.add(o.replace(/\/$/, ""));
}
}
}
return {
host,
port,
token: env.WECHAT_AI_TOKEN ?? "dev-insecure-token",
redisUrl: env.REDIS_URL ?? "redis://127.0.0.1:6379",
stickerMaxBytes: Number(env.STICKER_MAX_BYTES ?? String(2 * 1024 * 1024)),
uploadBodyLimit: Math.max(
12 * 1024 * 1024,
Number(env.STICKER_MAX_BYTES ?? String(2 * 1024 * 1024)) * 2,
),
stickerSendEnabled: env.STICKER_SEND_ENABLED !== "false",
maxStickersPerReply: Number(env.MAX_STICKERS_PER_REPLY ?? "2"),
visionEnabled: env.VISION_ENABLED === "true",
visionMode: resolveVisionMode(env.VISION_MODE),
visionBaseUrl: (env.VISION_BASE_URL ?? "").trim(),
visionApiKey: (env.VISION_API_KEY ?? "").trim(),
visionModel: (env.VISION_MODEL ?? "").trim(),
visionCaptionMaxTokens: Math.max(
32,
Number(env.VISION_CAPTION_MAX_TOKENS ?? "300") || 300,
),
voiceTranscriptEnabled: env.VOICE_TRANSCRIPT_ENABLED !== "false",
visionMaxImages: Math.max(1, Number(env.VISION_MAX_IMAGES ?? "2") || 2),
inboundMediaMaxBytes: Math.max(
64 * 1024,
Number(env.INBOUND_MEDIA_MAX_BYTES ?? String(4 * 1024 * 1024)) ||
4 * 1024 * 1024,
),
proactiveEnabled: env.PROACTIVE_ENABLED === "true",
proactiveIdleHours: Number(env.PROACTIVE_IDLE_HOURS ?? "12"),
proactiveMinIntervalHours: Number(
env.PROACTIVE_MIN_INTERVAL_HOURS ?? "24",
),
proactiveMaxPerDay: Number(env.PROACTIVE_MAX_PER_DAY ?? "1"),
proactiveQuietHours:
env.PROACTIVE_QUIET_HOURS === undefined
? "0-8"
: String(env.PROACTIVE_QUIET_HOURS).trim(),
proactiveScanIntervalSec: Number(
env.PROACTIVE_SCAN_INTERVAL_SEC ?? "300",
),
proactiveMaxPerScan: Number(env.PROACTIVE_MAX_PER_SCAN ?? "10"),
proactiveLockTtlSec: Number(env.PROACTIVE_LOCK_TTL_SEC ?? "180"),
proactiveAttemptCooldownHours: Number(
env.PROACTIVE_ATTEMPT_COOLDOWN_HOURS ?? "1",
),
memoryTopK: Number(env.MEMORY_TOP_K ?? "12"),
memoryFullInjectMax: Number(env.MEMORY_FULL_INJECT_MAX ?? "20"),
memoryMaxItems: Number(env.MEMORY_MAX_ITEMS ?? "100"),
timeToolEnabled: env.TIME_TOOL_ENABLED !== "false",
timeToolTimeZone: (env.TIME_TOOL_TIMEZONE ?? "Asia/Shanghai").trim() ||
"Asia/Shanghai",
p2pEnabled: env.P2P_ENABLED !== "false",
p2pBindCodeTtlSec: Number(env.P2P_BIND_CODE_TTL_SEC ?? "600"),
p2pRequestTtlSec: Number(env.P2P_REQUEST_TTL_SEC ?? "300"),
p2pSessionIdleSec: Number(env.P2P_SESSION_IDLE_SEC ?? "1800"),
p2pRelayMaxChars: Number(env.P2P_RELAY_MAX_CHARS ?? "500"),
p2pMaxRequestsPerDay: Number(env.P2P_MAX_REQUESTS_PER_DAY ?? "20"),
broadcastIntervalMs: Number(env.BROADCAST_INTERVAL_MS ?? "200"),
broadcastMaxText: Number(env.BROADCAST_MAX_TEXT ?? "2000"),
broadcastHistory: Number(env.BROADCAST_HISTORY ?? "100"),
tryChatEnabled: env.TRY_CHAT_ENABLED !== "false",
tryChatMaxUserMsgsPerDay: Number(
env.TRY_CHAT_MAX_USER_MSGS_PER_DAY ?? "40",
),
tryChatMaxUserMsgsPerSession: Number(
env.TRY_CHAT_MAX_USER_MSGS_PER_SESSION ?? "20",
),
tryChatSessionTtlSec: Number(env.TRY_CHAT_SESSION_TTL_SEC ?? "3600"),
tryChatMaxHistory: Number(env.TRY_CHAT_MAX_HISTORY ?? "40"),
personaForkEnabled: env.PERSONA_FORK_ENABLED !== "false",
localAuthEnabled: env.LOCAL_AUTH_ENABLED !== "false",
passwordMinLength: Number(env.PASSWORD_MIN_LENGTH ?? "8"),
inviteRequiredForLocal: env.INVITE_REQUIRED_FOR_LOCAL !== "false",
inviteCodeTtlSec: Number(env.INVITE_CODE_TTL_SEC ?? String(7 * 24 * 3600)),
inviteCodeLength: Number(env.INVITE_CODE_LENGTH ?? "10"),
inviteMaxPendingPerUser: Number(env.INVITE_MAX_PENDING_PER_USER ?? "20"),
inviteQuotaWindowHours: Number(env.INVITE_QUOTA_WINDOW_HOURS ?? "24"),
inviteQuotaMax: Number(env.INVITE_QUOTA_MAX ?? "3"),
firstUserIsAdmin: env.FIRST_USER_IS_ADMIN !== "false",
llmBaseUrl: env.LLM_BASE_URL ?? "https://api.openai.com/v1",
llmApiKey: env.LLM_API_KEY ?? "",
llmModel: env.LLM_MODEL ?? "gpt-4o-mini",
toolsBaseUrl: (env.TOOLS_BASE_URL ?? "").trim().replace(/\/+$/, ""),
toolsApiKey: (env.TOOLS_API_KEY ?? "").trim(),
toolsTimeoutMs: Number(env.TOOLS_TIMEOUT_MS ?? "60000"),
webSearchEnabled: env.WEB_SEARCH_ENABLED === "true",
webSearchMaxResults: Number(env.WEB_SEARCH_MAX_RESULTS ?? "5"),
llmProviderSecret: (env.LLM_PROVIDER_SECRET ?? "").trim(),
chatflowHttpAllowlist: (env.CHATFLOW_HTTP_ALLOWLIST ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
chatflowMaxSteps: Number(env.CHATFLOW_MAX_STEPS ?? "32"),
chatflowMaxNodes: Number(env.CHATFLOW_MAX_NODES ?? "40"),
defaultPersonaSlug: env.DEFAULT_PERSONA_SLUG ?? "catgirl",
allowUnapproved: env.ALLOW_UNAPPROVED_USERS === "true",
shortHistoryLimit: Number(env.SHORT_HISTORY_LIMIT ?? "20"),
memoryExtractEveryN: Number(env.MEMORY_EXTRACT_EVERY_N ?? "8"),
workerEnabled: env.WORKER_ENABLED !== "false",
maxBotsPerWorker: Number(env.MAX_BOTS_PER_WORKER ?? "500"),
leaseTtlSec: Number(env.LEASE_TTL_SEC ?? "45"),
leaseRenewSec: Number(env.LEASE_RENEW_SEC ?? "15"),
rebalanceEnabled: env.REBALANCE_ENABLED !== "false",
rebalanceIntervalSec: Number(env.REBALANCE_INTERVAL_SEC ?? "60"),
rebalanceSlack: Number(env.REBALANCE_SLACK ?? "2"),
rebalanceMaxPerTick: Number(env.REBALANCE_MAX_PER_TICK ?? "50"),
workerWeightTtlSec: Number(env.WORKER_WEIGHT_TTL_SEC ?? "3600"),
replyConcurrency: Number(env.REPLY_CONCURRENCY ?? "16"),
inboxMaxLen: Number(env.INBOX_MAX_LEN ?? "20000"),
logLevel: resolveLogLevel(env.LOG_LEVEL),
logSlowRequestMs: Math.max(
50,
Number(env.LOG_SLOW_REQUEST_MS ?? "1000") || 1000,
),
peerRatePerMinute: Number(env.PEER_RATE_PER_MINUTE ?? "20"),
splitReply: env.SPLIT_REPLY !== "false",
maxReplyChunks: Number(env.MAX_REPLY_CHUNKS ?? "5"),
maxChunkChars: Number(env.MAX_CHUNK_CHARS ?? "72"),
multiBubbleJson: env.MULTI_BUBBLE_JSON !== "false",
replyFilterEnabled: env.REPLY_FILTER_ENABLED === "true",
replyDelayMsPerChar: Number(env.REPLY_DELAY_MS_PER_CHAR ?? "90"),
replyDelayMinMs: Number(env.REPLY_DELAY_MIN_MS ?? "1400"),
replyDelayMaxMs: Number(env.REPLY_DELAY_MAX_MS ?? "5500"),
replyDelayFirstMinMs: Number(env.REPLY_DELAY_FIRST_MIN_MS ?? "900"),
replyDelayFirstMaxMs: Number(env.REPLY_DELAY_FIRST_MAX_MS ?? "2200"),
replyDelayThinkExtraMs: Number(env.REPLY_DELAY_THINK_EXTRA_MS ?? "400"),
repoRoot,
publicBaseUrl,
sessionCookieName: env.SESSION_COOKIE_NAME ?? "wa_session",
adminIds: parseAdminIds(env.LINUXDO_ADMIN_IDS),
cookieSecure: env.COOKIE_SECURE === "true",
corsOrigins,
nodeLabel: (env.NODE_LABEL ?? "").trim(),
nodeRegion: (env.NODE_REGION ?? "").trim(),
appVersion: resolveAppVersion(env, repoRoot),
otaEnabled: env.OTA_ENABLED !== "false",
otaAllowInstall: env.OTA_ALLOW_INSTALL !== "false",
otaStagingDir: (env.OTA_STAGING_DIR ?? ".wa-update-staging").trim() ||
".wa-update-staging",
dataStreamEnabled: env.DATA_STREAM_ENABLED !== "false",
dataStreamRedisSample: Math.min(
1,
Math.max(0, Number(env.DATA_STREAM_REDIS_SAMPLE ?? "0.08")),
),
dataStreamMaxEps: Math.max(5, Number(env.DATA_STREAM_MAX_EPS ?? "80")),
};
}
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { InboundMediaRef } from "@wechat-ai/ilink";
import { planInboundMedia, unreadableMediaReply } from "./inbound-media.js";
function ref(
kind: InboundMediaRef["kind"],
extra: Partial<InboundMediaRef> = {},
): InboundMediaRef {
return {
kind,
index: 0,
encryptQueryParam: "eqp",
aesKey: "a".repeat(32),
...extra,
};
}
const visionOn = { visionEnabled: true, maxImages: 2 };
const visionOff = { visionEnabled: false, maxImages: 2 };
describe("planInboundMedia", () => {
it("downloads an image when vision is on", () => {
const plan = planInboundMedia([ref("image")], visionOn);
assert.equal(plan.length, 1);
assert.equal(plan[0]!.download, true);
});
it("never downloads when vision is off", () => {
const plan = planInboundMedia([ref("image")], visionOff);
assert.equal(plan.length, 1);
assert.equal(
plan[0]!.download,
false,
"an image must stay notice-only so the persona says it cannot see it",
);
});
it("never downloads non-image media, even with vision on", () => {
// WeChat voice is SILK/AMR and no chat completions endpoint accepts it;
// video/file bytes are equally unusable. Fetching them is pure bandwidth.
for (const kind of ["voice", "video", "file"] as const) {
const plan = planInboundMedia([ref(kind)], visionOn);
assert.equal(plan.length, 1, kind);
assert.equal(plan[0]!.download, false, kind);
}
});
it("drops a voice note that iLink already transcribed", () => {
// extractText folded the transcript into the message text, so listing the
// attachment would claim the model cannot hear what it is about to read.
assert.deepEqual(
planInboundMedia([ref("voice", { transcript: "语音转写" })], visionOn),
[],
);
});
it("keeps a transcribed voice note when transcripts are disabled", () => {
// With VOICE_TRANSCRIPT_ENABLED=false the text never contained the
// transcript, so the ref must survive to produce the "didn't catch that"
// reply — dropping it would leave the message with nothing at all.
const plan = planInboundMedia([ref("voice", { transcript: "语音转写" })], {
...visionOn,
voiceTranscriptEnabled: false,
});
assert.equal(plan.length, 1);
assert.equal(plan[0]!.download, false);
});
it("keeps a voice note with a blank transcript as notice-only", () => {
const plan = planInboundMedia([ref("voice", { transcript: " " })], visionOn);
assert.equal(plan.length, 1);
assert.equal(plan[0]!.download, false);
});
it("caps how many images one message may cost", () => {
const plan = planInboundMedia(
[ref("image"), ref("image"), ref("image"), ref("image")],
{ visionEnabled: true, maxImages: 2 },
);
assert.equal(plan.length, 4, "every attachment is still reported");
assert.deepEqual(
plan.map((p) => p.download),
[true, true, false, false],
"images past the cap become notice-only rather than disappearing",
);
});
it("counts only images against the cap", () => {
const plan = planInboundMedia(
[ref("video"), ref("image"), ref("file"), ref("image")],
{ visionEnabled: true, maxImages: 2 },
);
assert.deepEqual(
plan.map((p) => [p.ref.kind, p.download]),
[
["video", false],
["image", true],
["file", false],
["image", true],
],
);
});
it("clamps a nonsensical cap to at least one", () => {
for (const maxImages of [0, -3, 0.4]) {
const plan = planInboundMedia([ref("image"), ref("image")], {
visionEnabled: true,
maxImages,
});
assert.equal(
plan.filter((p) => p.download).length,
1,
`maxImages=${maxImages}`,
);
}
});
it("preserves order and the original refs", () => {
const a = ref("image", { index: 1, encryptQueryParam: "a" });
const b = ref("file", { index: 2, fileName: "x.pdf" });
const plan = planInboundMedia([a, b], visionOn);
assert.equal(plan[0]!.ref, a);
assert.equal(plan[1]!.ref, b);
});
it("handles an empty list", () => {
assert.deepEqual(planInboundMedia([], visionOn), []);
});
});
describe("unreadableMediaReply", () => {
it("names the kind that actually arrived", () => {
assert.match(unreadableMediaReply([ref("image")]), /看不了图片/);
assert.match(unreadableMediaReply([ref("voice")]), /语音/);
assert.match(unreadableMediaReply([ref("video")]), /视频/);
assert.match(unreadableMediaReply([ref("file")]), /文件/);
});
it("keeps the original generic line when there is nothing to name", () => {
assert.equal(
unreadableMediaReply([]),
"目前只支持文字消息喵~请发文字聊天。",
);
});
it("leads with the first attachment for a mixed message", () => {
assert.match(
unreadableMediaReply([ref("video"), ref("image")]),
/视频/,
);
});
});
+91
View File
@@ -0,0 +1,91 @@
import type { InboundMediaRef } from "@wechat-ai/ilink";
/**
* Decides what to do with the attachments on an inbound WeChat message.
*
* Split out of BotWorkerManager so the gating rules — which attachments are
* worth pulling off the CDN, how many images one message may cost, and which
* ones are already covered by the text — are testable without a Redis handle or
* a live iLink client.
*/
export interface MediaPlanEntry {
ref: InboundMediaRef;
/**
* True when the bytes should be fetched and offered to the model. False means
* notice-only: the persona is told the attachment exists but that it cannot
* perceive it, which is what stops it inventing contents.
*/
download: boolean;
}
export interface MediaPlanOptions {
/** Global switch; off means no image is ever downloaded */
visionEnabled: boolean;
/** Max images from one message that may be sent to the model */
maxImages: number;
/**
* Whether the message text already carries iLink's voice transcript
* (default true, matching VOICE_TRANSCRIPT_ENABLED).
*
* Must agree with what was passed to extractText: when transcripts are in use
* the voice note is already readable as text, and when they are not it has to
* stay in the plan so the peer gets the "didn't catch that" line.
*/
voiceTranscriptEnabled?: boolean;
}
export function planInboundMedia(
refs: readonly InboundMediaRef[],
opts: MediaPlanOptions,
): MediaPlanEntry[] {
const maxImages = Math.max(1, Math.floor(opts.maxImages));
const useTranscript = opts.voiceTranscriptEnabled !== false;
const plan: MediaPlanEntry[] = [];
let images = 0;
for (const ref of refs) {
// iLink already transcribed this one and extractText folded it into the
// message text. Listing it would tell the model it cannot hear something it
// is about to read. With transcripts disabled there is no such text, so the
// ref stays and becomes a notice-only attachment.
if (useTranscript && ref.kind === "voice" && ref.transcript?.trim()) {
continue;
}
// Only images can be handed to an OpenAI-compatible model. Fetching voice /
// video / file bytes we cannot use would be pure bandwidth, so they are
// never downloaded — WeChat voice is SILK/AMR, which no chat completions
// endpoint accepts anyway.
const wantImage =
ref.kind === "image" && opts.visionEnabled && images < maxImages;
if (wantImage) images++;
plan.push({ ref, download: wantImage });
}
return plan;
}
/**
* Reply for a message that is nothing but media the bot cannot perceive.
*
* Replaces a blanket "目前只支持文字消息喵~" with a line that names what
* actually arrived, so the user knows whether to retype it or that this kind of
* attachment simply is not supported.
*/
export function unreadableMediaReply(
refs: readonly InboundMediaRef[],
): string {
switch (refs[0]?.kind) {
case "image":
return "我这边还看不了图片呢~你用文字跟我说说好不好?";
case "voice":
return "这段语音我没听清,方便打字告诉我吗?";
case "video":
return "视频我还看不了呀,用文字聊好不好~";
case "file":
return "文件我打不开呢,重要内容可以贴成文字发我~";
default:
return "目前只支持文字消息喵~请发文字聊天。";
}
}
+492
View File
@@ -0,0 +1,492 @@
import path from "node:path";
import { constants as zlibConstants } from "node:zlib";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import compress from "@fastify/compress";
import { ChatService, TryChatService } from "@wechat-ai/core";
import { openDatabase, seedPersonas, setRedisCommandHook } from "@wechat-ai/db";
import { LlmClient } from "@wechat-ai/llm";
import { BotLoginSessionManager } from "./bot-login-sessions.js";
import {
CC_HTML_APP,
CC_HTML_MARKETING,
CC_OG,
CDN_HTML_APP,
CDN_HTML_MARKETING,
CDN_OG,
ifNoneMatchHits,
setPublicCache,
} from "./cache-headers.js";
import { initActivityBus } from "./activity-stream.js";
import { LOG_LEVELS, loadConfig } from "./config.js";
import { registerRoutes } from "./routes.js";
import {
buildFastifyOptions,
registerRequestLogging,
} from "./server-options.js";
import { RuntimeConfigManager } from "./runtime-config.js";
import {
applyRuntimeConfigToServices,
type RuntimeConfigTargets,
} from "./runtime-config-apply.js";
import {
loadStaticAssets,
pickEncoded,
upgradeStaticCompression,
} from "./static-pages.js";
import { BotWorkerManager } from "./worker.js";
import { loadLinuxDoConfig } from "./oauth-linuxdo.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The bot worker runs in this same process/event loop. A stray rejection from
// any of its detached loops must not take the HTTP server down with it.
process.on("unhandledRejection", (reason) => {
console.error("[fatal] unhandled rejection (kept alive):", reason);
});
process.on("uncaughtException", (err) => {
console.error("[fatal] uncaught exception (kept alive):", err);
});
async function main(): Promise<void> {
const cfg = loadConfig();
console.log(`[config] repoRoot=${cfg.repoRoot}`);
console.log(`[config] redis=${cfg.redisUrl}`);
console.log(
`[config] stickers=redis blob (max ${cfg.stickerMaxBytes} bytes)`,
);
const db = openDatabase(cfg.redisUrl);
try {
await db.ping();
console.log("[redis] PONG");
} catch (err) {
console.error(
"[redis] 无法连接 REDIS_URL,请检查远端 Redis",
cfg.redisUrl,
err,
);
process.exit(1);
}
await seedPersonas(db);
// Redis-stored admin overrides on top of env. Loaded BEFORE any service is
// constructed so boot already uses the effective values; the fan-out target
// is filled in once the services exist, and the 5s poll starts after that.
let runtimeTargets: RuntimeConfigTargets | null = null;
const settings = new RuntimeConfigManager(db, cfg, (changed, live) => {
if (runtimeTargets) {
applyRuntimeConfigToServices(changed, live, runtimeTargets);
}
});
await settings.init();
{
const v = settings.view();
console.log(
`[settings] runtime overrides=${v.overriddenCount}/${v.items.length}` +
(v.updatedAt ? ` updatedAt=${v.updatedAt} by=${v.updatedBy}` : ""),
);
for (const w of settings.currentWarnings()) console.warn(`[settings] ${w}`);
}
const activityBus = initActivityBus({
db,
source: process.env.WORKER_ID?.trim() || "api",
enabled: cfg.dataStreamEnabled,
maxEps: cfg.dataStreamMaxEps,
redisSample: cfg.dataStreamRedisSample,
});
// Installed unconditionally: noteRedisCmd() no-ops while the bus is
// disabled, and the admin panel can turn DATA_STREAM_ENABLED on at runtime —
// a boot-time branch here would leave that switch permanently dead.
setRedisCommandHook((info) => activityBus.noteRedisCmd(info));
if (cfg.dataStreamEnabled) {
void activityBus.start().then(() => {
console.log(
`[stream] activity bus on sample=${cfg.dataStreamRedisSample} maxEps=${cfg.dataStreamMaxEps}`,
);
});
}
// Platform (admin) LLM: direct. User custom APIs + search: TOOLS gateway only.
const llm = LlmClient.forPlatform({
baseURL: cfg.llmBaseUrl,
apiKey: cfg.llmApiKey || "missing",
model: cfg.llmModel,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
});
if (!cfg.llmApiKey) {
console.warn("[warn] LLM_API_KEY not set (platform / admin LLM)");
}
if (cfg.webSearchEnabled && !cfg.toolsBaseUrl) {
console.warn(
"[warn] WEB_SEARCH_ENABLED but TOOLS_BASE_URL empty — search will fail until HF tools is configured",
);
}
if (cfg.toolsBaseUrl) {
console.log(`[config] tools gateway=${cfg.toolsBaseUrl} (user custom LLM + search)`);
} else {
console.log(
"[config] TOOLS_BASE_URL not set — user custom LLM APIs and web search unavailable",
);
}
/**
* Vision endpoint for reading inbound images.
*
* Separate from the platform LLM on purpose: the roleplay model is usually
* text-only (deepseek et al), so caption mode sends the image to a
* vision-capable endpoint and passes only its text description onward.
* Base/key default to the platform LLM's, which covers providers that host a
* vision model alongside the chat model.
*/
const visionLlm = cfg.visionEnabled
? LlmClient.forPlatform({
baseURL: cfg.visionBaseUrl || cfg.llmBaseUrl,
apiKey: cfg.visionApiKey || cfg.llmApiKey || "missing",
model: cfg.visionModel || cfg.llmModel,
maxTokens: cfg.visionCaptionMaxTokens,
})
: null;
if (cfg.visionEnabled) {
if (!cfg.visionModel) {
console.warn(
"[warn] VISION_ENABLED=true but VISION_MODEL is empty — images will be reported as unreadable. Set VISION_MODEL to a vision-capable model id.",
);
} else {
console.log(
`[config] vision mode=${cfg.visionMode} model=${cfg.visionModel} base=${
cfg.visionBaseUrl || cfg.llmBaseUrl
}`,
);
}
}
const publicBase = cfg.publicBaseUrl.replace(/\/$/, "");
const chat = new ChatService(
db,
llm,
{
shortHistoryLimit: cfg.shortHistoryLimit,
memoryExtractEveryN: cfg.memoryExtractEveryN,
allowUnapproved: cfg.allowUnapproved,
unapprovedReply:
`账号尚未开通对话权限。请前往网页端批准对话权限!\n(此项目为公益免费项目!使用文档:${publicBase}/docs`,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
maxStickersPerReply: cfg.maxStickersPerReply,
stickersEnabled: cfg.stickerSendEnabled,
memoryTopK: cfg.memoryTopK,
memoryFullInjectMax: cfg.memoryFullInjectMax,
memoryMaxItems: cfg.memoryMaxItems,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
webSearchEnabled: cfg.webSearchEnabled,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
llmProviderSecret: cfg.llmProviderSecret || undefined,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
visionMode: cfg.visionMode,
visionModel: cfg.visionModel || undefined,
visionCaptionMaxTokens: cfg.visionCaptionMaxTokens,
},
visionLlm,
);
const tryChat = new TryChatService(db, llm, {
sessionTtlSec: cfg.tryChatSessionTtlSec,
maxHistory: cfg.tryChatMaxHistory,
maxUserMsgsPerDay: cfg.tryChatMaxUserMsgsPerDay,
maxUserMsgsPerSession: cfg.tryChatMaxUserMsgsPerSession,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
webSearchEnabled: cfg.webSearchEnabled,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
});
const worker = new BotWorkerManager({
db,
chat,
stickerSendEnabled: cfg.stickerSendEnabled,
maxStickersPerReply: cfg.maxStickersPerReply,
visionEnabled: cfg.visionEnabled,
visionMaxImages: cfg.visionMaxImages,
inboundMediaMaxBytes: cfg.inboundMediaMaxBytes,
voiceTranscriptEnabled: cfg.voiceTranscriptEnabled,
peerRatePerMinute: cfg.peerRatePerMinute,
maxBotsPerWorker: cfg.maxBotsPerWorker,
leaseTtlSec: cfg.leaseTtlSec,
leaseRenewSec: cfg.leaseRenewSec,
rebalanceEnabled: cfg.rebalanceEnabled,
rebalanceIntervalSec: cfg.rebalanceIntervalSec,
rebalanceSlack: cfg.rebalanceSlack,
rebalanceMaxPerTick: cfg.rebalanceMaxPerTick,
workerWeightTtlSec: cfg.workerWeightTtlSec,
replyConcurrency: cfg.replyConcurrency,
inboxMaxLen: cfg.inboxMaxLen,
splitReply: cfg.splitReply,
maxReplyChunks: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
replyDelay: {
msPerChar: cfg.replyDelayMsPerChar,
minMs: cfg.replyDelayMinMs,
maxMs: cfg.replyDelayMaxMs,
firstMinMs: cfg.replyDelayFirstMinMs,
firstMaxMs: cfg.replyDelayFirstMaxMs,
thinkExtraMs: cfg.replyDelayThinkExtraMs,
},
proactive: {
globalEnabled: cfg.proactiveEnabled,
defaultIdleHours: cfg.proactiveIdleHours,
defaultMinIntervalHours: cfg.proactiveMinIntervalHours,
defaultMaxPerDay: cfg.proactiveMaxPerDay,
defaultQuietHours: cfg.proactiveQuietHours,
scanIntervalSec: cfg.proactiveScanIntervalSec,
maxPerScan: cfg.proactiveMaxPerScan,
lockTtlSec: cfg.proactiveLockTtlSec,
attemptCooldownHours: cfg.proactiveAttemptCooldownHours,
},
broadcast: {
intervalMs: cfg.broadcastIntervalMs,
pollIntervalMs: 2_000,
lockTtlSec: 60,
},
p2pEnabled: cfg.p2pEnabled,
p2p: {
bindCodeTtlSec: cfg.p2pBindCodeTtlSec,
requestTtlSec: cfg.p2pRequestTtlSec,
sessionIdleSec: cfg.p2pSessionIdleSec,
relayMaxChars: cfg.p2pRelayMaxChars,
maxRequestsPerDay: cfg.p2pMaxRequestsPerDay,
},
nodeLabel: cfg.nodeLabel,
nodeRegion: cfg.nodeRegion,
appVersion: cfg.appVersion,
repoRoot: cfg.repoRoot,
otaEnabled: cfg.otaEnabled,
otaAllowInstall: cfg.otaAllowInstall,
otaStagingDir: cfg.otaStagingDir,
log: (msg, extra) => {
if (extra) console.log(msg, extra);
else console.log(msg);
},
});
runtimeTargets = { chat, tryChat, worker, activityBus };
settings.start();
const loginSessions = new BotLoginSessionManager(db, worker);
// Stickers / OTA blob upload as JSON base64 (~4/3 raw); allow up to ~12MB payload
// 12MB is only needed by the upload routes; as a global default it let any
// unauthenticated POST make the process buffer 12MB before a handler ran.
// Those routes set `bodyLimit: cfg.uploadBodyLimit` per route instead.
const app = Fastify(buildFastifyOptions(cfg));
const rawLogLevel = (process.env.LOG_LEVEL ?? "").trim();
if (rawLogLevel && rawLogLevel.toLowerCase() !== cfg.logLevel) {
app.log.warn(
{ requested: rawLogLevel, using: cfg.logLevel, valid: LOG_LEVELS },
"LOG_LEVEL is not a pino level — falling back",
);
}
registerRequestLogging(app, cfg);
await app.register(compress, {
global: true,
threshold: 4096,
encodings: ["br", "gzip", "deflate"],
// Dynamic JSON gets compressed synchronously on the event loop. Default
// brotli quality is far too slow for 30-60KB admin listings; q4 lands
// near gzip speed at better ratio. Static shells bypass this middleware
// entirely (static-pages.ts sets Content-Encoding itself).
brotliOptions: {
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
},
});
await registerRoutes(app, {
db,
chat,
tryChat,
worker,
loginSessions,
cfg,
activityBus,
settings,
});
const publicDir = path.join(__dirname, "../public");
const staticAssets = loadStaticAssets(publicDir, publicBase);
console.log(
`[static] pages=${[...staticAssets.pages.keys()].join(",") || "(none)"} og=${staticAssets.og ? "yes" : "no"}`,
);
const sendCachedPage = (
route: string,
browserCc: string,
edgeCc: string,
req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
const page = staticAssets.pages.get(route);
if (!page) return null;
// Serve the boot-time brotli/gzip buffer when the client accepts it.
// Setting Content-Encoding also tells @fastify/compress to stand down.
const variant = pickEncoded(page, req.headers["accept-encoding"]);
const etag = variant?.etag ?? page.etag;
setPublicCache(reply, browserCc, edgeCc, {
etag,
cacheTag: "html-shell",
});
reply.header("Vary", "Accept-Encoding");
if (ifNoneMatchHits(req.headers["if-none-match"], etag)) {
return reply.code(304).send();
}
reply.type(page.contentType);
if (variant) {
reply.header("Content-Encoding", variant.encoding);
return reply.send(variant.body);
}
return reply.send(page.body);
};
// Landing (feature intro + OG for link previews). App console stays at /app.
app.get("/", async (req, reply) => {
const sent = sendCachedPage(
"/",
CC_HTML_MARKETING,
CDN_HTML_MARKETING,
req,
reply,
);
if (sent) return sent;
return reply.redirect("/app");
});
app.get("/app", async (req, reply) => {
const sent = sendCachedPage(
"/app",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("app.html missing");
});
app.get("/docs", async (req, reply) => {
const sent = sendCachedPage(
"/docs",
CC_HTML_MARKETING,
CDN_HTML_MARKETING,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("docs.html missing");
});
app.get("/admin", async (req, reply) => {
const sent = sendCachedPage(
"/admin",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("admin.html missing");
});
app.get("/chatflow", async (req, reply) => {
const sent = sendCachedPage(
"/chatflow",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("chatflow.html missing");
});
app.get("/og.jpg", async (req, reply) => {
const og = staticAssets.og;
if (!og) return reply.code(404).send("og image missing");
setPublicCache(reply, CC_OG, CDN_OG, {
etag: og.etag,
cacheTag: "og-image",
});
if (ifNoneMatchHits(req.headers["if-none-match"], og.etag)) {
return reply.code(304).send();
}
return reply.type(og.contentType).send(og.body);
});
await app.listen({ host: cfg.host, port: cfg.port });
// Max-quality shell compression, off the boot path
void upgradeStaticCompression(staticAssets).then(
() => console.log("[static] shells recompressed (brotli q11)"),
(err) => console.warn("[static] recompress failed (serving q5):", err),
);
const oauth = loadLinuxDoConfig();
console.log(`Landing http://${cfg.host}:${cfg.port}/`);
console.log(`App UI http://${cfg.host}:${cfg.port}/app`);
console.log(`Docs http://${cfg.host}:${cfg.port}/docs`);
console.log(`Admin UI http://${cfg.host}:${cfg.port}/admin`);
console.log(`Chatflow http://${cfg.host}:${cfg.port}/chatflow`);
console.log(
`[version] ${cfg.appVersion} ota=${cfg.otaEnabled ? "on" : "off"}`,
);
console.log(
oauth
? `[oauth] LINUX DO enabled → ${oauth.redirectUri}`
: "[oauth] LINUX DO 未配置(设置 LINUXDO_CLIENT_ID/SECRET/REDIRECT_URI",
);
if (cfg.workerEnabled) {
// Do not block process forever if Redis is slow; start() is still awaited
// but bootstrap is now batched. Log clearly on failure.
try {
await worker.start();
} catch (err) {
console.error(
"[worker] start failed (API stays up; check Redis / logs):",
err,
);
}
} else {
console.log("WORKER_ENABLED=false");
}
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
settings.stop();
// Await the fleet deregistration so peers re-claim this node's bots
// immediately instead of waiting out the lease TTL.
await worker.stopAsync().catch(() => undefined);
await app.close().catch(() => undefined);
await db.close().catch(() => undefined);
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+236
View File
@@ -0,0 +1,236 @@
import { randomBytes, createHash } from "node:crypto";
export interface LinuxDoOAuthConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
authorizeUrl: string;
tokenUrl: string;
userInfoUrl: string;
scope: string;
}
export interface LinuxDoUserInfo {
id: string | number;
username: string;
name?: string;
avatar_url?: string;
avatar_template?: string;
trust_level?: number;
active?: boolean;
silenced?: boolean;
email?: string;
sub?: string;
login?: string;
}
export function loadLinuxDoConfig(
env: NodeJS.ProcessEnv = process.env,
): LinuxDoOAuthConfig | null {
const clientId = env.LINUXDO_CLIENT_ID ?? "";
const clientSecret = env.LINUXDO_CLIENT_SECRET ?? "";
const redirectUri = env.LINUXDO_REDIRECT_URI ?? "";
if (!clientId || !clientSecret || !redirectUri) return null;
return {
clientId,
clientSecret,
redirectUri,
authorizeUrl:
env.LINUXDO_AUTHORIZE_URL ??
"https://connect.linux.do/oauth2/authorize",
tokenUrl:
env.LINUXDO_TOKEN_URL ?? "https://connect.linux.do/oauth2/token",
userInfoUrl:
env.LINUXDO_USERINFO_URL ?? "https://connect.linux.do/api/user",
// OIDC discovery: scopes_supported = openid, profile, email
scope: env.LINUXDO_SCOPE ?? "openid profile",
};
}
export function newOAuthState(): string {
return randomBytes(24).toString("hex");
}
export function buildAuthorizeUrl(
cfg: LinuxDoOAuthConfig,
state: string,
): string {
const u = new URL(cfg.authorizeUrl);
u.searchParams.set("client_id", cfg.clientId);
u.searchParams.set("redirect_uri", cfg.redirectUri);
u.searchParams.set("response_type", "code");
u.searchParams.set("scope", cfg.scope);
u.searchParams.set("state", state);
return u.toString();
}
export async function exchangeCode(
cfg: LinuxDoOAuthConfig,
code: string,
): Promise<{ access_token: string; token_type?: string }> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
code,
redirect_uri: cfg.redirectUri,
});
const res = await fetch(cfg.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body,
});
const text = await res.text();
let data: {
access_token?: string;
token_type?: string;
error?: string;
error_description?: string;
};
try {
data = JSON.parse(text) as typeof data;
} catch {
throw new Error(`token exchange invalid JSON: HTTP ${res.status} ${text.slice(0, 200)}`);
}
if (!res.ok || !data.access_token) {
throw new Error(
data.error_description ||
data.error ||
`token exchange HTTP ${res.status}: ${text.slice(0, 200)}`,
);
}
return { access_token: data.access_token, token_type: data.token_type };
}
/**
* Resolve avatar URL from LINUX DO / Discourse / OIDC userinfo.
* Supports avatar_url, picture, and Discourse avatar_template ("…/{size}/…").
*/
export function resolveAvatarUrl(
raw: Record<string, unknown>,
size = 96,
): string | undefined {
const pick = (...keys: string[]): string | undefined => {
for (const k of keys) {
const v = raw[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return undefined;
};
let url =
pick("avatar_url", "avatarUrl", "picture", "image", "avatar") ||
undefined;
const template = pick("avatar_template", "avatarTemplate");
if (!url && template) {
url = template.includes("{size}")
? template.replace(/\{size\}/g, String(size))
: template;
}
if (!url) return undefined;
// Protocol-relative //cdn...
if (url.startsWith("//")) url = "https:" + url;
// Discourse sometimes returns /user_avatar/...
if (url.startsWith("/")) url = "https://linux.do" + url;
try {
const u = new URL(url);
if (u.protocol !== "http:" && u.protocol !== "https:") return undefined;
return u.toString();
} catch {
return undefined;
}
}
/**
* Normalize LINUX DO / OIDC userinfo into a stable shape.
* Fields may be id|sub, username|login, name, avatar_url|picture|avatar_template, trust_level.
*/
export function normalizeUserInfo(raw: Record<string, unknown>): LinuxDoUserInfo {
const id = raw.id ?? raw.sub ?? raw.user_id;
const username =
(raw.username as string) ||
(raw.login as string) ||
(raw.preferred_username as string) ||
(id != null ? String(id) : "");
if (id == null || !username) {
throw new Error(
`userinfo missing id/username: ${JSON.stringify(raw).slice(0, 300)}`,
);
}
return {
id: id as string | number,
username,
name: (raw.name as string) || username,
avatar_url: resolveAvatarUrl(raw),
avatar_template:
typeof raw.avatar_template === "string"
? raw.avatar_template
: typeof raw.avatarTemplate === "string"
? raw.avatarTemplate
: undefined,
trust_level: Number(raw.trust_level ?? 0),
active: raw.active as boolean | undefined,
silenced: raw.silenced as boolean | undefined,
email: raw.email as string | undefined,
sub: raw.sub as string | undefined,
login: raw.login as string | undefined,
};
}
export async function fetchUserInfo(
cfg: LinuxDoOAuthConfig,
accessToken: string,
): Promise<LinuxDoUserInfo> {
const res = await fetch(cfg.userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
});
const text = await res.text();
let data: Record<string, unknown>;
try {
data = JSON.parse(text) as Record<string, unknown>;
} catch {
throw new Error(`userinfo invalid JSON: HTTP ${res.status}`);
}
if (!res.ok) {
throw new Error(`userinfo HTTP ${res.status}: ${text.slice(0, 200)}`);
}
// Some providers nest under { user: {...} }
if (data.user && typeof data.user === "object") {
return normalizeUserInfo(data.user as Record<string, unknown>);
}
return normalizeUserInfo(data);
}
export function parseAdminIds(raw: string | undefined): Set<string> {
if (!raw) return new Set();
return new Set(
raw
.split(/[,;\s]+/)
.map((s) => s.trim())
.filter(Boolean),
);
}
export function defaultSessionSecret(): string {
return createHash("sha256")
.update(process.env.WECHAT_AI_TOKEN || "dev")
.digest("hex");
}
export function isPlaceholderRedisUrl(url: string): boolean {
return (
!url ||
/YOUR_ENDPOINT|YOUR_UPSTASH|password@your|localhost:6379\/0$/i.test(url) ||
url.includes("YOUR_")
);
}
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, it } from "node:test";
import { createHash } from "node:crypto";
import { scanLocalOtaHashes } from "./ota-apply.js";
describe("ota-apply scanLocalOtaHashes", () => {
it("scans allowed files under a fake monorepo", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wa-ota-"));
try {
fs.writeFileSync(path.join(root, "package.json"), '{"version":"1.0.0"}');
fs.writeFileSync(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n");
fs.mkdirSync(path.join(root, "apps", "api", "src"), { recursive: true });
fs.writeFileSync(
path.join(root, "apps", "api", "src", "index.ts"),
"export {}\n",
);
fs.mkdirSync(path.join(root, "apps", "api", "node_modules", "x"), {
recursive: true,
});
fs.writeFileSync(
path.join(root, "apps", "api", "node_modules", "x", "a.js"),
"nope",
);
fs.mkdirSync(path.join(root, "packages", "db", "src"), { recursive: true });
fs.writeFileSync(
path.join(root, "packages", "db", "package.json"),
"{}",
);
fs.writeFileSync(
path.join(root, "packages", "db", "src", "keys.ts"),
"export {}\n",
);
fs.writeFileSync(path.join(root, "packages", "db", "README.md"), "no");
const map = scanLocalOtaHashes(root);
assert.ok(map.has("package.json"));
assert.ok(map.has("apps/api/src/index.ts"));
assert.ok(map.has("packages/db/src/keys.ts"));
assert.ok(map.has("packages/db/package.json"));
assert.equal(map.has("apps/api/node_modules/x/a.js"), false);
assert.equal(map.has("packages/db/README.md"), false);
const h = createHash("sha256")
.update(fs.readFileSync(path.join(root, "package.json")))
.digest("hex");
assert.equal(map.get("package.json"), h);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
});
+435
View File
@@ -0,0 +1,435 @@
/**
* Local OTA apply: hash scan, download blobs, staging, atomic swap, optional install.
* Invoked by BotWorkerManager when a Redis update job is pending.
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import {
clearWorkerUpdateJob,
diffReleaseFiles,
getBlob,
getReleaseMeta,
getWorkerUpdateJob,
isAllowedOtaPath,
normalizeOtaPath,
setWorkerUpdateStatus,
type Db,
type NodeUpdateJob,
type NodeUpdateStatus,
type ReleaseFileEntry,
type ReleaseMeta,
} from "@wechat-ai/db";
export interface OtaApplyOptions {
db: Db;
workerId: string;
repoRoot: string;
appVersion: string;
stagingDir: string;
allowInstall: boolean;
log?: (msg: string, extra?: unknown) => void;
/** Called after files applied, before process exit */
beforeRestart?: () => void | Promise<void>;
}
function sha256File(abs: string): string | null {
try {
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return null;
const h = createHash("sha256");
h.update(fs.readFileSync(abs));
return h.digest("hex");
} catch {
return null;
}
}
/** Walk whitelist trees under repo root and return path → sha256. */
export function scanLocalOtaHashes(repoRoot: string): Map<string, string> {
const out = new Map<string, string>();
const root = path.resolve(repoRoot);
const tryFile = (relPosix: string) => {
if (!isAllowedOtaPath(relPosix)) return;
const abs = path.join(root, ...relPosix.split("/"));
const hash = sha256File(abs);
if (hash) out.set(relPosix, hash);
};
for (const f of [
"package.json",
"pnpm-workspace.yaml",
"pnpm-lock.yaml",
"tsconfig.base.json",
]) {
tryFile(f);
}
const walk = (absDir: string, relPrefix: string) => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
return;
}
for (const ent of entries) {
const name = ent.name;
if (
name === "node_modules" ||
name === "data" ||
name === ".git" ||
name === ".wa-update-staging" ||
name === ".wa-backup"
) {
continue;
}
const rel = relPrefix ? `${relPrefix}/${name}` : name;
const abs = path.join(absDir, name);
if (ent.isDirectory()) {
walk(abs, rel);
} else if (ent.isFile()) {
tryFile(rel.replace(/\\/g, "/"));
}
}
};
for (const dir of [
"apps/api",
"packages/core",
"packages/db",
"packages/ilink",
"packages/llm",
"scripts",
]) {
const abs = path.join(root, ...dir.split("/"));
if (fs.existsSync(abs)) walk(abs, dir);
}
return out;
}
function resolveStagingAbs(repoRoot: string, stagingDir: string): string {
if (path.isAbsolute(stagingDir)) return stagingDir;
return path.join(path.resolve(repoRoot), stagingDir);
}
function safeWriteFile(abs: string, data: Buffer): void {
fs.mkdirSync(path.dirname(abs), { recursive: true });
const tmp = abs + ".tmp." + process.pid;
fs.writeFileSync(tmp, data);
fs.renameSync(tmp, abs);
}
async function runPnpmInstall(repoRoot: string, log?: OtaApplyOptions["log"]): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(
"pnpm",
["install", "--frozen-lockfile"],
{
cwd: repoRoot,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
shell: true,
},
);
let err = "";
child.stderr?.on("data", (c: Buffer) => {
err += c.toString();
if (err.length > 4000) err = err.slice(-4000);
});
child.on("error", (e) => reject(e));
child.on("close", (code) => {
if (code === 0) resolve();
else {
log?.(`[ota] pnpm install exit ${code}: ${err.slice(0, 500)}`);
reject(new Error(`pnpm_install_failed:${code}`));
}
});
});
}
async function downloadNeeded(
db: Db,
needed: ReleaseFileEntry[],
onProgress: (done: number, bytesDone: number) => Promise<void>,
): Promise<Map<string, Buffer>> {
const map = new Map<string, Buffer>();
let done = 0;
let bytesDone = 0;
for (const f of needed) {
const buf = await getBlob(db, f.sha256);
if (!buf) throw new Error(`blob_missing:${f.path}`);
if (buf.length !== f.size) {
throw new Error(`blob_size_mismatch:${f.path}`);
}
map.set(f.path, buf);
done++;
bytesDone += buf.length;
await onProgress(done, bytesDone);
}
return map;
}
/**
* Apply one OTA job if present. Returns:
* - `null` if no job
* - `"applied"` if files written and caller should restart
* - `"noop"` if already on version / skipped
* - `"failed"` if error recorded
*/
export async function tryApplyOtaUpdate(
opts: OtaApplyOptions,
): Promise<"applied" | "noop" | "failed" | null> {
const job = await getWorkerUpdateJob(opts.db, opts.workerId);
if (!job) return null;
return applyOtaJob(opts, job);
}
export async function applyOtaJob(
opts: OtaApplyOptions,
job: NodeUpdateJob,
): Promise<"applied" | "noop" | "failed"> {
const log = opts.log;
const workerId = opts.workerId;
const now = () => new Date().toISOString();
const patchStatus = async (
phase: NodeUpdateStatus["phase"],
extra?: Partial<NodeUpdateStatus>,
) => {
const st: NodeUpdateStatus = {
workerId,
version: job.version,
phase,
error: null,
startedAt: extra?.startedAt ?? now(),
updatedAt: now(),
progress: extra?.progress,
changedFiles: extra?.changedFiles,
message: extra?.message ?? null,
};
await setWorkerUpdateStatus(opts.db, st);
};
try {
// Idempotent: already running this version
if (!job.force && opts.appVersion === job.version) {
await patchStatus("done", {
startedAt: now(),
message: "already_on_version",
progress: { done: 0, total: 0 },
});
await clearWorkerUpdateJob(opts.db, workerId);
log?.(`[ota] skip already on version ${job.version}`);
return "noop";
}
const release =
(await getReleaseMeta(opts.db, job.version)) ?? null;
if (!release) {
throw new Error("release_not_found");
}
await patchStatus("downloading", {
startedAt: now(),
message: "scanning_local",
progress: { done: 0, total: 0 },
});
const local = scanLocalOtaHashes(opts.repoRoot);
const needed = diffReleaseFiles(release, local);
const bytesTotal = needed.reduce((a, f) => a + f.size, 0);
log?.(
`[ota] update ${opts.appVersion}${job.version}: ${needed.length}/${release.fileCount} file(s) changed (${bytesTotal} bytes)`,
);
await patchStatus("downloading", {
startedAt: now(),
message: "downloading",
changedFiles: needed.length,
progress: {
done: 0,
total: needed.length,
bytesDone: 0,
bytesTotal,
},
});
const blobs =
needed.length === 0
? new Map<string, Buffer>()
: await downloadNeeded(
opts.db,
needed,
async (done, bytesDone) => {
await patchStatus("downloading", {
startedAt: now(),
message: "downloading",
changedFiles: needed.length,
progress: {
done,
total: needed.length,
bytesDone,
bytesTotal,
},
});
},
);
await patchStatus("applying", {
startedAt: now(),
message: "staging",
changedFiles: needed.length,
progress: {
done: 0,
total: needed.length,
bytesDone: bytesTotal,
bytesTotal,
},
});
const staging = resolveStagingAbs(opts.repoRoot, opts.stagingDir);
// clean staging
fs.rmSync(staging, { recursive: true, force: true });
fs.mkdirSync(staging, { recursive: true });
const root = path.resolve(opts.repoRoot);
const backupRoot = path.join(root, ".wa-backup", job.version);
fs.rmSync(backupRoot, { recursive: true, force: true });
let applied = 0;
for (const f of needed) {
const n = normalizeOtaPath(f.path);
if (!n || !isAllowedOtaPath(n)) {
throw new Error(`path_not_allowed:${f.path}`);
}
const data = blobs.get(f.path);
if (!data) throw new Error(`blob_missing:${f.path}`);
const stageAbs = path.join(staging, ...n.split("/"));
safeWriteFile(stageAbs, data);
applied++;
if (applied % 20 === 0 || applied === needed.length) {
await patchStatus("applying", {
startedAt: now(),
message: "staging",
changedFiles: needed.length,
progress: {
done: applied,
total: needed.length,
bytesDone: bytesTotal,
bytesTotal,
},
});
}
}
// Atomic swap: backup then replace
for (const f of needed) {
const n = f.path;
const dest = path.join(root, ...n.split("/"));
const stageAbs = path.join(staging, ...n.split("/"));
if (fs.existsSync(dest) && fs.statSync(dest).isFile()) {
const bak = path.join(backupRoot, ...n.split("/"));
fs.mkdirSync(path.dirname(bak), { recursive: true });
try {
fs.copyFileSync(dest, bak);
} catch {
/* best effort backup */
}
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(stageAbs, dest);
}
// Write version stamp (read before APP_VERSION env on next boot)
safeWriteFile(
path.join(root, ".wa-version"),
Buffer.from(`${job.version}\n`, "utf8"),
);
// Full-tree files not in local but in release that weren't "needed" are same hash — OK.
// Files only local extra: leave in place (no delete of unknown files for safety).
if (release.requiresInstall || needsInstallFromNeeded(needed, release)) {
if (!opts.allowInstall) {
throw new Error("install_required_but_ota_allow_install_false");
}
await patchStatus("installing", {
startedAt: now(),
message: "pnpm_install",
changedFiles: needed.length,
progress: {
done: needed.length,
total: needed.length,
bytesDone: bytesTotal,
bytesTotal,
},
});
log?.(`[ota] running pnpm install --frozen-lockfile`);
await runPnpmInstall(root, log);
}
await patchStatus("restarting", {
startedAt: now(),
message: "restarting",
changedFiles: needed.length,
progress: {
done: needed.length,
total: needed.length,
bytesDone: bytesTotal,
bytesTotal,
},
});
// Clear job so reboot doesn't re-apply immediately
await clearWorkerUpdateJob(opts.db, workerId);
try {
fs.rmSync(staging, { recursive: true, force: true });
} catch {
/* */
}
log?.(`[ota] applied ${job.version}, restarting process`);
await opts.beforeRestart?.();
return "applied";
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log?.(`[ota] failed: ${message}`);
try {
await setWorkerUpdateStatus(opts.db, {
workerId,
version: job.version,
phase: "failed",
error: message.slice(0, 500),
startedAt: now(),
updatedAt: now(),
message: "failed",
});
} catch {
/* */
}
try {
await clearWorkerUpdateJob(opts.db, workerId);
} catch {
/* allow retry via new enqueue */
}
return "failed";
}
}
function needsInstallFromNeeded(
needed: ReleaseFileEntry[],
release: ReleaseMeta,
): boolean {
if (release.requiresInstall) {
// only if install-related files actually changed
return needed.some((f) =>
/package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$/.test(f.path),
);
}
return false;
}
+354
View File
@@ -0,0 +1,354 @@
import {
type Db,
getBotAccount,
getContextToken,
getProactiveDayCount,
incrProactiveDayCount,
listProactivePeerIds,
markPeerProactive,
releaseProactiveLock,
tryAcquireProactiveLock,
writeAudit,
type Peer,
K,
} from "@wechat-ai/db";
import {
isProactiveEligible,
mergeBotProactiveConfig,
type ChatService,
type ReplyPart,
} from "@wechat-ai/core";
import type { ILinkClient } from "@wechat-ai/ilink";
export interface ProactiveSchedulerOptions {
db: Db;
chat: ChatService;
/** Global hard switch */
globalEnabled: boolean;
defaultIdleHours: number;
defaultMinIntervalHours: number;
defaultMaxPerDay: number;
defaultQuietHours: string;
scanIntervalSec: number;
maxPerScan: number;
lockTtlSec: number;
attemptCooldownHours: number;
log?: (msg: string, extra?: unknown) => void;
/**
* Bots this process currently long-polls (has live ILinkClient).
*/
getLocalBotIds: () => string[];
getClient: (botId: string) => ILinkClient | undefined;
/**
* Serialize send with inbound reply chain for the same peer.
* Handler should perform send + mark success.
*/
runOnPeerChain: (
botId: string,
peerId: string,
fn: () => Promise<void>,
) => Promise<void>;
sendParts: (
client: ILinkClient,
peerId: string,
contextToken: string,
parts: ReplyPart[],
ownerUserId: string,
) => Promise<void>;
}
/**
* Periodic scan: idle peers with proactive enabled get an LLM-generated nudge.
*/
export class ProactiveScheduler {
private stopped = true;
private timer: ReturnType<typeof setTimeout> | null = null;
private running = false;
/**
* Bumped on every stop(). An in-flight tick re-arms the chain from its
* `finally`, which stop() cannot cancel — without this, a disable/enable
* toggle during a tick leaves the old chain running untracked alongside the
* new one and the scan rate doubles per toggle.
*/
private gen = 0;
constructor(private opts: ProactiveSchedulerOptions) {}
/**
* Apply admin-editable settings in place (runtime settings reload).
*
* `globalEnabled` is latched by start(): the scheduler early-returns and
* leaves `stopped = true`, so flipping it on has to re-enter start() rather
* than just mutate the flag.
*/
applyRuntimeOptions(patch: Partial<ProactiveSchedulerOptions>): void {
const wasEnabled = this.opts.globalEnabled;
Object.assign(this.opts, patch);
if (patch.globalEnabled === undefined) return;
if (!wasEnabled && this.opts.globalEnabled) {
this.stop();
this.start();
} else if (wasEnabled && !this.opts.globalEnabled) {
this.stop();
}
}
isRunning(): boolean {
return !this.stopped;
}
start(): void {
if (!this.opts.globalEnabled) {
this.opts.log?.(
"[proactive] disabled (PROACTIVE_ENABLED=false); scheduler not started",
);
return;
}
this.stopped = false;
this.opts.log?.(
`[proactive] scheduler start interval=${this.opts.scanIntervalSec}s ` +
`maxPerScan=${this.opts.maxPerScan}`,
);
this.scheduleNext(2_000, this.gen);
}
stop(): void {
this.stopped = true;
this.gen++;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
private scheduleNext(ms: number, gen: number): void {
if (this.stopped || gen !== this.gen) return;
this.timer = setTimeout(() => {
if (gen !== this.gen) return;
void this.tick()
.catch((err) => {
this.opts.log?.(
`[proactive] tick error: ${
err instanceof Error ? err.message : String(err)
}`,
);
})
.finally(() => {
// A tick started before a stop() must not resurrect the chain.
this.scheduleNext(this.opts.scanIntervalSec * 1000, gen);
});
}, ms);
}
/** Exposed for tests / manual trigger */
async tick(): Promise<{ sent: number; skipped: number; considered: number }> {
if (this.stopped || !this.opts.globalEnabled) {
return { sent: 0, skipped: 0, considered: 0 };
}
if (this.running) {
return { sent: 0, skipped: 0, considered: 0 };
}
this.running = true;
let sent = 0;
let skipped = 0;
let considered = 0;
try {
const botIds = this.opts.getLocalBotIds();
if (!botIds.length) return { sent, skipped, considered };
const budget = Math.max(1, this.opts.maxPerScan);
for (const botId of botIds) {
if (this.stopped || sent >= budget) break;
const client = this.opts.getClient(botId);
if (!client) continue;
const bot = await getBotAccount(this.opts.db, botId);
if (!bot || bot.status !== "active") continue;
const cfg = mergeBotProactiveConfig(bot, {
idleHours: this.opts.defaultIdleHours,
minIntervalHours: this.opts.defaultMinIntervalHours,
maxPerDay: this.opts.defaultMaxPerDay,
quietHours: this.opts.defaultQuietHours,
});
if (!cfg.enabled) continue;
const peerIds = await listProactivePeerIds(this.opts.db, botId);
if (!peerIds.length) continue;
for (const peerId of peerIds) {
if (this.stopped || sent >= budget) break;
considered++;
const peer = await this.opts.db.getJson<Peer>(
K.peer(botId, peerId),
);
if (!peer) continue;
const contextToken = await getContextToken(
this.opts.db,
botId,
peerId,
);
const dayCount = await getProactiveDayCount(
this.opts.db,
botId,
peerId,
);
const elig = isProactiveEligible({
botStatus: bot.status,
botProactiveEnabled: cfg.enabled ? 1 : 0,
peerApproved: peer.approved,
peerProactiveEnabled: peer.proactive_enabled,
hasContextToken: Boolean(contextToken),
lastActivityAt: peer.last_activity_at,
peerCreatedAt: peer.created_at,
lastProactiveAt: peer.last_proactive_at,
lastProactiveAttemptAt: peer.last_proactive_attempt_at,
dayCount,
idleHours: cfg.idleHours,
minIntervalHours: cfg.minIntervalHours,
maxPerDay: cfg.maxPerDay,
quietHours: cfg.quietHours,
attemptCooldownHours: this.opts.attemptCooldownHours,
});
if (!elig.ok) {
skipped++;
continue;
}
const locked = await tryAcquireProactiveLock(
this.opts.db,
botId,
peerId,
this.opts.lockTtlSec,
);
if (!locked) {
this.opts.log?.(
`[proactive] bot=${botId} peer=${peerId} action=lock_miss`,
);
skipped++;
continue;
}
try {
await this.opts.runOnPeerChain(botId, peerId, async () => {
// Re-check context in case it was cleared
const tok =
contextToken ||
(await getContextToken(this.opts.db, botId, peerId));
if (!tok) {
await markPeerProactive(this.opts.db, botId, peerId, {
sent: false,
});
this.opts.log?.(
`[proactive] bot=${botId} peer=${peerId} action=no_ctx`,
);
skipped++;
return;
}
const result = await this.opts.chat.handleProactive({
botAccountId: botId,
peerId,
contextToken: tok,
idleHours: elig.idleHoursActual ?? cfg.idleHours,
});
if (result.kind === "skip" || result.kind === "reject") {
await markPeerProactive(this.opts.db, botId, peerId, {
sent: false,
});
this.opts.log?.(
`[proactive] bot=${botId} peer=${peerId} action=skip ` +
`reason=${result.skipReason ?? result.kind} ` +
`idle=${(elig.idleHoursActual ?? 0).toFixed(1)}h`,
);
skipped++;
return;
}
if (result.kind !== "reply") {
await markPeerProactive(this.opts.db, botId, peerId, {
sent: false,
});
skipped++;
return;
}
let parts: ReplyPart[] =
result.parts && result.parts.length > 0
? result.parts
: result.bubbles && result.bubbles.length > 0
? result.bubbles.map((t) => ({
kind: "text" as const,
text: t,
}))
: result.text
? [{ kind: "text" as const, text: result.text }]
: [];
if (!parts.length) {
await markPeerProactive(this.opts.db, botId, peerId, {
sent: false,
});
skipped++;
return;
}
const ownerUserId = bot.owner_user_id || "";
await this.opts.sendParts(
client,
peerId,
tok,
parts,
ownerUserId,
);
await markPeerProactive(this.opts.db, botId, peerId, {
sent: true,
});
await incrProactiveDayCount(this.opts.db, botId, peerId);
await writeAudit(this.opts.db, "proactive_sent", "system", {
botId,
peerId,
idleHours: elig.idleHoursActual,
personaId: result.personaId,
});
sent++;
this.opts.log?.(
`[proactive] bot=${botId} peer=${peerId} action=send ` +
`idle=${(elig.idleHoursActual ?? 0).toFixed(1)}h ` +
`parts=${parts.length}`,
);
});
} catch (err) {
await markPeerProactive(this.opts.db, botId, peerId, {
sent: false,
}).catch(() => undefined);
this.opts.log?.(
`[proactive] bot=${botId} peer=${peerId} action=error: ${
err instanceof Error ? err.message : String(err)
}`,
);
} finally {
await releaseProactiveLock(this.opts.db, botId, peerId).catch(
() => undefined,
);
}
}
}
} finally {
this.running = false;
}
if (sent || considered) {
this.opts.log?.(
`[proactive] tick done considered=${considered} sent=${sent} skipped=${skipped}`,
);
}
return { sent, skipped, considered };
}
}
+655
View File
@@ -0,0 +1,655 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
MAX_VERSION,
QrError,
addEccAndInterleave,
alignmentPatternPositions,
byteCapacity,
encodeQr,
gfMultiply,
maskPredicate,
numDataCodewords,
numEccBlocks,
numRawDataModules,
pickVersion,
qrSvg,
renderQrSvg,
rsComputeDivisor,
rsComputeRemainder,
type EcLevel,
type QrCode,
} from "./qrcode.js";
// ── Independent helpers, written against the spec rather than reusing the
// encoder's internals, so a bug in one side does not hide behind the other. ──
/** Bit-polynomial remainder; used to check the BCH-protected fields. */
function polyRemainder(value: number, gen: number, genBits: number): number {
let v = value;
for (;;) {
const bits = 32 - Math.clz32(v);
if (bits < genBits) return v;
v ^= gen << (bits - genBits);
}
}
function gfPow(base: number, exp: number): number {
let r = 1;
for (let i = 0; i < exp; i++) r = gfMultiply(r, base);
return r;
}
/** Evaluate a codeword polynomial (first byte = highest degree) at x. */
function gfEvaluate(codeword: readonly number[], x: number): number {
let acc = 0;
for (const c of codeword) acc = gfMultiply(acc, x) ^ c;
return acc;
}
/**
* A ReedSolomon codeword built with generator prod(x - a^i), i<t, must vanish
* at every a^i. Checking that is independent of how the generator was built.
*/
function assertValidRsCodeword(codeword: readonly number[], t: number): void {
for (let i = 0; i < t; i++) {
const syndrome = gfEvaluate(codeword, gfPow(2, i));
assert.equal(syndrome, 0, `syndrome ${i} should vanish, got ${syndrome}`);
}
}
const ECC_PER_BLOCK: Record<EcLevel, (v: number) => number> = {
L: (v) => numRawDataModulesCodewords(v) - numDataCodewords(v, "L"),
M: (v) => numRawDataModulesCodewords(v) - numDataCodewords(v, "M"),
Q: (v) => numRawDataModulesCodewords(v) - numDataCodewords(v, "Q"),
H: (v) => numRawDataModulesCodewords(v) - numDataCodewords(v, "H"),
};
function numRawDataModulesCodewords(v: number): number {
return Math.floor(numRawDataModules(v) / 8);
}
/** Function-module map derived from the spec, not from the encoder. */
function functionMap(version: number, size: number): boolean[][] {
const fn = Array.from({ length: size }, () =>
new Array<boolean>(size).fill(false),
);
const box = (x0: number, y0: number, x1: number, y1: number): void => {
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
if (x >= 0 && x < size && y >= 0 && y < size) fn[y]![x] = true;
}
}
};
// Finder + separator + the format strips that hug them
box(0, 0, 8, 8);
box(size - 8, 0, size - 1, 8);
box(0, size - 8, 8, size - 1);
// Timing patterns
box(0, 6, size - 1, 6);
box(6, 0, 6, size - 1);
// Alignment patterns, minus the three finder corners
const aligns = alignmentPatternPositions(version);
const last = aligns.length - 1;
for (let i = 0; i <= last; i++) {
for (let j = 0; j <= last; j++) {
const corner =
(i === 0 && j === 0) ||
(i === 0 && j === last) ||
(i === last && j === 0);
if (corner) continue;
box(aligns[j]! - 2, aligns[i]! - 2, aligns[j]! + 2, aligns[i]! + 2);
}
}
// Version information blocks
if (version >= 7) {
box(0, size - 11, 5, size - 9);
box(size - 11, 0, size - 9, 5);
}
return fn;
}
interface Decoded {
text: string;
ec: EcLevel;
mask: number;
blocks: number[][];
}
/**
* Read a symbol back: format info → unmask → zigzag → de-interleave → segment.
* Deliberately re-derives every step instead of calling encoder helpers.
*/
function decodeQr(qr: QrCode): Decoded {
const { size, version } = qr;
// Format info copy 1: bits 0..5 down column 8, then the corner, then along row 8
const c1: boolean[] = [];
for (let i = 0; i <= 5; i++) c1.push(qr.modules[i]![8]!);
c1.push(qr.modules[7]![8]!);
c1.push(qr.modules[8]![8]!);
c1.push(qr.modules[8]![7]!);
for (let i = 9; i < 15; i++) c1.push(qr.modules[8]![14 - i]!);
// Format info copy 2: along row 8 from the right, then up column 8
const c2: boolean[] = [];
for (let i = 0; i < 8; i++) c2.push(qr.modules[8]![size - 1 - i]!);
for (let i = 8; i < 15; i++) c2.push(qr.modules[size - 15 + i]![8]!);
assert.deepEqual(c1, c2, "the two format-info copies must agree");
let raw = 0;
for (let i = 14; i >= 0; i--) raw = (raw << 1) | (c1[i] ? 1 : 0);
assert.equal(
polyRemainder(raw ^ 0x5412, 0x537, 11),
0,
"format info must satisfy its BCH(15,5) check",
);
const unmasked = (raw ^ 0x5412) >>> 10;
const ecBits = (unmasked >>> 3) & 0b11;
const mask = unmasked & 0b111;
const ec = (["M", "L", "H", "Q"] as const)[ecBits]!;
if (version >= 7) {
let vRaw = 0;
for (let i = 17; i >= 0; i--) {
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
vRaw = (vRaw << 1) | (qr.modules[b]![a] ? 1 : 0);
}
assert.equal(
polyRemainder(vRaw, 0x1f25, 13),
0,
"version info must satisfy its BCH(18,6) check",
);
assert.equal(vRaw >>> 12, version, "version info must encode the version");
}
// Unmask the data region
const fn = functionMap(version, size);
const grid = qr.modules.map((row) => row.slice());
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!fn[y]![x] && maskPredicate(mask, x, y)) grid[y]![x] = !grid[y]![x];
}
}
// Zigzag read
const total = numRawDataModulesCodewords(version);
const bits: number[] = [];
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!fn[y]![x] && bits.length < total * 8) {
bits.push(grid[y]![x] ? 1 : 0);
}
}
}
}
assert.equal(bits.length, total * 8, "zigzag must cover every data module");
const stream: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let b = 0;
for (let k = 0; k < 8; k++) b = (b << 1) | bits[i + k]!;
stream.push(b);
}
// De-interleave
const eccTotal = ECC_PER_BLOCK[ec](version);
const dataTotal = numDataCodewords(version, ec);
const numBlocks = countBlocks(version, ec);
const blockEccLen = eccTotal / numBlocks;
assert.ok(
Number.isInteger(blockEccLen),
"ECC codewords must divide evenly across blocks",
);
const numShortBlocks = numBlocks - (total % numBlocks);
const shortBlockLen = Math.floor(total / numBlocks);
const blocks: number[][] = Array.from({ length: numBlocks }, () => []);
let p = 0;
for (let i = 0; i < shortBlockLen + 1; i++) {
for (let j = 0; j < numBlocks; j++) {
if (i === shortBlockLen - blockEccLen && j < numShortBlocks) continue;
blocks[j]!.push(stream[p++]!);
}
}
assert.equal(p, stream.length, "de-interleave must consume the whole stream");
const data: number[] = [];
for (let j = 0; j < numBlocks; j++) {
const dataLen = shortBlockLen - blockEccLen + (j < numShortBlocks ? 0 : 1);
data.push(...blocks[j]!.slice(0, dataLen));
}
assert.equal(data.length, dataTotal, "recovered data codeword count");
// Parse the byte-mode segment
const dbits: number[] = [];
for (const b of data) {
for (let k = 7; k >= 0; k--) dbits.push((b >>> k) & 1);
}
let at = 0;
const take = (n: number): number => {
let v = 0;
for (let i = 0; i < n; i++) v = (v << 1) | dbits[at++]!;
return v;
};
assert.equal(take(4), 0b0100, "mode indicator must be byte mode");
const len = take(version <= 9 ? 8 : 16);
const out = Buffer.alloc(len);
for (let i = 0; i < len; i++) out[i] = take(8);
return { text: out.toString("utf8"), ec, mask, blocks };
}
/**
* The block count genuinely cannot be inferred from the totals — many
* (blocks, eccPerBlock) pairs give the same product — so this reader takes the
* table value. The *split* is still independently validated: if it were wrong,
* blocks would be sliced at the wrong boundaries and the ReedSolomon syndrome
* assertions below could not vanish.
*/
function countBlocks(version: number, ec: EcLevel): number {
return numEccBlocks(version, ec);
}
// ── Tests ──
describe("GF(256) arithmetic", () => {
it("has 1 as the multiplicative identity and 0 as annihilator", () => {
for (let x = 0; x < 256; x++) {
assert.equal(gfMultiply(x, 1), x);
assert.equal(gfMultiply(x, 0), 0);
}
});
it("is commutative", () => {
for (let x = 0; x < 256; x += 7) {
for (let y = 0; y < 256; y += 11) {
assert.equal(gfMultiply(x, y), gfMultiply(y, x));
}
}
});
it("is associative and distributive over XOR", () => {
for (const [a, b, c] of [
[2, 3, 5],
[0x53, 0xca, 0x1f],
[255, 128, 7],
]) {
assert.equal(
gfMultiply(gfMultiply(a!, b!), c!),
gfMultiply(a!, gfMultiply(b!, c!)),
);
assert.equal(
gfMultiply(a!, b! ^ c!),
gfMultiply(a!, b!) ^ gfMultiply(a!, c!),
);
}
});
it("2 is primitive: its powers cycle with order 255", () => {
const seen = new Set<number>();
let v = 1;
for (let i = 0; i < 255; i++) {
assert.equal(seen.has(v), false, `repeat at exponent ${i}`);
seen.add(v);
v = gfMultiply(v, 2);
}
assert.equal(v, 1, "a^255 must wrap to 1");
assert.equal(seen.size, 255);
});
});
describe("Reed-Solomon", () => {
it("generator polynomial vanishes at a^0..a^(t-1)", () => {
for (const t of [7, 10, 13, 17, 22, 26, 30]) {
const divisor = rsComputeDivisor(t);
// g(x) = x^t + divisor[0]x^(t-1) + ... ; prepend the implicit leading 1.
const g = [1, ...divisor];
for (let i = 0; i < t; i++) {
assert.equal(
gfEvaluate(g, gfPow(2, i)),
0,
`g(a^${i}) should be 0 for t=${t}`,
);
}
}
});
it("produces codewords with vanishing syndromes", () => {
for (const t of [7, 10, 18, 28]) {
const divisor = rsComputeDivisor(t);
const data = Array.from({ length: 20 }, (_, i) => (i * 37 + 11) & 0xff);
const ecc = rsComputeRemainder(data, divisor);
assert.equal(ecc.length, t);
assertValidRsCodeword([...data, ...ecc], t);
}
});
it("rejects an out-of-range degree", () => {
assert.throws(() => rsComputeDivisor(0), QrError);
assert.throws(() => rsComputeDivisor(256), QrError);
});
});
describe("capacity tables", () => {
// Cross-check against the spec's published byte-mode maxima. These numbers
// are independent of the tables in qrcode.ts, so a transcription slip in
// ECC_CODEWORDS_PER_BLOCK / NUM_ECC_BLOCKS shows up here.
it("matches the published version 1 capacities", () => {
assert.equal(byteCapacity(1, "L"), 17);
assert.equal(byteCapacity(1, "M"), 14);
assert.equal(byteCapacity(1, "Q"), 11);
assert.equal(byteCapacity(1, "H"), 7);
});
it("matches the published version 40 capacities", () => {
assert.equal(byteCapacity(40, "L"), 2953);
assert.equal(byteCapacity(40, "M"), 2331);
assert.equal(byteCapacity(40, "Q"), 1663);
assert.equal(byteCapacity(40, "H"), 1273);
});
it("matches published mid-range capacities", () => {
assert.equal(byteCapacity(2, "M"), 26);
assert.equal(byteCapacity(3, "M"), 42);
// v7 is the first version with a 16-bit-free header but multiple blocks:
// 156 data codewords at L, 124 at M.
assert.equal(byteCapacity(7, "L"), 154);
assert.equal(byteCapacity(7, "M"), 122);
assert.equal(byteCapacity(10, "M"), 213);
assert.equal(byteCapacity(27, "H"), 625);
});
it("raw codewords equal data + ECC for every version and level", () => {
for (let v = 1; v <= MAX_VERSION; v++) {
for (const ec of ["L", "M", "Q", "H"] as EcLevel[]) {
const data = numDataCodewords(v, ec);
assert.ok(data > 0, `v${v} ${ec} must have data capacity`);
assert.ok(
data < numRawDataModulesCodewords(v),
`v${v} ${ec} must leave room for ECC`,
);
}
}
});
it("capacity grows monotonically with version", () => {
for (const ec of ["L", "M", "Q", "H"] as EcLevel[]) {
for (let v = 2; v <= MAX_VERSION; v++) {
assert.ok(
byteCapacity(v, ec) > byteCapacity(v - 1, ec),
`v${v} ${ec} should exceed v${v - 1}`,
);
}
}
});
it("stronger EC never has more capacity", () => {
for (let v = 1; v <= MAX_VERSION; v++) {
assert.ok(byteCapacity(v, "L") >= byteCapacity(v, "M"));
assert.ok(byteCapacity(v, "M") >= byteCapacity(v, "Q"));
assert.ok(byteCapacity(v, "Q") >= byteCapacity(v, "H"));
}
});
});
describe("alignment patterns", () => {
it("version 1 has none", () => {
assert.deepEqual(alignmentPatternPositions(1), []);
});
it("known positions", () => {
assert.deepEqual(alignmentPatternPositions(2), [6, 18]);
assert.deepEqual(alignmentPatternPositions(7), [6, 22, 38]);
assert.deepEqual(alignmentPatternPositions(32), [6, 34, 60, 86, 112, 138]);
});
it("count and bounds hold for every version", () => {
for (let v = 2; v <= MAX_VERSION; v++) {
const pos = alignmentPatternPositions(v);
const size = v * 4 + 17;
assert.equal(pos.length, Math.floor(v / 7) + 2, `count for v${v}`);
assert.equal(pos[0], 6);
assert.equal(pos[pos.length - 1], size - 7);
for (let i = 1; i < pos.length; i++) {
assert.ok(pos[i]! > pos[i - 1]!, `ascending for v${v}`);
}
}
});
});
describe("symbol structure", () => {
const qr = encodeQr("https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=abc");
it("is square with the right side length", () => {
assert.equal(qr.size, qr.version * 4 + 17);
assert.equal(qr.modules.length, qr.size);
for (const row of qr.modules) assert.equal(row.length, qr.size);
});
it("draws all three finder patterns", () => {
const centres: Array<[number, number]> = [
[3, 3],
[qr.size - 4, 3],
[3, qr.size - 4],
];
for (const [cx, cy] of centres) {
for (let dy = -3; dy <= 3; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
assert.equal(
qr.modules[cy + dy]![cx + dx],
dist !== 2,
`finder at (${cx},${cy}) offset (${dx},${dy})`,
);
}
}
}
});
it("keeps the separator ring light", () => {
for (let i = 0; i <= 7; i++) {
assert.equal(qr.modules[7]![i], false, `top-left separator row at ${i}`);
assert.equal(qr.modules[i]![7], false, `top-left separator col at ${i}`);
}
});
it("draws alternating timing patterns", () => {
for (let i = 8; i < qr.size - 8; i++) {
assert.equal(qr.modules[6]![i], i % 2 === 0, `h timing at ${i}`);
assert.equal(qr.modules[i]![6], i % 2 === 0, `v timing at ${i}`);
}
});
it("sets the always-dark module", () => {
assert.equal(qr.modules[qr.size - 8]![8], true);
});
it("picks a mask in range", () => {
assert.ok(qr.mask >= 0 && qr.mask <= 7);
});
});
describe("round trip through an independent decoder", () => {
const cases: Array<{ name: string; text: string; ec?: EcLevel }> = [
{ name: "short ascii", text: "hi" },
{ name: "single char", text: "a" },
{
name: "the real iLink scan link",
text:
"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=" +
"AQAAAO8xZ2s2S2hZbFZ4dGpuTWs5OFRvUXc9PQ%3D%3D&bot_type=3",
},
{ name: "utf-8 chinese", text: "扫码登录微信机器人:小铃" },
{ name: "mixed", text: "登录 https://a.example/x?y=1&z=2 #frag" },
{ name: "url with padding chars", text: "=".repeat(40) },
{ name: "exactly one codeword short", text: "x".repeat(13) },
{ name: "medium", text: "y".repeat(200) },
{ name: "large", text: "z".repeat(1200) },
{ name: "ec L", text: "level L payload", ec: "L" },
{ name: "ec Q", text: "level Q payload", ec: "Q" },
{ name: "ec H", text: "level H payload", ec: "H" },
];
for (const c of cases) {
it(`recovers ${c.name}`, () => {
const qr = encodeQr(c.text, c.ec ? { ec: c.ec } : {});
const decoded = decodeQr(qr);
assert.equal(decoded.text, c.text);
assert.equal(decoded.ec, c.ec ?? "M");
assert.equal(decoded.mask, qr.mask);
});
}
it("every ECC block is a valid Reed-Solomon codeword", () => {
for (const [text, ec] of [
["hello", "M"],
["x".repeat(300), "M"],
["x".repeat(300), "L"],
["x".repeat(300), "H"],
] as Array<[string, EcLevel]>) {
const qr = encodeQr(text, { ec });
const { blocks } = decodeQr(qr);
const t = ECC_PER_BLOCK[ec](qr.version) / blocks.length;
for (const block of blocks) assertValidRsCodeword(block, t);
}
});
it("survives every version that a forced minVersion can reach", () => {
// Walk a sample of versions so multi-block, short/long-block, and
// version-info (>= 7) code paths all get exercised.
for (const v of [1, 2, 6, 7, 10, 14, 20, 27, 32, 40]) {
const qr = encodeQr("payload-" + v, { minVersion: v });
assert.equal(qr.version, v);
assert.equal(decodeQr(qr).text, "payload-" + v);
}
});
it("bumps the version when the payload does not fit", () => {
const small = encodeQr("x".repeat(14));
assert.equal(small.version, 1);
const bigger = encodeQr("x".repeat(15));
assert.equal(bigger.version, 2);
assert.equal(decodeQr(bigger).text, "x".repeat(15));
});
});
describe("version selection", () => {
it("picks the smallest version that fits", () => {
assert.equal(pickVersion(14, "M"), 1);
assert.equal(pickVersion(15, "M"), 2);
assert.equal(pickVersion(2953, "L"), 40);
assert.equal(pickVersion(2954, "L"), null);
});
it("honours a minimum version", () => {
assert.equal(pickVersion(1, "M", 9), 9);
});
it("throws on oversized payloads", () => {
assert.throws(() => encodeQr("x".repeat(2954), { ec: "L" }), QrError);
assert.throws(() => encodeQr("x".repeat(2332), { ec: "M" }), QrError);
});
it("counts UTF-8 bytes, not characters", () => {
// 14 Chinese characters = 42 bytes, well past v1-M's 14-byte capacity.
const qr = encodeQr("一二三四五六七八九十壹贰叁肆");
assert.ok(qr.version >= 3, `expected >= v3, got v${qr.version}`);
assert.equal(decodeQr(qr).text, "一二三四五六七八九十壹贰叁肆");
});
it("rejects a bad EC level", () => {
assert.throws(
() => encodeQr("x", { ec: "Z" as unknown as EcLevel }),
QrError,
);
});
});
describe("interleaving invariants", () => {
it("produces exactly the raw codeword count for every version/level", () => {
for (const v of [1, 3, 5, 7, 13, 21, 33, 40]) {
for (const ec of ["L", "M", "Q", "H"] as EcLevel[]) {
const data = new Uint8Array(numDataCodewords(v, ec)).fill(0x42);
const out = addEccAndInterleave(data, v, ec);
assert.equal(out.length, numRawDataModulesCodewords(v), `v${v} ${ec}`);
}
}
});
it("rejects a wrong-sized data block", () => {
assert.throws(() => addEccAndInterleave(new Uint8Array(3), 5, "M"), QrError);
});
});
describe("SVG rendering", () => {
const qr = encodeQr("https://example.test/login?ticket=abc123");
it("wraps the symbol in a quiet zone", () => {
const svg = renderQrSvg(qr, { border: 4 });
const dim = qr.size + 8;
assert.ok(svg.includes(`viewBox="0 0 ${dim} ${dim}"`), svg.slice(0, 120));
});
it("honours a custom border", () => {
const svg = renderQrSvg(qr, { border: 0 });
assert.ok(svg.includes(`viewBox="0 0 ${qr.size} ${qr.size}"`));
});
it("emits a light background and a single dark path", () => {
const svg = renderQrSvg(qr);
assert.equal((svg.match(/<path /g) ?? []).length, 1);
assert.ok(svg.includes('fill="#ffffff"'));
assert.ok(svg.includes('fill="#000000"'));
});
it("merges horizontal runs instead of one shape per module", () => {
const svg = renderQrSvg(qr);
const moves = (svg.match(/M\d+ \d+h/g) ?? []).length;
let darkModules = 0;
for (const row of qr.modules) {
for (const m of row) if (m) darkModules++;
}
assert.ok(
moves < darkModules,
`expected run merging: ${moves} runs vs ${darkModules} modules`,
);
});
it("escapes the title", () => {
const svg = renderQrSvg(qr, { title: '登录 <a> & "b"' });
assert.ok(svg.includes("&lt;a&gt;"));
assert.ok(svg.includes("&amp;"));
assert.ok(svg.includes("&quot;"));
assert.ok(!svg.includes("<a>"));
});
it("sets explicit pixel dimensions on the svg element only", () => {
// The background <rect> always carries width/height, so inspect the opening
// <svg> tag rather than the whole document.
const openTag = (svg: string): string => svg.slice(0, svg.indexOf(">") + 1);
assert.ok(
openTag(renderQrSvg(qr, { pixelSize: 200 })).includes('width="200"'),
);
assert.ok(!openTag(renderQrSvg(qr)).includes("width="));
});
it("qrSvg is encode + render in one call", () => {
assert.equal(qrSvg("abc"), renderQrSvg(encodeQr("abc")));
});
it("never leaks the payload into the markup", () => {
// The whole point of local rendering: the ticket must not appear anywhere
// a proxy or log could read it as text.
const ticket = "SECRETTICKET123";
const svg = qrSvg(`https://x.test/q?qrcode=${ticket}`);
assert.ok(!svg.includes(ticket));
});
});
+685
View File
@@ -0,0 +1,685 @@
/**
* Dependency-free QR Code encoder (byte mode) → SVG.
*
* Exists so the iLink login QR is rendered locally instead of by a third party.
* The scan link carries a login ticket, and the previous implementation handed
* it to `api.qrserver.com` as a query parameter — an outbound copy of a
* credential to a service that has no business seeing it.
*
* Hand-rolled rather than pulled from npm on purpose: this ships through the
* OTA channel, which packs `.ts` sources and cannot carry node_modules, so a
* dependency here would force `requiresInstall` on every release.
*
* Structure follows ISO/IEC 18004. The version/ECC tables and the raw-module
* formula are cross-checked in qrcode.test.ts against the spec's published
* byte-mode capacities (v1: 17/14/11/7, v40: 2953/2331/1663/1273), and the
* generated ECC blocks are checked against the ReedSolomon syndrome property
* rather than against my own generator polynomial.
*/
export type EcLevel = "L" | "M" | "Q" | "H";
/** Format-info value per EC level (spec table, not the L<Q<M<H ordering). */
const EC_FORMAT_BITS: Record<EcLevel, number> = { L: 1, M: 0, Q: 3, H: 2 };
const EC_LEVELS: EcLevel[] = ["L", "M", "Q", "H"];
/** ECC codewords per block, indexed [ecLevel][version]; index 0 unused. */
const ECC_CODEWORDS_PER_BLOCK: Record<EcLevel, readonly number[]> = {
L: [
-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30,
28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
30, 30, 30,
],
M: [
-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26,
26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
28, 28, 28,
],
Q: [
-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28,
26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
30, 30, 30,
],
H: [
-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28,
26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30,
30, 30, 30,
],
};
/** Number of ECC blocks, indexed [ecLevel][version]; index 0 unused. */
const NUM_ECC_BLOCKS: Record<EcLevel, readonly number[]> = {
L: [
-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10,
12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25,
],
M: [
-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17,
17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49,
],
Q: [
-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23,
23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68,
],
H: [
-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25,
25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77,
81,
],
};
export const MIN_VERSION = 1;
export const MAX_VERSION = 40;
const PENALTY_N1 = 3;
const PENALTY_N2 = 3;
const PENALTY_N3 = 40;
const PENALTY_N4 = 10;
export class QrError extends Error {
constructor(message: string) {
super(message);
this.name = "QrError";
}
}
// ── GF(256) with the QR primitive polynomial x^8+x^4+x^3+x^2+1 (0x11D) ──
/** Carry-less multiply then reduce; no lookup tables to get out of sync. */
export function gfMultiply(x: number, y: number): number {
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z & 0xff;
}
/** Coefficients of the RS generator polynomial, minus the leading 1. */
export function rsComputeDivisor(degree: number): Uint8Array {
if (degree < 1 || degree > 255) {
throw new QrError(`RS degree out of range: ${degree}`);
}
const result = new Uint8Array(degree);
result[degree - 1] = 1;
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = gfMultiply(result[j]!, root);
if (j + 1 < degree) result[j]! ^= result[j + 1]!;
}
root = gfMultiply(root, 0x02);
}
return result;
}
export function rsComputeRemainder(
data: Uint8Array | readonly number[],
divisor: Uint8Array,
): Uint8Array {
const result = new Uint8Array(divisor.length);
for (const b of data) {
const factor = b ^ result[0]!;
result.copyWithin(0, 1);
result[result.length - 1] = 0;
for (let i = 0; i < result.length; i++) {
result[i]! ^= gfMultiply(divisor[i]!, factor);
}
}
return result;
}
// ── Version geometry ──
/** Modules available for data+ECC, in bits, before block splitting. */
export function numRawDataModules(version: number): number {
assertVersion(version);
let result = (16 * version + 128) * version + 64;
if (version >= 2) {
const numAlign = Math.floor(version / 7) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (version >= 7) result -= 36;
}
return result;
}
export function numDataCodewords(version: number, ec: EcLevel): number {
return (
Math.floor(numRawDataModules(version) / 8) -
ECC_CODEWORDS_PER_BLOCK[ec][version]! * NUM_ECC_BLOCKS[ec][version]!
);
}
/**
* Number of ECC blocks the codewords are split across.
*
* Exposed because the block count is not derivable from the totals — several
* (blocks, eccPerBlock) pairs multiply to the same ECC total, so anything
* reading a symbol back needs the table value.
*/
export function numEccBlocks(version: number, ec: EcLevel): number {
assertVersion(version);
return NUM_ECC_BLOCKS[ec][version]!;
}
/** Character-count field width for byte mode. */
function charCountBits(version: number): number {
return version <= 9 ? 8 : 16;
}
/** Max payload bytes for byte mode at this version + EC level. */
export function byteCapacity(version: number, ec: EcLevel): number {
const bits = numDataCodewords(version, ec) * 8 - 4 - charCountBits(version);
return Math.max(0, Math.floor(bits / 8));
}
function assertVersion(version: number): void {
if (
!Number.isInteger(version) ||
version < MIN_VERSION ||
version > MAX_VERSION
) {
throw new QrError(`version out of range: ${version}`);
}
}
export function alignmentPatternPositions(version: number): number[] {
assertVersion(version);
if (version === 1) return [];
const size = version * 4 + 17;
const numAlign = Math.floor(version / 7) + 2;
const step =
version === 32
? 26
: Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const result = [6];
for (let pos = size - 7; result.length < numAlign; pos -= step) {
result.splice(1, 0, pos);
}
return result;
}
/** Smallest version that fits `byteLen` payload bytes, or null if none does. */
export function pickVersion(
byteLen: number,
ec: EcLevel,
minVersion = MIN_VERSION,
): number | null {
for (
let v = Math.max(MIN_VERSION, minVersion);
v <= MAX_VERSION;
v++
) {
if (byteLen <= byteCapacity(v, ec)) return v;
}
return null;
}
// ── Bit buffer ──
class BitBuffer {
readonly bits: number[] = [];
append(value: number, len: number): void {
for (let i = len - 1; i >= 0; i--) {
this.bits.push((value >>> i) & 1);
}
}
}
// ── Encoder ──
export interface QrOptions {
/** Error correction level; M is the usual choice for scanning off a screen. */
ec?: EcLevel;
/** Force at least this version (never lowers the auto-picked one). */
minVersion?: number;
}
export interface QrCode {
version: number;
ec: EcLevel;
mask: number;
size: number;
/** Row-major; true = dark */
modules: boolean[][];
}
export function encodeQr(text: string, opts: QrOptions = {}): QrCode {
const ec = opts.ec ?? "M";
if (!EC_LEVELS.includes(ec)) throw new QrError(`bad EC level: ${ec}`);
const payload = Buffer.from(text, "utf8");
const version = pickVersion(payload.length, ec, opts.minVersion ?? 1);
if (version === null) {
throw new QrError(
`data too long: ${payload.length} bytes exceeds ${byteCapacity(
MAX_VERSION,
ec,
)} for EC ${ec}`,
);
}
const dataCodewords = buildDataCodewords(payload, version, ec);
const finalCodewords = addEccAndInterleave(dataCodewords, version, ec);
return drawSymbol(finalCodewords, version, ec);
}
/** Mode + count + payload + terminator + pad, to exactly dataCodewords bytes. */
function buildDataCodewords(
payload: Buffer,
version: number,
ec: EcLevel,
): Uint8Array {
const capacityBits = numDataCodewords(version, ec) * 8;
const bb = new BitBuffer();
bb.append(0b0100, 4); // byte mode
bb.append(payload.length, charCountBits(version));
for (const b of payload) bb.append(b, 8);
if (bb.bits.length > capacityBits) {
throw new QrError("internal: payload overflowed the chosen version");
}
// Terminator: up to 4 zero bits, only as many as fit.
bb.append(0, Math.min(4, capacityBits - bb.bits.length));
// Pad to a byte boundary, then alternate the spec's pad bytes.
bb.append(0, (8 - (bb.bits.length % 8)) % 8);
const out = new Uint8Array(capacityBits / 8);
for (let i = 0; i < bb.bits.length; i++) {
if (bb.bits[i]) out[i >>> 3]! |= 0x80 >>> (i & 7);
}
for (let i = bb.bits.length / 8, pad = 0xec; i < out.length; i++) {
out[i] = pad;
pad = pad === 0xec ? 0x11 : 0xec;
}
return out;
}
/** Split into blocks, append RS ECC per block, then interleave per the spec. */
export function addEccAndInterleave(
data: Uint8Array,
version: number,
ec: EcLevel,
): Uint8Array {
const numBlocks = NUM_ECC_BLOCKS[ec][version]!;
const blockEccLen = ECC_CODEWORDS_PER_BLOCK[ec][version]!;
const rawCodewords = Math.floor(numRawDataModules(version) / 8);
if (data.length !== numDataCodewords(version, ec)) {
throw new QrError("internal: data codeword count mismatch");
}
const numShortBlocks = numBlocks - (rawCodewords % numBlocks);
const shortBlockLen = Math.floor(rawCodewords / numBlocks);
const divisor = rsComputeDivisor(blockEccLen);
const blocks: number[][] = [];
for (let i = 0, k = 0; i < numBlocks; i++) {
const datLen =
shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1);
const dat = Array.from(data.subarray(k, k + datLen));
k += datLen;
const ecc = rsComputeRemainder(dat, divisor);
// Short blocks get a placeholder so column indexing lines up below; it is
// skipped during interleaving and never reaches the symbol.
if (i < numShortBlocks) dat.push(0);
blocks.push([...dat, ...ecc]);
}
const result: number[] = [];
for (let i = 0; i < blocks[0]!.length; i++) {
blocks.forEach((block, j) => {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) {
result.push(block[i]!);
}
});
}
if (result.length !== rawCodewords) {
throw new QrError("internal: interleave produced the wrong length");
}
return Uint8Array.from(result);
}
// ── Symbol drawing ──
function drawSymbol(
codewords: Uint8Array,
version: number,
ec: EcLevel,
): QrCode {
const size = version * 4 + 17;
const modules: boolean[][] = Array.from({ length: size }, () =>
new Array<boolean>(size).fill(false),
);
const isFunction: boolean[][] = Array.from({ length: size }, () =>
new Array<boolean>(size).fill(false),
);
const set = (x: number, y: number, dark: boolean): void => {
modules[y]![x] = dark;
isFunction[y]![x] = true;
};
// Timing patterns
for (let i = 0; i < size; i++) {
set(6, i, i % 2 === 0);
set(i, 6, i % 2 === 0);
}
// Finder patterns + separators, anchored at the three corners
drawFinder(set, size, 3, 3);
drawFinder(set, size, size - 4, 3);
drawFinder(set, size, 3, size - 4);
// Alignment patterns, skipping the three finder corners
const aligns = alignmentPatternPositions(version);
const last = aligns.length - 1;
for (let i = 0; i <= last; i++) {
for (let j = 0; j <= last; j++) {
const corner =
(i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0);
if (corner) continue;
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
set(
aligns[j]! + dx,
aligns[i]! + dy,
Math.max(Math.abs(dx), Math.abs(dy)) !== 1,
);
}
}
}
}
drawVersionInfo(set, size, version);
// Reserve the format areas now (mask 0); rewritten once the mask is chosen.
drawFormatBits(set, size, ec, 0);
drawCodewords(modules, isFunction, size, codewords);
let bestMask = 0;
let bestPenalty = Number.POSITIVE_INFINITY;
for (let mask = 0; mask < 8; mask++) {
applyMask(modules, isFunction, size, mask);
drawFormatBits(set, size, ec, mask);
const penalty = penaltyScore(modules, size);
if (penalty < bestPenalty) {
bestPenalty = penalty;
bestMask = mask;
}
applyMask(modules, isFunction, size, mask); // XOR is its own inverse
}
applyMask(modules, isFunction, size, bestMask);
drawFormatBits(set, size, ec, bestMask);
return { version, ec, mask: bestMask, size, modules };
}
type SetFn = (x: number, y: number, dark: boolean) => void;
/** 7x7 finder centred at (cx, cy) plus its light separator ring. */
function drawFinder(set: SetFn, size: number, cx: number, cy: number): void {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || x >= size || y < 0 || y >= size) continue;
const dist = Math.max(Math.abs(dx), Math.abs(dy));
set(x, y, dist !== 2 && dist !== 4);
}
}
}
function drawFormatBits(
set: SetFn,
size: number,
ec: EcLevel,
mask: number,
): void {
const data = (EC_FORMAT_BITS[ec] << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = (((data << 10) | rem) ^ 0x5412) & 0x7fff;
const bit = (i: number): boolean => ((bits >>> i) & 1) !== 0;
// Copy 1 — around the top-left finder
for (let i = 0; i <= 5; i++) set(8, i, bit(i));
set(8, 7, bit(6));
set(8, 8, bit(7));
set(7, 8, bit(8));
for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
// Copy 2 — split across the other two finders
for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
set(8, size - 8, true); // always-dark module
}
function drawVersionInfo(set: SetFn, size: number, version: number): void {
if (version < 7) return;
let rem = version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = ((version << 12) | rem) & 0x3ffff;
for (let i = 0; i < 18; i++) {
const dark = ((bits >>> i) & 1) !== 0;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
set(a, b, dark);
set(b, a, dark);
}
}
/** Zigzag fill of the two-column strips, skipping function modules. */
function drawCodewords(
modules: boolean[][],
isFunction: boolean[][],
size: number,
codewords: Uint8Array,
): void {
let i = 0;
const totalBits = codewords.length * 8;
for (let right = size - 1; right >= 1; right -= 2) {
// Column 6 is the vertical timing pattern — the strip shifts left past it.
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (!isFunction[y]![x] && i < totalBits) {
modules[y]![x] = ((codewords[i >>> 3]! >>> (7 - (i & 7))) & 1) !== 0;
i++;
}
}
}
}
if (i !== totalBits) {
throw new QrError(
`internal: placed ${i} of ${totalBits} data bits`,
);
}
}
export function maskPredicate(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0:
return (x + y) % 2 === 0;
case 1:
return y % 2 === 0;
case 2:
return x % 3 === 0;
case 3:
return (x + y) % 3 === 0;
case 4:
return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0;
case 5:
return ((x * y) % 2) + ((x * y) % 3) === 0;
case 6:
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
case 7:
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
default:
throw new QrError(`bad mask: ${mask}`);
}
}
function applyMask(
modules: boolean[][],
isFunction: boolean[][],
size: number,
mask: number,
): void {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!isFunction[y]![x] && maskPredicate(mask, x, y)) {
modules[y]![x] = !modules[y]![x];
}
}
}
}
const FINDER_RUN = [true, false, true, true, true, false, true];
/** Spec penalty rules N1N4; drives mask selection only, never correctness. */
export function penaltyScore(modules: boolean[][], size: number): number {
let penalty = 0;
const lines: boolean[][] = [];
for (let y = 0; y < size; y++) lines.push(modules[y]!.slice());
for (let x = 0; x < size; x++) {
lines.push(Array.from({ length: size }, (_, y) => modules[y]![x]!));
}
for (const line of lines) {
// N1: runs of 5+
let runLen = 1;
for (let i = 1; i <= line.length; i++) {
if (i < line.length && line[i] === line[i - 1]) {
runLen++;
continue;
}
if (runLen >= 5) penalty += PENALTY_N1 + (runLen - 5);
runLen = 1;
}
// N3: finder-like 1:1:3:1:1 with 4 light modules on either side
for (let i = 0; i + 7 <= line.length; i++) {
let match = true;
for (let k = 0; k < 7; k++) {
if (line[i + k] !== FINDER_RUN[k]) {
match = false;
break;
}
}
if (!match) continue;
const beforeClear =
i >= 4 && line.slice(i - 4, i).every((v) => !v);
const afterClear =
i + 11 <= line.length && line.slice(i + 7, i + 11).every((v) => !v);
if (beforeClear || afterClear) penalty += PENALTY_N3;
}
}
// N2: 2x2 blocks of one colour
for (let y = 0; y + 1 < size; y++) {
for (let x = 0; x + 1 < size; x++) {
const c = modules[y]![x];
if (
c === modules[y]![x + 1] &&
c === modules[y + 1]![x] &&
c === modules[y + 1]![x + 1]
) {
penalty += PENALTY_N2;
}
}
}
// N4: deviation of dark proportion from 50%, in 5% steps
let dark = 0;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) if (modules[y]![x]) dark++;
}
const total = size * size;
const k = Math.floor(Math.abs((dark * 100) / total - 50) / 5);
penalty += k * PENALTY_N4;
return penalty;
}
// ── SVG rendering ──
export interface SvgOptions {
/** Quiet zone in modules; the spec requires 4 and scanners rely on it. */
border?: number;
/** Rendered pixel size of the whole square (viewBox stays in modules). */
pixelSize?: number;
dark?: string;
light?: string;
title?: string;
}
/**
* Render to SVG. Horizontal runs are merged into one path so the markup stays
* a few KB instead of one element per module.
*/
export function renderQrSvg(qr: QrCode, opts: SvgOptions = {}): string {
const border = Math.max(0, Math.floor(opts.border ?? 4));
const dark = opts.dark ?? "#000000";
const light = opts.light ?? "#ffffff";
const dim = qr.size + border * 2;
const segments: string[] = [];
for (let y = 0; y < qr.size; y++) {
let x = 0;
while (x < qr.size) {
if (!qr.modules[y]![x]) {
x++;
continue;
}
let run = 1;
while (x + run < qr.size && qr.modules[y]![x + run]) run++;
segments.push(`M${x + border} ${y + border}h${run}v1h-${run}z`);
x += run;
}
}
const sizeAttrs =
opts.pixelSize && opts.pixelSize > 0
? ` width="${Math.round(opts.pixelSize)}" height="${Math.round(
opts.pixelSize,
)}"`
: "";
const titleEl = opts.title
? `<title>${escapeXml(opts.title)}</title>`
: "";
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}"` +
`${sizeAttrs} shape-rendering="crispEdges" role="img">` +
titleEl +
`<rect width="${dim}" height="${dim}" fill="${light}"/>` +
`<path fill="${dark}" d="${segments.join("")}"/>` +
`</svg>`
);
}
function escapeXml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
c === "&"
? "&amp;"
: c === "<"
? "&lt;"
: c === ">"
? "&gt;"
: c === '"'
? "&quot;"
: "&#39;",
);
}
/** One-shot: text → SVG markup. */
export function qrSvg(
text: string,
opts: QrOptions & SvgOptions = {},
): string {
return renderQrSvg(encodeQr(text, opts), opts);
}
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { RateLimiter } from "./rate-limit.js";
describe("RateLimiter", () => {
it("allows up to max within window", () => {
const rl = new RateLimiter(3, 60_000);
assert.equal(rl.tryTake("a", 1000), true);
assert.equal(rl.tryTake("a", 1001), true);
assert.equal(rl.tryTake("a", 1002), true);
assert.equal(rl.tryTake("a", 1003), false);
});
it("resets after window", () => {
const rl = new RateLimiter(1, 1000);
assert.equal(rl.tryTake("b", 0), true);
assert.equal(rl.tryTake("b", 500), false);
assert.equal(rl.tryTake("b", 1001), true);
});
it("still denies across many distinct keys", () => {
const rl = new RateLimiter(2, 60_000, 128);
for (let i = 0; i < 500; i++) rl.tryTake(`k${i}`, 1000);
// The key we keep hitting must never be evicted out from under itself
assert.equal(rl.tryTake("hot", 1000), true);
assert.equal(rl.tryTake("hot", 1001), true);
assert.equal(rl.tryTake("hot", 1002), false);
});
it("bounds the key map under unbounded distinct keys", () => {
const rl = new RateLimiter(5, 60_000, 128);
for (let i = 0; i < 100_000; i++) rl.tryTake(`ip-${i}`, 1000);
assert.ok(
rl.size <= 128,
`expected bounded map, got ${rl.size}`,
);
});
it("drops keys whose window went empty", () => {
const rl = new RateLimiter(5, 1000);
rl.tryTake("gone", 0);
assert.equal(rl.size, 1);
// Same key, long after the window — re-inserted with a single hit
assert.equal(rl.tryTake("gone", 10_000), true);
assert.equal(rl.remaining("gone", 10_000), 4);
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Simple sliding-window rate limiter (in-memory, per process).
*
* Keys are attacker-influenced (client IP on the public /cdn/s route, username
* on login), so the map is bounded. Evicting a bucket is always safe in this
* direction: the key simply gets a fresh window, it can never turn an allow
* into a deny.
*/
export class RateLimiter {
private hits = new Map<string, number[]>();
private readonly maxKeys: number;
constructor(
private max: number,
private windowMs: number,
maxKeys = 20_000,
) {
this.maxKeys = Math.max(64, maxKeys);
}
/**
* Runtime settings reload. Existing buckets are kept: raising the cap frees
* callers immediately, lowering it applies from the next request onward.
*/
setLimits(max: number, windowMs = this.windowMs): void {
this.max = Math.max(1, max);
this.windowMs = Math.max(1_000, windowMs);
}
/** Drop timestamps that fell out of the window, in place (no re-allocation). */
private prune(list: number[], cutoff: number): number[] {
let i = 0;
while (i < list.length && list[i]! <= cutoff) i++;
if (i > 0) list.splice(0, i);
return list;
}
private evictIfFull(): void {
if (this.hits.size < this.maxKeys) return;
// Drop the oldest ~10% by insertion order
const n = Math.ceil(this.maxKeys * 0.1);
let i = 0;
for (const k of this.hits.keys()) {
this.hits.delete(k);
if (++i >= n) break;
}
}
/** Returns true if the key is allowed; records a hit when allowed. */
tryTake(key: string, now = Date.now()): boolean {
const cutoff = now - this.windowMs;
const existing = this.hits.get(key);
const prev = existing ? this.prune(existing, cutoff) : [];
if (prev.length >= this.max) {
// `prev` is the same array instance already in the map
return false;
}
if (!prev.length) {
// Window went empty — forget the key so idle callers stop occupying it,
// then re-insert at the tail so eviction order stays LRU-ish.
this.hits.delete(key);
this.evictIfFull();
}
prev.push(now);
this.hits.set(key, prev);
return true;
}
remaining(key: string, now = Date.now()): number {
const cutoff = now - this.windowMs;
const existing = this.hits.get(key);
const prev = existing ? this.prune(existing, cutoff) : [];
return Math.max(0, this.max - prev.length);
}
/** Tracked key count — for tests / diagnostics. */
get size(): number {
return this.hits.size;
}
}
+178
View File
@@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { LOG_LEVELS, resolveLogLevel } from "./config.js";
import {
describeRequest,
isQuietPath,
isStreamingPath,
logPath,
requestLogLevel,
} from "./request-log.js";
const base = {
method: "GET",
url: "/api/v1/me/bots",
status: 200,
elapsedMs: 12,
slowMs: 1000,
};
describe("resolveLogLevel", () => {
it("accepts every pino level", () => {
for (const level of LOG_LEVELS) {
assert.equal(resolveLogLevel(level), level);
}
});
it("normalises case and whitespace", () => {
assert.equal(resolveLogLevel(" DEBUG "), "debug");
});
it("falls back to info rather than letting pino throw at boot", () => {
for (const bad of ["verbose", "", " ", undefined, "10", "critical"]) {
assert.equal(resolveLogLevel(bad), "info", String(bad));
}
});
});
describe("logPath", () => {
it("keeps a plain path", () => {
assert.equal(logPath("/api/v1/auth/me"), "/api/v1/auth/me");
});
it("drops the query string so OAuth credentials never reach the log", () => {
assert.equal(
logPath("/api/v1/auth/callback?code=SECRET&state=ALSOSECRET"),
"/api/v1/auth/callback",
);
});
it("never returns empty", () => {
assert.equal(logPath(""), "/");
assert.equal(logPath("?a=1"), "/");
});
});
describe("requestLogLevel", () => {
it("escalates on server errors", () => {
assert.equal(requestLogLevel(500, 5, 1000), "error");
assert.equal(requestLogLevel(503, 5, 1000), "error");
});
it("warns on client errors", () => {
assert.equal(requestLogLevel(400, 5, 1000), "warn");
assert.equal(requestLogLevel(404, 5, 1000), "warn");
assert.equal(requestLogLevel(429, 5, 1000), "warn");
});
it("warns on a slow success", () => {
assert.equal(requestLogLevel(200, 1001, 1000), "warn");
assert.equal(requestLogLevel(200, 1000, 1000), "info");
});
it("treats redirects and 304 as ordinary", () => {
assert.equal(requestLogLevel(302, 5, 1000), "info");
assert.equal(requestLogLevel(304, 5, 1000), "info");
});
});
describe("streaming paths", () => {
it("recognises the admin activity stream", () => {
assert.equal(isStreamingPath("/api/v1/admin/stream"), true);
assert.equal(isStreamingPath("/api/v1/admin/stream/recent"), false);
assert.equal(isStreamingPath("/api/v1/me/bots"), false);
});
it("does not escalate a held-open stream to warn on duration", () => {
// The SSE route hijacks the reply and stays open for as long as the
// dashboard is on screen; elapsed time is the viewer's dwell time.
assert.equal(requestLogLevel(200, 600_000, 1000, true), "info");
// Real failures still escalate.
assert.equal(requestLogLevel(500, 600_000, 1000, true), "error");
assert.equal(requestLogLevel(403, 5, 1000, true), "warn");
});
it("logs a long SSE session at info end to end", () => {
const line = describeRequest({
method: "GET",
url: "/api/v1/admin/stream?types=message",
status: 200,
elapsedMs: 8 * 60_000,
slowMs: 1000,
});
assert.equal(line?.level, "info");
assert.equal(line?.path, "/api/v1/admin/stream");
});
});
describe("isQuietPath", () => {
it("covers both health endpoints and nothing else", () => {
assert.equal(isQuietPath("/health"), true);
assert.equal(isQuietPath("/health/ready"), true);
assert.equal(isQuietPath("/healthz"), false);
assert.equal(isQuietPath("/"), false);
assert.equal(isQuietPath("/api/v1/auth/me"), false);
});
});
describe("describeRequest", () => {
it("emits fields for a normal request", () => {
assert.deepEqual(describeRequest({ ...base, socketIp: "10.0.0.5" }), {
level: "info",
method: "GET",
path: "/api/v1/me/bots",
status: 200,
ms: 12,
ip: "10.0.0.5",
});
});
it("stays quiet for a healthy probe", () => {
assert.equal(describeRequest({ ...base, url: "/health" }), null);
assert.equal(describeRequest({ ...base, url: "/health/ready" }), null);
});
it("logs a failing probe — the only time anyone reads them", () => {
const line = describeRequest({
...base,
url: "/health/ready",
status: 503,
});
assert.ok(line);
assert.equal(line.level, "error");
assert.equal(line.path, "/health/ready");
});
it("prefers the Cloudflare header over the socket address", () => {
const line = describeRequest({
...base,
cfConnectingIp: " 203.0.113.9 ",
socketIp: "10.0.0.5",
});
assert.equal(line?.ip, "203.0.113.9");
});
it("falls back through socket IP to unknown", () => {
assert.equal(describeRequest({ ...base, socketIp: "10.0.0.5" })?.ip, "10.0.0.5");
assert.equal(describeRequest({ ...base })?.ip, "unknown");
assert.equal(
describeRequest({ ...base, cfConnectingIp: " ", socketIp: undefined })?.ip,
"unknown",
);
});
it("rounds and floors latency", () => {
assert.equal(describeRequest({ ...base, elapsedMs: 12.6 })?.ms, 13);
assert.equal(describeRequest({ ...base, elapsedMs: -1 })?.ms, 0);
});
it("never carries a query string into the fields", () => {
const line = describeRequest({
...base,
url: "/api/v1/auth/callback?code=SECRET",
status: 302,
});
assert.equal(line?.path, "/api/v1/auth/callback");
assert.ok(!JSON.stringify(line).includes("SECRET"));
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* Decides what a completed request should log.
*
* Split out of the onResponse hook so the rules — which paths stay quiet, when
* a 200 still deserves a warning, and above all that query strings never reach
* the log — are unit-testable instead of buried in a closure.
*/
export type RequestLogLevel = "info" | "warn" | "error";
export interface RequestLogFields {
level: RequestLogLevel;
method: string;
path: string;
status: number;
ms: number;
ip: string;
}
export interface RequestLogInput {
method: string;
/** Raw request URL, query string included */
url: string;
status: number;
elapsedMs: number;
cfConnectingIp?: string | undefined;
socketIp?: string | undefined;
/** Successful requests slower than this log at warn */
slowMs: number;
}
/**
* Probe endpoints. Docker's HEALTHCHECK and the Cloudflare LB hit these every
* few seconds, so a successful probe logs nothing — a failing one still does,
* which is the only time anyone wants to read them.
*/
const QUIET_PATHS = new Set(["/health", "/health/ready"]);
/**
* Long-lived responses, where elapsed time measures how long a client stayed
* connected rather than how slow we were.
*
* The admin activity stream calls `reply.hijack()` and holds the socket open for
* as long as the dashboard is on screen. Fastify still fires onResponse when the
* raw socket finishes, so without this every dashboard visit would close with a
* "slow request" warning that means nothing.
*/
const STREAMING_PATHS = new Set(["/api/v1/admin/stream"]);
export function isStreamingPath(path: string): boolean {
return STREAMING_PATHS.has(path);
}
/**
* Strip the query string.
*
* `/api/v1/auth/callback?code=…&state=…` carries single-use OAuth credentials;
* writing the full URL would persist them to whatever collects stdout.
*/
export function logPath(url: string): string {
const path = url.split("?")[0] || "/";
return path;
}
export function isQuietPath(path: string): boolean {
return QUIET_PATHS.has(path);
}
export function requestLogLevel(
status: number,
ms: number,
slowMs: number,
streaming = false,
): RequestLogLevel {
if (status >= 500) return "error";
if (status >= 400) return "warn";
// Duration is meaningless for a held-open stream — never escalate on it.
if (streaming) return "info";
return ms > slowMs ? "warn" : "info";
}
/** Fields to log, or null when this request should stay quiet. */
export function describeRequest(
input: RequestLogInput,
): RequestLogFields | null {
const path = logPath(input.url);
const status = input.status;
if (isQuietPath(path) && status < 400) return null;
const ms = Math.max(0, Math.round(input.elapsedMs));
return {
level: requestLogLevel(status, ms, input.slowMs, isStreamingPath(path)),
method: input.method,
path,
status,
ms,
// trustProxy is deliberately off, so only Cloudflare's header is trusted
// here — a client-supplied X-Forwarded-For must not shape our logs either.
ip: input.cfConnectingIp?.trim() || input.socketIp || "unknown",
};
}
File diff suppressed because it is too large Load Diff
+260
View File
@@ -0,0 +1,260 @@
import type { ChatService, TryChatService } from "@wechat-ai/core";
import type { ActivityBus } from "./activity-stream.js";
import type { AppConfig } from "./config.js";
import type { RuntimeSettingKey } from "./runtime-settings-spec.js";
import type { BotWorkerManager } from "./worker.js";
export interface RuntimeConfigTargets {
chat: ChatService;
tryChat: TryChatService;
worker: BotWorkerManager;
activityBus: ActivityBus;
}
/**
* Keys whose change requires re-pushing a subsystem's options.
*
* Deliberately coarse: when any key in a set changes we re-push that whole
* subsystem from the current `cfg`. The push is idempotent and cheap, and it
* keeps this file from drifting into a per-key dispatch table that silently
* misses a field when someone adds one.
*/
const CHAT_KEYS: RuntimeSettingKey[] = [
"shortHistoryLimit",
"memoryExtractEveryN",
"allowUnapproved",
"multiBubbleJson",
"replyFilterEnabled",
"maxReplyChunks",
"maxChunkChars",
"maxStickersPerReply",
"stickerSendEnabled",
"memoryTopK",
"memoryFullInjectMax",
"memoryMaxItems",
"timeToolEnabled",
"timeToolTimeZone",
"webSearchEnabled",
"webSearchMaxResults",
"toolsBaseUrl",
"toolsApiKey",
"toolsTimeoutMs",
"chatflowHttpAllowlist",
"chatflowMaxSteps",
"chatflowMaxNodes",
"visionMode",
"visionModel",
"visionCaptionMaxTokens",
];
const TRYCHAT_KEYS: RuntimeSettingKey[] = [
"tryChatSessionTtlSec",
"tryChatMaxHistory",
"tryChatMaxUserMsgsPerDay",
"tryChatMaxUserMsgsPerSession",
"multiBubbleJson",
"replyFilterEnabled",
"maxReplyChunks",
"maxChunkChars",
"timeToolEnabled",
"timeToolTimeZone",
"toolsBaseUrl",
"toolsApiKey",
"toolsTimeoutMs",
"webSearchEnabled",
"webSearchMaxResults",
"chatflowHttpAllowlist",
"chatflowMaxSteps",
"chatflowMaxNodes",
];
const WORKER_KEYS: RuntimeSettingKey[] = [
"stickerSendEnabled",
"maxStickersPerReply",
"visionEnabled",
"visionMaxImages",
"voiceTranscriptEnabled",
"inboundMediaMaxBytes",
"splitReply",
"replyDelayMsPerChar",
"replyDelayMinMs",
"replyDelayMaxMs",
"replyDelayFirstMinMs",
"replyDelayFirstMaxMs",
"replyDelayThinkExtraMs",
"peerRatePerMinute",
"maxBotsPerWorker",
"leaseTtlSec",
"leaseRenewSec",
"rebalanceEnabled",
"rebalanceIntervalSec",
"rebalanceSlack",
"rebalanceMaxPerTick",
"workerWeightTtlSec",
"inboxMaxLen",
"proactiveEnabled",
"proactiveIdleHours",
"proactiveMinIntervalHours",
"proactiveMaxPerDay",
"proactiveQuietHours",
"proactiveScanIntervalSec",
"proactiveMaxPerScan",
"proactiveLockTtlSec",
"proactiveAttemptCooldownHours",
"broadcastIntervalMs",
"p2pEnabled",
"p2pBindCodeTtlSec",
"p2pRequestTtlSec",
"p2pSessionIdleSec",
"p2pRelayMaxChars",
"p2pMaxRequestsPerDay",
"nodeLabel",
"nodeRegion",
"otaEnabled",
"otaAllowInstall",
"otaStagingDir",
];
const STREAM_KEYS: RuntimeSettingKey[] = [
"dataStreamEnabled",
"dataStreamMaxEps",
"dataStreamRedisSample",
];
function touched(
changed: Set<RuntimeSettingKey>,
keys: RuntimeSettingKey[],
): boolean {
return keys.some((k) => changed.has(k));
}
/**
* Push the current effective config into services that snapshot their options
* at construction. Route handlers need nothing here — they read `ctx.cfg.*`
* per request and `cfg` is mutated in place by the settings manager.
*/
export function applyRuntimeConfigToServices(
changed: Set<RuntimeSettingKey>,
cfg: AppConfig,
targets: RuntimeConfigTargets,
): void {
if (touched(changed, CHAT_KEYS)) {
targets.chat.applyRuntimeOptions({
shortHistoryLimit: cfg.shortHistoryLimit,
memoryExtractEveryN: cfg.memoryExtractEveryN,
allowUnapproved: cfg.allowUnapproved,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
maxStickersPerReply: cfg.maxStickersPerReply,
stickersEnabled: cfg.stickerSendEnabled,
memoryTopK: cfg.memoryTopK,
memoryFullInjectMax: cfg.memoryFullInjectMax,
memoryMaxItems: cfg.memoryMaxItems,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
webSearchEnabled: cfg.webSearchEnabled,
webSearchMaxResults: cfg.webSearchMaxResults,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
toolsTimeoutMs: cfg.toolsTimeoutMs,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
visionMode: cfg.visionMode,
visionModel: cfg.visionModel || undefined,
visionCaptionMaxTokens: cfg.visionCaptionMaxTokens,
});
}
if (touched(changed, TRYCHAT_KEYS)) {
targets.tryChat.applyRuntimeOptions({
sessionTtlSec: cfg.tryChatSessionTtlSec,
maxHistory: cfg.tryChatMaxHistory,
maxUserMsgsPerDay: cfg.tryChatMaxUserMsgsPerDay,
maxUserMsgsPerSession: cfg.tryChatMaxUserMsgsPerSession,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
toolsTimeoutMs: cfg.toolsTimeoutMs,
webSearchEnabled: cfg.webSearchEnabled,
webSearchMaxResults: cfg.webSearchMaxResults,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
});
}
if (touched(changed, WORKER_KEYS)) {
targets.worker.applyRuntimeConfig({
stickerSendEnabled: cfg.stickerSendEnabled,
maxStickersPerReply: cfg.maxStickersPerReply,
visionEnabled: cfg.visionEnabled,
visionMaxImages: cfg.visionMaxImages,
voiceTranscriptEnabled: cfg.voiceTranscriptEnabled,
inboundMediaMaxBytes: cfg.inboundMediaMaxBytes,
splitReply: cfg.splitReply,
peerRatePerMinute: cfg.peerRatePerMinute,
maxBotsPerWorker: cfg.maxBotsPerWorker,
leaseTtlSec: cfg.leaseTtlSec,
leaseRenewSec: cfg.leaseRenewSec,
rebalanceEnabled: cfg.rebalanceEnabled,
rebalanceIntervalSec: cfg.rebalanceIntervalSec,
rebalanceSlack: cfg.rebalanceSlack,
rebalanceMaxPerTick: cfg.rebalanceMaxPerTick,
workerWeightTtlSec: cfg.workerWeightTtlSec,
inboxMaxLen: cfg.inboxMaxLen,
replyDelay: {
msPerChar: cfg.replyDelayMsPerChar,
minMs: cfg.replyDelayMinMs,
maxMs: cfg.replyDelayMaxMs,
firstMinMs: cfg.replyDelayFirstMinMs,
firstMaxMs: cfg.replyDelayFirstMaxMs,
thinkExtraMs: cfg.replyDelayThinkExtraMs,
},
proactive: {
globalEnabled: cfg.proactiveEnabled,
defaultIdleHours: cfg.proactiveIdleHours,
defaultMinIntervalHours: cfg.proactiveMinIntervalHours,
defaultMaxPerDay: cfg.proactiveMaxPerDay,
defaultQuietHours: cfg.proactiveQuietHours,
scanIntervalSec: cfg.proactiveScanIntervalSec,
maxPerScan: cfg.proactiveMaxPerScan,
lockTtlSec: cfg.proactiveLockTtlSec,
attemptCooldownHours: cfg.proactiveAttemptCooldownHours,
},
broadcast: {
intervalMs: cfg.broadcastIntervalMs,
pollIntervalMs: 2_000,
lockTtlSec: 60,
},
p2pEnabled: cfg.p2pEnabled,
p2p: {
bindCodeTtlSec: cfg.p2pBindCodeTtlSec,
requestTtlSec: cfg.p2pRequestTtlSec,
sessionIdleSec: cfg.p2pSessionIdleSec,
relayMaxChars: cfg.p2pRelayMaxChars,
maxRequestsPerDay: cfg.p2pMaxRequestsPerDay,
},
nodeLabel: cfg.nodeLabel,
nodeRegion: cfg.nodeRegion,
otaEnabled: cfg.otaEnabled,
otaAllowInstall: cfg.otaAllowInstall,
otaStagingDir: cfg.otaStagingDir,
});
}
if (touched(changed, STREAM_KEYS)) {
targets.activityBus.applyRuntimeOptions({
enabled: cfg.dataStreamEnabled,
maxEps: cfg.dataStreamMaxEps,
redisSample: cfg.dataStreamRedisSample,
});
}
}
+476
View File
@@ -0,0 +1,476 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import type { Db } from "@wechat-ai/db";
import { loadConfig, type AppConfig } from "./config.js";
import {
RuntimeConfigManager,
RuntimeSettingsUnavailableError,
SECRET_CLEAR,
SECRET_MASK,
} from "./runtime-config.js";
import type { RuntimeSettingKey } from "./runtime-settings-spec.js";
import {
coerceSetting,
SETTING_SPECS,
SETTING_SPEC_BY_KEY,
} from "./runtime-settings-spec.js";
/**
* Minimal in-memory stand-in for the Redis surface the manager touches:
* one JSON doc plus the SET NX lock guarding its read-modify-write.
*/
function fakeDb(): Db & { store: Map<string, unknown>; strings: Map<string, string> } {
const store = new Map<string, unknown>();
const strings = new Map<string, string>();
return {
store,
strings,
async getJson<T>(key: string): Promise<T | null> {
return (store.get(key) as T) ?? null;
},
async setJson(key: string, value: unknown): Promise<void> {
store.set(key, JSON.parse(JSON.stringify(value)));
},
async del(...keys: string[]): Promise<void> {
for (const k of keys) {
store.delete(k);
strings.delete(k);
}
},
redis: {
async set(
key: string,
value: string,
_ex?: string,
_ttl?: number,
nx?: string,
): Promise<string | null> {
if (nx === "NX" && strings.has(key)) return null;
strings.set(key, value);
return "OK";
},
async get(key: string): Promise<string | null> {
return strings.get(key) ?? null;
},
},
} as unknown as Db & { store: Map<string, unknown>; strings: Map<string, string> };
}
function baseConfig(env: Record<string, string> = {}): AppConfig {
return loadConfig({
REDIS_URL: "redis://127.0.0.1:6379",
...env,
} as NodeJS.ProcessEnv);
}
describe("runtime settings spec", () => {
it("every spec key exists on AppConfig", () => {
const cfg = baseConfig() as unknown as Record<string, unknown>;
for (const spec of SETTING_SPECS) {
assert.ok(
spec.key in cfg,
`${spec.key} is declared in the registry but missing from AppConfig`,
);
}
});
it("excludes bootstrap-critical config", () => {
const forbidden = [
"redisUrl",
"llmBaseUrl",
"llmApiKey",
"llmModel",
"llmProviderSecret",
"sessionCookieName",
"cookieSecure",
"publicBaseUrl",
"corsOrigins",
"host",
"port",
"token",
"adminIds",
"repoRoot",
"appVersion",
];
for (const k of forbidden) {
assert.ok(
!SETTING_SPEC_BY_KEY.has(k as RuntimeSettingKey),
`${k} must stay env-only`,
);
}
});
/**
* Guard rail: every AppConfig field must be a deliberate decision — either
* admin-editable or explicitly listed as env-only. Adding a new config field
* fails this test until it is classified, which is what keeps the panel from
* silently drifting out of date.
*/
it("classifies every AppConfig field as editable or env-only", () => {
const ENV_ONLY = new Set([
// bootstrap / connection
"redisUrl",
"host",
"port",
"repoRoot",
// platform LLM credentials
"llmBaseUrl",
"llmApiKey",
"llmModel",
// vision endpoint credentials — same trust level as the platform LLM
"visionBaseUrl",
"visionApiKey",
// vision endpoint credentials — dialed directly like the platform LLM,
// and they fall back to it when empty, so same rule applies
"visionBaseUrl",
"visionApiKey",
// secrets whose rotation breaks stored data or locks admins out
"llmProviderSecret",
"token",
"adminIds",
// cookie / URL surface
"sessionCookieName",
"cookieSecure",
"publicBaseUrl",
"corsOrigins",
// derived, not directly settable
"appVersion",
"uploadBodyLimit",
// no consumer: the default persona comes from the DB is_default flag
"defaultPersonaSlug",
]);
/**
* Consumer not written yet. Exposing one of these would give the operator
* a control that reports success and changes nothing — the same defect we
* removed DEFAULT_PERSONA_SLUG for. Move to SETTING_SPECS once the read
* site lands.
*/
const PENDING_CONSUMER = new Set([
// declared on ChatServiceOptions, but nothing reads this.opts.visionMode
"visionMode",
]);
const cfg = baseConfig() as unknown as Record<string, unknown>;
const unclassified = Object.keys(cfg).filter(
(k) =>
!ENV_ONLY.has(k) &&
!PENDING_CONSUMER.has(k) &&
!SETTING_SPEC_BY_KEY.has(k as RuntimeSettingKey),
);
assert.deepEqual(
unclassified,
[],
`add these to SETTING_SPECS or to the ENV_ONLY list: ${unclassified.join(", ")}`,
);
});
it("has no duplicate keys or env names", () => {
const keys = new Set<string>();
const envs = new Set<string>();
for (const spec of SETTING_SPECS) {
assert.ok(!keys.has(spec.key), `duplicate key ${spec.key}`);
assert.ok(!envs.has(spec.env), `duplicate env ${spec.env}`);
keys.add(spec.key);
envs.add(spec.env);
}
});
it("clamps numbers into range and parses booleans/csv", () => {
const steps = SETTING_SPEC_BY_KEY.get("chatflowMaxSteps")!;
assert.equal(coerceSetting(steps, 9999), 200);
assert.equal(coerceSetting(steps, -5), 1);
assert.equal(coerceSetting(steps, "12"), 12);
assert.equal(coerceSetting(steps, "abc"), null);
const bool = SETTING_SPEC_BY_KEY.get("webSearchEnabled")!;
assert.equal(coerceSetting(bool, "true"), true);
assert.equal(coerceSetting(bool, "0"), false);
const csv = SETTING_SPEC_BY_KEY.get("chatflowHttpAllowlist")!;
assert.equal(coerceSetting(csv, " a.com , ,b.com "), "a.com,b.com");
});
});
describe("RuntimeConfigManager", () => {
let db: ReturnType<typeof fakeDb>;
let cfg: AppConfig;
let applied: Array<Set<RuntimeSettingKey>>;
let mgr: RuntimeConfigManager;
beforeEach(async () => {
db = fakeDb();
cfg = baseConfig({ WEB_SEARCH_ENABLED: "false", CHATFLOW_MAX_STEPS: "32" });
applied = [];
mgr = new RuntimeConfigManager(
db,
cfg,
(changed) => applied.push(new Set(changed)),
() => {},
);
await mgr.init();
});
it("starts from env with no overrides", () => {
const v = mgr.view();
assert.equal(v.overriddenCount, 0);
assert.equal(cfg.chatflowMaxSteps, 32);
assert.equal(applied.length, 0);
});
it("mutates the live cfg object in place and reports the diff", async () => {
const r = await mgr.patch({
patch: { chatflowMaxSteps: 64, webSearchEnabled: true },
actor: "tester",
});
assert.deepEqual(r.changed.sort(), ["chatflowMaxSteps", "webSearchEnabled"]);
assert.equal(cfg.chatflowMaxSteps, 64);
assert.equal(cfg.webSearchEnabled, true);
assert.equal(applied.length, 1);
assert.ok(applied[0]!.has("chatflowMaxSteps"));
});
it("clamps out-of-range input instead of storing it", async () => {
await mgr.patch({ patch: { chatflowMaxSteps: 100000 }, actor: "t" });
assert.equal(cfg.chatflowMaxSteps, 200);
});
it("drops the override when a value is set back to the env default", async () => {
await mgr.patch({ patch: { chatflowMaxSteps: 64 }, actor: "t" });
assert.equal(mgr.view().overriddenCount, 1);
await mgr.patch({ patch: { chatflowMaxSteps: 32 }, actor: "t" });
assert.equal(mgr.view().overriddenCount, 0);
assert.equal(cfg.chatflowMaxSteps, 32);
});
it("reset restores the env default", async () => {
await mgr.patch({ patch: { chatflowMaxSteps: 64 }, actor: "t" });
const r = await mgr.patch({ reset: ["chatflowMaxSteps"], actor: "t" });
assert.deepEqual(r.changed, ["chatflowMaxSteps"]);
assert.equal(cfg.chatflowMaxSteps, 32);
});
it("resetAll clears every override", async () => {
await mgr.patch({
patch: { chatflowMaxSteps: 64, memoryTopK: 33 },
actor: "t",
});
await mgr.patch({ resetAll: true, actor: "t" });
assert.equal(mgr.view().overriddenCount, 0);
assert.equal(cfg.chatflowMaxSteps, 32);
assert.equal(cfg.memoryTopK, 12);
});
it("csv settings round-trip into a string array on cfg", async () => {
await mgr.patch({
patch: { chatflowHttpAllowlist: "a.com, b.com" },
actor: "t",
});
assert.deepEqual(cfg.chatflowHttpAllowlist, ["a.com", "b.com"]);
});
it("never leaks a secret and treats blank as no-change", async () => {
await mgr.patch({ patch: { toolsApiKey: "sk-real" }, actor: "t" });
assert.equal(cfg.toolsApiKey, "sk-real");
const item = mgr.view().items.find((i) => i.key === "toolsApiKey")!;
assert.equal(item.value, SECRET_MASK);
// A blank submit must not wipe the stored key…
await mgr.patch({ patch: { toolsApiKey: "" }, actor: "t" });
assert.equal(cfg.toolsApiKey, "sk-real");
// …and echoing the mask back must not become the literal value.
await mgr.patch({ patch: { toolsApiKey: SECRET_MASK }, actor: "t" });
assert.equal(cfg.toolsApiKey, "sk-real");
// Explicit clear.
await mgr.patch({ patch: { toolsApiKey: SECRET_CLEAR }, actor: "t" });
assert.equal(cfg.toolsApiKey, "");
});
it("ignores unknown keys", async () => {
const r = await mgr.patch({
patch: { redisUrl: "redis://evil", notAKey: 1 },
actor: "t",
});
assert.deepEqual(r.changed, []);
assert.equal(cfg.redisUrl, "redis://127.0.0.1:6379");
});
it("picks up a peer node's write on the next refresh", async () => {
const peerDb = db;
const peerCfg = baseConfig({ CHATFLOW_MAX_STEPS: "32" });
const peer = new RuntimeConfigManager(peerDb, peerCfg, () => {}, () => {});
await peer.init();
await mgr.patch({ patch: { chatflowMaxSteps: 77 }, actor: "node-a" });
assert.equal(peerCfg.chatflowMaxSteps, 32, "not yet refreshed");
const changed = await peer.refresh();
assert.equal(changed, true);
assert.equal(peerCfg.chatflowMaxSteps, 77);
assert.equal(await peer.refresh(), false, "second refresh is a no-op");
});
it("surfaces cross-field warnings without rewriting input", async () => {
await mgr.patch({
patch: { leaseTtlSec: 20, leaseRenewSec: 30 },
actor: "t",
});
assert.equal(cfg.leaseTtlSec, 20);
assert.equal(cfg.leaseRenewSec, 30);
assert.ok(
mgr.currentWarnings().some((w) => w.includes("租约 TTL")),
"expected a lease TTL warning",
);
});
it("keeps uploadBodyLimit consistent with stickerMaxBytes", async () => {
await mgr.patch({ patch: { stickerMaxBytes: 20 * 1024 * 1024 }, actor: "t" });
assert.equal(cfg.uploadBodyLimit, 40 * 1024 * 1024);
});
it("releases the RMW lock so a second write is not blocked", async () => {
await mgr.patch({ patch: { chatflowMaxSteps: 40 }, actor: "a" });
assert.equal(db.strings.has("wa:settings:runtime:lock"), false);
await mgr.patch({ patch: { memoryTopK: 20 }, actor: "b" });
assert.equal(cfg.chatflowMaxSteps, 40);
assert.equal(cfg.memoryTopK, 20);
});
it("concurrent writes on two nodes do not lose each other's edits", async () => {
const peerCfg = baseConfig({ CHATFLOW_MAX_STEPS: "32" });
const peer = new RuntimeConfigManager(db, peerCfg, () => {}, () => {});
await peer.init();
// Both patches race against the same shared store.
await Promise.all([
mgr.patch({ patch: { chatflowMaxSteps: 50 }, actor: "node-a" }),
peer.patch({ patch: { memoryTopK: 40 }, actor: "node-b" }),
]);
await mgr.refresh();
await peer.refresh();
assert.equal(cfg.chatflowMaxSteps, 50, "node A's edit survived");
assert.equal(cfg.memoryTopK, 40, "node B's edit survived");
assert.equal(mgr.view().overriddenCount, 2);
});
it("view() payload matches what the admin page renders", () => {
const v = mgr.view();
const groupIds = new Set(v.groups.map((g) => g.id));
assert.ok(v.items.length > 50, "expected a broad settings surface");
for (const item of v.items) {
for (const f of [
"key",
"env",
"group",
"label",
"type",
"value",
"envDefault",
"overridden",
"restart",
]) {
assert.ok(f in item, `${item.key} is missing ${f}`);
}
assert.ok(
groupIds.has(item.group as never),
`${item.key} points at unknown group ${item.group}`,
);
assert.ok(
["bool", "int", "float", "string", "csv", "secret"].includes(item.type),
`${item.key} has unrenderable type ${item.type}`,
);
}
// Every declared group must actually hold at least one row.
for (const g of v.groups) {
assert.ok(
v.items.some((i) => i.group === g.id),
`group ${g.id} would render empty`,
);
}
});
it("rejects a log level outside the allowed set", async () => {
// A bad level would only bite on the NEXT boot (restart:true), where pino
// throws at Fastify construction and crash-loops every node.
const r = await mgr.patch({ patch: { logLevel: "verbose" }, actor: "t" });
assert.deepEqual(r.changed, []);
assert.equal(cfg.logLevel, "info");
await mgr.patch({ patch: { logLevel: "debug" }, actor: "t" });
assert.equal(cfg.logLevel, "debug");
});
it("a Redis read failure never wipes stored overrides", async () => {
await mgr.patch({
patch: { chatflowMaxSteps: 64, memoryTopK: 33 },
actor: "t",
});
const stored = JSON.parse(
JSON.stringify(db.store.get("wa:settings:runtime")),
);
// Simulate a transient Redis error on the next read.
const realGet = db.getJson.bind(db);
let failNext = true;
(db as { getJson: unknown }).getJson = async (key: string) => {
if (failNext) {
failNext = false;
throw new Error("ETIMEDOUT");
}
return realGet(key);
};
// refresh() must keep the last good state, not revert to .env.
assert.equal(await mgr.refresh(), false);
assert.equal(cfg.chatflowMaxSteps, 64);
assert.equal(cfg.memoryTopK, 33);
// patch() must refuse to write rather than persist an empty base.
failNext = true;
await assert.rejects(
() => mgr.patch({ patch: { memoryTopK: 44 }, actor: "t" }),
(e: unknown) => e instanceof RuntimeSettingsUnavailableError,
);
assert.deepEqual(db.store.get("wa:settings:runtime"), stored);
});
it("resetAll still works when the stored document is unreadable", async () => {
await mgr.patch({ patch: { chatflowMaxSteps: 64 }, actor: "t" });
const realGet = db.getJson.bind(db);
let fail = true;
(db as { getJson: unknown }).getJson = async (key: string) => {
if (fail) throw new Error("bad json");
return realGet(key);
};
const r = await mgr.patch({ resetAll: true, actor: "t" });
assert.ok(r.changed.includes("chatflowMaxSteps"));
fail = false;
await mgr.refresh();
assert.equal(cfg.chatflowMaxSteps, 32);
assert.equal(mgr.view().overriddenCount, 0);
});
it("resetAll clears a corrupt document even with nothing loaded locally", async () => {
// The node that boots into a corrupt doc has no overrides of its own, so
// `changed` is empty — the write must happen anyway or recovery is a no-op.
db.store.set("wa:settings:runtime", { values: { chatflowMaxSteps: 64 } });
const realGet = db.getJson.bind(db);
let fail = true;
(db as { getJson: unknown }).getJson = async (key: string) => {
if (fail) throw new Error("Unexpected token in JSON");
return realGet(key);
};
await mgr.patch({ resetAll: true, actor: "t" });
fail = false;
const doc = db.store.get("wa:settings:runtime") as { values: object };
assert.deepEqual(doc.values, {}, "corrupt doc was replaced with an empty one");
});
it("survives a malformed stored document", async () => {
db.store.set("wa:settings:runtime", {
values: { chatflowMaxSteps: "not-a-number", bogus: 1 },
updatedAt: "x",
updatedBy: "y",
});
await mgr.refresh();
assert.equal(cfg.chatflowMaxSteps, 32);
});
});
+476
View File
@@ -0,0 +1,476 @@
import { K, type Db } from "@wechat-ai/db";
import type { AppConfig } from "./config.js";
import {
coerceSetting,
configToSettingValue,
isRuntimeSettingKey,
SETTING_GROUPS,
SETTING_SPEC_BY_KEY,
SETTING_SPECS,
settingValueToConfig,
type RuntimeSettingKey,
type SettingSpec,
type SettingValue,
} from "./runtime-settings-spec.js";
/** Stored document at `wa:settings:runtime`. */
export interface RuntimeSettingsDoc {
values: Partial<Record<RuntimeSettingKey, SettingValue>>;
updatedAt: string;
updatedBy: string;
}
/**
* Per-node poll interval. Chosen over pub/sub: one GET every 5s per node is
* negligible next to the request-path Redis traffic, and it needs no extra
* subscriber connection. Worst-case propagation across the fleet is 5s.
*/
export const RUNTIME_SETTINGS_REFRESH_MS = 5_000;
/** Stored overrides could not be read; the caller must not write. */
export class RuntimeSettingsUnavailableError extends Error {
constructor(public readonly cause: string) {
super(`无法读取运行时配置(Redis):${cause}`);
this.name = "RuntimeSettingsUnavailableError";
}
}
/** Placeholder returned instead of secret values. */
export const SECRET_MASK = "••••••••";
/** Typing this into a secret field clears it. */
export const SECRET_CLEAR = "-";
export interface SettingItemView {
key: RuntimeSettingKey;
env: string;
group: string;
label: string;
type: SettingSpec["type"];
min?: number;
max?: number;
step?: number;
/** Closed set for string settings; rendered as a <select> */
options?: readonly string[];
restart: boolean;
hint?: string;
/** Effective value in use right now (secrets masked) */
value: SettingValue;
/** Value from the .env / process environment (secrets masked) */
envDefault: SettingValue;
/** True when a Redis override is in effect for this key */
overridden: boolean;
}
export interface RuntimeSettingsView {
groups: typeof SETTING_GROUPS;
items: SettingItemView[];
updatedAt: string;
updatedBy: string;
overriddenCount: number;
refreshMs: number;
}
export interface PatchResult {
view: RuntimeSettingsView;
/** Keys whose stored value actually changed */
changed: RuntimeSettingKey[];
/** Changed keys that only take effect after a restart */
restartRequired: RuntimeSettingKey[];
warnings: string[];
}
/** Fan-out hook: push the new effective config into live services. */
export type ApplyRuntimeConfigFn = (
changed: Set<RuntimeSettingKey>,
cfg: AppConfig,
) => void;
function maskIfSecret(spec: SettingSpec, v: SettingValue): SettingValue {
if (spec.type !== "secret") return v;
return v ? SECRET_MASK : "";
}
/**
* Owns the effective AppConfig.
*
* `cfg` is the very object handed to `registerRoutes` and every service, so
* mutating it in place is what makes route handlers (which read `ctx.cfg.*`
* per request) pick up changes for free. Services that snapshot their options
* at construction are updated through {@link ApplyRuntimeConfigFn}.
*/
export class RuntimeConfigManager {
private readonly envDefaults: Record<RuntimeSettingKey, SettingValue>;
private overrides: Partial<Record<RuntimeSettingKey, SettingValue>> = {};
private updatedAt = "";
private updatedBy = "";
private timer: ReturnType<typeof setInterval> | null = null;
/** Serialized last-seen doc; skips the apply pass when Redis is unchanged. */
private lastSeen = "";
/** Dedupes the read-failure log line across poll ticks. */
private lastReadError = "";
private log: (msg: string) => void;
constructor(
private db: Db,
private cfg: AppConfig,
private applyFn: ApplyRuntimeConfigFn,
log?: (msg: string) => void,
) {
this.log = log ?? ((m) => console.log(m));
const defaults = {} as Record<RuntimeSettingKey, SettingValue>;
for (const spec of SETTING_SPECS) {
defaults[spec.key] = configToSettingValue(spec, cfg);
}
this.envDefaults = defaults;
}
/** Read Redis once and apply. Call before the HTTP server starts serving. */
async init(): Promise<void> {
await this.refresh();
}
start(): void {
if (this.timer) return;
this.timer = setInterval(() => {
void this.refresh().catch((err) => {
this.log(
`[settings] refresh failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
});
}, RUNTIME_SETTINGS_REFRESH_MS);
this.timer.unref?.();
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* A read failure and an absent key must never be confused.
*
* `getJson` rejects on both a Redis transport error and malformed JSON, and
* returns null only when the key genuinely does not exist. Collapsing the
* two into null would make one dropped GET look like "there are no
* overrides" — which reverts the node to .env in refresh(), and in patch()
* would persist that emptiness over the whole fleet.
*/
private async readDoc(): Promise<
{ ok: true; doc: RuntimeSettingsDoc | null } | { ok: false; error: string }
> {
try {
const doc = await this.db.getJson<RuntimeSettingsDoc>(K.runtimeSettings);
return { ok: true, doc };
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Re-read overrides and push any diff into the live config.
* Returns true when something changed.
*
* On a read failure this keeps the last good state untouched and returns
* false — a transient Redis blip must not silently relax settings the panel
* had tightened.
*/
async refresh(): Promise<boolean> {
const read = await this.readDoc();
if (!read.ok) {
if (this.lastReadError !== read.error) {
this.lastReadError = read.error;
this.log(
`[settings] read failed, keeping last known config: ${read.error}`,
);
}
return false;
}
this.lastReadError = "";
const doc = read.doc;
const raw = doc ? JSON.stringify(doc) : "";
if (raw === this.lastSeen) return false;
this.lastSeen = raw;
const next: Partial<Record<RuntimeSettingKey, SettingValue>> = {};
for (const [k, v] of Object.entries(doc?.values ?? {})) {
if (!isRuntimeSettingKey(k)) continue;
const spec = SETTING_SPEC_BY_KEY.get(k)!;
const coerced = coerceSetting(spec, v);
if (coerced !== null) next[k] = coerced;
}
this.overrides = next;
this.updatedAt = doc?.updatedAt ?? "";
this.updatedBy = doc?.updatedBy ?? "";
return this.applyEffective();
}
/** Effective value for one key: Redis override, else env default. */
private effective(key: RuntimeSettingKey): SettingValue {
const o = this.overrides[key];
return o === undefined ? this.envDefaults[key] : o;
}
/** Write effective values into `cfg` in place; fan out the diff. */
private applyEffective(): boolean {
const changed = new Set<RuntimeSettingKey>();
const bag = this.cfg as unknown as Record<string, unknown>;
for (const spec of SETTING_SPECS) {
const want = settingValueToConfig(spec, this.effective(spec.key));
const have = bag[spec.key];
const same = Array.isArray(want)
? Array.isArray(have) && want.join(",") === have.join(",")
: want === have;
if (same) continue;
bag[spec.key] = want;
changed.add(spec.key);
}
if (changed.has("stickerMaxBytes")) {
// Kept consistent with loadConfig(); only takes effect after a restart.
this.cfg.uploadBodyLimit = Math.max(
12 * 1024 * 1024,
this.cfg.stickerMaxBytes * 2,
);
}
if (!changed.size) return false;
try {
this.applyFn(changed, this.cfg);
} catch (err) {
this.log(
`[settings] apply failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
this.log(
`[settings] applied ${changed.size} change(s): ${[...changed].join(", ")}`,
);
return true;
}
/** Cross-field sanity checks; advisory only, never silently rewrites input. */
private warnings(): string[] {
const out: string[] = [];
const c = this.cfg;
if (c.leaseTtlSec <= c.leaseRenewSec) {
out.push(
`租约 TTL(${c.leaseTtlSec}s) 必须明显大于续约间隔(${c.leaseRenewSec}s),否则节点会在续约前丢失租约`,
);
}
if (c.replyDelayMinMs > c.replyDelayMaxMs) {
out.push("气泡间隔下限大于上限,实际发送会以上限为准");
}
if (c.replyDelayFirstMinMs > c.replyDelayFirstMaxMs) {
out.push("首条延迟下限大于上限");
}
if (c.memoryFullInjectMax < c.memoryTopK) {
out.push("全量注入阈值小于 Top-KTop-K 将永远不会生效");
}
if (c.webSearchEnabled && !c.toolsBaseUrl) {
out.push("已开启联网搜索但未配置工具网关地址,搜索会直接失败");
}
if (c.toolsBaseUrl && !c.toolsApiKey) {
out.push("工具网关已配置但密钥为空,网关可能拒绝请求");
}
if (c.multiBubbleJson && c.replyFilterEnabled) {
out.push("二次过滤开启时主模型不再直出 JSON,「模型直出气泡 JSON」将被忽略");
}
return out;
}
view(): RuntimeSettingsView {
const items: SettingItemView[] = SETTING_SPECS.map((spec) => ({
key: spec.key,
env: spec.env,
group: spec.group,
label: spec.label,
type: spec.type,
min: spec.min,
max: spec.max,
step: spec.step,
options: spec.options,
restart: spec.restart === true,
hint: spec.hint,
value: maskIfSecret(spec, this.effective(spec.key)),
envDefault: maskIfSecret(spec, this.envDefaults[spec.key]),
overridden: this.overrides[spec.key] !== undefined,
}));
return {
groups: SETTING_GROUPS,
items,
updatedAt: this.updatedAt,
updatedBy: this.updatedBy,
overriddenCount: items.filter((i) => i.overridden).length,
refreshMs: RUNTIME_SETTINGS_REFRESH_MS,
};
}
/** Current effective warnings, for the GET payload. */
currentWarnings(): string[] {
return this.warnings();
}
/**
* Apply an admin patch: coerce, persist to Redis, then apply locally so the
* editing node reflects it immediately (peers pick it up within 5s).
*
* `patch` values for secret fields: empty string = leave unchanged,
* {@link SECRET_CLEAR} = clear.
*/
async patch(input: {
patch?: Record<string, unknown>;
reset?: string[];
resetAll?: boolean;
actor: string;
}): Promise<PatchResult> {
// Serialize the read-modify-write fleet-wide. Without this, two admins on
// two nodes each read the same base doc and the second SET drops the
// first's whole edit — the merge is per-document, not per-field.
const lock = await this.acquireLock();
try {
return await this.patchLocked(input);
} finally {
if (lock) await this.releaseLock(lock);
}
}
/** Best-effort short lock; on failure we still proceed (see patch()). */
private async acquireLock(): Promise<string | null> {
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
for (let i = 0; i < 5; i++) {
try {
const ok = await this.db.redis.set(
K.runtimeSettingsLock,
token,
"EX",
5,
"NX",
);
if (ok === "OK") return token;
} catch {
// Redis is already the source of truth for the write below; if the
// lock op itself fails, fall through rather than block the admin.
return null;
}
await new Promise((r) => setTimeout(r, 120));
}
return null;
}
private async releaseLock(token: string): Promise<void> {
try {
// Only drop our own lock — a slow write must not release a successor's.
const cur = await this.db.redis.get(K.runtimeSettingsLock);
if (cur === token) await this.db.del(K.runtimeSettingsLock);
} catch {
/* the 5s EX is the backstop */
}
}
private async patchLocked(input: {
patch?: Record<string, unknown>;
reset?: string[];
resetAll?: boolean;
actor: string;
}): Promise<PatchResult> {
// Read from Redis, not local state — this node's snapshot is up to 5s old.
const read = await this.readDoc();
// Writing a merge derived from a failed read would persist an empty base
// and destroy every other override fleet-wide. resetAll is the exception:
// it discards the base by definition, so it stays available as the
// in-product recovery path even when the stored doc is unreadable.
if (!read.ok && !input.resetAll) {
throw new RuntimeSettingsUnavailableError(read.error);
}
const doc = read.ok ? read.doc : null;
const values: Partial<Record<RuntimeSettingKey, SettingValue>> = {
...(doc?.values ?? {}),
};
const changed: RuntimeSettingKey[] = [];
// A corrupt document must still be clearable even when `changed` comes out
// empty (nothing readable to name), otherwise the recovery path is a no-op.
let forceWrite = false;
if (input.resetAll) {
// Fall back to this node's known overrides when the stored doc could not
// be read, so the audit record still names what was cleared.
const known = read.ok
? Object.keys(values)
: Object.keys(this.overrides);
for (const k of known) {
if (isRuntimeSettingKey(k)) changed.push(k);
}
for (const k of Object.keys(values)) {
delete values[k as RuntimeSettingKey];
}
forceWrite = !read.ok;
}
for (const k of input.reset ?? []) {
if (!isRuntimeSettingKey(k)) continue;
if (values[k] !== undefined) {
delete values[k];
changed.push(k);
}
}
for (const [k, rawValue] of Object.entries(input.patch ?? {})) {
if (!isRuntimeSettingKey(k)) continue;
const spec = SETTING_SPEC_BY_KEY.get(k)!;
if (spec.type === "secret") {
const s = typeof rawValue === "string" ? rawValue.trim() : "";
// Never let the masked placeholder round-trip back in as a real value.
if (!s || s === SECRET_MASK) continue;
const nextSecret = s === SECRET_CLEAR ? "" : s;
if (values[k] !== nextSecret) {
values[k] = nextSecret;
changed.push(k);
}
continue;
}
const coerced = coerceSetting(spec, rawValue);
if (coerced === null) continue;
// Setting a key back to its env default drops the override entirely,
// so a later .env change is picked up again.
if (coerced === this.envDefaults[k]) {
if (values[k] !== undefined) {
delete values[k];
changed.push(k);
}
continue;
}
if (values[k] !== coerced) {
values[k] = coerced;
changed.push(k);
}
}
const nextDoc: RuntimeSettingsDoc = {
values,
updatedAt: new Date().toISOString(),
updatedBy: input.actor || "admin",
};
if (changed.length || forceWrite) {
await this.db.setJson(K.runtimeSettings, nextDoc);
}
// Force the next refresh() to re-apply even if the doc string matches.
this.lastSeen = "";
await this.refresh();
const uniqueChanged = [...new Set(changed)];
return {
view: this.view(),
changed: uniqueChanged,
restartRequired: uniqueChanged.filter(
(k) => SETTING_SPEC_BY_KEY.get(k)?.restart === true,
),
warnings: this.warnings(),
};
}
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
import assert from "node:assert/strict";
import { Writable } from "node:stream";
import { describe, it } from "node:test";
import Fastify from "fastify";
import { loadConfig, type AppConfig, type LogLevel } from "./config.js";
import {
buildFastifyOptions,
registerRequestLogging,
} from "./server-options.js";
/** Collects the JSON lines pino writes, so we can assert on real output. */
class LineSink extends Writable {
readonly lines: Record<string, unknown>[] = [];
private buf = "";
override _write(
chunk: Buffer | string,
_enc: unknown,
cb: (err?: Error | null) => void,
): void {
this.buf += chunk.toString();
let nl = this.buf.indexOf("\n");
while (nl >= 0) {
const raw = this.buf.slice(0, nl).trim();
this.buf = this.buf.slice(nl + 1);
if (raw) {
try {
this.lines.push(JSON.parse(raw) as Record<string, unknown>);
} catch {
this.lines.push({ unparsed: raw });
}
}
nl = this.buf.indexOf("\n");
}
cb();
}
requestLines(): Record<string, unknown>[] {
return this.lines.filter((l) => l.msg === "request");
}
}
function cfgWith(patch: Partial<AppConfig> = {}): AppConfig {
// Real loadConfig so the options are built from the shape production uses.
const base = loadConfig({
LLM_API_KEY: "test",
REDIS_URL: "redis://127.0.0.1:6379",
} as NodeJS.ProcessEnv);
return { ...base, ...patch };
}
/** Boots a throwaway server with the production options and a captured log. */
async function withServer(
cfg: AppConfig,
run: (
app: Awaited<ReturnType<typeof buildApp>>["app"],
sink: LineSink,
) => Promise<void>,
): Promise<void> {
const { app, sink } = await buildApp(cfg);
try {
await run(app, sink);
} finally {
await app.close();
}
}
async function buildApp(cfg: AppConfig) {
const sink = new LineSink();
const app = Fastify(buildFastifyOptions(cfg, { logStream: sink }));
registerRequestLogging(app, cfg);
app.get("/ok", async () => ({ ok: true }));
app.get("/health", async () => ({ ok: true }));
app.get("/health/ready", async (_req, reply) =>
reply.code(503).send({ ok: false }),
);
app.get("/boom", async () => {
throw new Error("kaboom");
});
app.get("/api/v1/auth/callback", async (_req, reply) =>
reply.code(302).header("location", "/app").send(),
);
// Same path, but it throws — this is the shape that reaches Fastify's
// defaultErrorLog, which logs the serialized request.
app.get("/api/v1/auth/callback-boom", async () => {
throw new Error("Reached the max retries per request limit");
});
await app.ready();
return { app, sink };
}
describe("buildFastifyOptions", () => {
it("produces a logger config pino actually accepts", () => {
// The whole point of the extraction: a bad level or redact path throws at
// construction, so this is the boot smoke test.
for (const level of ["silent", "error", "info", "debug", "trace"] as LogLevel[]) {
const sink = new LineSink();
const app = Fastify(
buildFastifyOptions(cfgWith({ logLevel: level }), { logStream: sink }),
);
assert.equal(app.log.level, level);
void app.close();
}
});
it("silences Fastify's own request lines via logController, not the deprecated flag", () => {
const opts = buildFastifyOptions(cfgWith());
// disableRequestLogging would also gag defaultErrorLog (and is removed in
// Fastify 6), so it must stay unset.
assert.equal(opts.disableRequestLogging, undefined);
assert.ok(opts.logController);
});
it("still refuses to trust proxy headers", () => {
// Load-bearing security property: trustProxy would let a forged
// X-Forwarded-For walk past the login and CDN rate limiters.
assert.equal(buildFastifyOptions(cfgWith()).trustProxy, undefined);
});
it("assigns short request ids", () => {
const genReqId = buildFastifyOptions(cfgWith()).genReqId;
assert.ok(genReqId);
const id = genReqId({} as never);
assert.equal(typeof id, "string");
assert.equal(String(id).length, 8);
assert.notEqual(id, genReqId({} as never));
});
});
describe("request logging", () => {
it("logs one line per request at info", async () => {
await withServer(cfgWith(), async (app, sink) => {
const res = await app.inject({ method: "GET", url: "/ok" });
assert.equal(res.statusCode, 200);
const lines = sink.requestLines();
assert.equal(lines.length, 1);
assert.equal(lines[0]!.level, 30); // info
assert.equal(lines[0]!.method, "GET");
assert.equal(lines[0]!.path, "/ok");
assert.equal(lines[0]!.status, 200);
assert.equal(typeof lines[0]!.ms, "number");
assert.equal(typeof lines[0]!.reqId, "string");
});
});
it("stays silent for a healthy probe but logs a failing one", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({ method: "GET", url: "/health" });
assert.equal(sink.requestLines().length, 0);
await app.inject({ method: "GET", url: "/health/ready" });
const lines = sink.requestLines();
assert.equal(lines.length, 1);
assert.equal(lines[0]!.status, 503);
assert.equal(lines[0]!.level, 50); // error
});
});
it("logs a thrown route error — the regression that motivated this", async () => {
await withServer(cfgWith(), async (app, sink) => {
const res = await app.inject({ method: "GET", url: "/boom" });
assert.equal(res.statusCode, 500);
// Fastify's own error log (only emitted because the logger is enabled)
assert.ok(
sink.lines.some((l) => String(l.msg ?? "").includes("kaboom")),
"the framework error must be logged, not swallowed",
);
// Plus our own request line, escalated to error
const lines = sink.requestLines();
assert.equal(lines.length, 1);
assert.equal(lines[0]!.level, 50);
});
});
it("never writes the OAuth code or state to the log", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({
method: "GET",
url: "/api/v1/auth/callback?code=SUPERSECRET&state=ALSOSECRET",
});
const dump = JSON.stringify(sink.lines);
assert.ok(!dump.includes("SUPERSECRET"), dump);
assert.ok(!dump.includes("ALSOSECRET"), dump);
assert.equal(sink.requestLines()[0]!.path, "/api/v1/auth/callback");
});
});
it("keeps the OAuth code out even when the callback 500s", async () => {
// The dangerous path: Fastify's defaultErrorLog logs the serialized
// request on a 5xx, and its built-in `req` serializer emits `url` with the
// query string intact. Redacting headers does nothing about that — the
// serializer never emits headers — so this needs a custom `req` serializer.
// The OAuth code is still unredeemed at that point, i.e. a live credential.
await withServer(cfgWith(), async (app, sink) => {
const res = await app.inject({
method: "GET",
url: "/api/v1/auth/callback-boom?code=SUPERSECRET&state=ALSOSECRET",
});
assert.equal(res.statusCode, 500);
const dump = JSON.stringify(sink.lines);
assert.ok(
sink.lines.some((l) => String(l.msg ?? "").includes("max retries")),
"the 5xx must still be logged",
);
assert.ok(!dump.includes("SUPERSECRET"), dump);
assert.ok(!dump.includes("ALSOSECRET"), dump);
// The path is still there — we strip the query, not the whole URL.
assert.ok(dump.includes("/api/v1/auth/callback-boom"));
});
});
it("the framework request serializer emits no header bag at all", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({
method: "GET",
url: "/boom",
headers: { cookie: "wa_session=COOKIESECRET", "x-custom": "visible" },
});
const errLine = sink.lines.find((l) => l.req);
assert.ok(errLine, "expected a line carrying a serialized request");
const req = errLine.req as Record<string, unknown>;
assert.equal(req.headers, undefined);
assert.equal(req.url, undefined, "url must be replaced by path");
assert.equal(req.path, "/boom");
assert.equal(req.method, "GET");
});
});
it("never writes the session cookie or authorization header", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({
method: "GET",
url: "/boom",
headers: {
cookie: "wa_session=COOKIESECRET",
authorization: "Bearer TOKENSECRET",
"x-api-key": "KEYSECRET",
},
});
const dump = JSON.stringify(sink.lines);
for (const secret of ["COOKIESECRET", "TOKENSECRET", "KEYSECRET"]) {
assert.ok(!dump.includes(secret), `${secret} leaked: ${dump}`);
}
});
});
it("prefers the Cloudflare client IP", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({
method: "GET",
url: "/ok",
headers: { "cf-connecting-ip": "203.0.113.7" },
});
assert.equal(sink.requestLines()[0]!.ip, "203.0.113.7");
});
});
it("threads the slow-request threshold through to the hook", async () => {
// A local inject is sub-millisecond, so drive the branch from the config
// rather than racing the clock: below zero, every request counts as slow.
await withServer(cfgWith({ logSlowRequestMs: -1 }), async (app, sink) => {
await app.inject({ method: "GET", url: "/ok" });
assert.equal(sink.requestLines()[0]!.level, 40); // warn
});
await withServer(cfgWith({ logSlowRequestMs: 60_000 }), async (app, sink) => {
await app.inject({ method: "GET", url: "/ok" });
assert.equal(sink.requestLines()[0]!.level, 30); // info
});
});
it("emits nothing at all when the level is silent", async () => {
await withServer(cfgWith({ logLevel: "silent" }), async (app, sink) => {
await app.inject({ method: "GET", url: "/ok" });
await app.inject({ method: "GET", url: "/boom" });
assert.equal(sink.lines.length, 0);
});
});
it("logs 4xx at warn", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({ method: "GET", url: "/nope" });
const lines = sink.requestLines();
assert.equal(lines.length, 1);
assert.equal(lines[0]!.status, 404);
assert.equal(lines[0]!.level, 40); // warn
});
});
it("treats a redirect as ordinary traffic", async () => {
await withServer(cfgWith(), async (app, sink) => {
await app.inject({ method: "GET", url: "/api/v1/auth/callback" });
assert.equal(sink.requestLines()[0]!.level, 30);
});
});
});
+118
View File
@@ -0,0 +1,118 @@
import { randomUUID } from "node:crypto";
import { LogController } from "fastify";
import type {
FastifyInstance,
FastifyRequest,
FastifyServerOptions,
} from "fastify";
import type { AppConfig } from "./config.js";
import { describeRequest, logPath } from "./request-log.js";
/**
* Silences Fastify's own "incoming request" / "request completed" pair while
* leaving every error path intact.
*
* The obvious `disableRequestLogging: true` cannot be used: it is routed through
* `isLogDisabled`, which `defaultErrorLog`, `streamError`, `writeHeadError` and
* `serializerError` all consult first so it silences framework error logging
* as well, which is the single most valuable thing the logger does. (It is also
* deprecated in Fastify 5 and gone in 6.) Overriding just the two noisy methods
* gets the quiet request log without giving up error reporting.
*/
class QuietRequestLogController extends LogController {
override incomingRequest(): void {}
override requestCompleted(): void {}
}
/**
* Fastify construction options and the request-logging hook.
*
* Lives outside index.ts because index.ts self-invokes `main()` and so cannot
* be imported and because pino throws at construction on a bad level or
* redact path, which makes this the one config in the process where a typo is a
* boot failure. Having it here means a test can build a real instance with it.
*/
export interface ServerOptionsExtras {
/** Test seam: pino destination. Omit in production to write to stdout. */
logStream?: NodeJS.WritableStream;
}
export function buildFastifyOptions(
cfg: AppConfig,
extras: ServerOptionsExtras = {},
): FastifyServerOptions {
return {
// This used to be `cfg.logLevel === "debug"`, which meant the shipped
// default of LOG_LEVEL=info produced `logger: false` — no request logs, no
// latency, and none of Fastify's own error logging. A route that threw left
// nothing behind but a 500 on the wire.
logger: {
level: cfg.logLevel,
serializers: {
/**
* Fastify's built-in `req` serializer emits `url` verbatim query
* string and all and `LogController.defaultErrorLog` logs
* `{ req, res, err }` on every 5xx. That means a 500 on
* `/api/v1/auth/callback?code=…&state=…` would write a live single-use
* OAuth credential to the log, defeating the whole point of logPath().
* Redacting headers does not help: the built-in serializer never emits
* headers in the first place. So replace it and strip the query here.
*/
req: (req: FastifyRequest) => ({
method: req.method,
path: logPath(req.url),
host: req.host,
remoteAddress: req.ip,
}),
},
// Belt and braces: the serializer above emits no headers, but any code
// that logs a header bag explicitly must still not leak credentials.
redact: {
paths: [
"req.headers.cookie",
"req.headers.authorization",
'req.headers["x-api-key"]',
'res.headers["set-cookie"]',
],
remove: true,
},
...(extras.logStream ? { stream: extras.logStream } : {}),
},
// Fastify's own pair of lines per request carries no latency and floods on
// health probes; registerRequestLogging emits one useful line instead.
// Errors still log — see QuietRequestLogController.
logController: new QuietRequestLogController(),
genReqId: () => randomUUID().slice(0, 8),
bodyLimit: 1024 * 1024,
requestTimeout: 60_000,
connectionTimeout: 30_000,
// Do NOT enable trustProxy: origins are reachable directly by IP, so a
// forged X-Forwarded-For would bypass the login and CDN rate limiters.
// clientIp() reads cf-connecting-ip explicitly instead.
};
}
/**
* One line per completed request. Rules (quiet paths, slow-request warnings,
* and stripping credential-bearing query strings) live in request-log.ts.
*/
export function registerRequestLogging(
app: FastifyInstance,
cfg: AppConfig,
): void {
app.addHook("onResponse", async (req, reply) => {
const line = describeRequest({
method: req.method,
url: req.url,
status: reply.statusCode,
elapsedMs: reply.elapsedTime,
cfConnectingIp: req.headers["cf-connecting-ip"] as string | undefined,
socketIp: req.ip,
slowMs: cfg.logSlowRequestMs,
});
if (!line) return;
const { level, ...fields } = line;
req.log[level](fields, "request");
});
}
+124
View File
@@ -0,0 +1,124 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { loadConfig } from "./config.js";
import { planInboundMedia, unreadableMediaReply } from "./inbound-media.js";
/**
* Guards the shipped-off state of optional features.
*
* Image understanding costs tokens on every picture and needs a vision endpoint
* that most deployments do not have, so it must stay off unless an operator
* explicitly turns it on. A test is the only way that stays true a default in
* a 400-line config function is one careless edit away from flipping.
*/
/** Nothing set: exactly what a fresh checkout with no .env gets. */
const bare = () => loadConfig({} as NodeJS.ProcessEnv);
describe("shipped defaults: image understanding is off", () => {
it("visionEnabled is false with no env at all", () => {
assert.equal(bare().visionEnabled, false);
});
it("only the exact string \"true\" enables it", () => {
for (const raw of ["1", "yes", "on", "TRUE", "True", " true", "true "]) {
assert.equal(
loadConfig({ VISION_ENABLED: raw } as NodeJS.ProcessEnv).visionEnabled,
false,
`VISION_ENABLED=${JSON.stringify(raw)} must not enable vision`,
);
}
assert.equal(
loadConfig({ VISION_ENABLED: "true" } as NodeJS.ProcessEnv).visionEnabled,
true,
);
});
it("ships with no vision endpoint or model configured", () => {
const cfg = bare();
assert.equal(cfg.visionModel, "");
assert.equal(cfg.visionBaseUrl, "");
assert.equal(cfg.visionApiKey, "");
});
it("downloads nothing while it is off, whatever arrives", () => {
const refs = [
{ kind: "image" as const, index: 0, encryptQueryParam: "a" },
{ kind: "image" as const, index: 1, encryptQueryParam: "b" },
{ kind: "voice" as const, index: 2, encryptQueryParam: "c" },
{ kind: "video" as const, index: 3, encryptQueryParam: "d" },
{ kind: "file" as const, index: 4, encryptQueryParam: "e" },
];
const cfg = bare();
const plan = planInboundMedia(refs, {
visionEnabled: cfg.visionEnabled,
maxImages: cfg.visionMaxImages,
});
assert.equal(
plan.filter((p) => p.download).length,
0,
"no CDN fetch may happen while vision is off",
);
});
it("answers an image with a canned line, costing no model call", () => {
const reply = unreadableMediaReply([
{ kind: "image", index: 0, encryptQueryParam: "a" },
]);
assert.match(reply, /看不了图片/);
});
it("defaults the mode to caption, so turning it on never needs a vision-capable roleplay model", () => {
// Only matters once someone sets VISION_ENABLED=true, but the safe mode has
// to be the default one — `direct` errors outright on a text-only model.
assert.equal(bare().visionMode, "caption");
});
});
describe("shipped defaults: WeChat's own voice transcript is ON", () => {
it("is enabled with no env at all", () => {
// Deliberately opposite to vision: the transcript arrives inside the inbound
// message, so using it costs nothing and needs no model.
assert.equal(bare().voiceTranscriptEnabled, true);
});
it("only the exact string \"false\" disables it", () => {
for (const raw of ["0", "no", "off", "FALSE", "False", " false"]) {
assert.equal(
loadConfig({ VOICE_TRANSCRIPT_ENABLED: raw } as NodeJS.ProcessEnv)
.voiceTranscriptEnabled,
true,
`VOICE_TRANSCRIPT_ENABLED=${JSON.stringify(raw)} must not disable it`,
);
}
assert.equal(
loadConfig({ VOICE_TRANSCRIPT_ENABLED: "false" } as NodeJS.ProcessEnv)
.voiceTranscriptEnabled,
false,
);
});
it("is independent of the vision switch", () => {
const cfg = bare();
assert.equal(cfg.visionEnabled, false);
assert.equal(cfg.voiceTranscriptEnabled, true);
});
});
describe("shipped defaults: other optional features stay off", () => {
it("proactive outreach is off", () => {
assert.equal(bare().proactiveEnabled, false);
});
it("web search is off", () => {
assert.equal(bare().webSearchEnabled, false);
});
it("the second-pass reply filter is off", () => {
assert.equal(bare().replyFilterEnabled, false);
});
it("unapproved users cannot chat", () => {
assert.equal(bare().allowUnapproved, false);
});
});
+194
View File
@@ -0,0 +1,194 @@
/**
* In-memory static HTML / OG buffers with content ETags.
* Loaded once at boot (after SEO absolute-URL rewrite).
*
* The shells are large (admin.html ~486 KB). Letting @fastify/compress brotli
* them per request burns double-digit milliseconds of event-loop time on every
* page load and stalls concurrent API calls, so compress once here at boot and
* hand out the finished buffer.
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import zlib from "node:zlib";
import { etagFromHash } from "./cache-headers.js";
export type ContentEncoding = "br" | "gzip";
export interface EncodedBody {
encoding: ContentEncoding;
body: Buffer;
etag: string;
}
export interface StaticPage {
body: string | Buffer;
etag: string;
contentType: string;
/** Pre-compressed variants, best-first. Empty when compression is disabled. */
encoded: EncodedBody[];
}
/**
* Best pre-compressed variant the client accepts, or null for the raw body.
* Deliberately simple: no q-value ranking, br preferred over gzip.
*/
export function pickEncoded(
page: StaticPage,
acceptEncoding: string | string[] | undefined,
): EncodedBody | null {
if (!page.encoded.length) return null;
const raw = Array.isArray(acceptEncoding)
? acceptEncoding.join(",")
: acceptEncoding;
if (!raw) return null;
const accepted = raw.toLowerCase();
for (const v of page.encoded) {
if (accepted.includes(v.encoding)) return v;
}
return null;
}
const PRECOMPRESS_ENABLED = process.env.STATIC_PRECOMPRESS !== "false";
function brotliOpts(size: number, quality: number): zlib.BrotliOptions {
return {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: quality,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: size,
},
};
}
/**
* Fast pass so boot stays snappy (~20ms for all shells). `upgradeStaticCompression`
* re-does it at max quality once the server is listening.
*/
function precompress(html: string, baseEtag: string): EncodedBody[] {
if (!PRECOMPRESS_ENABLED) return [];
const buf = Buffer.from(html, "utf8");
const tag = (suffix: string) => `${baseEtag.slice(0, -1)}-${suffix}"`;
return [
{
encoding: "br",
body: zlib.brotliCompressSync(buf, brotliOpts(buf.length, 5)),
etag: tag("br"),
},
{
encoding: "gzip",
body: zlib.gzipSync(buf, { level: 6 }),
etag: tag("gz"),
},
];
}
/**
* Recompress the shells at max quality off the hot path (~30% smaller than the
* boot pass; admin.html 486 KB ~77 KB). Async zlib, so the event loop keeps
* serving. Safe to run late: variants are swapped in atomically per page and
* ETags describe the entity, not the encoding, so cached clients still 304.
*/
export async function upgradeStaticCompression(
assets: LoadedStaticAssets,
): Promise<void> {
if (!PRECOMPRESS_ENABLED) return;
const br = (b: Buffer) =>
new Promise<Buffer>((res, rej) =>
zlib.brotliCompress(b, brotliOpts(b.length, 11), (e, out) =>
e ? rej(e) : res(out),
),
);
const gz = (b: Buffer) =>
new Promise<Buffer>((res, rej) =>
zlib.gzip(b, { level: 9 }, (e, out) => (e ? rej(e) : res(out))),
);
for (const page of assets.pages.values()) {
if (!page.encoded.length) continue;
const buf = Buffer.from(page.body as string, "utf8");
const [brBody, gzBody] = await Promise.all([br(buf), gz(buf)]);
page.encoded = page.encoded.map((v) => ({
...v,
body: v.encoding === "br" ? brBody : gzBody,
}));
}
}
function sha16(data: string | Buffer): string {
return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16);
}
/** Rewrite relative SEO URLs to absolute (crawlers require full og:url / og:image). */
export function withAbsoluteSeo(
html: string,
publicBase: string,
pagePath: string,
): string {
const base = publicBase.replace(/\/$/, "");
const pageUrl = `${base}${pagePath.startsWith("/") ? pagePath : `/${pagePath}`}`;
const imageUrl = `${base}/og.jpg`;
return html
.replace(
/(<meta\s+property="og:image"\s+content=")[^"]*(")/i,
`$1${imageUrl}$2`,
)
.replace(
/(<meta\s+name="twitter:image"\s+content=")[^"]*(")/i,
`$1${imageUrl}$2`,
)
.replace(
/(<meta\s+property="og:url"\s+content=")[^"]*(")/i,
`$1${pageUrl}$2`,
)
.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")/i, `$1${pageUrl}$2`);
}
export interface LoadedStaticAssets {
pages: Map<string, StaticPage>;
og: StaticPage | null;
}
export function loadStaticAssets(
publicDir: string,
publicBase: string,
): LoadedStaticAssets {
const pages = new Map<string, StaticPage>();
const entries: Array<{ file: string; route: string }> = [
{ file: "index.html", route: "/" },
{ file: "app.html", route: "/app" },
{ file: "docs.html", route: "/docs" },
{ file: "admin.html", route: "/admin" },
{ file: "chatflow.html", route: "/chatflow" },
];
for (const { file, route } of entries) {
const p = path.join(publicDir, file);
if (!fs.existsSync(p)) continue;
const raw = fs.readFileSync(p, "utf8");
const html = withAbsoluteSeo(raw, publicBase, route);
const hash = sha16(html);
const etag = etagFromHash(hash);
pages.set(route, {
body: html,
etag,
contentType: "text/html; charset=utf-8",
encoded: precompress(html, etag),
});
}
let og: StaticPage | null = null;
const ogPath = path.join(publicDir, "og.jpg");
if (fs.existsSync(ogPath)) {
const buf = fs.readFileSync(ogPath);
og = {
body: buf,
etag: etagFromHash(sha16(buf)),
contentType: "image/jpeg",
// JPEG is already compressed — never re-encode it
encoded: [],
};
}
return { pages, og };
}
+157
View File
@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { describe, it } from "node:test";
import {
assertSafeStickerImage,
extractTextRuns,
sniffImageMime,
StickerSecurityError,
} from "./sticker-security.js";
/** Minimal 1x1 PNG */
const PNG_1X1 = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
"base64",
);
/** Minimal JPEG SOI+APP0+EOI-ish (not valid full image but magic ok) — use real tiny jpeg */
const JPEG_1X1 = Buffer.from(
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGcP//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEABj8Cf//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAT8hf//Z",
"base64",
);
describe("sticker-security", () => {
it("accepts valid PNG", () => {
const r = assertSafeStickerImage(PNG_1X1, "image/png");
assert.equal(r.mime, "image/png");
});
it("accepts valid JPEG", () => {
const r = assertSafeStickerImage(JPEG_1X1);
assert.equal(r.mime, "image/jpeg");
});
it("rejects SVG", () => {
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
);
assert.equal(sniffImageMime(svg), "image/svg+xml");
assert.throws(
() => assertSafeStickerImage(svg),
(e: unknown) =>
e instanceof StickerSecurityError && e.code === "svg_forbidden",
);
});
it("rejects PNG with script polyglot", () => {
const evil = Buffer.concat([
PNG_1X1,
Buffer.from('<script>alert(1)</script>', "utf8"),
]);
assert.throws(
() => assertSafeStickerImage(evil, "image/png"),
(e: unknown) =>
e instanceof StickerSecurityError &&
(e.code === "script_tag" || e.code === "polyglot_tail"),
);
});
it("rejects mime mismatch", () => {
assert.throws(
() => assertSafeStickerImage(PNG_1X1, "image/jpeg"),
(e: unknown) =>
e instanceof StickerSecurityError && e.code === "mime_mismatch",
);
});
it("rejects empty", () => {
assert.throws(
() => assertSafeStickerImage(Buffer.alloc(0)),
(e: unknown) => e instanceof StickerSecurityError && e.code === "empty",
);
});
it("rejects too large", () => {
assert.throws(
() => assertSafeStickerImage(PNG_1X1, "image/png", { maxBytes: 10 }),
(e: unknown) =>
e instanceof StickerSecurityError && e.code === "too_large",
);
});
});
/**
* Compressed-image-like filler: high entropy, and deliberately free of long
* readable runs (every 8th byte is forced to NUL) so these fixtures are
* deterministic rather than merely usually-passing.
*/
function binaryNoise(len: number, seed = 1): Buffer {
const b = Buffer.alloc(len);
let x = seed >>> 0;
for (let i = 0; i < len; i++) {
x = (x * 1664525 + 1013904223) >>> 0;
b[i] = i % 8 === 0 ? 0x00 : (x >>> 24) & 0xff;
}
return b;
}
function gif(body: Buffer): Buffer {
return Buffer.concat([Buffer.from("GIF89a", "ascii"), body]);
}
describe("sticker-security payload scan", () => {
it("splits readable runs and ignores short ones", () => {
const buf = Buffer.concat([
Buffer.from([0x00, 0xff]),
Buffer.from("short", "ascii"), // below MIN_TEXT_RUN
Buffer.from([0x00]),
Buffer.from("a readable sentence", "ascii"),
Buffer.from([0x80]),
]);
assert.deepEqual(extractTextRuns(buf), ["a readable sentence"]);
});
// Regression: `<%[\s=]` is only 3 bytes, so on compressed image data it
// matched by pure chance — at STICKER_MAX_BYTES that rejected ~2 of every 3
// uploads, worst of all for animated GIFs, the largest sticker format.
it("does not reject a GIF for a 3-byte sequence buried in binary noise", () => {
const body = binaryNoise(64 * 1024);
body.write("<%=", 1001, "ascii"); // not inside any readable run
const r = assertSafeStickerImage(gif(body), "image/gif");
assert.equal(r.mime, "image/gif");
});
it("accepts a maximum-size random GIF", () => {
const r = assertSafeStickerImage(
gif(randomBytes(2 * 1024 * 1024 - 6)),
"image/gif",
);
assert.equal(r.mime, "image/gif");
});
it("still rejects a real JSP payload in a readable run", () => {
const body = Buffer.concat([
binaryNoise(4096),
Buffer.from('<%= request.getParameter("cmd") %>', "ascii"),
binaryNoise(4096, 7),
]);
assert.throws(
() => assertSafeStickerImage(gif(body), "image/gif"),
(e: unknown) =>
e instanceof StickerSecurityError && e.code === "asp_jsp",
);
});
it("still rejects long payloads anywhere in the buffer", () => {
const body = Buffer.concat([
binaryNoise(4096),
Buffer.from("<script>alert(1)</script>", "ascii"),
binaryNoise(4096, 7),
]);
assert.throws(
() => assertSafeStickerImage(gif(body), "image/gif"),
(e: unknown) =>
e instanceof StickerSecurityError && e.code === "script_tag",
);
});
});
+227
View File
@@ -0,0 +1,227 @@
/**
* Lightweight sticker image safety checks (no native deps).
* Blocks SVG, magic/mime mismatch, and common polyglot / script payloads.
* Not a full antivirus complements admin review.
*/
export class StickerSecurityError extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message);
this.name = "StickerSecurityError";
}
}
const ALLOWED = new Set([
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
]);
/**
* Suspicious ASCII patterns often embedded in polyglot / XSS payloads.
*
* `textRunOnly` marks a pattern too short to survive a whole-buffer scan. Image
* payloads are compressed, so their bytes are uniform noise: a 3-byte pattern
* matches somewhere with probability ~1/2^21 per offset, which over a 2 MiB
* upload is an expected hit count of ~1 i.e. it rejected roughly two thirds
* of maximum-size stickers on pure chance. Animated GIFs are the largest
* sticker format, so that read to users as "GIFs are not supported".
*
* Such patterns are matched against readable text runs instead (see
* extractTextRuns): a real injected payload is contiguous source text, random
* noise is not.
*/
const DANGEROUS_PATTERNS: {
re: RegExp;
code: string;
textRunOnly?: boolean;
}[] = [
{ re: /<\s*script\b/i, code: "script_tag" },
{ re: /javascript\s*:/i, code: "javascript_uri" },
{ re: /\bonerror\s*=/i, code: "onerror" },
{ re: /\bonload\s*=/i, code: "onload" },
{ re: /\bonclick\s*=/i, code: "onclick" },
{ re: /<\?php/i, code: "php" },
{ re: /<%[\s=]/, code: "asp_jsp", textRunOnly: true },
{ re: /#!\s*\/(?:usr\/)?bin\//i, code: "shell_shebang" },
{ re: /data\s*:\s*text\/html/i, code: "data_html" },
{ re: /<\s*iframe\b/i, code: "iframe" },
{ re: /<\s*object\b/i, code: "object" },
{ re: /<\s*embed\b/i, code: "embed" },
{ re: /<\s*svg\b/i, code: "svg_tag" },
{ re: /<\s*html\b/i, code: "html_tag" },
{ re: /<\s*body\b/i, code: "body_tag" },
{ re: /eval\s*\(/i, code: "eval" },
{ re: /Function\s*\(/, code: "function_ctor" },
];
/**
* Shortest run of readable bytes still worth scanning.
*
* Every DANGEROUS_PATTERN describes source text, and injected source is always
* longer than this. Random 2 MiB buffers contain a run this long only rarely,
* which is the whole point see DANGEROUS_PATTERNS.
*/
const MIN_TEXT_RUN = 16;
/** Printable ASCII plus tab/CR/LF — what injected source is made of. */
function isTextByte(b: number): boolean {
return b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e);
}
/** Maximal runs of readable bytes at least `minRun` long, as latin1 strings. */
export function extractTextRuns(buf: Buffer, minRun = MIN_TEXT_RUN): string[] {
const runs: string[] = [];
let start = -1;
for (let i = 0; i <= buf.length; i++) {
if (i < buf.length && isTextByte(buf[i]!)) {
if (start < 0) start = i;
continue;
}
if (start >= 0 && i - start >= minRun) {
runs.push(buf.toString("latin1", start, i));
}
start = -1;
}
return runs;
}
export function sniffImageMime(buf: Buffer): string | null {
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
return "image/jpeg";
}
if (
buf.length >= 8 &&
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47
) {
return "image/png";
}
if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF") {
return "image/gif";
}
if (
buf.length >= 12 &&
buf.toString("ascii", 0, 4) === "RIFF" &&
buf.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
// SVG / XML masquerading
const head = buf
.subarray(0, Math.min(256, buf.length))
.toString("utf8")
.trimStart()
.toLowerCase();
if (head.startsWith("<svg") || head.startsWith("<?xml")) {
return "image/svg+xml";
}
return null;
}
function normalizeMime(mime: string | undefined): string | null {
let m = (mime || "").trim().toLowerCase();
if (m === "image/jpg") m = "image/jpeg";
if (!ALLOWED.has(m)) return null;
return m;
}
/**
* Validate sticker image bytes. Throws StickerSecurityError on failure.
* Returns canonical mime from magic bytes.
*/
export function assertSafeStickerImage(
buf: Buffer,
claimedMime?: string,
opts?: { maxBytes?: number },
): { mime: string } {
const maxBytes = opts?.maxBytes ?? 2 * 1024 * 1024;
if (!buf?.length) {
throw new StickerSecurityError("empty image", "empty");
}
if (buf.length > maxBytes) {
throw new StickerSecurityError(
`image too large (max ${maxBytes} bytes)`,
"too_large",
);
}
const sniffed = sniffImageMime(buf);
if (!sniffed) {
throw new StickerSecurityError(
"unrecognized or unsupported image format",
"bad_magic",
);
}
if (sniffed === "image/svg+xml") {
throw new StickerSecurityError("SVG images are not allowed", "svg_forbidden");
}
if (!ALLOWED.has(sniffed)) {
throw new StickerSecurityError(
`mime not allowed: ${sniffed}`,
"mime_forbidden",
);
}
const claimed = normalizeMime(claimedMime);
if (claimed && claimed !== sniffed) {
throw new StickerSecurityError(
`mime mismatch: claimed ${claimed}, actual ${sniffed}`,
"mime_mismatch",
);
}
// Scan full buffer as latin1 so we catch ASCII payloads in binary; patterns
// too short to be meaningful at that scale see readable text runs only.
const text = buf.toString("latin1");
let runs: string[] | null = null;
for (const { re, code, textRunOnly } of DANGEROUS_PATTERNS) {
let hit: boolean;
if (textRunOnly) {
runs ??= extractTextRuns(buf);
hit = runs.some((run) => re.test(run));
} else {
hit = re.test(text);
}
if (hit) {
throw new StickerSecurityError(
`suspicious payload detected (${code})`,
code,
);
}
}
// Polyglot: HTML after image end markers (JPEG EOI / PNG IEND)
if (sniffed === "image/jpeg") {
const eoi = buf.lastIndexOf(Buffer.from([0xff, 0xd9]));
if (eoi >= 0 && eoi < buf.length - 2) {
const tail = buf.subarray(eoi + 2).toString("latin1");
if (/<\s*(?:html|script|svg|iframe|body)\b/i.test(tail)) {
throw new StickerSecurityError(
"trailing HTML after JPEG EOI",
"polyglot_tail",
);
}
}
}
if (sniffed === "image/png") {
const iend = buf.lastIndexOf(Buffer.from("IEND", "ascii"));
if (iend >= 0 && iend + 8 < buf.length) {
const tail = buf.subarray(iend + 8).toString("latin1");
if (/<\s*(?:html|script|svg|iframe|body)\b/i.test(tail)) {
throw new StickerSecurityError(
"trailing HTML after PNG IEND",
"polyglot_tail",
);
}
}
}
return { mime: sniffed };
}
+74
View File
@@ -0,0 +1,74 @@
/** Sticker image helpers (decode / mime). Blobs are stored in Redis, not disk. */
const ALLOWED_MIME = new Set([
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]);
const MIME_EXT: Record<string, string> = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/webp": ".webp",
"image/gif": ".gif",
};
export function normalizeMime(mime: string): string | null {
let m = (mime || "").trim().toLowerCase();
if (m === "image/jpg") m = "image/jpeg";
if (!ALLOWED_MIME.has(m)) return null;
return m;
}
export function isAllowedStickerMime(mime: string): boolean {
return normalizeMime(mime) !== null;
}
export function extForMime(mime: string): string {
const n = normalizeMime(mime) || "image/png";
return MIME_EXT[n] || ".bin";
}
/** Decode data URL or raw base64 into a Buffer. */
export function decodeBase64Image(dataBase64: string): Buffer {
let raw = (dataBase64 || "").trim();
const dataUrl = raw.match(/^data:([^;]+);base64,(.+)$/i);
if (dataUrl) {
raw = dataUrl[2]!;
}
raw = raw.replace(/\s+/g, "");
return Buffer.from(raw, "base64");
}
export function sniffMimeFromBuffer(buf: Buffer): string | null {
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
return "image/jpeg";
}
if (
buf.length >= 8 &&
buf[0] === 0x89 &&
buf[1] === 0x50 &&
buf[2] === 0x4e &&
buf[3] === 0x47
) {
return "image/png";
}
if (buf.length >= 6 && buf.toString("ascii", 0, 3) === "GIF") {
return "image/gif";
}
if (
buf.length >= 12 &&
buf.toString("ascii", 0, 4) === "RIFF" &&
buf.toString("ascii", 8, 12) === "WEBP"
) {
return "image/webp";
}
return null;
}
export function makeStickerFileName(id: string, mime: string): string {
return `${id}${extForMime(mime)}`;
}
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"noUnusedLocals": false,
"noUnusedParameters": false,
"noEmit": true
},
"include": ["src/**/*"]
}
+179
View File
@@ -0,0 +1,179 @@
# Cloudflare Worker 负载均衡(WeChat-AI 多节点)
把**主域名**反代到多台源站 Docker 节点,做健康检查 + 轮询分流。
源站地址只写在本 Worker 的 `ORIGINS` 里;应用内 `PUBLIC_BASE_URL` 一律填**主域名**。管理后台「节点」页显示的是 Redis 注册的进程,**不展示**源站 URL。
## 为什么 API 会慢 3~5 秒?(即使没配测活变量)
旧版 Worker **在每次用户请求里都会 `await` 健康检查**
- 代码默认 `HEALTH_PATH=/health/ready`(会 ping Redis
- 默认超时约 **3s**
- Cloudflare isolate **几乎不跨请求复用内存** → 探活缓存经常是冷的
- 结果:用户 API **先等探活,再访问源站** → 固定多 13s+
**与是否配置 `HEALTH_*` 变量无关**——变量只是覆盖默认值,默认本身就会探活。
其它次要因素:用户 → CF POP → 源站 IP 多一跳 RTT(通常几十~几百 ms,一般到不了 3~5s)。
### 已修复(请重新粘贴最新 `worker.js` 并 Deploy
- 请求路径**不再 await 探活**;用 `waitUntil` 后台探活
- 默认探活改为轻量 **`/health`**
- 默认乐观转发(先当全健康 round-robin
- 仅代理失败 / 502–504 时快速试下一台
部署后自检:`https://主域名/__lb/health` 应含 `"mode":"non_blocking_probe"`
DevTools 看 `/api/v1/...` 的 TTFB 应接近直连源站。
## 架构
```
浏览器 ──► 主域名 (CF Worker)
├─► http://node1:8787
├─► http://node2:8787
└─► …
└── 共享 REDIS_URL
```
## 方式 ACloudflare 控制台(Hello World,推荐上手)
Dashboard **不能**直接上传整个 `src/` 文件夹;「Upload your static files」是静态站,不是 Worker 逻辑。
1. 创建 Worker → **从 Hello World! 开始**
2. 打开在线编辑器,**整份替换**为仓库里的 **`worker.js`**(单文件,已合并 LB 逻辑)
3. **Save and Deploy**
4. Worker → **Settings → Variables and Secrets** 添加:
| 名称 | 类型 | 示例 |
|------|------|------|
| `ORIGINS` | Text | `http://1.2.3.4:8787,http://5.6.7.8:8787` |
| `HEALTH_PATH` | Text(可选) | `/health`(默认;勿用 ready 除非你清楚) |
| `HEALTH_INTERVAL_MS` | Text(可选) | `15000`(后台探活间隔) |
| `HEALTH_TIMEOUT_MS` | Text(可选) | `1500` |
| `HEALTH_ON_REQUEST` | Text(可选) | 默认关;`true` 会恢复慢路径,**勿开** |
| `ORIGIN_HOST_MODE` | Text(可选) | `preserve` |
| `ORIGIN_PROXY_SECRET` | Secret(可选) | 随机长串 |
| `ADSENSE_CLIENT` | Text(可选) | 如 `ca-pub-…`;有值则注入广告脚本 |
| `ADSENSE_ENABLED` | Text(可选) | 默认 `true`;设 `false` 关闭注入 |
| `ADSENSE_SKIP_PATHS` | Text(可选) | 默认 `/admin,/api/,/__lb/,/cdn/,/health` |
| `ADSENSE_ADS_TXT` | Text(可选) | 自定义 `ads.txt` 全文;空则按 CLIENT 自动生成 |
5. **Settings → Domains & Routes / Custom Domains** 绑定主域名
6. 自检:`https://你的主域名/__lb/health`
若编辑器固定文件名 `worker.js`:把本仓库 `cloudflare-worker/worker.js` 内容粘进去即可。
## 方式 BWrangler CLI(多文件 TS
```bash
cd cloudflare-worker
npm install
# 编辑 wrangler.toml [vars] ORIGINS,或:
npx wrangler secret put ORIGIN_PROXY_SECRET # 可选
npx wrangler deploy
```
### 必填
| 变量 | 说明 |
|------|------|
| `ORIGINS` | 逗号分隔源站根地址,如 `http://1.2.3.4:8787,http://5.6.7.8:8787` |
### 可选
| 变量 | 默认 | 说明 |
|------|------|------|
| `HEALTH_PATH` | `/health` | 后台探活路径(轻量;不要用 ready 除非必要) |
| `HEALTH_INTERVAL_MS` | `15000` | 后台探活间隔(不阻塞用户请求) |
| `HEALTH_TIMEOUT_MS` | `1500` | 探活超时 |
| `HEALTH_ON_REQUEST` | 关 | `true` 时每次请求 await 探活(慢,仅调试) |
| `ORIGIN_HOST_MODE` | `preserve` | `preserve` 转发用户 Host`origin` 改写为源站 Host |
| `ORIGIN_PROXY_SECRET` | — | 若设置,注入请求头 `X-WeChat-AI-Proxy-Secret` |
| `ADSENSE_CLIENT` | — | AdSense 发布商 ID`ca-pub-…`);设置后对公开 HTML 注入脚本 |
| `ADSENSE_ENABLED` | `true` | `false` 时关闭注入(即使配置了 CLIENT) |
| `ADSENSE_SKIP_PATHS` | 见上 | 不注入广告的路径前缀(逗号分隔) |
| `ADSENSE_ADS_TXT` | 自动 | 覆盖边缘返回的 `/ads.txt` 内容 |
## Google AdSense(边缘注入)
Worker 在**不改源站 HTML** 的前提下:
1. 对成功的 **`text/html`** 响应,用 `HTMLRewriter``<head>` 末尾注入:
```html
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-…"
crossorigin="anonymous"></script>
```
2. 直接响应 **`/ads.txt`**(不必源站提供),默认内容:
```text
google.com, pub-…, DIRECT, f08c47fec0942fa0
```
3. **默认跳过** `/admin``/api/*``/__lb/*``/cdn/*``/health`(管理后台与 API 不插广告)
4. 注入后的响应带 `X-WeChat-AI-Adsense: 1``GET /__lb/health` 的 JSON 含 `adsense.enabled` / `adsense.client`
控制台粘贴 `worker.js` 时,在 Variables 里加上 `ADSENSE_CLIENT`。关闭:清空 `ADSENSE_CLIENT` 或设 `ADSENSE_ENABLED=false` 后重新 Deploy。
自检:
```bash
# 应看到 adsense.enabled / client
curl -s https://你的主域名/__lb/health
# 应返回 google.com, pub-… 行
curl -s https://你的主域名/ads.txt
# 首页 HTML 的 <head> 应含 pagead2.googlesyndication.com
curl -s https://你的主域名/ | head -n 40
```
说明:这是 **Auto ads 用的全局脚本**;若要用手动广告位,仍需在页面 HTML 里放 `<ins class="adsbygoogle">`(或再扩展 Worker 注入广告位)。
### 本地调试
```bash
# 终端 1/2:两台源站(不同端口,同一 REDIS_URL)
# 终端 3
npx wrangler dev --var ORIGINS:http://127.0.0.1:8787,http://127.0.0.1:8788
```
Worker 自检:`GET /__lb/health``mode: non_blocking_probe`
## DNS
1. 主域名 **Custom Domain** 绑到本 Worker(或 CNAME 到 workers.dev 路由)。
2. 源站 IP **不必** 橙云代理到用户;可仅允许 Cloudflare IP 访问 8787。
3. 应用 `.env`
```env
PUBLIC_BASE_URL=https://你的主域名
LINUXDO_REDIRECT_URI=https://你的主域名/api/v1/auth/callback
COOKIE_SECURE=true
WORKER_ID=node-01 # 每机唯一
```
## 与 Cache Rules
源站仍输出 `Cache-Control` / `Cloudflare-CDN-Cache-Control`。全站经 Worker 时,可继续用 Dashboard Cache Rules 缓存 HTML 壳与 `/cdn/s/*`(见 `docs/cloudflare.md`)。
## 算法摘要
1. 解析 `ORIGINS`
2. 按间隔对 `HEALTH_PATH` 探活,标记 healthy
3. 在 healthy 集合 round-robin;全挂时仍尝试一次
4. 转发 Cookie / Method / Body;附加 `CF-Connecting-IP``X-Forwarded-*`
5. 响应增加 `X-WeChat-AI-LB: 1`(标识经 LB);不向客户端暴露源站主机名
6. 若配置了 AdSense:公开 HTML 注入脚本;`/ads.txt` 由边缘直接返回
## 扩缩容
1. 新机器 `docker run` 同镜像,配同一 `REDIS_URL` / `PUBLIC_BASE_URL`,唯一 `WORKER_ID`
2. 更新 Worker `ORIGINS``wrangler deploy`
3. 管理后台 **节点** 页应出现新 `WORKER_ID` 心跳
下线:从 `ORIGINS` 移除 → deploy;停容器后租约 TTL 过期自动转移 bot。
+14
View File
@@ -0,0 +1,14 @@
{
"name": "wechat-ai-cf-worker",
"private": true,
"version": "0.1.0",
"description": "Cloudflare Worker reverse-proxy load balancer for WeChat-AI multi-node origins",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"devDependencies": {
"wrangler": "^4.20.0"
}
}
+156
View File
@@ -0,0 +1,156 @@
import type { LbEnv } from "./origins";
/** Normalize to `ca-pub-…` or null if unset/invalid. */
export function normalizeAdsenseClient(
raw: string | undefined | null,
): string | null {
const s = (raw || "").trim();
if (!s) return null;
const ca = s.match(/^ca-pub-(\d+)$/i);
if (ca) return `ca-pub-${ca[1]}`;
const pub = s.match(/^pub-(\d+)$/i);
if (pub) return `ca-pub-${pub[1]}`;
if (/^\d+$/.test(s)) return `ca-pub-${s}`;
// Strict: only accept known shapes (avoid injecting arbitrary URLs)
return null;
}
/** Publisher id for ads.txt (`pub-…`). */
export function publisherIdFromClient(client: string): string {
return client.replace(/^ca-/i, "");
}
export function adsTxtFromClient(client: string): string {
const pub = publisherIdFromClient(client);
// Standard AdSense authorization record (CERTIFICATION_AUTHORITY_ID is fixed for Google)
return `google.com, ${pub}, DIRECT, f08c47fec0942fa0\n`;
}
/**
* Paths that should never get ads (app consoles, API, LB internals, CDN, health).
* Comma-separated env `ADSENSE_SKIP_PATHS` overrides the default list.
*
* /app and /chatflow are logged-in consoles with no ad slots injecting the
* loader there only cost a third-party DNS+TLS+~100KB (and a hanging socket
* where googlesyndication is unreachable), and the HTMLRewriter pass strips
* Content-Encoding, defeating the origin's pre-compressed shells.
*/
export function parseSkipPaths(env: LbEnv): string[] {
const raw =
env.ADSENSE_SKIP_PATHS?.trim() ||
"/admin,/app,/chatflow,/api/,/__lb/,/cdn/,/health";
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export function pathSkipped(pathname: string, skips: string[]): boolean {
const path = pathname.toLowerCase() || "/";
for (const skip of skips) {
const sk = skip.toLowerCase();
if (sk.endsWith("/")) {
if (path === sk.slice(0, -1) || path.startsWith(sk)) return true;
} else if (path === sk || path.startsWith(`${sk}/`)) {
return true;
}
}
return false;
}
export function adsenseEnabled(env: LbEnv): boolean {
if (String(env.ADSENSE_ENABLED || "true").toLowerCase() === "false") {
return false;
}
return Boolean(normalizeAdsenseClient(env.ADSENSE_CLIENT));
}
export function shouldInjectAdsense(
pathname: string,
env: LbEnv,
): boolean {
if (!adsenseEnabled(env)) return false;
return !pathSkipped(pathname, parseSkipPaths(env));
}
/**
* Inject AdSense loader into `<head>` of successful HTML responses.
* Strips Content-Length / Content-Encoding because the body changes.
*/
export function injectAdsenseScript(
response: Response,
client: string,
): Response {
const ct = (response.headers.get("content-type") || "").toLowerCase();
if (!ct.includes("text/html")) return response;
if (response.status < 200 || response.status >= 300) return response;
const safe = client.replace(/[^a-zA-Z0-9-]/g, "");
if (!/^ca-pub-\d+$/i.test(safe)) return response;
const snippet =
`\n<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${safe}" crossorigin="anonymous"></script>\n`;
const headers = new Headers(response.headers);
headers.delete("content-length");
headers.delete("content-encoding");
headers.set("X-WeChat-AI-Adsense", "1");
return new HTMLRewriter()
.on("head", {
element(el) {
el.append(snippet, { html: true });
},
})
.transform(
new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
}),
);
}
/** Apply injection when request path + env allow it. */
export function maybeInjectAdsense(
request: Request,
response: Response,
env: LbEnv,
): Response {
if (request.method !== "GET") return response;
const pathname = new URL(request.url).pathname;
if (!shouldInjectAdsense(pathname, env)) return response;
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
if (!client) return response;
return injectAdsenseScript(response, client);
}
/** Serve `/ads.txt` from Worker when AdSense is configured (or custom body). */
export function tryServeAdsTxt(
request: Request,
env: LbEnv,
): Response | null {
const url = new URL(request.url);
if (url.pathname !== "/ads.txt") return null;
if (request.method !== "GET" && request.method !== "HEAD") return null;
const custom = env.ADSENSE_ADS_TXT?.trim();
let body: string | null = null;
if (custom) {
body = custom.endsWith("\n") ? custom : `${custom}\n`;
} else {
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
if (client) body = adsTxtFromClient(client);
}
if (!body) return null;
const headers = {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
"X-WeChat-AI-Adsense": "ads.txt",
};
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers });
}
return new Response(body, { status: 200, headers });
}
+121
View File
@@ -0,0 +1,121 @@
import {
getOrCreateStates,
parseOrigins,
pickCandidates,
nextRoundRobinIndex,
runBackgroundProbes,
probeOrigin,
type LbEnv,
} from "./origins";
import { proxyRequest } from "./proxy";
import {
adsenseEnabled,
maybeInjectAdsense,
normalizeAdsenseClient,
tryServeAdsTxt,
} from "./adsense";
export default {
async fetch(
request: Request,
env: LbEnv,
ctx: { waitUntil: (p: Promise<unknown>) => void },
): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/__lb/health") {
const origins = parseOrigins(env.ORIGINS);
const states = getOrCreateStates(origins);
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
return Response.json({
ok: true,
service: "wechat-ai-lb",
mode: "non_blocking_probe",
adsense: {
enabled: adsenseEnabled(env),
client: client || null,
},
origins: states.map((s) => ({
url: s.url,
healthy: s.healthy,
lastCheck: s.lastCheck,
failCount: s.failCount,
})),
});
}
// Edge-served ads.txt (AdSense site verification / authorization)
const adsTxt = tryServeAdsTxt(request, env);
if (adsTxt) return adsTxt;
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
return new Response("WebSocket not supported", { status: 426 });
}
const origins = parseOrigins(env.ORIGINS);
if (!origins.length) {
return new Response(
JSON.stringify({
error: "ORIGINS not configured",
hint: "Set wrangler vars ORIGINS to comma-separated node base URLs",
}),
{
status: 503,
headers: { "Content-Type": "application/json" },
},
);
}
const states = getOrCreateStates(origins);
// Never block user requests on health probes (default).
if (String(env.HEALTH_ON_REQUEST || "").toLowerCase() === "true") {
const timeout = Math.max(
300,
Number(env.HEALTH_TIMEOUT_MS ?? "1500") || 1500,
);
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
await Promise.all(
states.map((s) => probeOrigin(s, healthPath, timeout)),
);
} else {
ctx.waitUntil(runBackgroundProbes(states, env));
}
const candidates = pickCandidates(states);
const start = nextRoundRobinIndex(candidates.length);
const order =
candidates.length <= 1
? candidates
: candidates.slice(start).concat(candidates.slice(0, start));
let lastErr: unknown;
for (const origin of order) {
try {
const res = await proxyRequest(request, origin.url, env);
if (res.status >= 502 && res.status <= 504 && order.length > 1) {
lastErr = new Error(`upstream ${res.status}`);
origin.failCount += 1;
if (origin.failCount >= 2) origin.healthy = false;
continue;
}
origin.healthy = true;
origin.failCount = 0;
return maybeInjectAdsense(request, res, env);
} catch (err) {
origin.failCount += 1;
if (origin.failCount >= 2) origin.healthy = false;
lastErr = err;
}
}
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
return new Response(
JSON.stringify({ error: "all origins failed", detail: msg }),
{
status: 502,
headers: { "Content-Type": "application/json" },
},
);
},
};
+149
View File
@@ -0,0 +1,149 @@
export interface OriginState {
url: string;
healthy: boolean;
lastCheck: number;
failCount: number;
}
export interface LbEnv {
ORIGINS: string;
HEALTH_PATH?: string;
HEALTH_INTERVAL_MS?: string;
HEALTH_TIMEOUT_MS?: string;
/** If "true", await probes on every request (slow — debug only) */
HEALTH_ON_REQUEST?: string;
ORIGIN_HOST_MODE?: string;
ORIGIN_PROXY_SECRET?: string;
/**
* Google AdSense client id, e.g. `ca-pub-…`.
* When set, Worker injects adsbygoogle.js into public HTML and may serve /ads.txt.
*/
ADSENSE_CLIENT?: string;
/** Set to "false" to disable injection even if ADSENSE_CLIENT is set */
ADSENSE_ENABLED?: string;
/**
* Comma-separated path prefixes that never get ads.
* Default: /admin,/api/,/__lb/,/cdn/,/health
*/
ADSENSE_SKIP_PATHS?: string;
/** Optional full ads.txt body; if empty, generated from ADSENSE_CLIENT */
ADSENSE_ADS_TXT?: string;
}
const states = new Map<string, OriginState>();
let rr = 0;
let bgProbeRunning = false;
export function parseOrigins(raw: string | undefined): string[] {
if (!raw?.trim()) return [];
const out: string[] = [];
for (const part of raw.split(/[,\n]/)) {
let s = part.trim();
if (!s) continue;
s = s.replace(/\/$/, "");
if (!/^https?:\/\//i.test(s)) s = "http://" + s;
try {
out.push(new URL(s).origin);
} catch {
/* skip */
}
}
return [...new Set(out)];
}
export function getOrCreateStates(origins: string[]): OriginState[] {
for (const url of origins) {
if (!states.has(url)) {
states.set(url, {
url,
healthy: true,
lastCheck: 0,
failCount: 0,
});
}
}
for (const key of [...states.keys()]) {
if (!origins.includes(key)) states.delete(key);
}
return origins.map((u) => states.get(u)!);
}
export async function probeOrigin(
state: OriginState,
healthPath: string,
timeoutMs: number,
): Promise<boolean> {
const path = healthPath.startsWith("/") ? healthPath : `/${healthPath}`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${state.url}${path}`, {
method: "GET",
redirect: "manual",
signal: ctrl.signal,
headers: { Accept: "application/json, */*", "Cache-Control": "no-store" },
});
try {
await res.arrayBuffer();
} catch {
/* */
}
const ok = res.status >= 200 && res.status < 400;
state.healthy = ok;
state.failCount = ok ? 0 : state.failCount + 1;
state.lastCheck = Date.now();
return ok;
} catch {
state.healthy = false;
state.failCount += 1;
state.lastCheck = Date.now();
return false;
} finally {
clearTimeout(t);
}
}
/** Background probes only — do not await on request path. */
export async function runBackgroundProbes(
origins: OriginState[],
env: LbEnv,
): Promise<void> {
if (bgProbeRunning || !origins.length) return;
bgProbeRunning = true;
try {
const interval = Math.max(
3000,
Number(env.HEALTH_INTERVAL_MS ?? "15000") || 15_000,
);
const timeout = Math.max(
300,
Number(env.HEALTH_TIMEOUT_MS ?? "1500") || 1500,
);
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
const now = Date.now();
const due = origins.filter((s) => now - s.lastCheck >= interval);
for (let i = 0; i < due.length; i += 4) {
const chunk = due.slice(i, i + 4);
await Promise.all(chunk.map((s) => probeOrigin(s, healthPath, timeout)));
}
} finally {
bgProbeRunning = false;
}
}
export function pickCandidates(origins: OriginState[]): OriginState[] {
const healthy = origins.filter((s) => s.healthy);
return healthy.length ? healthy : origins;
}
export function nextRoundRobinIndex(len: number): number {
if (len <= 1) return 0;
const i = rr % len;
rr = (rr + 1) % len;
return i;
}
export function pickOrigin(candidates: OriginState[]): OriginState {
if (candidates.length === 1) return candidates[0]!;
return candidates[nextRoundRobinIndex(candidates.length)]!;
}
+124
View File
@@ -0,0 +1,124 @@
import type { LbEnv } from "./origins";
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"cf-connecting-ip",
"cf-ray",
"cf-visitor",
"cf-ipcountry",
"cdn-loop",
"x-forwarded-for",
"x-forwarded-proto",
"x-real-ip",
]);
/** Response headers from origin that must not reach clients */
const STRIP_RESPONSE_HEADERS = [
"x-wechat-ai-origin",
"x-railway-edge",
"x-railway-request-id",
"x-railway-cdn-edge",
"x-hikari-trace",
"server-timing",
] as const;
export function buildUpstreamHeaders(
req: Request,
originUrl: string,
env: LbEnv,
clientHost: string,
): Headers {
const out = new Headers();
req.headers.forEach((value, key) => {
const lk = key.toLowerCase();
if (HOP_BY_HOP.has(lk)) return;
if (lk.startsWith("cf-")) return;
out.set(key, value);
});
const mode = (env.ORIGIN_HOST_MODE || "preserve").toLowerCase();
if (mode === "origin") {
try {
out.set("Host", new URL(originUrl).host);
} catch {
/* */
}
} else if (clientHost) {
out.set("Host", clientHost);
}
const clientIp =
req.headers.get("CF-Connecting-IP") ||
req.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
"";
if (clientIp) {
out.set("CF-Connecting-IP", clientIp);
out.set("X-Real-IP", clientIp);
const prior = req.headers.get("X-Forwarded-For");
out.set(
"X-Forwarded-For",
prior ? `${clientIp}, ${prior}` : clientIp,
);
}
const proto =
req.headers.get("X-Forwarded-Proto") ||
(new URL(req.url).protocol === "https:" ? "https" : "http");
out.set("X-Forwarded-Proto", proto);
if (clientHost) out.set("X-Forwarded-Host", clientHost);
if (env.ORIGIN_PROXY_SECRET) {
out.set("X-WeChat-AI-Proxy-Secret", env.ORIGIN_PROXY_SECRET);
}
return out;
}
export async function proxyRequest(
req: Request,
originBase: string,
env: LbEnv,
): Promise<Response> {
const incoming = new URL(req.url);
const target = new URL(
incoming.pathname + incoming.search,
originBase.endsWith("/") ? originBase : originBase + "/",
);
const clientHost = incoming.host;
const headers = buildUpstreamHeaders(req, originBase, env, clientHost);
const init: RequestInit = {
method: req.method,
headers,
redirect: "manual",
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = req.body;
// @ts-expect-error duplex required for streaming body in Workers
init.duplex = "half";
}
const upstream = await fetch(target.toString(), init);
const resHeaders = new Headers(upstream.headers);
resHeaders.set("X-WeChat-AI-LB", "1");
// Strip origin infra / ops headers (do not leak Railway host, edge, etc.)
for (const h of STRIP_RESPONSE_HEADERS) {
resHeaders.delete(h);
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: resHeaders,
});
}
+440
View File
@@ -0,0 +1,440 @@
const HOP_BY_HOP = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"cf-connecting-ip",
"cf-ray",
"cf-visitor",
"cf-ipcountry",
"cdn-loop",
"x-forwarded-for",
"x-forwarded-proto",
"x-real-ip",
]);
/** @type {Map<string, { url: string, healthy: boolean, lastCheck: number, failCount: number }>} */
const states = new Map();
let rr = 0;
let bgProbeRunning = false;
function parseOrigins(raw) {
if (!raw || !String(raw).trim()) return [];
const out = [];
for (const part of String(raw).split(/[,\n]/)) {
let s = part.trim();
if (!s) continue;
s = s.replace(/\/$/, "");
if (!/^https?:\/\//i.test(s)) s = "http://" + s;
try {
out.push(new URL(s).origin);
} catch {
/* skip */
}
}
return [...new Set(out)];
}
function getOrCreateStates(origins) {
for (const url of origins) {
if (!states.has(url)) {
// Start optimistic healthy — never block first request on probe
states.set(url, { url, healthy: true, lastCheck: 0, failCount: 0 });
}
}
for (const key of [...states.keys()]) {
if (!origins.includes(key)) states.delete(key);
}
return origins.map((u) => states.get(u));
}
async function probeOrigin(state, healthPath, timeoutMs) {
const path = healthPath.startsWith("/") ? healthPath : `/${healthPath}`;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(`${state.url}${path}`, {
method: "GET",
redirect: "manual",
signal: ctrl.signal,
headers: { Accept: "application/json, */*", "Cache-Control": "no-store" },
// Avoid CF caching the health fetch oddly
cf: { cacheTtl: 0, cacheEverything: false },
});
const ok = res.status >= 200 && res.status < 400;
// drain body so connection can reuse
try {
await res.arrayBuffer();
} catch {
/* */
}
state.healthy = ok;
state.failCount = ok ? 0 : state.failCount + 1;
state.lastCheck = Date.now();
return ok;
} catch {
state.healthy = false;
state.failCount += 1;
state.lastCheck = Date.now();
return false;
} finally {
clearTimeout(t);
}
}
/**
* Background-only probes. Never call this with await on the request path
* unless HEALTH_ON_REQUEST=true (debug).
*/
async function runBackgroundProbes(origins, env) {
if (bgProbeRunning || !origins.length) return;
bgProbeRunning = true;
try {
const interval = Math.max(
3000,
Number(env.HEALTH_INTERVAL_MS || "15000") || 15000,
);
const timeout = Math.max(
300,
Number(env.HEALTH_TIMEOUT_MS || "1500") || 1500,
);
// Lightweight process check by default (NOT /health/ready + Redis)
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
const now = Date.now();
const due = origins.filter((s) => now - s.lastCheck >= interval);
if (!due.length) return;
// Cap concurrency: probe at most 4 at a time to avoid stampede
for (let i = 0; i < due.length; i += 4) {
const chunk = due.slice(i, i + 4);
await Promise.all(chunk.map((s) => probeOrigin(s, healthPath, timeout)));
}
} finally {
bgProbeRunning = false;
}
}
/** Prefer known-healthy; if none known yet, use all (optimistic). */
function pickCandidates(origins) {
const healthy = origins.filter((s) => s.healthy);
if (healthy.length) return healthy;
// All marked bad — still try them (avoid total outage from stale probes)
return origins;
}
function buildUpstreamHeaders(req, originUrl, env, clientHost) {
const out = new Headers();
req.headers.forEach((value, key) => {
const lk = key.toLowerCase();
if (HOP_BY_HOP.has(lk)) return;
if (lk.startsWith("cf-")) return;
out.set(key, value);
});
const mode = String(env.ORIGIN_HOST_MODE || "preserve").toLowerCase();
if (mode === "origin") {
try {
out.set("Host", new URL(originUrl).host);
} catch {
/* */
}
} else if (clientHost) {
out.set("Host", clientHost);
}
const clientIp =
req.headers.get("CF-Connecting-IP") ||
req.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() ||
"";
if (clientIp) {
out.set("CF-Connecting-IP", clientIp);
out.set("X-Real-IP", clientIp);
const prior = req.headers.get("X-Forwarded-For");
out.set("X-Forwarded-For", prior ? `${clientIp}, ${prior}` : clientIp);
}
const proto =
req.headers.get("X-Forwarded-Proto") ||
(new URL(req.url).protocol === "https:" ? "https" : "http");
out.set("X-Forwarded-Proto", proto);
if (clientHost) out.set("X-Forwarded-Host", clientHost);
if (env.ORIGIN_PROXY_SECRET) {
out.set("X-WeChat-AI-Proxy-Secret", env.ORIGIN_PROXY_SECRET);
}
return out;
}
async function proxyRequest(req, originBase, env) {
const incoming = new URL(req.url);
const target = new URL(
incoming.pathname + incoming.search,
originBase.endsWith("/") ? originBase : originBase + "/",
);
const headers = buildUpstreamHeaders(req, originBase, env, incoming.host);
/** @type {RequestInit} */
const init = {
method: req.method,
headers,
redirect: "manual",
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = req.body;
// @ts-ignore
init.duplex = "half";
}
const upstream = await fetch(target.toString(), init);
const resHeaders = new Headers(upstream.headers);
resHeaders.set("X-WeChat-AI-LB", "1");
// Strip origin infra / ops headers (do not leak Railway host, edge, etc.)
for (const h of [
"x-wechat-ai-origin",
"x-railway-edge",
"x-railway-request-id",
"x-railway-cdn-edge",
"x-hikari-trace",
"server-timing",
]) {
resHeaders.delete(h);
}
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: resHeaders,
});
}
// ── Google AdSense (edge inject + ads.txt) ──────────────────────────
function normalizeAdsenseClient(raw) {
const s = (raw || "").trim();
if (!s) return null;
const ca = s.match(/^ca-pub-(\d+)$/i);
if (ca) return `ca-pub-${ca[1]}`;
const pub = s.match(/^pub-(\d+)$/i);
if (pub) return `ca-pub-${pub[1]}`;
if (/^\d+$/.test(s)) return `ca-pub-${s}`;
return null;
}
function adsTxtFromClient(client) {
const pub = client.replace(/^ca-/i, "");
return `google.com, ${pub}, DIRECT, f08c47fec0942fa0\n`;
}
function parseSkipPaths(env) {
const raw =
(env.ADSENSE_SKIP_PATHS && String(env.ADSENSE_SKIP_PATHS).trim()) ||
"/admin,/api/,/__lb/,/cdn/,/health";
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function pathSkipped(pathname, skips) {
const path = (pathname || "/").toLowerCase();
for (const skip of skips) {
const sk = skip.toLowerCase();
if (sk.endsWith("/")) {
if (path === sk.slice(0, -1) || path.startsWith(sk)) return true;
} else if (path === sk || path.startsWith(sk + "/")) {
return true;
}
}
return false;
}
function adsenseEnabled(env) {
if (String(env.ADSENSE_ENABLED || "true").toLowerCase() === "false") {
return false;
}
return Boolean(normalizeAdsenseClient(env.ADSENSE_CLIENT));
}
function shouldInjectAdsense(pathname, env) {
if (!adsenseEnabled(env)) return false;
return !pathSkipped(pathname, parseSkipPaths(env));
}
function injectAdsenseScript(response, client) {
const ct = (response.headers.get("content-type") || "").toLowerCase();
if (!ct.includes("text/html")) return response;
if (response.status < 200 || response.status >= 300) return response;
const safe = client.replace(/[^a-zA-Z0-9-]/g, "");
if (!/^ca-pub-\d+$/i.test(safe)) return response;
const snippet =
`\n<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${safe}" crossorigin="anonymous"></script>\n`;
const headers = new Headers(response.headers);
headers.delete("content-length");
headers.delete("content-encoding");
headers.set("X-WeChat-AI-Adsense", "1");
return new HTMLRewriter()
.on("head", {
element(el) {
el.append(snippet, { html: true });
},
})
.transform(
new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
}),
);
}
function maybeInjectAdsense(request, response, env) {
if (request.method !== "GET") return response;
const pathname = new URL(request.url).pathname;
if (!shouldInjectAdsense(pathname, env)) return response;
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
if (!client) return response;
return injectAdsenseScript(response, client);
}
function tryServeAdsTxt(request, env) {
const url = new URL(request.url);
if (url.pathname !== "/ads.txt") return null;
if (request.method !== "GET" && request.method !== "HEAD") return null;
const custom = env.ADSENSE_ADS_TXT && String(env.ADSENSE_ADS_TXT).trim();
let body = null;
if (custom) {
body = custom.endsWith("\n") ? custom : custom + "\n";
} else {
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
if (client) body = adsTxtFromClient(client);
}
if (!body) return null;
const headers = {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
"X-WeChat-AI-Adsense": "ads.txt",
};
if (request.method === "HEAD") {
return new Response(null, { status: 200, headers });
}
return new Response(body, { status: 200, headers });
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/__lb/health") {
const origins = parseOrigins(env.ORIGINS);
const list = getOrCreateStates(origins);
const client = normalizeAdsenseClient(env.ADSENSE_CLIENT);
return Response.json({
ok: true,
service: "wechat-ai-lb",
mode: "non_blocking_probe",
adsense: {
enabled: adsenseEnabled(env),
client: client || null,
},
origins: list.map((s) => ({
url: s.url,
healthy: s.healthy,
lastCheck: s.lastCheck,
failCount: s.failCount,
})),
});
}
const adsTxt = tryServeAdsTxt(request, env);
if (adsTxt) return adsTxt;
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
return new Response("WebSocket not supported", { status: 426 });
}
const origins = parseOrigins(env.ORIGINS);
if (!origins.length) {
return new Response(
JSON.stringify({
error: "ORIGINS not configured",
hint: "Settings → Variables → ORIGINS = http://ip1:8787,http://ip2:8787",
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
);
}
const list = getOrCreateStates(origins);
// Optional debug: old blocking path (DO NOT use in production)
if (String(env.HEALTH_ON_REQUEST || "").toLowerCase() === "true") {
const timeout = Math.max(
300,
Number(env.HEALTH_TIMEOUT_MS || "1500") || 1500,
);
const healthPath = (env.HEALTH_PATH || "/health").trim() || "/health";
await Promise.all(
list.map((s) => probeOrigin(s, healthPath, timeout)),
);
} else if (ctx && typeof ctx.waitUntil === "function") {
// Fire-and-forget: never await on request path
ctx.waitUntil(runBackgroundProbes(list, env));
}
const candidates = pickCandidates(list);
// Try primary + failover without long waits (only real proxy errors)
const order = [...candidates];
// rotate start
if (order.length > 1) {
const start = rr % order.length;
rr = (rr + 1) % order.length;
const rotated = order.slice(start).concat(order.slice(0, start));
let lastErr;
for (const origin of rotated) {
try {
const res = await proxyRequest(request, origin.url, env);
// Soft signal: 5xx from origin → try next if multi
if (res.status >= 502 && res.status <= 504 && rotated.length > 1) {
lastErr = new Error(`upstream ${res.status}`);
continue;
}
// mark success
origin.healthy = true;
origin.failCount = 0;
return maybeInjectAdsense(request, res, env);
} catch (err) {
origin.failCount += 1;
if (origin.failCount >= 2) origin.healthy = false;
lastErr = err;
}
}
const msg = lastErr instanceof Error ? lastErr.message : String(lastErr);
return new Response(
JSON.stringify({ error: "all origins failed", detail: msg }),
{ status: 502, headers: { "Content-Type": "application/json" } },
);
}
try {
const res = await proxyRequest(request, order[0].url, env);
return maybeInjectAdsense(request, res, env);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return new Response(
JSON.stringify({ error: "origin failed", detail: msg }),
{ status: 502, headers: { "Content-Type": "application/json" } },
);
}
},
};
+30
View File
@@ -0,0 +1,30 @@
# Cloudflare Worker: WeChat-AI multi-origin load balancer
#
# 1. Set ORIGINS to comma-separated origin base URLs (direct node addresses).
# 2. wrangler deploy
# 3. Bind your main domain (Custom Domain) to this Worker.
#
# Application PUBLIC_BASE_URL must be the main domain, not individual origins.
name = "wechat-ai-lb"
main = "src/index.ts"
compatibility_date = "2025-07-01"
compatibility_flags = ["nodejs_compat"]
[vars]
# Comma-separated origins, e.g. "http://1.2.3.4:8787,http://5.6.7.8:8787"
ORIGINS = ""
# Lightweight process health (background only — does not block user requests)
HEALTH_PATH = "/health"
HEALTH_INTERVAL_MS = "15000"
HEALTH_TIMEOUT_MS = "1500"
# preserve = forward client Host (recommended when PUBLIC_BASE_URL is main domain)
# origin = rewrite Host to origin hostname
ORIGIN_HOST_MODE = "preserve"
# Google AdSense: inject adsbygoogle.js into public HTML + serve /ads.txt
# Clear ADSENSE_CLIENT or set ADSENSE_ENABLED=false to disable
ADSENSE_CLIENT = ""
ADSENSE_ENABLED = "false"
# 登录态控制台(/app /chatflow /admin)不投广告:无广告位,且注入会去掉
# Content-Encoding,让源站启动时预压缩的 shell 失效
ADSENSE_SKIP_PATHS = "/admin,/app,/chatflow,/api/,/__lb/,/cdn/,/health"
+87
View File
@@ -0,0 +1,87 @@
# WeChat-AI
#
# 1. 配置 .envREDIS_URL / LINUXDO_* / LLM_* / PUBLIC_BASE_URL
# 用户自定义 LLM + 联网搜索:TOOLS_BASE_URL / TOOLS_API_KEY → wechat-ai-tools
# 2. pnpm docker:up
# 升版 + 本地 OTA pack + compose up --build
# 通道发布:/admin → 上传 files.json(无需 CLI Cookie
# (直接 docker compose up -d --build 不会升版本、不会 pack
# 3. 打开 https://你的域名/app
#
# 注意:容器内 WECHAT_AI_HOST 强制 0.0.0.0OAuth 回调请用公网域名。
#
# 多节点:本 compose 默认单副本。多机时在各服务器分别 docker run 同镜像,
# 共享 REDIS_URL / PUBLIC_BASE_URL(主域名),每机设唯一 WORKER_ID,
# 主域名反代用 cloudflare-worker(静态 ORIGINS)。见 docs/docker.md。
#
# 工具网关(可选 profile tools):
# docker compose --profile tools up -d --build
# 主站 .env: TOOLS_BASE_URL=http://wechat-ai-tools:7860 TOOLS_API_KEY=...
services:
wechat-ai:
build:
context: .
dockerfile: Dockerfile
image: wechat-ai:latest
container_name: wechat-ai
restart: unless-stopped
# Give the graceful shutdown time to release bot leases before SIGKILL
stop_grace_period: 30s
ports:
- "${WECHAT_AI_PORT:-8787}:8787"
env_file:
- .env
environment:
WECHAT_AI_HOST: "0.0.0.0"
WECHAT_AI_PORT: "8787"
NODE_ENV: production
# 单机默认;多节点请在各机覆盖为唯一 ID
# WORKER_ID: node-01
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8787/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 8s
retries: 3
start_period: 50s
# User custom LLM egress + web search (platform LLM stays on wechat-ai)
wechat-ai-tools:
profiles: ["tools"]
build:
context: ./huggingface/wechat-ai-tools
dockerfile: Dockerfile
image: wechat-ai-tools:latest
container_name: wechat-ai-tools
restart: unless-stopped
ports:
- "${TOOLS_PORT:-7860}:7860"
environment:
HOST: "0.0.0.0"
PORT: "7860"
TOOLS_API_KEY: "${TOOLS_API_KEY:-}"
ALLOW_REQUEST_UPSTREAM: "true"
UPSTREAM_DENY_PRIVATE: "true"
SEARCH_PROVIDER: "${SEARCH_PROVIDER:-ddg}"
# Optional demo fallback only (main site uses its own LLM_* for platform)
UPSTREAM_LLM_BASE_URL: "${UPSTREAM_LLM_BASE_URL:-}"
UPSTREAM_LLM_API_KEY: "${UPSTREAM_LLM_API_KEY:-}"
UPSTREAM_LLM_MODEL: "${UPSTREAM_LLM_MODEL:-gpt-4o-mini}"
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=5)",
]
interval: 30s
timeout: 8s
retries: 3
start_period: 25s
+70
View File
@@ -0,0 +1,70 @@
# 验收状态
**日期**2026-07-19
**结论**:**系统已完成并可验收**(离线/自动化门禁 + 文档化真机清单)
## 已交付范围(v1
| 能力 | 实现 |
|------|------|
| 直连 iLink(无 OpenClaw | `packages/ilink` |
| 多用户隔离会话/记忆 | `packages/core` + DB |
| 人设模板与发布 | seed 猫娘/女友 + Admin/API |
| 仅后台分配角色 | `/角色` 拒绝自助 |
| OpenAI 兼容 LLM | `packages/llm` |
| 多 Bot 登录 | `pnpm ilink:login -- --name …` |
| Admin Web | `/admin` |
| 限流 / typing / 非文字提示 | worker |
| 语音转写文本(有则用) | `extractText` |
| 运维 | `pnpm diag``docs/runbook.md` |
| E2E 清单 | `docs/e2e-checklist.md` |
## 自动化门禁
```bash
pnpm accept
```
包含:全仓单元测试、migrate/seed、diag、关键文件存在性。
## 真机项(人工)
`docs/e2e-checklist.md` 章节 C–E(需扫码 + 真实 LLM Key)。
## 非目标 / 可选后续
- ~~图片/文件 CDN 加解密全链路 / 入站 Vision~~**入站图片已实现**CDN 下载 + AES-128-ECB 解密 + 视觉模型,`VISION_ENABLED`,默认关)。仍未做:入站语音 ASR(微信 SILK/AMR 无 OpenAI 兼容端点可用,现依赖 iLink 自带转写)、入站文件解析
- Embedding 记忆检索(无 Embedding API;已用文本 top-k 替代)
- 多模型路由
- 联网搜索工具
- React 工程化 Admin
- Windows 服务安装包
- 固定时段问候(可叠在主动联系调度器上)
## 已补充(2026-07-20
- 无向量记忆 top-k`MEMORY_TOP_K` / `MEMORY_FULL_INJECT_MAX`+ 条数上限/去重
- 记忆治理:单条删除 + 用户中心「记忆」面板
- `get_current_time` 工具(`TIME_TOOL_ENABLED`,默认开)
## 主动找用户(空闲)
- 全局 `PROACTIVE_ENABLED` + Bot 开关 + peer「允许主动」
- LLM 生成;可 skip;见 `docs/runbook.md` §2.1
## AI 网关 + Chatflow2026-07-26
| 能力 | 实现 |
|------|------|
| 唯一外网 AI 出口 | `huggingface/wechat-ai-tools`FastAPI + Dockerfile |
| 用户自定义 OpenAI 兼容 API | 加密存储;**仅** HF 代发,主站不 dial 用户 base_url |
| 联网搜索 | 仅 HF `/v1/web-search`;全局 + 人设双开关 |
| 「我的模型」UI | `/app` → 我的模型(增删改 / 启停 / 掩码 Key) |
| Chatflow 引擎 | `packages/core/src/chatflow/*`;节点 start/llm/answer/if-else/http/memory/search |
| Chatflow 编辑器 | `/chatflow`(拖拽 + 连线 + 属性 + 保存启用) |
| 试聊支持 chatflow | 强制平台上游,不消耗作者额度 |
| 就绪探针 | `/health/ready` 纳入 tools 健康(缓存 15s |
约束:chatflow 不做主动联系;Fork 不继承作者密钥;http 节点默认仅工具域。
文档:`docs/ai-gateway.md``docs/chatflow.md`;验收项见 `docs/e2e-checklist.md` §I / §J。
+280
View File
@@ -0,0 +1,280 @@
# API 概览(Redis + LINUX DO OAuth
Base: `http://127.0.0.1:8787`
Auth: **Cookie 会话**OAuth 登录后 `wa_session`),`credentials: include`
## 认证
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/auth/config` | `{ oauthEnabled, provider, localAuthEnabled, inviteRequiredForLocal, passwordMinLength }` |
| GET | `/api/v1/auth/login` | 跳转 LINUX DO OAuth |
| GET | `/api/v1/auth/callback` | OAuth 回调(新 OAuth 用户**不需要**邀请码) |
| POST | `/api/v1/auth/register` | 本地注册:`{ inviteCode, username, password, name? }` → cookie |
| POST | `/api/v1/auth/password-login` | 用户名密码登录:`{ username, password }` → cookie |
| GET | `/api/v1/auth/invite/:code` | 预检邀请码(不消费) |
| POST | `/api/v1/auth/logout` | 退出 |
| GET | `/api/v1/auth/me` | 当前用户(含 `authProvider``isSuperAdmin`,不含密码) |
## 邀请(登录用户)
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/me/invites` | pending 列表 + 配额 + 邀请链接 |
| POST | `/api/v1/me/invites` | 生成一次性码;受「每 X 小时 N 个」滑动窗口限制 |
| DELETE | `/api/v1/me/invites/:code` | 撤销未使用码 |
邀请链接形态:`{PUBLIC_BASE_URL}/app?invite={CODE}`(打开后自动填入注册表单)。
## 用户
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/me/bots` | 我的机器人 |
| POST | `/api/v1/me/bots/login/start` | 扫码添加机器人(新建 botId) |
| POST | `/api/v1/me/bots/:botId/relogin/start` | **重新扫码绑定**(更新 token,保留 peers/记忆/分配) |
| GET | `/api/v1/me/bots/login/:sessionId` | 扫码状态 |
| DELETE | `/api/v1/me/bots/:botId` | 删除自己的机器人(含 Redis token |
| GET | `/api/v1/me/peers` | 私聊用户 |
| POST | `/api/v1/me/peers/approve` | 批准 |
| PUT | `/api/v1/me/assignments` | 分配人设(须在库中) |
| GET | `/api/v1/me/personas` | 我的库 + 我创建的 |
| POST | `/api/v1/me/personas/:id/add` | 添加人设到库 |
| DELETE | `/api/v1/me/personas/:id` | 从库移除 |
| GET | `/api/v1/me/memories?botAccountId=&peerId=&personaId?` | 长期记忆;无 personaId 时返回 `{total,groups}` |
| POST | `/api/v1/me/memories/reset` | `{ botAccountId, peerId, personaId? }` 清空记忆 |
| DELETE | `/api/v1/me/memories` | `{ botAccountId, peerId, personaId, memoryId }` 删单条 |
| GET | `/api/v1/me/wechat-bind` | 当前 LINUX DO ↔ 微信绑定状态(`reachable` 表示是否有 context_token |
| POST | `/api/v1/me/wechat-bind/code` | 生成 6 位绑定码(微信 `/绑定 CODE` |
| DELETE | `/api/v1/me/wechat-bind` | 解除绑定并结束相关用户对话 |
| GET | `/api/v1/me/blocks` | 我的黑名单(LINUX DO 用户) |
| POST | `/api/v1/me/blocks` | `{ username }``{ userId }` 拉黑 |
| DELETE | `/api/v1/me/blocks/:userId` | 取消拉黑 |
## 人设广场
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/square/personas?q=&page=&limit=&sort=heat\|use\|recent\|name` | 搜索公开人设(默认 sort=heat;字段含 useCount / assignCount / forkCount / heatScore / forkedFrom |
| GET | `/api/v1/square/personas/:id` | 详情(含 systemPrompt |
| POST | `/api/v1/square/personas` | 发布(public/private |
| POST | `/api/v1/square/personas/:id/fork` | Fork 为当前用户私有草稿(`PERSONA_FORK_ENABLED`,默认开) |
| PUT | `/api/v1/square/personas/:id` | 作者更新 |
| DELETE | `/api/v1/square/personas/:id` | 作者软删除 |
| GET | `/api/v1/square/stickers?q=&page=&limit=&sort=use\|recent\|name` | 已审核公开表情包(默认 sort=use) |
| GET | `/api/v1/square/stickers/:id` | 详情(`imageUrl` 对公开已审为 CDN 路径) |
| GET | `/api/v1/square/stickers/:id/image` | 预览图(**鉴权**;私有/待审/作者预览) |
| GET | `/cdn/s/:id?v={content_hash}` | **公开 CDN**(无 Cookie):仅 `public`+`approved`+`enabled`;长缓存 immutable |
| POST | `/api/v1/square/stickers` | 投稿(public→待审 / private 自用) |
| PUT | `/api/v1/square/stickers/:id` | 作者更新(公开改动回待审) |
| DELETE | `/api/v1/square/stickers/:id` | 作者软删除 |
| GET | `/api/v1/me/stickers` | 我的库 + 我创建的 |
| POST | `/api/v1/me/stickers/:id/add` | 加入表情库 |
| DELETE | `/api/v1/me/stickers/:id` | 从库移除(对称递减 use_count) |
## 网页试聊
不经微信,在 `/app` 内限量体验公开/可用人设。计入用户 Token 用量;会话存 Redis TTL。
| Method | Path | 说明 |
|--------|------|------|
| POST | `/api/v1/try-chat/sessions` | `{ personaId, botName? }``{ sessionId, persona, remainingToday, expiresInSec }` |
| POST | `/api/v1/try-chat/sessions/:sessionId/messages` | `{ text }``{ messages[], remainingToday, remainingSession, usage }` |
| DELETE | `/api/v1/try-chat/sessions/:sessionId` | 结束会话 |
环境变量:`TRY_CHAT_ENABLED``TRY_CHAT_MAX_USER_MSGS_PER_DAY``TRY_CHAT_MAX_USER_MSGS_PER_SESSION``TRY_CHAT_SESSION_TTL_SEC``TRY_CHAT_MAX_HISTORY`
热度公式:`heatScore = use_count*2 + assign_count*5 + fork_count*3`(分配仅在 persona 变化时 +1)。
## 管理员
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/admin/dashboard` | 仪表盘 + 今日 Token + **全舰队** workerStats + nodes 摘要 + Redis |
| GET | `/api/v1/admin/system` | 系统健康 / doctor / **全舰队** workerStats / nodes 摘要 |
| GET | `/api/v1/admin/nodes` | **仅超管** 部署节点列表(含 fenced |
| POST | `/api/v1/admin/nodes/:workerId/force-offline` | **仅超管** 强制下线 |
| POST | `/api/v1/admin/nodes/:workerId/clear-fence` | **仅超管** 解除封锁 |
| POST | `/api/v1/admin/nodes/:workerId/weight` | **仅超管** 设负载权重 `{ weight: 0..500 }` |
| DELETE | `/api/v1/admin/nodes/:workerId/weight` | **仅超管** 恢复默认权重(100% |
| POST | `/api/v1/admin/nodes/weights/prune` | **仅超管** 立即清理失效权重(平时会自动过期) |
| POST | `/api/v1/admin/workers/restart-all` | **仅超管** 批量恢复 pollable 并尝试认领(有 token 的 active bot |
| POST | `/api/v1/admin/workers/stop-all` | **仅超管** 暂停全部可轮询 bot(写 pause 标记,不删账号) |
| POST | `/api/v1/admin/system/seed-personas` | 幂等补种官方人设 |
| GET | `/api/v1/admin/memories?botAccountId=&peerId=` | 查看 peer 长期记忆(按人设分组) |
| DELETE | `/api/v1/admin/memories` | `{ botAccountId, peerId, personaId, memoryId }` 删单条 |
| POST | `/api/v1/admin/memories/reset` | `{ botAccountId, peerId, personaId? }` 清记忆 |
| POST | `/api/v1/admin/messages/clear` | `{ botAccountId, peerId }` 清除短期对话历史(保留长期记忆) |
| GET | `/api/v1/admin/peers?status=unapproved\|approved\|all` | 全站私聊 peer(默认待批准) |
| POST | `/api/v1/admin/peers/approve` | `{ botAccountId, peerId }` 管理员代批 |
| POST | `/api/v1/admin/peers/approve-all` | 批准全部待批准 peer |
| GET | `/api/v1/admin/usage?day=` | 按日用量 |
| GET | `/api/v1/admin/usage?days=7` | 近 N 日用量列表 |
| GET | `/api/v1/admin/users` | 用户全量列表(含 botCount、isBanned、authProvider、isSuperAdmin |
| GET | `/api/v1/admin/users/:id` | 用户详情 + 名下机器人 |
| PATCH | `/api/v1/admin/users/:id` | **仅超管** `{ isAdmin }` 授予/撤销管理员(不可撤自己/最后一位;**不可撤超管**) |
| POST | `/api/v1/admin/users/:id/ban` | `{ reason?, cascadeBots? }` 封禁(踢 session;默认级联停用 bot;**不可封超管**) |
| POST | `/api/v1/admin/users/:id/unban` | 解封(不自动启 bot |
| DELETE | `/api/v1/admin/users/:id?confirm=username` | 删除用户(级联删 bot/session/索引;**不可删超管** |
> **超管**:仍为管理员的用户中 `created_at` 最早者(系统首位管理员)。对超管的撤销管理 / 封禁 / 删除一律拒绝(`cannot_revoke_super_admin` / `cannot_ban_super_admin` / `cannot_delete_super_admin`)。
| GET | `/api/v1/admin/settings/invites` | 邀请策略(配额窗口小时 / 每窗口上限 / TTL / pending |
| PATCH | `/api/v1/admin/settings/invites` | 更新邀请策略(Redis 覆盖 env 默认) |
| GET | `/api/v1/admin/settings/runtime` | **仅超管** 运行时配置:分组 + 全部项(当前值 / env 默认 / 是否已覆盖 / 是否需重启)+ 警告 |
| PATCH | `/api/v1/admin/settings/runtime` | **仅超管** `{ patch: {key: value}, reset: [key] }` 写 Redis 覆盖 |
| POST | `/api/v1/admin/settings/runtime/reset` | **仅超管** 删除全部覆盖,回到 `.env` |
> 运行时配置详见 [`docs/runtime-settings.md`](./runtime-settings.md)`.env` 是默认值,Redis 存覆盖,各节点 5 秒内同步。密钥字段 GET 只返回掩码;PATCH 传空 = 不改,传 `-` = 清空。
| GET | `/api/v1/admin/bots` | 全部机器人(owner、worker、hasToken、peer 计数;前端分页,后端批量读) |
| GET | `/api/v1/admin/bots/:botId` | 机器人详情 + peers |
| PATCH | `/api/v1/admin/bots/:botId` | 改名 / `{ status: active\|inactive }` 启停 |
| POST | `/api/v1/admin/bots/:botId/stop-worker` | **仅超管** 停止 Worker 轮询 |
| POST | `/api/v1/admin/bots/:botId/start-worker` | **仅超管** 启动/重启 Worker(需已有 Redis token |
| DELETE | `/api/v1/admin/bots/:botId` | 删除机器人 |
| GET | `/api/v1/admin/bots/:botId/send-targets` | **仅超管** 该 bot 的 peers + `hasContextToken`(广播勾选) |
| POST | `/api/v1/admin/broadcast` | **仅超管** 创建广播任务或 `preview:true` 仅预估人数 |
| GET | `/api/v1/admin/broadcast?limit=` | **仅超管** 最近广播任务列表 |
| GET | `/api/v1/admin/broadcast/:id` | **仅超管** 任务详情与进度 |
| POST | `/api/v1/admin/broadcast/:id/cancel` | **仅超管** 取消 pending/running 任务 |
| GET | `/api/v1/admin/personas?q=&includeDisabled=1` | 人设列表 |
| GET | `/api/v1/admin/personas/:id` | 详情(含 prompt |
| POST | `/api/v1/admin/personas` | 创建官方人设(可带 tags / isDefault |
| PUT | `/api/v1/admin/personas/:id` | 更新元信息 / prompt |
| POST | `/api/v1/admin/personas/:id/publish` | 发布新版本 prompt |
| POST | `/api/v1/admin/personas/:id/takedown` | 下架非官方人设 |
| POST | `/api/v1/admin/personas/:id/restore` | 恢复已下架 |
| POST | `/api/v1/admin/personas/:id/set-default` | 设为默认人设 |
| GET | `/api/v1/admin/audit?limit=` | 审计 |
| GET | `/api/v1/admin/stream/recent?limit=&types=&full=` | **仅超管** 活动数据流 backlog(消息 / Worker / LLM`full=1` 消息预览加长;`types=message,redis,worker,llm` |
| GET | `/api/v1/admin/stream?types=&full=&heartbeat=` | **仅超管** SSE 实时数据流(`text/event-stream`;Redis 命令为进程内抽样,不写 Redis backlog |
| GET | `/api/v1/admin/stickers?q=&enabled=` | 表情包列表 |
| GET | `/api/v1/admin/stickers/:id` | 表情详情 |
| GET | `/api/v1/admin/stickers/:id/image` | 预览原图(admin cookie |
| POST | `/api/v1/admin/stickers` | 上传:`{ slug, displayName, description?, tags?, mime?, dataBase64, enabled? }` |
| PUT | `/api/v1/admin/stickers/:id` | 更新元信息 / 可选换图 |
| DELETE | `/api/v1/admin/stickers/:id` | 删除 meta + 本地文件 |
页面:`/` 功能介绍 · `/app` 用户中心 · `/admin` 管理后台 · `/og.jpg` 社交分享图
### 表情包广场 / 回图
- **元数据 + 图片二进制均在 Redis**`wa:sticker:{id}` / `wa:sticker:{id}:blob`)。
- Meta 含 **`content_hash`**blob sha256 前缀);换图会更新 hashCDN 用 `?v=` 缓存破坏。
- **公开已审图**可走无登录 `GET /cdn/s/:id?v=hash``public, max-age=31536000, immutable`),供 Cloudflare 边缘缓存。
- **公开投稿必须管理员审核**`pending``approve` 后进广场与 CDN);私有仅作者 bot 可用。
- 上传强制安全扫描:禁 SVG、magic/mime 校验、脚本/PHP/polyglot 尾部检测(非杀软)。
- 运行时按 **机器人主人** 的表情库 + 自建可用表情注入 LLM;`{"messages":["文字",{"type":"sticker","slug":"..."}]}`
- Admin`GET /admin/stickers?status=pending``POST .../approve|reject|takedown|restore`
- 环境变量:`STICKER_SEND_ENABLED``MAX_STICKERS_PER_REPLY``STICKER_MAX_BYTES`(默认 2MB)。
- Cloudflare 部署:见 `docs/cloudflare.md`
### Worker 相关配置(与 API 同进程 / 单镜像)
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `WORKER_ENABLED` | `true` | 是否在本进程跑 iLink 轮询与回复 |
| `MAX_BOTS_PER_WORKER` | `500` | 本进程最多同时 long-poll 的 bot 数 |
| `REPLY_CONCURRENCY` | `16` | 进程内 LLM/发送并发 |
| `INBOX_MAX_LEN` | `20000` | 入站队列深度上限 |
### 管理后台广播(纯文本推送)
- **仅纯文本**;不经 LLM、不写短期对话 / 长期记忆。
- **必须有 `context_token`**(peer 曾给该 bot 发过消息);无 token 的目标在展开时跳过。
- **不按批准过滤**:未批准但可触达的 peer 也会进入队列。
- 异步任务:`POST` 只写 Redis jobWorker 进程内 `BroadcastRunner` 限速发送(`BROADCAST_INTERVAL_MS`,默认 200ms;生产可调到 50100ms 加速,注意 iLink 限流)。
- `scope`
- `all_bots` — 全部机器人下可触达 peer
- `bots``botIds[]` 指定机器人
- `targets` — 显式 `{ botId, peerId }[]`
- `POST` body 带 `preview: true` 时只返回 `{ deliverable, skippedNoToken, missingBots }`,不创建任务。
- 需 `WORKER_ENABLED=true`(默认)才会实际发送;否则任务保持 `pending`
- 环境变量:`BROADCAST_INTERVAL_MS``BROADCAST_MAX_TEXT`(默认 2000)、`BROADCAST_HISTORY`(默认 100)。
### `workerStats` 字段(dashboard / system
**全舰队**租约汇总(`scope: "fleet"`):
| 字段 | 说明 |
|------|------|
| `scope` | `fleet`(管理页)或 `local` |
| `leasedLocal` / `leasedFleet` | 全舰队正在 poll 的 bot 数(fleet 模式下两者相同) |
| `maxBots` | 全舰队容量合计(各节点 maxBots 之和) |
| `pollable` | 应被轮询的 botactive + token + 未 pause |
| `nodesOnline` / `nodesTotal` | 在线 / 注册部署节点数 |
| `atCapacity` | 任一点触顶或舰队已满 |
| `inboxDepth` 等 | **当前应答节点本机** inbox/任务计数(非全舰队加总) |
### 超管(super admin
`created_at` 最早的 `is_admin` 用户。节点管理 API 与后台「节点」页仅超管可用;普通管理员仍可看 Workers 全舰队 bot 列表。
### `nodes` / `GET /admin/nodes`fleet
Redis 心跳注册的**部署进程**(多机时每台一条),**不包含**源站公网 URL(用户统一走主域名;源站 IP 只在 CF Worker `ORIGINS`)。
| 字段 | 说明 |
|------|------|
| `id` | `WORKER_ID` |
| `hostname` / `pid` | 主机与进程 |
| `botCount` / `maxBots` / `leasedCount` / `leasedBotIds` | 容量与租约 |
| `label` / `region` / `version` | 可选运维标签;`version` 为进程 appVersion |
| `online` / `isSelf` | 心跳是否新鲜;是否为当前应答节点 |
| `fenced` / `fenceReason` / `fencedAt` / `fencedBy` | 管理员强制下线封锁(进程可仍在跑 HTTP,但不 poll) |
| `weight` / `weightOverride` / `weightUpdatedAt` / `weightBy` | 负载权重(百分比,默认 100)与是否管理员覆盖 |
| `weightLastSeenAt` | 舰队最后一次确认该节点存活的时间(自动过期计时起点) |
| `targetShare` | 按权重应分配的 bot 数(受 `maxBots` 约束);离线/封锁节点为 `null` |
| `update` | OTA`outdated` / `desiredVersion` / `status` / `progress` / `error` |
| `startedAt` / `updatedAt` | 启动与心跳时间 |
响应另含 `release`(通道当前版本摘要)、`appVersion``otaEnabled``weights``weightTotal`(在线节点权重和)、`weightLimits``weightTtlSec`(权重自动清理宽限期)、`pollableTotal``rebalanceEnabled``rebalanceIntervalSec`
**强制下线说明:** 不停止 Docker/OS 进程;目标 `WORKER_ID` 在 fence 清除前不会 re-register / claim。租约由其他节点接管。从 CF Worker `ORIGINS` 移除源站 IP 是流量侧下线,与本 API 独立。
### 节点负载权重(Worker 负载调节,仅超管)
后台「节点」页 → **负载权重**列 → 滑杆 / 预设 / 手填百分比,保存前可预览各节点调整后的目标 bot 数。
- **相对值**:在线节点按各自权重占比分摊 `wa:bots:pollable`。A=200%、B=100% → 2:1;两个节点都是 200% 与都是 100% 等价。
- **范围** 0500,默认 100。写入 100 等同删除覆盖(`wa:workers:weights` 只保存真正调过的节点)。
- **0% = 腾空**:节点保持在线心跳、继续处理 HTTP,但不再认领 bot,并会把已持有的租约全部释放(此时忽略 `REBALANCE_SLACK`,能真正归零)。与「强制下线」不同:不写 fence,改回非 0 即刻恢复。
- **上限仍然生效**:实际数量取 `min(权重份额, MAX_BOTS_PER_WORKER)`。权重只在容量内重新分配,不能突破单进程上限。
- **单节点例外**:只有一个在线节点时它拿全部(权重是节点之间的比例)。全舰队都设 0% 时退化为均分,避免所有 bot 停止轮询。
- **生效时机**:写入后 publish `wa:worker:wake`,各节点丢弃权重缓存;认领在下一个 `LEASE_RENEW_SEC` 生效,多余租约按 `REBALANCE_INTERVAL_SEC` / `REBALANCE_MAX_PER_TICK` 逐步释放。`REBALANCE_ENABLED=false` 时已认领的租约不迁移,权重只影响后续认领(后台会提示)。
#### 存储与自动清理
存储在 Redis,不进 env,节点重启不丢失。两个 hash 分开写,互不干扰:
| Key | 内容 | 写入方 |
|-----|------|--------|
| `wa:workers:weights` | `workerId` → JSON `{ percent, updatedAt, byUserId, byUsername }` | 仅管理员操作 |
| `wa:workers:weights:seen` | `workerId` → 最后一次确认存活的 ISO 时间 | 仅 GC |
> 拆成两个 hash 是必需的:GC 若为了盖时间戳而回写整条权重记录,会把管理员同一时刻的改动覆盖掉(read-modify-write 丢失更新)。
**节点消失后权重会自动删除**,无需人工干预:
- 判活以心跳 meta`wa:worker:<id>`TTL `WORKER_STALE_SEC`)为准,不看时间戳——所有版本的进程都会写 meta,因此 OTA 混版滚动期间旧版本节点不会被误判为消失。
- 任一在线节点每 5 分钟扫一次(`HSET`/`HDEL` 幂等,多节点并发无害),给还活着的节点刷新时间戳,给已消失且超过宽限期的删除记录,并顺带清掉没有对应权重的孤儿时间戳。权重 hash 为空时完全不产生 Redis 调用。
- 宽限期 `WORKER_WEIGHT_TTL_SEC`(默认 3600 秒,下限 60 秒)必须长于一次重启 / OTA 应用,否则每次发版都会重置调节。
- **已封锁(force-offline)节点永不过期**:封锁是临时的,解除后权重仍在。
- 「立即清理权重」按钮跳过宽限期,立刻删除所有无心跳且未封锁节点的权重(`POST /admin/nodes/weights/prune`)。
> **注意**:未在环境变量固定 `WORKER_ID` 时,进程每次重启都会生成新的随机 ID(`w_<host>_<pid>_<rand>`),权重不会跟随到新 ID——这也正是必须自动清理的原因,否则 hash 会随重启次数无限增长。需要权重长期生效,请为每个节点固定 `WORKER_ID`
### OTA 发布与节点更新(仅超管)
| Method | Path | 说明 |
|--------|------|------|
| GET | `/api/v1/admin/releases/current` | 通道当前 release |
| GET | `/api/v1/admin/releases` | 最近版本列表 |
| POST | `/api/v1/admin/releases` | `mode=blob\|publish\|pack` 上传/注册 |
| POST | `/api/v1/admin/releases/current` | `{ version }` 切换通道版本 |
| POST | `/api/v1/admin/nodes/:workerId/update` | 对该节点下发更新 job |
| POST | `/api/v1/admin/nodes/update-outdated` | 批量更新落后在线节点 |
| GET | `/api/v1/admin/nodes/:workerId/update-status` | 单节点进度 |
CLI 打包:`pnpm release:pack` / `pnpm docker:build`(见 `docs/docker.md`)。
发布通道:`/admin` → 部署节点 → **上传通道包**`files.json`,浏览器超管会话)→ 节点「更新」。
另有公开探活:`GET /health`(进程)、`GET /health/ready`(含 Redis,供 LB)。
+32
View File
@@ -0,0 +1,32 @@
# ADR-0001: 直连 iLink,不依赖 OpenClaw Gateway
## Status
Accepted (2026-07-19)
## Context
产品需要接入微信 ClawBot / OpenClaw 生态下的官方通道,并支持多人角色扮演。
曾考虑以 OpenClaw Gateway + `@tencent-weixin/openclaw-weixin` 为运行时。
## Decision
**本系统直接调用腾讯 iLink Bot HTTP API**`https://ilinkai.weixin.qq.com`):
- 扫码登录拿 `bot_token`
- `getupdates` 长轮询收消息
- `sendmessage` 回复(必带 `context_token`
会话、人设、记忆、LLM 均在本仓库实现。OpenClaw **不是运行时依赖**
## Consequences
- 运维更轻:只需本服务 + LLM Key
- 需自行维护 iLink 适配与协议变更
- 多人隔离用 DB 行级 `(bot_account_id, peer_id[, persona_id])` 即可
- 协议细节以 PR0 实测与官方插件行为为准;社区文档可能滞后
## References
- OpenClaw WeChat channel docs (plugin path)
- Community iLink protocol notes / `@tencent-weixin/openclaw-weixin` behavior
+59
View File
@@ -0,0 +1,59 @@
# AI 网关:平台 LLM vs 用户自定义 / 搜索
## 原则
| 流量 | 出站位置 |
|------|----------|
| **管理员配置的平台 LLM**`LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` | **主站直连** |
| **用户自定义 OpenAI 兼容 API** | **仅** `huggingface/wechat-ai-tools`HF / Docker |
| **联网搜索** | **仅** tools 服务 |
主站 Node 进程**不会** `fetch(用户的 base_url)`,也不会直连搜索引擎。
```
主站 ──平台 LLM──► 管理员配置的上游
└──TOOLS_BASE_URL──► wechat-ai-tools
├── /v1/chat/completions + body.upstream ──► 用户 API
└── /v1/web-search ──► DDG / SearXNG / …
```
## 主站环境变量
```env
# 平台(管理员)
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o-mini
# 工具网关
TOOLS_BASE_URL=http://127.0.0.1:7860 # 或 https://xxx.hf.space
TOOLS_API_KEY=shared-secret
WEB_SEARCH_ENABLED=true
LLM_PROVIDER_SECRET=... # 加密用户保存的 API Key
```
## 打包 tools 镜像
```bash
# 在 tools 目录
docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
# 或 compose profile
docker compose --profile tools up -d --build
# 主站 .env 使用:TOOLS_BASE_URL=http://wechat-ai-tools:7860
```
详见 `huggingface/wechat-ai-tools/README.md`
## Chatflow
编排图中的 `llm` / `search` / `http` 同样遵守上表:用户自定义与搜索只经 tools;`http` 节点默认仅 tools host。见 `docs/chatflow.md`
## 诊断
```bash
pnpm diag
```
会检查平台 `LLM_API_KEY`,以及(若配置了)`TOOLS_BASE_URL/health`
+134
View File
@@ -0,0 +1,134 @@
# Chatflow
可视化对话编排(MVP)。人设可在 **prompt****chatflow** 两种模式间切换。
## 入口
- 用户中心「我的人设」:运行模式选 **Chatflow 流程**
- 编辑器:`/chatflow?persona=<id>`
- API
- `GET /api/v1/square/personas/:id/graph`
- `PUT /api/v1/square/personas/:id/graph`(保存图并切换为 chatflow
## 节点
| 类型 | 说明 | 出站 |
|------|------|------|
| `start` | 唯一入口 | 本地 |
| `llm` | 调用模型 | 平台 LLM 直连,或用户自定义 → **仅** HF tools `/v1/chat/completions` |
| `search` | 联网搜索 | **仅** HF tools `/v1/web-search` |
| `http` | HTTP 请求 | **仅** `TOOLS` 主机 + `CHATFLOW_HTTP_ALLOWLIST``*` = 任意公网,见下)|
| `memory` | 注入本轮已选记忆 | 本地 |
| `if-else` | 条件分支(true/false 句柄) | 本地 |
| `answer` | 最终回复模板 | 本地 |
默认图:`start → llm → answer`
## 模板变量
节点文案支持 `{{query}}``{{system_prompt}}``{{history}}``{{memories}}``{{bot_name}}``{{llm_text}}``{{节点id.text}}` 等。
## 试聊
网页试聊支持 chatflow 人设:执行同一张已发布的图,但 **强制平台上游**`upstream: null`),
避免消耗或泄露作者的自定义模型额度。
## 限制(MVP
- Chatflow 人设 **禁用主动联系**`skipReason: chatflow_no_proactive`
- Fork 复制 graph/mode/web_search**不复制** `llm_provider_id`
- 试聊不注入长期记忆(`memories` 为空)与表情目录
- LLM 节点的 `temperature` 暂用客户端默认值
- 完整 Dify(知识库、代码节点等)不做
## 环境变量
```env
CHATFLOW_HTTP_ALLOWLIST= # 额外允许的 http 节点 host`*` = 任意公网
CHATFLOW_MAX_STEPS=32
CHATFLOW_MAX_NODES=40
CHATFLOW_HTTP_TIMEOUT_MS=15000 # 单个 http 节点的墙钟上限
TOOLS_BASE_URL=... # 搜索 / 用户自定义 LLM 必需
TOOLS_API_KEY=...
WEB_SEARCH_ENABLED=true # 全局搜索开关;人设还需 webSearchEnabled
WEB_SEARCH_MAX_RESULTS=5 # 默认条数;search 节点可用 max_results 覆盖
```
以上都是**默认值**`/admin` → 设置 → 「Chatflow」「联网搜索与工具网关」可直接改,
覆盖存 Redis,各节点 5 秒内生效,无需重启。详见 `docs/runtime-settings.md`
## http 节点的出站边界
图是**人设作者**写的(`PUT .../graph` 仅 owner`requireUser` 之外无审核),
响应正文还会回灌进 `vars`(截断 50KB)供 `answer` 引用。也就是说 http 节点等于把一次
**可读的**服务端请求交到普通注册用户手里,所以出站要按白名单管。
两个放大器值得单独记住:
- URL 是**模板**`renderTemplate(node.data.url, vars)`,而 `vars.query` 就是原始
用户消息(`engine.ts:127-133`)、`vars.llm_text` 是模型输出。作者写成
`https://{{query}}/` 就等于把 host 的选择权交给**任何发消息的人**;写成引用上游
`llm` 节点的变量,就等于交给可被 prompt 注入的模型输出。所以校验必须发生在
**渲染之后**(现实现即如此),作者时校验 URL 是没用的。
- 触发不需要微信:网页试聊走同一张图,人设可以一直是 private,作者从不出现在广场
或任何审核面上。
`CHATFLOW_HTTP_ALLOWLIST` 的两种写法:
| 值 | 含义 |
|----|------|
| 空 | http 节点只能打 `TOOLS_BASE_URL` 的 host:port。没配 TOOLS 就完全不可用 |
| `a.com,b.com` | 精确匹配 hostname**不支持通配**`a.com` 不覆盖 `api.a.com` |
| `*` | 任意**公网**地址 |
`*` 不是「不做检查」。放行前仍然逐项拦掉(`packages/core/src/chatflow/http-guard.ts`):
**第一道:地址段**(写死的 IP 字面量)
- 回环 `127.0.0.0/8``::1``localhost`
- 私有段 `10/8``172.16/12``192.168/16`
- 链路本地 `169.254/16` —— 含 AWS/Azure IMDS `169.254.169.254`
- 运营商级 NAT `100.64/10` —— 含阿里云元数据 `100.100.100.200`
- IPv6 `fc00::/7`、**整个 `fe00::/8`**(不只 `fe80::/10`,站点本地 `fec0::/10` 同样在内)、
`ff00::/8`,以及 `::ffff:``64:ff9b::` 里嵌的 IPv4
- IPv6 兜底:**不在全球单播 `2000::/3` 内的一律拒**,免得再漏某个保留前缀
- `0/8``192.0/16``198.18/15`、多播与保留段
判断走**地址段**而不是字面量,因为 `new URL()` 会把 `2130706433``0x7f.0.0.1`
`0177.0.0.1``::ffff:127.0.0.1` 归一成别的写法 —— 只比字符串是拦不住的。
**第二道:主机名**
- 单标签主机名(docker service 名、`metadata``instance-data`
- `.local` `.localhost` `.internal` `.svc` `.lan` `.intranet` `.corp` `.private` `.home.arpa`
- 长得像公网、其实是云元数据的名字:`metadata.tencentyun.com`(腾讯云 CVM,直接给
角色凭证,无 token 步骤)、`metadata.goog`
**第三道:解析结果**
只看字面量挡不住 `169-254-169-254.nip.io` 这种通配 DNS(一个普通 `.io` 域名,解析到
IMDS 地址,不需要攻击者自建任何东西),也挡不住攻击者把自家 A 记录指向 `10.0.0.5`
或 docker 内嵌 DNS 把 `service.network` 解析成网桥地址。所以放行前会 `dns.lookup`
一次,**任一**返回地址落在上面的段里就拒。
解析失败**不**算拦截:解析不出来的名字 fetch 也出不去,没有任何流量发生,不该把一次
DNS 抖动升级成 `http_blocked` 把整个流程打断。
**第四道:重定向与凭证**
- **每一跳重定向都重新过一遍全部检查**`redirect: "manual"`,最多 3 跳)。否则一个允许的
公网域名只要回一个 `302 → http://169.254.169.254/`,前面三道全白做。
- `Authorization` 跨 origin 会被摘掉;`TOOLS_API_KEY` 只发给 `TOOLS_BASE_URL`
**host:port** —— 同一台机器换个端口都不给,`TOOLS_BASE_URL` 指向 loopback 时尤其重要。
### 已知边界
**DNS rebinding 仍有窗口。** 这里解析一次、`fetch` 自己再解析一次,控制着权威 DNS 且
TTL 压到极短的攻击者可以在两次之间翻记录。要堵死得把 socket 钉在**已经校验过的那个
地址**上,即 undici `Agent``connect.lookup` —— 需要把 undici 收成直接依赖,当前没做。
现在挡住的是所有直接写内网地址、以及所有**静态**解析到内网的名字。
**白名单是全站共享的**:加进去的 host 等于允许所有人的人设去请求它,别放带鉴权的内部接口。
联网搜索是**两道闸的与**:全局开关 + 人设自身的「联网」开关(用户中心「我的人设」里勾选)。
详见 `docs/ai-gateway.md`
+195
View File
@@ -0,0 +1,195 @@
# Cloudflare Business 缓存适配
本项目源站(Fastify Docker)已输出 **Cloudflare 友好** 的缓存头。你只需在 Cloudflare Dashboard 配好 **Cache Rules** 与区域级开关,即可最大化边缘命中。
相关代码:
| 能力 | 位置 |
|------|------|
| HTML / OG 内存缓冲 + ETag | `apps/api/src/static-pages.ts`, `index.ts` |
| 统一 Cache-Control | `apps/api/src/cache-headers.ts` |
| 公开表情 CDN | `GET /cdn/s/:id?v={content_hash}` |
| 默认 API `private, no-store` | `apps/api/src/routes.ts` `onSend` |
| CORS 白名单 | `CORS_ORIGINS` + `PUBLIC_BASE_URL` |
**本阶段不上 R2**;公开表情仍由源站 Redis blob 提供,靠边缘长缓存降压。
---
## 1. 源站响应策略(已实现)
| 路径 | Cache-Control | Cloudflare-CDN-Cache-Control | 鉴权 |
|------|---------------|------------------------------|------|
| `/` `/docs` | `public, max-age=300` | `max-age=3600, swr=86400` | 无 |
| `/app` `/admin` | `public, max-age=60` | `max-age=3600, swr=86400` | 无(壳静态;数据走 API |
| `/og.jpg` | `public, max-age=86400, immutable` | `max-age=604800` | 无 |
| `/cdn/s/:id?v=` | `public, max-age=31536000, immutable` | 同左 | **无**;仅 public+approved+enabled |
| `/api/v1/auth/config` | `public, max-age=60` | `max-age=300` | 无 |
| `/health` | `no-store` | — | 无 |
| 其余 `/api/v1/**` | `private, no-store` | — | Cookie 会话 |
| 鉴权表情图 | `private, no-store` | — | 登录 / admin |
HTML 带 `ETag` + `Cache-Tag: html-shell`;公开表情带 `Cache-Tag: sticker-{id}`(便于定向 Purge)。
---
## 2. DNS / SSL
1. 域名 A/AAAA/CNAME 橙云代理到源站
2. SSL/TLS → **Full (strict)**
- 源站用有效证书,或 [Cloudflare Origin CA](https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/)
3. Always Use HTTPS = On
4. 源站 `.env`
- `PUBLIC_BASE_URL=https://你的域名`
- `COOKIE_SECURE=true`
- `LINUXDO_REDIRECT_URI=https://你的域名/api/v1/auth/callback`
- 可选 `CORS_ORIGINS=https://你的域名`(默认已含 PUBLIC_BASE_URL origin
---
## 3. Cache Rules(按顺序创建,先匹配先生效)
路径:**Caching → Cache Rules**Business 推荐用 Cache Rules,而不是旧 Page Rules)。
### Rule 1 — Bypass 动态 API
- **Name:** `bypass-api-private`
- **When:**
`(starts_with(http.request.uri.path, "/api/v1/") and not http.request.uri.path eq "/api/v1/auth/config")`
或方法为 `POST` / `PUT` / `PATCH` / `DELETE`
- **Then:** Cache eligibility = **Bypass cache**
### Rule 2 — HTML 壳 Cache Everything + 忽略 Cookie
- **Name:** `cache-html-shells`
- **When:**
`http.request.uri.path in {"/" "/app" "/docs" "/admin"}`
- **Then:**
- Eligible for cache
- Edge TTL: **Respect origin**(或 Override 1 hour
- Browser TTL: Respect origin
- **Cache key → Ignore query string**(可选)
- **Cookie handling → Ignore presence of cookies****关键**:否则带 `wa_session` 永远 MISS
### Rule 3 — OG 图
- **Name:** `cache-og`
- **When:** `http.request.uri.path eq "/og.jpg"`
- **Then:** Eligible for cacheEdge TTL Respect origin 或 7d**Ignore cookies**
### Rule 4 — 公开表情 CDN
- **Name:** `cache-cdn-stickers`
- **When:** `starts_with(http.request.uri.path, "/cdn/s/")`
- **Then:** Eligible for cacheEdge TTL Respect origin**Ignore cookies**
- Cache key **保留 query string**`v=` 内容哈希)
### Rule 5 — auth/config 短缓存
- **Name:** `cache-auth-config`
- **When:** `http.request.uri.path eq "/api/v1/auth/config"`
- **Then:** Eligible for cacheEdge TTL 5 minutes 或 Respect originIgnore cookies
### 默认
未匹配规则时:尊重源站 `Cache-Control`;无 `public` 的不进共享缓存。
---
## 4. 区域级推荐(Business
| 设置 | 建议 |
|------|------|
| HTTP/3 (QUIC) | On |
| Brotli | On |
| Tiered Cache | On |
| Early Hints | 可选(当前 HTML 内联,收益有限) |
| Auto Minify | **Off**(避免改 HTML 导致 ETag 与部署不一致) |
| Rocket Loader | **Off** |
| Polish | 可选;若开 WebP,注意 `Accept` 变体,或只对 `/cdn/s/*` 试 |
| Mirage | 可选(移动列表) |
| Cache Reserve | 可选(长尾表情) |
| Argo Smart Routing | 源站距用户远时可选 |
| WAF Managed Rules | On |
| Rate limiting | 建议:`/api/v1/auth/*`、try-chat、上传 POST |
| Bot Fight | 按需;勿误伤 OAuth 回调 |
---
## 5. 部署后刷新缓存
| 变更 | 做法 |
|------|------|
| 新版本 HTMLapp/admin/docs/landing | Purge by URL`/` `/app` `/docs` `/admin`,或 Purge by Tag`html-shell` |
| 换图表情 | **无需 purge**`?v={content_hash}` 自动新键 |
| 下架 / 拒绝公开表情 | 源站 404;边缘可能仍 HIT 至 TTL → Purge URL 或 Tag `sticker-{id}` |
| 紧急全站 | Purge Everything(会短暂升高源站压力) |
API Purge 示例(可选):
```bash
# 需要 Zone ID + API Token (Cache Purge 权限)
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{"files":["https://你的域名/","https://你的域名/app","https://你的域名/docs","https://你的域名/admin"]}'
```
---
## 6. 验收 curl
```bash
HOST=https://你的域名
# HTML:第二次应 CF-Cache-Status: HIT(即使带假 cookie
curl -sI "$HOST/" | grep -iE 'cf-cache-status|cache-control|etag|content-encoding'
curl -sI "$HOST/app" -H "Cookie: wa_session=fake" | grep -i cf-cache-status
# 公开表情
curl -sI "$HOST/cdn/s/$STICKER_ID?v=$HASH" | grep -iE 'cf-cache-status|cache-control'
curl -sI "$HOST/cdn/s/$STICKER_ID?v=$HASH" | grep -i cf-cache-status # HIT
# 私有 API
curl -sI "$HOST/api/v1/auth/me" -H "Cookie: wa_session=..." | grep -iE 'cache-control|cf-cache-status'
# 期望: private, no-storeDYNAMIC 或 BYPASS
# 压缩
curl -sI -H 'Accept-Encoding: br' "$HOST/app" | grep -i content-encoding
# 私有 / 未审表情不得公开
curl -sI "$HOST/cdn/s/$PRIVATE_ID" # 404
```
Dashboard → Caching → Cache Analytics 可看 HIT 率。
---
## 7. 反代注意
- 优先 **CF 橙云 → 源站 443/8787**,中间少一层
- **多节点负载均衡**:使用仓库内 **`cloudflare-worker/`**(静态 `ORIGINS` + 健康检查 + 轮询),主域名绑 Worker;详见 `cloudflare-worker/README.md`
- 若 Nginx 仅 TLS 终结再反代 Node
- 转发 `Host``X-Forwarded-Proto``CF-Connecting-IP`
- **不要**强行剥掉 `Accept-Encoding` 导致双重压缩混乱
- 源站健康检查:`GET /health`(进程)或 `GET /health/ready`(含 Redis,推荐给 LB
- 应用侧 `PUBLIC_BASE_URL` 始终为主域名;源站 IP 不必暴露在后台 UI
---
## 8. 安全边界
- `/cdn/s/*` **仅** `visibility=public``review_status=approved``enabled`
- 私有 / pending / rejected → 统一 404
- 用户 JSON、admin、try-chat、OAuth callback **永不**边缘共享缓存
- 广场列表含 `inLibrary` 个性化,**不**做公共 CDN 缓存(升级路径:拆无个性化的 public catalog API
---
## 9. 可选升级路径(未实现)
1. 公开表情同步 **R2** + 自定义域,源站只写 meta
2. 拆 `GET /api/v1/public/square/*``inLibrary` 的目录接口短缓存
3. 内联 CSS/JS 拆出带 content-hash 的静态文件
4. Cache Reserve + Image Resizing
详见主 README / 本文件与 `docs/docker.md` 交叉引用。
+244
View File
@@ -0,0 +1,244 @@
# Docker 部署
## 前置条件
| 依赖 | 说明 |
|------|------|
| Docker + Compose | 本机或服务器已安装 |
| Upstash Redis | `.env``REDIS_URL=rediss://...` |
| LINUX DO OAuth | Client ID/Secret + **公网回调地址** |
| 平台 LLM(主站直连) | `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` |
| 工具网关(用户自定义 API + 搜索) | `TOOLS_BASE_URL` / `TOOLS_API_KEY`;镜像见 `huggingface/wechat-ai-tools` |
## 快速启动
```bash
cd /path/to/WeChat-AI
# 配置环境变量(勿提交 .env
# 生产务必修改:
# PUBLIC_BASE_URL=https://你的域名
# LINUXDO_REDIRECT_URI=https://你的域名/api/v1/auth/callback
# REDIS_URL / LLM_* / LINUXDO_CLIENT_*
# 用户自定义 API + 搜索:TOOLS_BASE_URL / TOOLS_API_KEY
# docker compose --profile tools up -d --build
# TOOLS_BASE_URL=http://wechat-ai-tools:7860
# 推荐:升版 + 打 OTA 包 + 构建(无需 Cookie
pnpm docker:up
# 自定义镜像名:
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
# 包在 dist/release/<版本>/files.json
# 浏览器登录 /admin → 部署节点 →「上传通道包」→ 再点节点「更新」
docker compose logs -f wechat-ai
```
### 单独构建 tools 镜像
```bash
docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
docker run --rm -p 7860:7860 -e TOOLS_API_KEY=secret -e ALLOW_REQUEST_UPSTREAM=true wechat-ai-tools:latest
```
详见 `docs/ai-gateway.md``huggingface/wechat-ai-tools/README.md`
> **版本与通道:** `pnpm docker:build` 默认:升版 → **本地 pack** → Docker。
> 通道发布只走网页:`/admin` → 上传 `files.json`(无 CLI Cookie)。
> 直接跑 `docker build` **不会**升版、也**不会**打通道包。
| 地址 | 说明 |
|------|------|
| `/` | 功能介绍落地页(OG 分享图 `/og.jpg` |
| `/app` | 用户中心(LINUX DO 登录、加机器人) |
| `/admin` | 管理后台(仪表盘 / Token) |
| `/health` | 健康检查 |
## 仅用 Dockerfile
```bash
# 升版 + docker build -t wechat-ai .
pnpm docker:build -- --raw
# 或自定义:node scripts/docker-build.mjs -- docker build -t wechat-ai:0.2.1 .
docker run -d --name wechat-ai --restart unless-stopped \
--env-file .env \
-e WECHAT_AI_HOST=0.0.0.0 \
-e WECHAT_AI_PORT=8787 \
-p 8787:8787 \
wechat-ai
```
Bot token 与表情包均存 **Redis**(与 `REDIS_URL` 同库),容器重建不丢;无需本地数据卷。
## 生产环境检查清单
1. **LINUX DO** 应用回调与 `.env` 完全一致:
`https://你的域名/api/v1/auth/callback`
2. **`PUBLIC_BASE_URL`** = `https://你的域名`(无尾斜杠)
3. **HTTPS** 时设置 `COOKIE_SECURE=true`
4. **Redis** 使用 Upstash `rediss://`,服务器能访问外网
5. Bot **token 已写入 Redis**(与 `REDIS_URL` 同库),重建容器不会丢登录
## 常用命令
```bash
docker compose ps
docker compose logs -f
docker compose restart
docker compose down # 停服务(Bot token 在 Redis,不受影响)
docker compose down -v # 同 down(本服务无持久化 volume
pnpm docker:up # 升版+本地 pack+compose up --build
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
# 然后 /admin → 上传通道包 (files.json) → 更新节点
pnpm docker:build -- --no-channel # 只升版构建,不 pack
```
## OTA 增量更新(多节点日常热修)
业务源码小改可不必每台 `docker build`:本地 pack → 管理后台上传通道包 → 对落后节点点「更新」。
```bash
# 构建顺带打通道包
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
# 或仅 pack
pnpm release:pack
# 浏览器 /admin → 部署节点 →「上传通道包」选 dist/release/<ver>/files.json
# →「更新全部落后」
```
| 项 | 说明 |
|----|------|
| 差量 | 按文件 sha256 比对,只下发变更文件 |
| 重启 | 节点 `process.exit(0)`,依赖 `restart: unless-stopped` 拉起**同一容器**(可写层保留补丁) |
| 版本 | 心跳 `version``.wa-version`OTA 写入)→ `APP_VERSION` → 根 `package.json` |
| 仍需镜像 | Node 基础镜像、系统包、Dockerfile、`OTA_ALLOW_INSTALL=false` 时的依赖大变 |
环境变量:`OTA_ENABLED`(默认 true)、`OTA_ALLOW_INSTALL``APP_VERSION`(无 OTA 戳时可选)、`OTA_STAGING_DIR`
**注意:** OTA 只改文件、不改环境变量;版本靠 `/app/.wa-version` 上报,无需、也不应靠 `APP_VERSION` 跟版。
`docker compose up --build` / 重建容器会丢掉仅靠 OTA 写入的补丁;长期仍以镜像为 source of truth。
## 反代示例
生产推荐把域名挂在 **Cloudflare**(橙云代理 + Cache Rules),见 **`docs/cloudflare.md`**Business 缓存规则、忽略 Cookie、Purge 清单)。
### Caddy(无 CF 时)
```caddy
your.domain.com {
reverse_proxy 127.0.0.1:8787
}
```
### Nginx(无 CF 时,或 CF → Nginx → Node
```nginx
server {
listen 443 ssl;
server_name your.domain.com;
# ssl_certificate ...;
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cloudflare 还原真实 IP 时可用:
# proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
}
}
```
源站已输出 `Cache-Control` / `Cloudflare-CDN-Cache-Control`、HTML ETag、公开表情 `/cdn/s/:id?v=`。反代层**不必**再写 `proxy_cache`,除非你不用 Cloudflare。
## 镜像说明
- 基础镜像:`node:22-bookworm-slim`
- 包管理:pnpm monorepo
- 启动:`pnpm db:seed`(幂等)→ `pnpm --filter @wechat-ai/api start`**API + Worker 同进程**
- 非 root 用户 `appuser` 运行
- 健康检查:`GET /health`
## Worker 规模(单镜像)
默认仍是 **一个容器跑全部**HTTP + iLink 长轮询 + AI 回复。
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `MAX_BOTS_PER_WORKER` | `500` | 本进程最多同时 long-poll 的 bot 数 |
| `REPLY_CONCURRENCY` | `16` | 同时进行的 LLM/发送任务数 |
| `LEASE_TTL_SEC` | `45` | 租约 TTL(同镜像多副本时防重复 poll) |
机器人很多时优先调高 `MAX_BOTS_PER_WORKER` 与系统 `ulimit -n`(注意内存与出站连接数)。
默认 **单副本一体部署** 即可;同镜像多副本已支持(Redis 租约分片 poll)。
## 多节点同构部署(10+ 台)
每台服务器跑**同一镜像**(API + Worker),共用一个 Upstash Redis;用户只访问**主域名**。
### 应用 env(全站一致)
```env
PUBLIC_BASE_URL=https://你的主域名
LINUXDO_REDIRECT_URI=https://你的主域名/api/v1/auth/callback
REDIS_URL=rediss://...
COOKIE_SECURE=true
WORKER_ENABLED=true
```
### 每节点不同
```env
WORKER_ID=node-01 # 必填且唯一
NODE_LABEL=cn-east-1a # 可选,管理后台展示
NODE_REGION=cn-east # 可选
```
**不要**给每台设不同的 `PUBLIC_BASE_URL`。源站直连地址(IP:8787)只写在 Cloudflare Worker 的 `ORIGINS`,见 `cloudflare-worker/README.md`
### 部署步骤摘要
1. 各机:`docker run ... --env-file .env -e WORKER_ID=node-0N -p 8787:8787 wechat-ai`
2. 配置并部署 `cloudflare-worker``ORIGINS=http://ip1:8787,http://ip2:8787,...`
3. 主域名绑到 Worker
4. 打开 `/admin`**节点**:应看到各 `WORKER_ID` 心跳与租约 bot 数
| 探活 | 路径 |
|------|------|
| Docker / 轻量 | `GET /health` |
| LB 就绪(含 Redis | `GET /health/ready` |
管理 API`GET /api/v1/admin/nodes`Cookie 管理员)。
扫码加机器人会话状态在 Redis,HTTP 无需粘性会话。
### 租约自动再平衡(rebalance
多节点默认 **开启**:租约偏多的进程会周期性 **主动释放** 多余 lease(不 pause bot),空闲节点下一轮 `claim` 捡走,使各节点 bot 数接近均分。
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `REBALANCE_ENABLED` | `true` | 设为 `false` 关闭(租约粘在首占节点) |
| `REBALANCE_INTERVAL_SEC` | `60` | 同一进程两次 shed 最小间隔 |
| `REBALANCE_SLACK` | `2` | 允许高出均分多少个再释放 |
| `REBALANCE_MAX_PER_TICK` | `50` | 每次最多释放数(避免瞬间空窗过大) |
日志关键字:`[worker] rebalance shed N bot(s)`。约 `ceil(超额 / 50)` 分钟内收敛。
### 强制下线节点
管理后台 **节点** 页 → **强制下线**
1. 写入 Redis fence`wa:worker:{id}:fence`
2. 释放该 WORKER_ID 下全部 bot 租约
3. 从 `wa:workers:reg` 移除
目标进程下一轮 reconcile 发现 fence 后停止认领;其他节点 claim 这些 bot。
**解除下线** 后该节点可重新加入。
这**不会** `docker stop`;若要从 LB 摘流量,还要从 Cloudflare Worker `ORIGINS` 去掉该源站。
+113
View File
@@ -0,0 +1,113 @@
# E2E 验收清单
环境:已配置 `.env`、完成 `db:migrate` + `db:seed`、至少一个 Bot 登录、LLM 可用。
## A. 安装与诊断
- [ ] `pnpm install` 成功
- [ ] `pnpm db:migrate` / `pnpm db:seed` 成功
- [ ] `pnpm diag` 在配置正确时 PASS(弱 token / 无 bot 仅警告)
- [ ] `pnpm test` 全部通过
## B. Admin
- [ ] 打开 `http://127.0.0.1:8787/admin` 可加载
- [ ] 填入正确 Token 后总览卡片有数据
- [ ] 可见默认人设 `catgirl` / `girlfriend`
- [ ] 可创建新人设并出现在列表
- [ ] 可发布人设新版本
## C. Bot 与多人
- [ ] `pnpm ilink:login` 扫码成功,Admin → Bot 可见账号
- [ ] 微信用户 A 发消息后,Admin → 用户出现 pending
- [ ] 批准 A 后,A 收到角色扮演回复(默认猫娘风格)
- [ ] 用户 B 同时发消息:会话与记忆不与 A 串味
- [ ] Admin 将 A 分配为女友人设,A 回复风格变化
- [ ] A 发送 `/角色 xxx` 收到「仅后台分配」提示
- [ ] 未批准用户收到开通引导(或被忽略)
## D. 记忆
- [ ] A 陈述偏好(如「我叫小明」)后,多轮后记忆列表出现相关事实(每 N 轮抽取)
- [ ] 重启 `pnpm dev` 后 A 的记忆仍在
- [ ] Admin 重置 A 记忆后列表清空
## E. 多 Bot(可选)
- [ ] 第二次 `pnpm ilink:login -- --name bot2` 增加第二个 Bot
- [ ] 两个 Bot 的 peer 互不混淆
## F. 限流与体验
- [ ] 同一用户短时间连发超限,收到「发得太快」提示
- [ ] typing 调用失败不影响正常回复(best-effort
### F.1 输入状态(getconfig + status 1/2
- [ ] 发消息后微信显示「对方正在输入中」
- [ ] 回复送达后指示器**消失**(不是等服务端超时)
- [ ] 多气泡回复期间指示器在气泡之间持续显示
- [ ] 被限流 / 未批准被拒 / 用户互聊中继后,指示器也停止
- [ ] 抓包确认每个 peer 只有一次 `getconfig`(票据缓存生效),之后只有 `sendtyping`
- [ ] `sendtyping` 携带 `typing_ticket``status`
- [ ] 人为让 `getconfig` 失败(改 baseUrl)→ 回复仍正常送达
### F.2 入站媒体
- [ ] `VISION_ENABLED=false` 时发图 → 回「看不了图片」,且**没有**产生 LLM 调用
- [ ] `VISION_ENABLED=true` 且模型支持视觉时发图 → 回复据实描述图中内容
- [ ] 发图 + 文字说明 → 两者一起进入同一轮对话
- [ ] 一条消息发 3+ 张图 → 只识别 `VISION_MAX_IMAGES` 张,其余按「看不了」告知
- [ ] 超过 `INBOUND_MEDIA_MAX_BYTES` 的图 → 降级为「看不了」,回复不失败
- [ ] 发视频 / 文件 → 按类型回话,且模型**没有**编造内容
- [ ] 发带转写的微信语音 → 按文字正常对话,不出现「我听不到」
- [ ] `VOICE_TRANSCRIPT_ENABLED=false` 后同一条语音 → 回「没听清,麻烦打字」,且不走 LLM
- [ ] 该开关与 `VISION_ENABLED` 互不影响(图片关、语音开是默认组合)
- [ ] 发图后下一轮追问 → 历史里是 `[图片]` 占位符,上下文没丢
- [ ] chatflow 模式人设收到图 → 看到 `[图片]` 占位符,不报错
## G. 安全
- [ ] 无 Token 访问 `/api/v1/*` 返回 401
- [ ] Token 不出现在审计全文中;仅存 Redis `wa:bot:{id}:creds`
## H. 用户对话(@LINUX DO
- [ ] 用户中心可生成绑定码;微信 `/绑定 CODE` 成功;`/我的身份` 显示用户名
- [ ] 双方均绑定且各聊过机器人后,A 发 `@B用户名`B 收到请求
- [ ] B `/同意` 后互发文字,内容带 `[用户名]` 前缀
- [ ] `/断开` 后普通消息恢复 AI 角色扮演
- [ ] 未绑定 / 无 context_token 的目标:发起方收到明确错误提示
- [ ] `hello @user` 不触发请求(整条消息匹配)
## I. AI 网关(自定义模型 / 联网搜索)
前置:已部署 `huggingface/wechat-ai-tools`,主站配置 `TOOLS_BASE_URL` / `TOOLS_API_KEY` / `LLM_PROVIDER_SECRET`
- [ ] `/app`**我的模型**:可添加连接(名称 / Base URL / Key / 模型),列表显示掩码 Key
- [ ] 可停用 / 启用 / 删除连接
- [ ] 未配置 `LLM_PROVIDER_SECRET` 时页面提示明确、不落库
- [ ] 人设选择「我的连接」后对话正常;抓包确认主站 **未**直连该 base_url(仅打 TOOLS
- [ ] 人设开启联网搜索 + `WEB_SEARCH_ENABLED=true`:问时事类问题触发 `/v1/web-search`
- [ ] 审计日志 / 应用日志中 **不含** upstream api_key 明文
- [ ] Fork 他人人设:新人设 `llmProviderId` 为空(不继承作者密钥)
## J. Chatflow
- [ ] 人设编辑器切「Chatflow 流程」保存后,卡片显示 Chatflow 徽章
- [ ] `/chatflow?persona=<id>` 可加载(未保存图时显示默认 start→llm→answer
- [ ] 拖拽节点、连线、编辑属性后「保存并启用」成功
- [ ] 非本人人设打开为只读(保存按钮禁用)
- [ ] 微信对该人设发消息,回复由流程产出
- [ ] 网页试聊 chatflow 人设可正常回复(走平台模型,不消耗作者额度)
- [ ] `search` 节点在人设未开搜索 / 未配 TOOLS 时报错清晰
- [ ] `http` 节点填非 allowlist 域名 → 被拒(`http_blocked`
- [ ] chatflow 人设不触发主动联系(skip: `chatflow_no_proactive`
- [ ] 图校验:无 answer / 多个 start / 超 `CHATFLOW_MAX_NODES` → 400
## 验收结论
全部关键项(A–D、F–G)通过即可判定:**系统已完成并可验收**。
E 为多 Bot 增强项;H 为用户对话增强项;真机扫码依赖本机微信与 iLink 可用性。
I / J 需先部署 HF 工具服务(`docs/ai-gateway.md``docs/chatflow.md`);未部署时这两节整体跳过。
+55
View File
@@ -0,0 +1,55 @@
# LINUX DO OAuth 接入
## 1. 申请应用
1. 打开 [LINUX DO Connect](https://connect.linux.do/)(或社区应用管理入口)
2. 创建 OAuth 应用,回调地址填:
```text
http://你的域名/api/v1/auth/callback
```
本地开发示例:
```text
http://127.0.0.1:8787/api/v1/auth/callback
```
3. 拿到 `client_id` / `client_secret`
## 2. 配置 `.env`
```env
LINUXDO_CLIENT_ID=...
LINUXDO_CLIENT_SECRET=...
LINUXDO_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/callback
LINUXDO_ADMIN_IDS=你的LINUXDO数字ID,你的用户名
REDIS_URL=redis://:密码@远端:6379/0
PUBLIC_BASE_URL=http://127.0.0.1:8787
```
`LINUXDO_ADMIN_IDS`:匹配 OAuth 返回的 **用户 id****username** 即视为管理员。
## 3. 流程
1. 用户访问 `/app` → 可用 **用户名密码** 登录,或点「LINUX DO 登录」
2. LINUX DO 跳转 `connect.linux.do` 授权(scope`openid profile`
3. 回调 `/api/v1/auth/callback` 写 Redis session cookie
- **OAuth 新用户不需要邀请码**(与本地注册不同)
4. 本地注册:需好友分享的一次性邀请码/链接(`/app?invite=CODE`),成功后同样写 session cookie
5. 普通用户:管理自己的机器人(添加/删除、批准 peer、分配人设)、生成邀请
6. 管理员:`/admin` 仪表盘;可配置「每 X 小时可生成 N 个邀请」、封禁/删除用户、停用/删除机器人
未配置 `LINUXDO_ADMIN_IDS` 时,**第一个成功注册/登录的用户**自动成为管理员(`FIRST_USER_IS_ADMIN=true`)。
封禁用户后 OAuth 仍可在 LINUX DO 授权,但 callback / 密码登录 / 会话校验会返回 `user_banned`
## 4. 协议端点(默认 / OIDC Discovery
| 用途 | URL |
|------|-----|
| Discovery | `https://connect.linux.do/.well-known/openid-configuration` |
| 授权 | `https://connect.linux.do/oauth2/authorize` |
| Token | `https://connect.linux.do/oauth2/token` |
| 用户信息 | `https://connect.linux.do/api/user` |
| 支持 scope | `openid` `profile` `email` |
+289
View File
@@ -0,0 +1,289 @@
# WeChat-AI 运维手册
## 1. 首次部署清单
### 1.1 依赖
- Node.js 20+
- pnpm
- **Upstash Redis**`rediss://...`
- **LINUX DO OAuth** 应用
- LLM API KeyOpenAI 兼容)
### 1.2 `.env` 必填
```env
REDIS_URL=rediss://default:密码@xxxx.upstash.io:6379
LLM_API_KEY=...
LLM_BASE_URL=...
LLM_MODEL=...
LINUXDO_CLIENT_ID=...
LINUXDO_CLIENT_SECRET=...
LINUXDO_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/callback
# 可选:数字 ID 或用户名;留空时「第一个登录的用户」自动成为管理员
LINUXDO_ADMIN_IDS=
PUBLIC_BASE_URL=http://127.0.0.1:8787
```
LINUX DO 应用回调地址必须与 `LINUXDO_REDIRECT_URI` **完全一致**
### 1.4 多节点运维要点
| 项 | 说明 |
|----|------|
| 共享 | 所有节点同一 `REDIS_URL``PUBLIC_BASE_URL`(主域名)、OAuth 回调 |
| 每机 | 唯一 `WORKER_ID`;可选 `NODE_LABEL` / `NODE_REGION` |
| 入口 | 主域名 → `cloudflare-worker``ORIGINS`(源站 IP:端口) |
| 后台 | `/admin`**节点**:进程心跳与 bot 租约;**不显示**源站 URL |
| 扫码 | 登录会话在 Redis,无需粘性会话 |
| 探活 | LB 用 `/health/ready`Docker 可用 `/health` |
| 下线 | 从 Worker `ORIGINS` 移除并 deploy;停容器后租约 TTL 过期自动转移 |
| 扩容 | 新机起容器 + 更新 `ORIGINS`bot 由租约自动分片 |
| 日常代码热修 | `pnpm release:pack``pnpm docker:build``/admin` 上传通道包 → 节点「更新」 |
| 基础镜像变更 | 仍需各机 `docker build` / 拉新镜像 |
进程内 HTTP 限流为单机计数;生产建议在 Cloudflare 对 `/api/v1/auth/*` 做 Rate Limiting。
### 1.3 启动
```powershell
cd F:\Code-Other-4\WeChat-AI
pnpm install
pnpm diag
pnpm db:seed
pnpm dev
```
| 页面 | URL |
|------|-----|
| 用户中心 | http://127.0.0.1:8787/app |
| 管理后台 | http://127.0.0.1:8787/admin |
## 2. 用户操作流程
1. 打开 `/app` → **LINUX DO 登录**
2. **扫码添加微信机器人**ClawBot
3. 微信好友私聊机器人 → 在用户中心 **批准**
4. 可选:分配人设(猫娘 / 女友)
5. 正常聊天(AI 会分多条气泡回复)
### 2.0 输入状态(「对方正在输入中」)
两步协议,`packages/ilink` 内部完成,无需配置:
1. `POST /ilink/bot/getconfig { ilink_user_id, context_token }``typing_ticket`
2. `POST /ilink/bot/sendtyping { ilink_user_id, typing_ticket, status }``status: 1` 开始,`status: 2` 停止
票据**按用户缓存**(服务端有效期约 24h,本地按 20h 过期后自动重取),所以每个 peer 大约一天一次 `getconfig`;并发的多次输入调用会合并成一次取票。
指示器的生命周期:收到消息立刻开始 → 调模型前再次开始 → 多气泡之间每条前再次开始 → **`handleJob``finally` 统一停止**。回复、拒绝、限流、用户互聊中继、异常,任何出口都会停止,不会把「正在输入中」留在对方屏幕上。主动联系发完也会停止。
排查:`getconfig` 失败时会退化成不带票据的 `sendtyping`(指示器可能不显示,但**绝不影响回复**);票据被服务端提前失效时会强制重取并重试一次。
### 2.0.1 图片理解(入站 Vision
默认**关闭**,且由 `apps/api/src/shipped-defaults.test.ts` 守着——`VISION_ENABLED` 只认精确的 `"true"``"1"` / `"yes"` / `"TRUE"` 都不算开。关闭状态下:不去 CDN 取字节、不调任何模型,收到图片直接回一句按类型区分的话。
> **出站语音 / 视频 / 文件**`sendVoice` / `sendVideo` / `sendFile` 已在 `packages/ilink` 实现并有测试,但**没有任何调用点**,回复路径不会用到。要真正让角色发语音,还差两段:TTS 产出音频、再转成微信用的 SILK 编码(TTS 给的是 mp3/wav/opus,直接发大概率放不出来)。另外出站 voice 的 item type 只在入站验证过,需真机确认。
**关键点:人设模型不需要支持视觉。** 默认的 `caption` 模式先让一个识图端点把图片转成文字描述,只把这段**文字**交给人设模型——所以 `deepseek-v4-flash` 这类纯文本模型照样能"看图"。描述还会写进对话历史,隔几轮再问"刚那张图里的猫呢"仍然接得上。
| 模式 | 人设模型要求 | 说明 |
|------|--------------|------|
| `caption`(默认) | 无 | 识图端点出描述 → 文字进人设模型 |
| `direct` | **必须支持视觉** | 图片原样交给人设模型;不支持就直接报错 |
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `VISION_ENABLED` | `false` | 总闸。关闭时不下载、不调模型 |
| `VISION_MODE` | `caption` | 见上表 |
| `VISION_BASE_URL` / `VISION_API_KEY` | 空 | 识图端点。**env-only**(与平台 LLM 同信任级,直连不走 tools)。留空则复用 `LLM_BASE_URL` / `LLM_API_KEY` |
| `VISION_MODEL` | 空 | **必填**,留空则所有图片按「看不了」处理。对「我的模型连接」无效(那条链路只认连接里的模型名) |
| `VISION_CAPTION_MAX_TOKENS` | `300` | 描述长度上限 |
| `VISION_MAX_IMAGES` | `2` | 单条消息最多识别几张(每张都实打实花 token) |
| `INBOUND_MEDIA_MAX_BYTES` | `4194304` | 单个附件下载上限(解密后原始大小;base64 再涨约 1/3) |
`caption` 模式的成本:每张图多一次识图调用(记在机器人主人账上),人设模型那一轮只多几十个 token 的描述文字——比把 4MB base64 塞进上下文便宜得多。识图失败会降级成「看不了」,**不会让回复失败**。
行为说明:
- **只有图片会被下载**。语音/视频/文件的字节拿来也喂不进模型,所以根本不去 CDN 取——微信语音是 SILK/AMR,没有 OpenAI 兼容端点收。
- `caption` 模式下人设模型**收不到字节**,只收到方括号里的描述;提示词会要求它「当作亲眼所见,但只依据描述内容,不要往外扩写」。
- **语音靠微信自带转写**`VOICE_TRANSCRIPT_ENABLED`,**默认开**):转写文字随入站消息一起到,`extractText` 把它并入文本,这类语音当文字处理,不再额外列为附件(否则会告诉模型「你听不到」它正要读的内容)。这个开关与 `VISION_ENABLED` **互不相干**——用转写不花钱、不需要任何模型。设 `false` 后语音一律回「没听清,麻烦打字」。
- 模型看不到的附件仍会写进系统提示,并明确要求**不许猜测内容**——否则人设会张口就编视频里有什么。
- 整条消息只有看不了的媒体且没有文字时,直接回一句按类型区分的话(不调模型,不花钱)。
- 附件下载失败不会拖垮回复,降级成「只告知存在」。
- Chatflow 模式的人设看到的是 `[图片]` 占位符——图执行器没有多模态节点。
- 历史里存的也是 `[图片]` 占位符(字节不落库),所以下一轮追问「所以呢?」时上下文仍知道发过图。
### 2.1 智能体主动找用户(空闲触发)
默认**全局关闭**。开启后,空闲一段时间的用户可被角色主动联系。
1. `.env` 设置 `PROACTIVE_ENABLED=true` 并重启服务
2. 在 `/app` 机器人卡片中打开 **「主动找用户聊天」** 并保存参数(空闲小时 / 间隔 / 每日上限 / 安静时段)
3. 在用户列表对该 peer 勾选 **「允许主动」**(仅已批准用户)
4. 对方须**曾经聊过**(系统存有 iLink `context_token`);从未发过消息的人无法冷启动主动触达
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `PROACTIVE_ENABLED` | `false` | 全局总闸 |
| `PROACTIVE_IDLE_HOURS` | `12` | 默认空闲阈值(小时) |
| `PROACTIVE_MIN_INTERVAL_HOURS` | `24` | 两次主动最小间隔 |
| `PROACTIVE_MAX_PER_DAY` | `1` | 每用户每日上限 |
| `PROACTIVE_QUIET_HOURS` | `0-8` | 安静时段(上海时区);空字符串关闭 |
| `PROACTIVE_SCAN_INTERVAL_SEC` | `300` | 扫描周期 |
| `PROACTIVE_MAX_PER_SCAN` | `10` | 每轮最多发送数 |
### 2.2 用户之间通过 @LINUX DO 用户名对话
机器人可中继两个已绑定用户的文字消息(**不经过 LLM**)。
1. 双方均用 LINUX DO 登录 `/app`
2. 用户中心 → **用户对话** → 生成绑定码 → 微信给任意机器人发 `/绑定 ABC123`
3. 双方至少各给机器人发过一次消息(写入 `context_token`,否则不可达)
4. A 发送整条消息 `@对方用户名` → B 收到请求 → `/同意`
5. 会话中直接发文字(前缀 `[用户名]`);`/断开` 结束;空闲约 30 分钟自动结束
| 命令 | 说明 |
|------|------|
| `/绑定 CODE` | 认领绑定码 |
| `/解绑` | 解除绑定 |
| `/我的身份` | 查看绑定与会话状态 |
| `@username` | 发起对话请求(整条消息) |
| `/同意` `/拒绝` | 处理入站请求 |
| `/取消请求` | 取消自己发出的请求 |
| `/断开` | 结束当前用户对话 |
| `/拉黑 用户名` | 拉黑(无法再互相 @ |
| `/取消拉黑 用户名` | 移出黑名单 |
| `/黑名单` | 查看黑名单 |
### 2.3 管理后台广播(全站 / 单 bot 推送)
管理员在 `/admin`**广播** 可向微信用户推送**纯文本**(系统更新、通知等)。
1. 登录管理后台 → 侧栏 **广播**
2. 撰写文本,选择范围:
- **全部机器人**:每个 bot 下全部有 `context_token` 的 peer(含未批准)
- **指定机器人**:多选 bot,再向其可触达 peer 群发
- **指定 Peer**:选 bot → 勾选 peers
3. **预估人数** → 确认后创建异步任务;列表可看进度 / 取消
4. 机器人详情页也可 **向此 bot 群发** / 对某 peer **发消息**(跳转并预填)
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `BROADCAST_INTERVAL_MS` | `200` | 两条消息间隔(限速,降低 iLink 风险) |
| `BROADCAST_MAX_TEXT` | `2000` | 文本最大长度 |
| `BROADCAST_HISTORY` | `100` | 保留的历史任务数 |
约束:
- 从未给 bot 发过消息的用户**无法送达**(无 context_token
- 需 Worker 开启(`WORKER_ENABLED` 默认 true);Worker 关则任务一直 `pending`
- 已发出的消息**无法撤回**;取消只停止剩余目标
- 不进 LLM / 人设记忆
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `P2P_ENABLED` | `true` | 总开关(`false` 关闭) |
| `P2P_BIND_CODE_TTL_SEC` | `600` | 绑定码有效期 |
| `P2P_REQUEST_TTL_SEC` | `300` | 对话请求有效期 |
| `P2P_SESSION_IDLE_SEC` | `1800` | 会话空闲超时 |
| `P2P_RELAY_MAX_CHARS` | `500` | 单条中继最大字数 |
| `P2P_MAX_REQUESTS_PER_DAY` | `20` | 每 peer 每日发起 `@` 次数 |
跨 Bot 可用:A 绑在 Bot1、B 绑在 Bot2,中继走目标方 bot 凭证 + 其 `context_token`
### 2.4 记忆检索与时间工具
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `MEMORY_TOP_K` | `12` | 记忆超过全量阈值时注入条数 |
| `MEMORY_FULL_INJECT_MAX` | `20` | ≤ 此数时仍全量注入(与旧行为一致) |
| `MEMORY_MAX_ITEMS` | `100` | 每个 peer+人设最多存储条数 |
| `TIME_TOOL_ENABLED` | `true` | 允许模型调用 `get_current_time` |
| `TIME_TOOL_TIMEZONE` | `Asia/Shanghai` | 默认时区 |
用户中心 → 微信用户行 → **记忆**:查看 / 删单条 / 清空。
日志关键字:`[proactive]``action=send|skip|lock_miss|no_ctx`);`[broadcast]`(管理广播任务)。
### 2.5 自定义模型与联网搜索(经 HF 工具服务)
主站**只**直连管理员配置的平台 LLM;用户自定义 API 与联网搜索一律经
`huggingface/wechat-ai-tools` 出站。
| 环境变量 | 说明 |
|----------|------|
| `TOOLS_BASE_URL` | 工具服务根地址(HF Space / 自托管容器) |
| `TOOLS_API_KEY` | 与工具服务共享的调用密钥 |
| `LLM_PROVIDER_SECRET` | 加密用户保存的自定义 API Key(必填,否则无法添加连接) |
| `WEB_SEARCH_ENABLED` | 全局搜索开关;人设还需自行开启 |
用户路径:`/app`**我的模型** 添加连接 → 人设编辑器里选择该连接 / 勾选联网搜索。
排查:
- `pnpm diag` 会探测 `TOOLS_BASE_URL/health`
- `/health/ready``WEB_SEARCH_ENABLED=true` 且 tools 不可达时返回 503(结果缓存 15s)
- 日志与审计**不记录** upstream api_key
### 2.6 Chatflow
人设可切 `chatflow` 模式,用 `/chatflow?persona=<id>` 编排流程图。
| 环境变量 | 默认 | 说明 |
|----------|------|------|
| `CHATFLOW_HTTP_ALLOWLIST` | 空 | http 节点额外允许的 hosttools host:port 始终允许);`*` = 任意公网,内网/云元数据仍拦 |
| `CHATFLOW_MAX_STEPS` | `32` | 单次执行最大步数 |
| `CHATFLOW_MAX_NODES` | `40` | 图最大节点数 |
要点:chatflow 人设**不参与主动联系**;试聊强制走平台模型。详见 `docs/chatflow.md`
## 3. 管理员
- 配置了 `LINUXDO_ADMIN_IDS`:名单内用户登录后为管理员
- 未配置:`FIRST_USER_IS_ADMIN` 默认 true,**首个登录用户**自动管理员
- 打开 `/admin`:今日 Token、用户数、机器人、审计、**广播**
## 4. Worker 与规模
API 与 iLink Worker **同进程**(单镜像 / 单容器)。
- 收消息:`getUpdates` 长轮询(每 bot 一路,有 `MAX_BOTS_PER_WORKER` 上限)
- 回消息:进程内 inbox 队列 + `REPLY_CONCURRENCY` 并发,避免 LLM 堵住轮询
- 日志出现 `at capacity`:提高 `MAX_BOTS_PER_WORKER`,或同镜像多副本分片
## 5. 故障
| 现象 | 处理 |
|------|------|
| Redis Connection closed / 占位符 | 填真实 Upstash `rediss://` URL |
| OAuth redirect_uri mismatch | 控制台回调与 `.env` 一致 |
| OAuth userinfo 失败 | 确认 scope `openid profile`(已默认) |
| 无管理员 | 清空 Redis 用户或设置 `LINUXDO_ADMIN_IDS` 后重登 |
| 微信 session expired | 在用户中心删除机器人后重新扫码 |
| 无 AI 回复 | 检查 LLM_API_KEY、用户是否批准 |
| `at capacity` / 部分 bot 不 poll | 调高 `MAX_BOTS_PER_WORKER` |
| 自定义模型报 TOOLS_BASE_URL required | 部署工具服务并配置 `TOOLS_BASE_URL` / `TOOLS_API_KEY` |
| 添加模型连接 503 | 未设置 `LLM_PROVIDER_SECRET` |
| chatflow `http_blocked` | 目标域名不在 tools host / `CHATFLOW_HTTP_ALLOWLIST`;或命中内网段(报文里带 `private host blocked` 与原因);或重定向跳进内网 |
| chatflow http 节点全部 `resolves to ...` 被拦 | 本机 DNS 在劫持解析(把所有域名答成 CGNAT/`198.18` 之类占位地址)。先 `nslookup` 确认,再排查 resolver,不要直接放宽白名单 |
| chatflow `search_disabled` | 人设未开搜索,或 `WEB_SEARCH_ENABLED` / TOOLS 未配 |
| `/health/ready` 503 但 Redis 正常 | tools 网关不可达(见 §2.5 |
## 6. 备份
- Upstash:控制台备份 / 导出(按套餐)
- Redis `wa:bot:{id}:creds`Bot token,敏感)
- `.env`(勿提交 Git
## 7. 文档
- Upstash`docs/upstash-redis.md`
- OAuth`docs/oauth-linuxdo.md`
- API`docs/admin-api.md`
- AI 网关(主站↔HF 契约):`docs/ai-gateway.md`
- Chatflow`docs/chatflow.md`
+145
View File
@@ -0,0 +1,145 @@
# 运行时配置(管理面板)
`/admin`**设置** 页可以改绝大多数原本只能写在 `.env` 里的配置。
## 优先级
```
.env(进程启动时读一次) ← 默认值
↓ 被覆盖
wa:settings:runtimeRedis JSON ← 管理面板写入
生效配置(进程内的同一个 cfg 对象)
```
- Redis 里**没有**某一项时,就用 `.env` 的值。
- 面板里把某项改回 `.env` 默认值,会**删除**该覆盖 —— 以后再改 `.env` 又能生效。
- 「全部恢复默认」删除整份 Redis 覆盖文档。
## 传播
每个节点每 **5 秒** 读一次 `wa:settings:runtime`,有变化才写入本进程配置并推给各服务。
所以:改动在**本节点立即生效**,其他节点**最多 5 秒**。没有用 pub/sub —— 一次 GET
相对请求路径上的 Redis 流量可以忽略,也省掉一条订阅连接。
## 范围
**不可配置(env-only**,因为它们要么在能读 Redis 之前就得正确,要么改了会破坏已有数据:
| 变量 | 原因 |
|------|------|
| `REDIS_URL` | 鸡生蛋:覆盖本身存在 Redis 里 |
| `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` | 平台模型凭证 |
| `LLM_PROVIDER_SECRET` | 改了之后所有用户已加密的自定义模型 key 全部解不开 |
| `WECHAT_AI_TOKEN` / `LINUXDO_ADMIN_IDS` | 权限根,改错会把自己锁在外面 |
| `SESSION_COOKIE_NAME` / `COOKIE_SECURE` | 改了当场踢掉所有会话 |
| `PUBLIC_BASE_URL` / `CORS_ORIGINS` | 站点自身 URL 与跨域白名单 |
| `WECHAT_AI_HOST` / `WECHAT_AI_PORT` | 监听端口在 `listen()` 时固定 |
其余全部在面板里,包括 `TOOLS_BASE_URL` / `TOOLS_API_KEY`(密钥在 UI 里只显示掩码)。
`apps/api/src/runtime-config.test.ts` 有一条守卫用例:**任何新增的 AppConfig 字段**要么进
`SETTING_SPECS`,要么进那份 env-only 名单,否则测试直接失败。
`DEFAULT_PERSONA_SLUG` 刻意不在面板里:`cfg.defaultPersonaSlug` 在代码里没有任何消费方,
默认人设走的是数据库 `is_default`(管理台「人设」→ 设默认)。放进面板只会得到一个
「保存成功但什么都没发生」的控件。
## 需重启的项
绝大多数配置是热生效的。以下几项写入 Redis 后会打上橙色「需重启」徽章:
| 项 | 为什么 |
|----|--------|
| `WORKER_ENABLED` | `worker.start()` 只在启动时跑一次 |
| `REPLY_CONCURRENCY` | 消费者池在 `start()` 一次性拉起,没有单消费者取消机制,缩容做不到 |
| `LOG_LEVEL` | Fastify 创建实例时固化 logger |
| `STICKER_MAX_BYTES` | 路由注册时固化 `bodyLimit` |
## 热生效是怎么做到的
`cfg` 对象**按引用**传给 `registerRoutes` 和各个服务,所以:
- **路由**87 处 `ctx.cfg.*` 是每请求读的,原地改 `cfg` 就够了,零改动。
- **在构造时快照选项的服务**:由 `runtime-config-apply.ts` 显式推送 ——
`ChatService` / `TryChatService` / `BotWorkerManager` / `ActivityBus` 各有一个
`applyRuntimeOptions`(或 `applyRuntimeConfig`)。
几个需要特殊处理、否则会静默失效的地方:
- `ChatService.webSearch` 原本在构造函数里判断一次,`WEB_SEARCH_ENABLED=false` 启动就永远
`null`。现在按 tools 配置的指纹惰性重建。
- `BotWorkerManager` 的 10 个 `readonly` 标量改成可写,且 setter 里复刻了构造函数的
clamp —— 面板不能写进一个构造函数本来会拒绝的值(比如 `leaseRenewSec=0`)。
- `leaseRenewSec` 还决定 `setInterval` 周期,改动时重新装载定时器。
- `ProactiveScheduler.globalEnabled``start()` 闩住(早退且留下 `stopped=true`),
false→true 必须重新进 `start()`,光改标志没用。
- `setRedisCommandHook` 现在无条件安装,否则 `DATA_STREAM_ENABLED` 这个开关永远是死的。
## API
**整个页面(含只读)限超管。** 超管 = 仍是管理员的用户里 `created_at` 最早那位。
| Method | Path | 权限 | 说明 |
|--------|------|------|------|
| GET | `/api/v1/admin/settings/runtime` | **超管** | 分组 + 全部项(当前值 / env 默认值 / 是否已覆盖 / 是否需重启)+ 交叉校验警告 |
| PATCH | `/api/v1/admin/settings/runtime` | **超管** | `{ patch: {key: value}, reset: [key] }` |
| POST | `/api/v1/admin/settings/runtime/reset` | **超管** | 删除全部覆盖 |
连读也限超管:这个面板能拿到 tools 网关密钥,能整个集群关掉 worker,
payload 本身也把每一个调参项和当前生效值列了出来。普通管理员访问返回
403 `super_admin_required`
前端跟着服务端一起收口,和「节点 / 广播 / 数据流」用同一套三处门禁:
`switchTab` 拦截并回落到「系统」页、`boot()` 隐藏侧栏按钮与 `<section>`
命令面板(Ctrl/⌘K)过滤掉该条目。
两个写操作都会记审计(`runtime_settings_updated` / `runtime_settings_reset`),
含变更的 key 列表与需重启的 key。
密钥字段(`type: "secret"`)的约定:
- GET 只返回掩码 `••••••••`,永远不回真值
- PATCH 传空字符串 = **不修改**
- PATCH 传 `-` = **清空**
- 把掩码原样回传会被忽略,不会变成字面值
## 交叉校验
保存时会返回警告(只提示,不改用户填的值):租约 TTL ≤ 续约间隔、延迟上下限倒挂、
`memoryFullInjectMax < memoryTopK`、开了联网但没配 tools 网关、配了网关但密钥为空、
二次过滤与模型直出 JSON 同时开启。
## Redis 读失败时的行为
「读不到」和「不存在」必须分开——把两者都当成「没有覆盖」会让一次网络抖动
把整个节点悄悄退回 `.env`(此前被面板收紧的开关会瞬间放开)。所以:
- **轮询读失败** → 保持上一份已知配置不动,打一条日志,`refresh()` 返回 false。
- **写路径读失败** → 直接拒绝,返回 **503**。绝不能基于失败的读做合并再写回,
那会把其他所有覆盖一起抹掉。
- **例外:全部恢复默认**。它本来就要丢弃旧文档,所以读不到时也允许执行 ——
这是文档损坏时唯一的产品内自救路径。
## 取值受限的项
`SettingSpec.options` 声明闭集,面板渲染成下拉框,服务端对不在集合里的值直接拒绝
(不做替换,免得把拼写错误藏起来)。目前只有 `LOG_LEVEL` 用到:pino 遇到未知级别会在
Fastify 建实例时抛错,而这一项又是「需重启」,一个拼写错误会在下次重启时让每个节点
反复崩溃,而且管理接口起不来、改不回去。
## 并发写
`patch()` 是读-改-写:读 Redis 当前文档 → 合并 → 整份写回。合并是**按文档**而不是按字段的,
所以两个超管在两个节点同时保存,后写的那份会带着自己的改动覆盖掉前一份的全部改动。
为此写路径上加了一把短锁 `wa:settings:runtime:lock``SET NX EX 5`,最多重试 5 次 ×
120ms),把整个集群的读-改-写串起来。锁只在写时用,读路径完全不碰它。
拿不到锁时不阻塞管理员,仍然继续写 —— 锁是降低窗口,不是强一致保证;写操作是超管专属
且极低频,这个取舍是刻意的。
## 多节点注意
`MAX_BOTS_PER_WORKER``LEASE_TTL_SEC``REBALANCE_SLACK` 参与的是**集群共享**的租约/再平衡
协议。因为覆盖存在 Redis、所有节点读同一份,各节点最终看到的是同一组值 —— 但在 5 秒收敛
窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。
+95
View File
@@ -0,0 +1,95 @@
# 使用 Upstash Redis
本项目通过 **ioredis + Redis 协议** 连接远端 Redis,兼容 [Upstash](https://upstash.com/)。
## 1. 创建数据库
1. 登录 [Upstash Console](https://console.upstash.com/)
2. **Create Database** → 选区域(建议离你服务器近的,如 `ap-southeast-1`
3. 打开数据库 → **Connect** / **REST API & Redis**
## 2. 复制连接串
在 Connect 面板选 **ioredis / Node.js**,复制 **Redis URL**,形如:
```text
rediss://default:AXxxxx@xxxxxx.upstash.io:6379
```
注意:
| 项 | 说明 |
|----|------|
| 协议 | 必须是 **`rediss://`**(多一个 s = TLS |
| 用户名 | 一般为 `default` |
| 密码 | Upstash 提供的 token,当作密码 |
| 端口 | `6379` |
不要用 Upstash 的 **REST URL**`https://xxx.upstash.io`)填到 `REDIS_URL`——那是 HTTP REST,不是本项目用的协议。
## 3. 写入 `.env`
```env
REDIS_URL=rediss://default:你的密码@你的主机.upstash.io:6379
```
可选:
```env
# 连接超时毫秒(默认 15000
REDIS_CONNECT_TIMEOUT_MS=15000
# 若 URL 是 redis:// 但强制 TLS(一般不需要,rediss:// 已够)
REDIS_TLS=true
# 并发命令自动 pipeline(默认开;高延迟远端 Redis 强烈建议保持)
REDIS_AUTO_PIPELINE=true
# 进程内 session/user 缓存(默认开;显著降低鉴权 RTT)
REDIS_L1_CACHE=true
# TCP keep-alive 毫秒(默认 10000
REDIS_KEEPALIVE_MS=10000
```
## 延迟优化建议
| 项 | 说明 |
|----|------|
| **区域** | Upstash 选与 API 服务器最近的 region(跨洲 RTT 常 150300ms,串行几条命令就到秒级) |
| **避免 N+1** | 列表接口应批量 MGET/pipeline;本项目已对 me/bots、me/peers、广场列表等做批处理 |
| **L1 缓存** | 鉴权路径对 session/user 做短 TTL 进程缓存,重复请求几乎零 RTT |
| **命令数** | 免费档按命令计费;pipeline/MGET 既降延迟也降用量 |
## 4. 验证
```powershell
cd F:\Code-Other-4\WeChat-AI
pnpm diag
```
应看到类似:
```text
✓ REDIS_URL=rediss://...
✓ PONG
```
然后:
```powershell
pnpm db:seed
pnpm dev
```
## 5. 常见问题
| 现象 | 处理 |
|------|------|
| `ECONNREFUSED` / 连不上 | 检查 URL 是否完整、是否 `rediss://`、密码有无复制错 |
| `ENOTFOUND` | 主机名错误 |
| TLS / certificate 错误 | 确认用 `rediss://`;升级 Node 20+ |
| 免费额度耗尽 | Upstash 控制台看 Command 用量;开发时减少轮询/调试 |
| 误填 REST token URL | 改用 **Redis** 协议 URL,不是 `https://...upstash.io` |
## 6. 安全建议
- 不要把 `REDIS_URL` 提交到 Git(已在 `.gitignore``.env` 中)
- 生产环境在 Upstash 开启合适的 IP 限制(若控制台提供)
- 定期轮换 token
+12
View File
@@ -0,0 +1,12 @@
.env
.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.mypy_cache
.git
*.md
!README.md
tests
.ruff_cache
+28
View File
@@ -0,0 +1,28 @@
# ── Auth (must match main site TOOLS_API_KEY) ───────────
TOOLS_API_KEY=change-me-shared-with-main-site
# ── Optional platform fallback when request has no upstream ──
# Main site usually calls platform LLM itself; these are used when
# chat/completions is invoked without body.upstream (e.g. demos).
UPSTREAM_LLM_BASE_URL=https://api.openai.com/v1
UPSTREAM_LLM_API_KEY=
UPSTREAM_LLM_MODEL=gpt-4o-mini
# Allow per-request upstream from main site (user custom APIs)
ALLOW_REQUEST_UPSTREAM=true
# Reject private/metadata IPs for upstream base_url
UPSTREAM_DENY_PRIVATE=true
UPSTREAM_TIMEOUT_MS=60000
MAX_BODY_BYTES=1048576
# Search
SEARCH_PROVIDER=ddg
# SEARXNG_URL=https://searx.example/search
# TAVILY_API_KEY=
SEARCH_TIMEOUT_MS=15000
SEARCH_MAX_RESULTS=8
# Server
HOST=0.0.0.0
PORT=7860
LOG_LEVEL=info
+46
View File
@@ -0,0 +1,46 @@
# syntax=docker/dockerfile:1
# wechat-ai-tools — Hugging Face Spaces / self-hosted Docker image
#
# Build:
# docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
# # or from this directory:
# docker build -t wechat-ai-tools:latest .
#
# Run:
# docker run --rm -p 7860:7860 --env-file .env wechat-ai-tools:latest
#
# HF Spaces: set SDK to Docker; Space will use this Dockerfile.
FROM python:3.12-slim-bookworm
LABEL org.opencontainers.image.title="wechat-ai-tools" \
org.opencontainers.image.description="WeChat-AI tools gateway: web search + user custom LLM proxy" \
org.opencontainers.image.source="https://github.com/wechat-ai/wechat-ai"
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
HOST=0.0.0.0 \
PORT=7860
# Non-root user (HF Spaces compatible)
RUN groupadd --system --gid 1000 app \
&& useradd --system --uid 1000 --gid app --create-home --home-dir /home/app app
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=app:app . .
USER app
EXPOSE 7860
HEALTHCHECK --interval=30s --timeout=8s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=5)"
# HF Spaces and local both use PORT (default 7860)
CMD ["sh", "-c", "uvicorn app:app --host ${HOST:-0.0.0.0} --port ${PORT:-7860}"]
+139
View File
@@ -0,0 +1,139 @@
# wechat-ai-tools
WeChat-AI 的 **工具网关**(可部署到 Hugging Face Spaces 或任意 Docker 主机)。
## 职责边界
| 调用方 | 走哪里 |
|--------|--------|
| **管理员配置的平台 LLM** | **主站直连**`LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` |
| **用户自定义 OpenAI 兼容 API** | **本服务出站**(主站只请求本服务,body 带 `upstream` |
| **联网搜索** | **本服务出站**`POST /v1/web-search` |
主站配置:
```env
# 平台 LLM(管理员)
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o-mini
# 工具网关(用户自定义 API + 搜索)
TOOLS_BASE_URL=http://127.0.0.1:7860
TOOLS_API_KEY=change-me-shared-with-main-site
WEB_SEARCH_ENABLED=true
```
本服务:
```env
TOOLS_API_KEY=change-me-shared-with-main-site
ALLOW_REQUEST_UPSTREAM=true
UPSTREAM_DENY_PRIVATE=true
# 可选:无 upstream 时的兜底模型(演示用)
# UPSTREAM_LLM_BASE_URL=...
# UPSTREAM_LLM_API_KEY=...
# UPSTREAM_LLM_MODEL=...
```
## API
### `GET /health`
存活与配置摘要(不含密钥)。
### `POST /v1/web-search`
```json
{ "query": "微信开放平台", "max_results": 5 }
```
鉴权:`Authorization: Bearer <TOOLS_API_KEY>``X-API-Key`
### `POST /v1/chat/completions`
OpenAI 兼容子集。用户自定义模型示例:
```json
{
"model": "gpt-4o-mini",
"messages": [{ "role": "user", "content": "hi" }],
"upstream": {
"base_url": "https://api.siliconflow.cn/v1",
"api_key": "sk-user-key",
"model": "Qwen/Qwen2.5-7B-Instruct"
}
}
```
- `upstream` 由主站解密用户连接后注入;本服务**不落库**密钥。
- 日志只记 host/model,不记 api_key。
- `UPSTREAM_DENY_PRIVATE=true` 时拒绝指向内网的 base_url。
## 本地运行
```bash
cd huggingface/wechat-ai-tools
python -m venv .venv
# Windows: .venv\Scripts\activate
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # 编辑 TOOLS_API_KEY 等
uvicorn app:app --host 0.0.0.0 --port 7860 --reload
```
测试:
```bash
pytest -q
```
## Dockerfile 打包镜像
在本目录:
```bash
docker build -t wechat-ai-tools:latest .
docker run --rm -p 7860:7860 --env-file .env wechat-ai-tools:latest
```
在仓库根目录:
```bash
docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
```
推送到私有仓库示例:
```bash
docker tag wechat-ai-tools:latest registry.example.com/wechat-ai-tools:1.0.0
docker push registry.example.com/wechat-ai-tools:1.0.0
```
镜像特性:
- 基础镜像 `python:3.12-slim-bookworm`
- 非 root 用户 `app` (uid 1000)
- 暴露 `7860`HF Spaces 默认)
- `HEALTHCHECK``GET /health`
- 入口:`uvicorn app:app --host 0.0.0.0 --port $PORT`
## Hugging Face Spaces
1. 新建 Space**SDK = Docker**
2. 将本目录文件推到 Space 仓库根(或 monorepo 中指定 Dockerfile 路径)
3. Space Secrets 设置:`TOOLS_API_KEY`、可选 `UPSTREAM_LLM_*`
4. 主站 `TOOLS_BASE_URL=https://<your-space>.hf.space`
## 与主站 compose(可选 profile
主站 `docker-compose` 可将本镜像作为 `tools` 服务侧车;主站容器只访问 `http://tools:7860`,**不要**把用户自定义 API 的出站放到主站容器。
## 安全清单
- [x] 共享 `TOOLS_API_KEY`
- [x] upstream SSRF 防护(禁私网 / metadata
- [x] 请求体大小限制
- [x] 上游超时
- [x] 密钥不写日志
- [ ] 生产建议:仅允许主站出口 IP(反代层)
+90
View File
@@ -0,0 +1,90 @@
"""
wechat-ai-tools AI gateway for WeChat-AI user custom LLM + web search.
Platform (admin) LLM is called by the main site directly.
User-configured custom APIs and web search egress through this service.
Deploy: Docker image / Hugging Face Spaces (Docker).
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from config import get_settings
from routers import chat, health, search
settings = get_settings()
logging.basicConfig(
level=getattr(logging, (settings.log_level or "info").upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
log = logging.getLogger("wechat-ai-tools")
app = FastAPI(
title="wechat-ai-tools",
description=(
"HTTP tools gateway for WeChat-AI: web search + proxy for user custom "
"OpenAI-compatible LLM APIs. Platform LLM stays on the main site."
),
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(search.router)
app.include_router(chat.router)
@app.middleware("http")
async def limit_body_size(request: Request, call_next):
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > settings.max_body_bytes:
return JSONResponse(
status_code=413,
content={"detail": "request body too large"},
)
return await call_next(request)
@app.get("/")
async def root() -> dict:
return {
"service": "wechat-ai-tools",
"docs": "/docs",
"health": "/health",
"endpoints": [
"POST /v1/web-search",
"POST /v1/chat/completions",
],
"note": (
"Main site uses platform LLM directly; user custom APIs and search "
"go through this gateway."
),
}
def main() -> None:
import uvicorn
uvicorn.run(
"app:app",
host=settings.host,
port=settings.port,
log_level=(settings.log_level or "info").lower(),
)
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
"""Runtime configuration for wechat-ai-tools (HF / Docker)."""
from __future__ import annotations
import os
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
populate_by_name=True,
)
tools_api_key: str = Field(default="", alias="TOOLS_API_KEY")
upstream_llm_base_url: str = Field(
default="https://api.openai.com/v1",
alias="UPSTREAM_LLM_BASE_URL",
)
upstream_llm_api_key: str = Field(default="", alias="UPSTREAM_LLM_API_KEY")
upstream_llm_model: str = Field(default="gpt-4o-mini", alias="UPSTREAM_LLM_MODEL")
allow_request_upstream: bool = Field(default=True, alias="ALLOW_REQUEST_UPSTREAM")
upstream_deny_private: bool = Field(default=True, alias="UPSTREAM_DENY_PRIVATE")
upstream_timeout_ms: int = Field(default=60_000, alias="UPSTREAM_TIMEOUT_MS")
max_body_bytes: int = Field(default=1_048_576, alias="MAX_BODY_BYTES")
search_provider: str = Field(default="ddg", alias="SEARCH_PROVIDER")
searxng_url: str = Field(default="", alias="SEARXNG_URL")
tavily_api_key: str = Field(default="", alias="TAVILY_API_KEY")
search_timeout_ms: int = Field(default=15_000, alias="SEARCH_TIMEOUT_MS")
search_max_results: int = Field(default=8, alias="SEARCH_MAX_RESULTS")
host: str = Field(default="0.0.0.0", alias="HOST")
port: int = Field(default=7860, alias="PORT")
log_level: str = Field(default="info", alias="LOG_LEVEL")
@lru_cache
def get_settings() -> Settings:
return Settings()
def env_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
@@ -0,0 +1,9 @@
# Target Python 3.113.12 (Docker image uses 3.12). Avoid 3.14 until wheels catch up.
fastapi==0.115.12
uvicorn[standard]==0.34.2
httpx==0.28.1
pydantic==2.11.3
pydantic-settings==2.8.1
duckduckgo-search==8.0.1
pytest==8.3.5
pytest-asyncio==0.26.0
@@ -0,0 +1 @@
# routers package
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from config import Settings, get_settings
from routers.deps import require_tools_auth
from services.upstream_llm import UpstreamLlmError, chat_completions
router = APIRouter(prefix="/v1", tags=["chat"])
@router.post("/chat/completions")
async def openai_chat_completions(
request: Request,
settings: Settings = Depends(get_settings),
_: None = Depends(require_tools_auth),
) -> dict[str, Any]:
"""
OpenAI-compatible chat completions proxy.
Main site uses this for **user custom LLM APIs** by sending:
{ "messages": [...], "model": "...", "upstream": { "base_url", "api_key", "model" } }
Without upstream, falls back to UPSTREAM_LLM_* on this service (optional).
"""
try:
body = await request.json()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail="invalid JSON body") from exc
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="body must be a JSON object")
if "messages" not in body:
raise HTTPException(status_code=400, detail="messages is required")
try:
return await chat_completions(body, settings)
except UpstreamLlmError as exc:
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
@@ -0,0 +1,28 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from config import Settings, get_settings
_bearer = HTTPBearer(auto_error=False)
def require_tools_auth(
request: Request,
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
settings: Settings = Depends(get_settings),
) -> None:
expected = (settings.tools_api_key or "").strip()
if not expected:
# Open mode (local demo only). Production should set TOOLS_API_KEY.
return
token = ""
if creds and creds.scheme.lower() == "bearer":
token = (creds.credentials or "").strip()
if not token:
token = (request.headers.get("x-api-key") or "").strip()
if token != expected:
raise HTTPException(status_code=401, detail="invalid or missing tools API key")
@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter
from config import get_settings
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> dict:
settings = get_settings()
has_platform = bool(
(settings.upstream_llm_base_url or "").strip()
and (settings.upstream_llm_api_key or "").strip()
)
return {
"ok": True,
"service": "wechat-ai-tools",
"auth_required": bool((settings.tools_api_key or "").strip()),
"allow_request_upstream": settings.allow_request_upstream,
"platform_upstream_configured": has_platform,
"search_provider": settings.search_provider,
}
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from config import Settings, get_settings
from routers.deps import require_tools_auth
from services.web_search import SearchError, search_web
router = APIRouter(prefix="/v1", tags=["search"])
class WebSearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=500)
max_results: int | None = Field(default=None, ge=1, le=10)
@router.post("/web-search")
async def web_search(
body: WebSearchRequest,
request: Request,
settings: Settings = Depends(get_settings),
_: None = Depends(require_tools_auth),
) -> dict[str, Any]:
_ = request
try:
results = await search_web(
body.query,
max_results=body.max_results,
settings=settings,
)
except SearchError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"query": body.query.strip(), "results": results}
@@ -0,0 +1 @@
# services package
@@ -0,0 +1,72 @@
"""SSRF guards for upstream LLM base URLs."""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
class UnsafeUpstreamError(ValueError):
pass
_BLOCKED_HOSTS = frozenset(
{
"localhost",
"localhost.localdomain",
"metadata.google.internal",
"metadata",
}
)
def _is_private_ip(ip: str) -> bool:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return True
return bool(
addr.is_private
or addr.is_loopback
or addr.is_link_local
or addr.is_reserved
or addr.is_multicast
or addr.is_unspecified
)
def validate_upstream_base_url(url: str, *, deny_private: bool = True) -> str:
"""Return normalized base URL or raise UnsafeUpstreamError."""
raw = (url or "").strip()
if not raw:
raise UnsafeUpstreamError("upstream base_url is empty")
if len(raw) > 2048:
raise UnsafeUpstreamError("upstream base_url too long")
parsed = urlparse(raw)
if parsed.scheme not in ("http", "https"):
raise UnsafeUpstreamError("upstream base_url must be http(s)")
if not parsed.hostname:
raise UnsafeUpstreamError("upstream base_url missing host")
if parsed.username or parsed.password:
raise UnsafeUpstreamError("upstream base_url must not embed credentials")
host = parsed.hostname.lower().rstrip(".")
if host in _BLOCKED_HOSTS or host.endswith(".local"):
raise UnsafeUpstreamError(f"upstream host not allowed: {host}")
if deny_private:
try:
infos = socket.getaddrinfo(host, parsed.port or 443, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise UnsafeUpstreamError(f"upstream host resolve failed: {host}") from exc
for info in infos:
ip = info[4][0]
if _is_private_ip(ip):
raise UnsafeUpstreamError(
f"upstream resolves to blocked address: {host} -> {ip}"
)
# Normalize: no trailing slash (callers append /chat/completions)
return raw.rstrip("/")
@@ -0,0 +1,136 @@
"""Proxy chat completions to an OpenAI-compatible upstream."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from config import Settings
from services.security import UnsafeUpstreamError, validate_upstream_base_url
log = logging.getLogger("wechat-ai-tools.llm")
class UpstreamLlmError(RuntimeError):
def __init__(self, message: str, *, status_code: int = 502):
super().__init__(message)
self.status_code = status_code
def resolve_upstream(
body: dict[str, Any],
settings: Settings,
) -> tuple[str, str, str]:
"""
Returns (base_url, api_key, model).
Prefer body.upstream when allowed; else platform defaults.
"""
upstream = body.get("upstream")
model_from_body = body.get("model")
if isinstance(upstream, dict) and settings.allow_request_upstream:
base = str(upstream.get("base_url") or upstream.get("baseUrl") or "").strip()
key = str(upstream.get("api_key") or upstream.get("apiKey") or "").strip()
model = str(
upstream.get("model")
or model_from_body
or settings.upstream_llm_model
or ""
).strip()
if not base or not key:
raise UpstreamLlmError(
"upstream.base_url and upstream.api_key are required",
status_code=400,
)
try:
base = validate_upstream_base_url(
base, deny_private=settings.upstream_deny_private
)
except UnsafeUpstreamError as exc:
raise UpstreamLlmError(str(exc), status_code=400) from exc
if not model:
raise UpstreamLlmError("model is required", status_code=400)
host = base.split("://", 1)[-1].split("/", 1)[0]
log.info("upstream mode=request host=%s model=%s", host, model)
return base, key, model
if isinstance(upstream, dict) and not settings.allow_request_upstream:
raise UpstreamLlmError(
"per-request upstream is disabled on this tools instance",
status_code=403,
)
base = (settings.upstream_llm_base_url or "").strip()
key = (settings.upstream_llm_api_key or "").strip()
model = str(model_from_body or settings.upstream_llm_model or "").strip()
if not base or not key:
raise UpstreamLlmError(
"platform upstream not configured (UPSTREAM_LLM_BASE_URL / API_KEY)",
status_code=503,
)
try:
base = validate_upstream_base_url(
base, deny_private=settings.upstream_deny_private
)
except UnsafeUpstreamError as exc:
raise UpstreamLlmError(str(exc), status_code=500) from exc
if not model:
raise UpstreamLlmError("model is required", status_code=400)
host = base.split("://", 1)[-1].split("/", 1)[0]
log.info("upstream mode=platform host=%s model=%s", host, model)
return base, key, model
def _strip_gateway_fields(body: dict[str, Any]) -> dict[str, Any]:
"""Remove fields that must not be forwarded to real providers."""
out = dict(body)
out.pop("upstream", None)
return out
async def chat_completions(
body: dict[str, Any],
settings: Settings,
) -> dict[str, Any]:
base_url, api_key, model = resolve_upstream(body, settings)
payload = _strip_gateway_fields(body)
payload["model"] = model
url = f"{base_url}/chat/completions"
timeout = max(1.0, settings.upstream_timeout_ms / 1000.0)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "wechat-ai-tools/1.0",
}
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
except httpx.TimeoutException as exc:
raise UpstreamLlmError("upstream LLM timeout", status_code=504) from exc
except httpx.RequestError as exc:
log.warning("upstream request error: %s", type(exc).__name__)
raise UpstreamLlmError(
f"upstream request failed: {type(exc).__name__}",
status_code=502,
) from exc
if resp.status_code >= 400:
# Do not leak upstream auth details
snippet = (resp.text or "")[:300]
log.warning("upstream HTTP %s body_len=%s", resp.status_code, len(resp.text or ""))
raise UpstreamLlmError(
f"upstream LLM error HTTP {resp.status_code}: {snippet}",
status_code=502 if resp.status_code >= 500 else 400,
)
try:
data = resp.json()
except Exception as exc: # noqa: BLE001
raise UpstreamLlmError("upstream returned non-JSON", status_code=502) from exc
if not isinstance(data, dict):
raise UpstreamLlmError("upstream returned invalid JSON object", status_code=502)
return data
@@ -0,0 +1,124 @@
"""Web search backends (DDG default; optional SearXNG / Tavily)."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from config import Settings
log = logging.getLogger("wechat-ai-tools.search")
class SearchError(RuntimeError):
pass
def _clamp_max(n: int | None, settings: Settings) -> int:
if n is None:
return max(1, min(settings.search_max_results, 10))
return max(1, min(int(n), settings.search_max_results, 10))
async def search_web(
query: str,
*,
max_results: int | None,
settings: Settings,
) -> list[dict[str, str]]:
q = (query or "").strip()
if not q:
raise SearchError("query is required")
if len(q) > 500:
raise SearchError("query too long (max 500)")
limit = _clamp_max(max_results, settings)
provider = (settings.search_provider or "ddg").strip().lower()
if provider == "tavily":
return await _search_tavily(q, limit, settings)
if provider == "searxng":
return await _search_searxng(q, limit, settings)
return await _search_ddg(q, limit, settings)
async def _search_ddg(query: str, limit: int, settings: Settings) -> list[dict[str, str]]:
try:
from duckduckgo_search import DDGS
except ImportError as exc:
raise SearchError("duckduckgo-search not installed") from exc
results: list[dict[str, str]] = []
timeout_s = max(1.0, settings.search_timeout_ms / 1000.0)
try:
# DDGS is sync; run in thread via anyio is ideal — use sync for MVP simplicity
with DDGS() as ddgs:
raw = list(ddgs.text(query, max_results=limit))
except Exception as exc: # noqa: BLE001
log.warning("ddg search failed: %s", type(exc).__name__)
raise SearchError(f"search failed: {type(exc).__name__}") from exc
for item in raw[:limit]:
results.append(
{
"title": str(item.get("title") or ""),
"url": str(item.get("href") or item.get("link") or ""),
"snippet": str(item.get("body") or item.get("snippet") or ""),
}
)
return results
async def _search_searxng(
query: str, limit: int, settings: Settings
) -> list[dict[str, str]]:
base = (settings.searxng_url or "").strip().rstrip("/")
if not base:
raise SearchError("SEARXNG_URL not configured")
timeout = max(1.0, settings.search_timeout_ms / 1000.0)
params = {"q": query, "format": "json", "categories": "general"}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(base, params=params)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
out: list[dict[str, str]] = []
for item in (data.get("results") or [])[:limit]:
out.append(
{
"title": str(item.get("title") or ""),
"url": str(item.get("url") or ""),
"snippet": str(item.get("content") or item.get("snippet") or ""),
}
)
return out
async def _search_tavily(
query: str, limit: int, settings: Settings
) -> list[dict[str, str]]:
key = (settings.tavily_api_key or "").strip()
if not key:
raise SearchError("TAVILY_API_KEY not configured")
timeout = max(1.0, settings.search_timeout_ms / 1000.0)
payload = {
"api_key": key,
"query": query,
"max_results": limit,
"include_answer": False,
}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post("https://api.tavily.com/search", json=payload)
resp.raise_for_status()
data: dict[str, Any] = resp.json()
out: list[dict[str, str]] = []
for item in (data.get("results") or [])[:limit]:
out.append(
{
"title": str(item.get("title") or ""),
"url": str(item.get("url") or ""),
"snippet": str(item.get("content") or ""),
}
)
return out
@@ -0,0 +1,30 @@
import pytest
from services.security import UnsafeUpstreamError, validate_upstream_base_url
def test_accepts_https_public_host():
# deny_private still resolves DNS; use example.com which is public
url = validate_upstream_base_url(
"https://example.com/v1/",
deny_private=False,
)
assert url == "https://example.com/v1"
def test_rejects_localhost():
with pytest.raises(UnsafeUpstreamError):
validate_upstream_base_url("http://localhost:8080/v1", deny_private=True)
def test_rejects_non_http():
with pytest.raises(UnsafeUpstreamError):
validate_upstream_base_url("ftp://example.com/v1", deny_private=False)
def test_rejects_embedded_credentials():
with pytest.raises(UnsafeUpstreamError):
validate_upstream_base_url(
"https://user:[email protected]/v1",
deny_private=False,
)
@@ -0,0 +1,50 @@
import pytest
from config import Settings
from services.upstream_llm import UpstreamLlmError, resolve_upstream
def test_request_upstream():
settings = Settings(
ALLOW_REQUEST_UPSTREAM=True,
UPSTREAM_DENY_PRIVATE=False,
UPSTREAM_LLM_API_KEY="",
)
base, key, model = resolve_upstream(
{
"model": "ignored-if-upstream-has-model",
"upstream": {
"base_url": "https://api.example.com/v1",
"api_key": "sk-test",
"model": "my-model",
},
},
settings,
)
assert base == "https://api.example.com/v1"
assert key == "sk-test"
assert model == "my-model"
def test_platform_fallback():
settings = Settings(
ALLOW_REQUEST_UPSTREAM=True,
UPSTREAM_DENY_PRIVATE=False,
UPSTREAM_LLM_BASE_URL="https://platform.example/v1",
UPSTREAM_LLM_API_KEY="sk-platform",
UPSTREAM_LLM_MODEL="gpt-mini",
)
base, key, model = resolve_upstream({"messages": []}, settings)
assert "platform.example" in base
assert key == "sk-platform"
assert model == "gpt-mini"
def test_missing_platform_raises():
settings = Settings(
ALLOW_REQUEST_UPSTREAM=True,
UPSTREAM_LLM_API_KEY="",
UPSTREAM_DENY_PRIVATE=False,
)
with pytest.raises(UpstreamLlmError):
resolve_upstream({"messages": []}, settings)
+22
View File
@@ -0,0 +1,22 @@
{
"name": "wechat-ai",
"private": true,
"version": "0.12.0",
"description": "Multi-user WeChat roleplay via iLink + Redis + LINUX DO OAuth",
"packageManager": "[email protected]",
"scripts": {
"build": "pnpm -r run build",
"dev": "pnpm --filter @wechat-ai/api dev",
"db:seed": "pnpm --filter @wechat-ai/db seed",
"db:migrate": "pnpm db:seed",
"diag": "pnpm --filter @wechat-ai/api diag",
"typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test",
"release:pack": "node scripts/release-pack.mjs",
"docker:build": "node scripts/docker-build.mjs",
"docker:up": "node scripts/docker-build.mjs --up"
},
"engines": {
"node": ">=20"
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@wechat-ai/core",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --import tsx --test src/**/*.test.ts"
},
"dependencies": {
"@wechat-ai/db": "workspace:*",
"@wechat-ai/llm": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.13.10",
"tsx": "^4.19.3",
"typescript": "^5.8.2"
}
}
+127
View File
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
approvePeer,
getPersonaBySlug,
listMemories,
openDatabase,
replaceMemories,
seedPersonas,
setAssignment,
upsertBotAccount,
} from "@wechat-ai/db";
import type { LlmClient } from "@wechat-ai/llm";
import { ChatService } from "./chat-service.js";
const redisUrl = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
class FakeLlm {
lastSystem = "";
constructor(private reply: string) {}
async chat() {
return this.reply;
}
async chatWithUsage(messages: { role: string; content: string }[]) {
this.lastSystem = messages.find((m) => m.role === "system")?.content ?? "";
return {
text: this.reply,
promptTokens: 1,
completionTokens: 1,
totalTokens: 2,
model: "fake",
};
}
}
describe("acceptance scenarios (Redis)", () => {
it("rejects unapproved and blocks /角色", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const botId = `bot_acc_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u1",
displayName: "t",
botToken: "test-token",
});
const chat = new ChatService(db, new FakeLlm("ok") as unknown as LlmClient, {
allowUnapproved: false,
memoryExtractEveryN: 999,
});
const unapproved = await chat.handleInbound({
botAccountId: botId,
peerId: "[email protected]",
text: "你好",
contextToken: "t1",
});
assert.equal(unapproved.kind, "reject");
await approvePeer(db, botId, "[email protected]");
const switchCmd = await chat.handleInbound({
botAccountId: botId,
peerId: "[email protected]",
text: "/角色 女友",
contextToken: "t2",
});
assert.match(switchCmd.text ?? "", /后台|主人/);
await db.close();
});
it("sticky memories per persona", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const botId = `bot_sticky_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u1",
displayName: "t",
botToken: "test-token",
});
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const gf = (await getPersonaBySlug(db, "girlfriend"))!;
const peer = "[email protected]";
await approvePeer(db, botId, peer);
await setAssignment(db, botId, peer, cat.id);
await replaceMemories(db, botId, peer, cat.id, ["用户喜欢猫粮"]);
await replaceMemories(db, botId, peer, gf.id, ["用户喜欢约会"]);
await setAssignment(db, botId, peer, gf.id);
assert.equal(
(await listMemories(db, botId, peer, cat.id))[0]?.content,
"用户喜欢猫粮",
);
await db.close();
});
});
+267
View File
@@ -0,0 +1,267 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { flattenChatContent } from "@wechat-ai/llm";
import type { ChatContentPart } from "@wechat-ai/llm";
import {
buildAttachmentBlock,
buildChatMessages,
buildImageCaptionMessages,
buildUserContent,
describeAttachments,
type PromptAttachment,
} from "./prompt.js";
/** Caption mode output: described in words, bytes deliberately not forwarded. */
const CAPTIONED: PromptAttachment = {
kind: "image",
mime: "image/png",
caption: "一只橘猫躺在键盘上",
};
const IMG: PromptAttachment = {
kind: "image",
mime: "image/png",
dataUri: "data:image/png;base64,AAAA",
};
const BLIND_IMG: PromptAttachment = { kind: "image", mime: "image/png" };
const VOICE: PromptAttachment = { kind: "voice", mime: "audio/silk" };
const VIDEO: PromptAttachment = { kind: "video", mime: "video/mp4" };
describe("buildAttachmentBlock", () => {
it("is empty without attachments", () => {
assert.equal(buildAttachmentBlock(undefined), "");
assert.equal(buildAttachmentBlock([]), "");
});
it("tells the model a readable image was actually sent", () => {
const block = buildAttachmentBlock([IMG]);
assert.match(block, /图片 ×1/);
assert.match(block, /已随本条消息发给你/);
assert.match(block, /据实描述/);
});
it("forbids inventing contents it cannot perceive", () => {
const block = buildAttachmentBlock([VIDEO]);
assert.match(block, /视频 ×1/);
assert.match(block, /无法查看/);
assert.match(block, /不要猜测或编造/);
assert.doesNotMatch(block, /已随本条消息发给你/);
});
it("reports readable and unreadable counts of the same kind", () => {
const block = buildAttachmentBlock([IMG, BLIND_IMG, IMG]);
assert.match(block, /图片 ×3/);
assert.match(block, /其中 2 个已随本条消息发给你/);
});
it("groups mixed kinds in a stable order", () => {
const block = buildAttachmentBlock([VIDEO, VOICE, IMG]);
const order = ["图片", "语音", "视频"].map((k) => block.indexOf(k));
assert.deepEqual(
order,
[...order].sort((a, b) => a - b),
"kinds should be listed image → voice → video → file",
);
});
});
describe("caption mode", () => {
it("tells the model to treat the description as what it saw", () => {
const block = buildAttachmentBlock([CAPTIONED]);
assert.match(block, /已由识图模型转成文字描述/);
assert.match(block, /当作你亲眼所见/);
assert.match(block, /不要往外扩写细节/);
// Must NOT claim the bytes were attached, and must NOT tell it to refuse.
assert.doesNotMatch(block, /已随本条消息发给你/);
assert.doesNotMatch(block, /无法查看/);
});
it("puts the caption in history so later turns keep the content", () => {
// The whole payoff: three turns later the model can still discuss the cat.
assert.equal(
describeAttachments("看这个", [CAPTIONED]),
"看这个\n[图片:一只橘猫躺在键盘上]",
);
assert.equal(
describeAttachments("", [CAPTIONED]),
"[图片:一只橘猫躺在键盘上]",
);
});
it("keeps the user turn plain text — the roleplay model gets no bytes", () => {
const content = buildUserContent("这是什么", [CAPTIONED]);
assert.equal(typeof content, "string");
assert.match(String(content), /一只橘猫躺在键盘上/);
});
it("mixes captioned and blind attachments correctly", () => {
const block = buildAttachmentBlock([CAPTIONED, VIDEO]);
assert.match(block, /已由识图模型转成文字描述/);
assert.match(block, /视频 ×1[^\n]*无法查看/);
});
it("falls back to a bare tag when captioning produced nothing", () => {
const failed: PromptAttachment = { kind: "image", mime: "image/png" };
assert.equal(describeAttachments("", [failed]), "[图片]");
assert.match(buildAttachmentBlock([failed]), /无法查看/);
});
it("counts several captions separately rather than collapsing them", () => {
const second: PromptAttachment = { ...CAPTIONED, caption: "一杯咖啡" };
assert.equal(
describeAttachments("", [CAPTIONED, second]),
"[图片:一只橘猫躺在键盘上][图片:一杯咖啡]",
);
});
});
describe("buildImageCaptionMessages", () => {
it("asks for an objective description, not roleplay", () => {
const msgs = buildImageCaptionMessages({
dataUri: "data:image/png;base64,AAAA",
});
assert.equal(msgs.length, 2);
const system = String(msgs[0]!.content);
assert.match(system, /图像描述器/);
assert.match(system, /不要扮演角色/);
assert.match(system, /不要猜测或编造/);
});
it("attaches the image to the user turn", () => {
const msgs = buildImageCaptionMessages({
dataUri: "data:image/png;base64,AAAA",
});
const parts = msgs[1]!.content as ChatContentPart[];
assert.equal(Array.isArray(parts), true);
assert.equal(parts[1]!.type, "image_url");
assert.equal(
(parts[1] as { image_url: { url: string } }).image_url.url,
"data:image/png;base64,AAAA",
);
});
it("folds in the user's own caption to focus the description", () => {
const msgs = buildImageCaptionMessages({
dataUri: "data:image/png;base64,AAAA",
userText: "这是我家猫",
});
const parts = msgs[1]!.content as ChatContentPart[];
assert.match((parts[0] as { text: string }).text, /这是我家猫/);
});
it("truncates an overlong user caption", () => {
const msgs = buildImageCaptionMessages({
dataUri: "data:image/png;base64,AAAA",
userText: "猫".repeat(500),
});
const text = (msgs[1]!.content as ChatContentPart[])[0] as { text: string };
assert.ok(text.text.length < 400, `got ${text.text.length} chars`);
});
});
describe("describeAttachments", () => {
it("returns bare text when nothing is attached", () => {
assert.equal(describeAttachments(" 你好 ", []), "你好");
});
it("appends a tag so later turns still see the image happened", () => {
assert.equal(describeAttachments("看这个", [IMG]), "看这个\n[图片]");
});
it("is never empty for a media-only message", () => {
assert.equal(describeAttachments("", [IMG]), "[图片]");
assert.equal(describeAttachments(" ", [VOICE]), "[语音]");
});
it("counts repeats", () => {
assert.equal(describeAttachments("", [IMG, IMG, VOICE]), "[图片×2][语音]");
});
});
describe("buildUserContent", () => {
it("stays a plain string when nothing is readable", () => {
assert.equal(buildUserContent("你好", []), "你好");
// The kind tag is kept so this turn matches what history will show later.
assert.equal(buildUserContent("你好", [VIDEO]), "你好\n[视频]");
});
it("falls back to the placeholder for an unreadable media-only message", () => {
assert.equal(buildUserContent("", [VOICE]), "[语音]");
});
it("emits content parts when an image is readable", () => {
const content = buildUserContent("这是什么", [IMG]);
assert.ok(Array.isArray(content));
const parts = content as ChatContentPart[];
assert.equal(parts.length, 2);
assert.deepEqual(parts[0], { type: "text", text: "这是什么" });
assert.deepEqual(parts[1], {
type: "image_url",
image_url: { url: "data:image/png;base64,AAAA" },
});
});
it("still leads with a text part when the user sent no caption", () => {
const parts = buildUserContent("", [IMG]) as ChatContentPart[];
// Some providers reject a user turn that is images only.
assert.equal(parts[0]!.type, "text");
assert.equal((parts[0] as { text: string }).text, "[图片]");
});
it("includes only readable attachments as image parts", () => {
const parts = buildUserContent("看", [IMG, VIDEO, IMG]) as ChatContentPart[];
assert.equal(parts.filter((p) => p.type === "image_url").length, 2);
});
});
describe("buildChatMessages with attachments", () => {
const base = {
systemPrompt: "你是猫娘。",
memories: [],
history: [],
botName: "小铃",
multiBubbleJson: false,
};
it("injects the attachment block into the system prompt", () => {
const msgs = buildChatMessages({
...base,
userText: "这是什么",
attachments: [IMG],
});
assert.match(flattenChatContent(msgs[0]!.content), /本条消息的附件/);
});
it("omits the block entirely for a plain text turn", () => {
const msgs = buildChatMessages({ ...base, userText: "你好" });
assert.doesNotMatch(flattenChatContent(msgs[0]!.content), /本条消息的附件/);
assert.equal(msgs[msgs.length - 1]!.content, "你好");
});
it("makes the final user turn multimodal", () => {
const msgs = buildChatMessages({
...base,
userText: "这是什么",
attachments: [IMG],
});
const last = msgs[msgs.length - 1]!;
assert.equal(last.role, "user");
assert.ok(Array.isArray(last.content));
});
it("keeps the final user turn a string when nothing is readable", () => {
const msgs = buildChatMessages({
...base,
userText: "看看",
attachments: [VIDEO],
});
assert.equal(msgs[msgs.length - 1]!.content, "看看\n[视频]");
});
it("tags an unsent attachment even when another one is sent", () => {
const parts = buildUserContent("看看", [IMG, VIDEO]) as ChatContentPart[];
// The image is right there; the video needs to be named or it vanishes.
assert.equal((parts[0] as { text: string }).text, "看看\n[视频]");
assert.equal(parts.filter((p) => p.type === "image_url").length, 1);
});
});
+337
View File
@@ -0,0 +1,337 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
approvePeer,
listMemories,
listRecentMessages,
openDatabase,
replaceMemories,
seedPersonas,
setAssignment,
setPeerProactiveEnabled,
getPersonaBySlug,
upsertBotAccount,
} from "@wechat-ai/db";
import type { LlmClient } from "@wechat-ai/llm";
import { ChatService } from "./chat-service.js";
const redisUrl = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
class FakeLlm implements Pick<LlmClient, "chat" | "chatWithUsage"> {
calls = 0;
constructor(private reply: string | string[]) {}
private next(): string {
this.calls++;
if (typeof this.reply === "string") return this.reply;
const i = Math.min(this.calls - 1, this.reply.length - 1);
return this.reply[i] ?? "";
}
async chat(): Promise<string> {
return this.next();
}
async chatWithUsage(_messages?: unknown, _opts?: unknown) {
return {
text: this.next(),
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
model: "fake",
};
}
}
function asLlm(fake: FakeLlm): LlmClient {
return fake as unknown as LlmClient;
}
describe("ChatService multi-user isolation (Redis)", () => {
it("keeps separate memories per peer", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const botId = `bot_test_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u_test",
displayName: "test",
botToken: "test-token",
});
await approvePeer(db, botId, "[email protected]");
await approvePeer(db, botId, "[email protected]");
await setAssignment(db, botId, "[email protected]", cat.id);
await setAssignment(db, botId, "[email protected]", cat.id);
await replaceMemories(db, botId, "[email protected]", cat.id, ["A 喜欢草莓"]);
await replaceMemories(db, botId, "[email protected]", cat.id, ["B 喜欢蓝莓"]);
const memA = await listMemories(db, botId, "[email protected]", cat.id);
const memB = await listMemories(db, botId, "[email protected]", cat.id);
assert.equal(memA[0]?.content, "A 喜欢草莓");
assert.equal(memB[0]?.content, "B 喜欢蓝莓");
const chat = new ChatService(db, asLlm(new FakeLlm("喵~你好")), {
allowUnapproved: false,
memoryExtractEveryN: 999,
// Keep isolation test free of second-pass filter coupling
replyFilterEnabled: false,
});
const r1 = await chat.handleInbound({
botAccountId: botId,
peerId: "[email protected]",
text: "嗨",
contextToken: "tok-a",
});
assert.equal(r1.kind, "reply");
await db.close();
});
it("handleInbound uses reply filter second LLM pass", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const botId = `bot_filter_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u_test",
displayName: "test",
botToken: "test-token",
});
await approvePeer(db, botId, "[email protected]");
await setAssignment(db, botId, "[email protected]", cat.id);
const fake = new FakeLlm([
"好呀~想你了 下次见哦",
JSON.stringify({ messages: ["好呀~", "想你了", "下次见哦"] }),
]);
const chat = new ChatService(db, asLlm(fake), {
allowUnapproved: false,
memoryExtractEveryN: 999,
replyFilterEnabled: true,
stickersEnabled: false,
});
const r = await chat.handleInbound({
botAccountId: botId,
peerId: "[email protected]",
text: "嗨",
contextToken: "tok-f",
});
assert.equal(r.kind, "reply");
assert.equal(fake.calls, 2, "primary + filter LLM");
assert.ok(r.parts && r.parts.length >= 2, JSON.stringify(r.parts));
assert.equal(r.bubblesFromJson, true);
await db.close();
});
it("handleProactive skips without writing user message", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const botId = `bot_proactive_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u_test",
displayName: "test",
botToken: "test-token",
});
await approvePeer(db, botId, "[email protected]");
await setAssignment(db, botId, "[email protected]", cat.id);
await setPeerProactiveEnabled(db, botId, "[email protected]", true);
const chat = new ChatService(
db,
asLlm(new FakeLlm('{"skip":true,"reason":"quiet"}')),
{
allowUnapproved: false,
memoryExtractEveryN: 999,
replyFilterEnabled: true,
},
);
const r = await chat.handleProactive({
botAccountId: botId,
peerId: "[email protected]",
contextToken: "tok-p",
idleHours: 14,
});
assert.equal(r.kind, "skip");
const hist = await listRecentMessages(db, botId, "[email protected]", 20);
assert.equal(hist.length, 0);
await db.close();
});
it("handleProactive stores assistant reply", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const botId = `bot_proactive2_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u_test",
displayName: "test",
botToken: "test-token",
});
await approvePeer(db, botId, "[email protected]");
await setAssignment(db, botId, "[email protected]", cat.id);
await setPeerProactiveEnabled(db, botId, "[email protected]", true);
const fake = new FakeLlm([
"想你啦 在干嘛喵",
JSON.stringify({ messages: ["想你啦", "在干嘛喵"] }),
]);
const chat = new ChatService(db, asLlm(fake), {
allowUnapproved: false,
memoryExtractEveryN: 999,
replyFilterEnabled: true,
stickersEnabled: false,
});
const r = await chat.handleProactive({
botAccountId: botId,
peerId: "[email protected]",
contextToken: "tok-q",
idleHours: 15,
});
assert.equal(r.kind, "reply");
assert.equal(fake.calls, 2);
assert.ok(r.parts && r.parts.length >= 1);
const hist = await listRecentMessages(db, botId, "[email protected]", 20);
assert.equal(hist.length, 1);
assert.equal(hist[0]?.role, "assistant");
await db.close();
});
it("handleInbound single-pass parses primary multi-bubble JSON (filter off)", async (t) => {
let db;
try {
db = openDatabase(redisUrl);
await Promise.race([
db.ping(),
new Promise((_, rej) =>
setTimeout(() => rej(new Error("timeout")), 2500),
),
]);
} catch {
try {
await db?.close();
} catch {
/* ignore */
}
t.skip("Redis not available");
return;
}
await seedPersonas(db);
const cat = (await getPersonaBySlug(db, "catgirl"))!;
const botId = `bot_single_${Date.now()}`;
await upsertBotAccount(db, {
id: botId,
ownerUserId: "u_test",
displayName: "test",
botToken: "test-token",
});
await approvePeer(db, botId, "[email protected]");
await setAssignment(db, botId, "[email protected]", cat.id);
const fake = new FakeLlm(
JSON.stringify({
messages: [
"给你看~",
{ type: "sticker", slug: "wave" },
"喜欢吗",
],
}),
);
const chat = new ChatService(db, asLlm(fake), {
allowUnapproved: false,
memoryExtractEveryN: 999,
// default path: no second-pass filter
replyFilterEnabled: false,
stickersEnabled: false,
});
const r = await chat.handleInbound({
botAccountId: botId,
peerId: "[email protected]",
text: "嗨",
contextToken: "tok-s",
});
assert.equal(r.kind, "reply");
assert.equal(fake.calls, 1, "primary LLM only");
assert.equal(r.bubblesFromJson, true);
// stickersEnabled false → no catalog; dropDisallowedStickers drops unknown stickers
assert.ok(r.parts && r.parts.length >= 2, JSON.stringify(r.parts));
assert.ok(
r.parts!.every((p) => p.kind === "text"),
"without sticker catalog, stickers must be dropped",
);
assert.ok(
!JSON.stringify(r.parts).includes('"type":"sticker"') ||
r.parts!.some((p) => p.kind === "text" && p.text.includes("给你看")),
);
await db.close();
});
});
+968
View File
@@ -0,0 +1,968 @@
import {
type Db,
type StickerPromptEntry,
clearMemories,
ensurePeer,
getBotAccount,
getPublishedGraph,
getPublishedPrompt,
getUser,
insertMessage,
listStickersForOwnerPrompt,
listMemories,
listRecentMessages,
recordTokenUsage,
replaceMemories,
resolvePersonaForPeer,
resolvePersonaUpstream,
touchPeerActivity,
touchPeerActivityFrom,
writeAudit,
} from "@wechat-ai/db";
import {
LlmClient,
WebSearchClient,
type BuiltinToolName,
type ChatCallOptions,
type LlmUpstream,
} from "@wechat-ai/llm";
import {
buildChatMessages,
buildImageCaptionMessages,
buildMemoryExtractMessages,
buildProactiveMessages,
describeAttachments,
parseFactsJson,
parseProactiveSkip,
type PromptAttachment,
} from "./prompt.js";
import {
parseMultiBubbleReply,
type ReplyPart,
} from "./reply-format.js";
import { dropDisallowedStickers, ReplyFilter } from "./reply-filter.js";
import {
normalizeFactList,
selectMemoriesForPrompt,
} from "./memory-retrieve.js";
import { ChatflowEngine } from "./chatflow/engine.js";
export interface ChatServiceOptions {
shortHistoryLimit: number;
memoryExtractEveryN: number;
allowUnapproved: boolean;
unapprovedReply: string;
noPersonaReply: string;
/** Ask model to return multi-bubble JSON (default true; ignored when replyFilterEnabled) */
multiBubbleJson?: boolean;
maxReplyBubbles?: number;
/** Soft max chars per WeChat bubble when re-splitting (default 72) */
maxChunkChars?: number;
/** Max stickers in one reply (default 2) */
maxStickersPerReply?: number;
/** Inject sticker catalog into system prompt (default true) */
stickersEnabled?: boolean;
/**
* Second-pass AI filter: reformat primary reply into multi-bubble JSON before send.
* When true, primary model does not receive REPLY_FORMAT_INSTRUCTION.
* Default false (primary model must emit multi-bubble JSON itself).
*/
replyFilterEnabled?: boolean;
/** Max memories injected when total exceeds fullInjectMax (default 12) */
memoryTopK?: number;
/** Inject all memories when count ≤ this (default 20) */
memoryFullInjectMax?: number;
/** Hard cap stored facts per peer+persona (default 100) */
memoryMaxItems?: number;
/** Allow model to call get_current_time (default true) */
timeToolEnabled?: boolean;
/** Default IANA timezone for get_current_time (default Asia/Shanghai) */
timeToolTimeZone?: string;
/**
* Global web search switch. Persona must also have web_search_enabled.
* Search always goes through tools gateway (never main-site DDG).
*/
webSearchEnabled?: boolean;
/** Default result count for the web_search tool (default 5) */
webSearchMaxResults?: number;
toolsBaseUrl?: string;
toolsApiKey?: string;
/** Wall clock for one tools-gateway search call */
toolsTimeoutMs?: number;
/** Decrypt user custom LLM keys; required for persona llm_provider_id */
llmProviderSecret?: string;
/** Host allowlist for chatflow http nodes (plus tools host) */
chatflowHttpAllowHosts?: string[];
chatflowMaxSteps?: number;
chatflowMaxNodes?: number;
/**
* How an attached image reaches the conversation.
*
* `caption` (default): describe it with `visionLlm` and pass only that text on,
* so the roleplay model needs no multimodal support. `direct`: hand the image
* to the roleplay model itself, which must then be vision-capable.
*/
visionMode?: "caption" | "direct";
/**
* Model id for the vision call. Required for either mode to do anything
* without it there is nothing that can read an image.
*/
visionModel?: string;
/** Cap on caption length */
visionCaptionMaxTokens?: number;
}
export interface InboundChatRequest {
botAccountId: string;
peerId: string;
text: string;
contextToken: string;
/**
* Media the user attached. Entries with a `dataUri` are handed to the model as
* content parts; the rest only appear in the attachment notice so the persona
* can say it cannot see them instead of inventing contents.
*/
attachments?: PromptAttachment[];
}
export interface ProactiveChatRequest {
botAccountId: string;
peerId: string;
contextToken: string;
/** Approximate idle hours for prompt (from scheduler) */
idleHours: number;
}
export interface InboundChatResult {
kind: "reply" | "reject" | "skip";
/** Joined plain text (for reject or single-shot) */
text?: string;
/** Ordered WeChat bubbles (roleplay reply) — legacy text view */
bubbles?: string[];
/** Structured parts for mixed text/sticker send */
parts?: ReplyPart[];
/** Whether bubbles came from model JSON */
bubblesFromJson?: boolean;
personaId?: string;
personaSlug?: string;
/** Owner of the bot — saves the worker a second getBotAccount per message */
ownerUserId?: string;
/** Present when kind=skip (proactive model declined) */
skipReason?: string;
}
const DEFAULTS: ChatServiceOptions = {
shortHistoryLimit: 20,
memoryExtractEveryN: 8,
allowUnapproved: false,
unapprovedReply:
"账号尚未开通对话权限。请前往网页端批准对话权限!\n(此项目为公益免费项目!使用文档:应用网址/docs)",
noPersonaReply: "系统尚未配置默认人设,请管理员先创建并设置默认角色。",
multiBubbleJson: true,
maxReplyBubbles: 5,
maxChunkChars: 72,
maxStickersPerReply: 2,
stickersEnabled: true,
replyFilterEnabled: false,
memoryTopK: 12,
memoryFullInjectMax: 20,
memoryMaxItems: 100,
timeToolEnabled: true,
timeToolTimeZone: "Asia/Shanghai",
webSearchEnabled: false,
webSearchMaxResults: 5,
visionMode: "caption",
visionCaptionMaxTokens: 300,
};
export class ChatService {
private opts: ChatServiceOptions;
private replyFilter: ReplyFilter;
private webSearch: WebSearchClient | null = null;
/** Identity of the tools config the current webSearch client was built from. */
private webSearchKey = "";
private chatflow: ChatflowEngine;
constructor(
private db: Db,
private llm: LlmClient,
opts: Partial<ChatServiceOptions> = {},
/**
* Vision endpoint for reading images. Separate client because the roleplay
* model is usually text-only see ChatServiceOptions.visionMode.
*/
private visionLlm: LlmClient | null = null,
) {
this.opts = { ...DEFAULTS, ...opts };
this.replyFilter = new ReplyFilter(llm, {
enabled: this.opts.replyFilterEnabled === true,
});
this.syncWebSearch();
this.chatflow = new ChatflowEngine({
platformLlm: llm,
toolsBaseUrl: this.opts.toolsBaseUrl,
toolsApiKey: this.opts.toolsApiKey,
toolsTimeoutMs: this.opts.toolsTimeoutMs,
webSearchEnabled: this.opts.webSearchEnabled === true,
webSearchMaxResults: this.opts.webSearchMaxResults,
maxSteps: this.opts.chatflowMaxSteps ?? 32,
maxNodes: this.opts.chatflowMaxNodes ?? 40,
httpAllowHosts: this.opts.chatflowHttpAllowHosts,
timeZone: this.opts.timeToolTimeZone || "Asia/Shanghai",
});
}
/**
* Rebuild the search client only when the tools config actually changed.
*
* Built lazily rather than once in the constructor so that turning
* WEB_SEARCH on (or filling in TOOLS_BASE_URL) from the admin panel does not
* leave a permanently search-less process behind.
*/
private syncWebSearch(): void {
const key =
this.opts.webSearchEnabled && this.opts.toolsBaseUrl
? [
this.opts.toolsBaseUrl,
this.opts.toolsApiKey ?? "",
this.opts.toolsTimeoutMs ?? "",
].join("|")
: "";
if (key === this.webSearchKey) return;
this.webSearchKey = key;
this.webSearch = key
? new WebSearchClient({
toolsBaseUrl: this.opts.toolsBaseUrl!,
toolsApiKey: this.opts.toolsApiKey || "",
timeoutMs: this.opts.toolsTimeoutMs,
})
: null;
}
/**
* Apply admin-editable settings in place (runtime settings reload).
* Only the keys present in `patch` are touched.
*/
applyRuntimeOptions(patch: Partial<ChatServiceOptions>): void {
Object.assign(this.opts, patch);
if ("replyFilterEnabled" in patch) {
this.replyFilter.setEnabled(this.opts.replyFilterEnabled === true);
}
this.syncWebSearch();
this.chatflow.applyOptions({
toolsBaseUrl: this.opts.toolsBaseUrl,
toolsApiKey: this.opts.toolsApiKey,
toolsTimeoutMs: this.opts.toolsTimeoutMs,
webSearchEnabled: this.opts.webSearchEnabled === true,
webSearchMaxResults: this.opts.webSearchMaxResults,
maxSteps: this.opts.chatflowMaxSteps ?? 32,
maxNodes: this.opts.chatflowMaxNodes ?? 40,
httpAllowHosts: this.opts.chatflowHttpAllowHosts,
timeZone: this.opts.timeToolTimeZone || "Asia/Shanghai",
});
}
/**
* Resolve LLM call options for a persona:
* - platform LLM: direct via this.llm
* - user custom: LlmClient.forUserUpstream HF tools only
*/
private async resolveChatClient(params: {
persona: { llm_provider_id?: string | null; web_search_enabled?: number };
ownerUserId?: string | null;
}): Promise<{
client: LlmClient;
callOpts: ChatCallOptions;
}> {
const tools: BuiltinToolName[] = [];
if (this.opts.timeToolEnabled !== false) {
tools.push("get_current_time");
}
const wantSearch =
this.opts.webSearchEnabled === true &&
Boolean(params.persona.web_search_enabled) &&
this.webSearch;
if (wantSearch) {
tools.push("web_search");
}
const callOpts: ChatCallOptions = {
tools,
timeZone: this.opts.timeToolTimeZone || "Asia/Shanghai",
};
if (wantSearch && this.webSearch) {
const ws = this.webSearch;
const defaultMax = this.opts.webSearchMaxResults ?? 5;
callOpts.webSearch = (query, maxResults) =>
ws.searchAsToolResult(query, maxResults ?? defaultMax);
}
const secret = this.opts.llmProviderSecret || "";
let upstream: LlmUpstream | null = null;
if (secret && params.persona.llm_provider_id) {
const resolved = await resolvePersonaUpstream(this.db, {
llmProviderId: params.persona.llm_provider_id,
ownerUserId: params.ownerUserId,
secret,
});
if (resolved) {
upstream = {
baseUrl: resolved.baseUrl,
apiKey: resolved.apiKey,
model: resolved.model,
};
}
}
if (upstream) {
if (!this.opts.toolsBaseUrl || !this.opts.toolsApiKey) {
throw new Error(
"User custom LLM requires TOOLS_BASE_URL and TOOLS_API_KEY (HF tools gateway)",
);
}
const client = LlmClient.forUserUpstream({
toolsBaseUrl: this.opts.toolsBaseUrl,
toolsApiKey: this.opts.toolsApiKey,
upstream,
});
return { client, callOpts };
}
return { client: this.llm, callOpts };
}
/** When filter is on, primary model should not emit send-plan JSON. */
private primaryMultiBubbleJson(): boolean {
if (this.opts.replyFilterEnabled === true) return false;
return this.opts.multiBubbleJson !== false;
}
/**
* Second-pass filter (or legacy parse) structured parts for WeChat send.
* Records filter token usage when the filter LLM ran.
*/
private async finalizeReplyParts(params: {
rawLlmText: string;
stickers: StickerPromptEntry[];
botAccountId: string;
ownerUserId?: string | null;
ownerUsername?: string;
botName?: string | null;
}): Promise<{
parts: ReplyPart[];
bubbles: string[];
displayText: string;
bubblesFromJson: boolean;
}> {
const maxBubbles = this.opts.maxReplyBubbles ?? 5;
const maxChunkChars = this.opts.maxChunkChars ?? 72;
const maxStickers = this.opts.maxStickersPerReply ?? 2;
const raw = (params.rawLlmText ?? "").trim();
if (this.opts.replyFilterEnabled === true) {
const filtered = await this.replyFilter.filter({
rawText: raw,
allowedStickerSlugs: params.stickers.map((s) => s.slug),
maxBubbles,
maxChunkChars,
maxStickers,
});
if (filtered.promptTokens > 0 || filtered.completionTokens > 0) {
await recordTokenUsage(this.db, {
userId: params.ownerUserId ?? undefined,
botId: params.botAccountId,
promptTokens: filtered.promptTokens,
completionTokens: filtered.completionTokens,
username: params.ownerUsername,
botName: params.botName ?? undefined,
});
}
const parts =
filtered.parts.length > 0
? filtered.parts
: raw
? [{ kind: "text" as const, text: raw }]
: [];
const bubbles =
filtered.bubbles.length > 0
? filtered.bubbles
: parts.map((p) =>
p.kind === "text" ? p.text : `[表情:${p.slug}]`,
);
const displayText =
filtered.displayText || bubbles.join("\n") || raw;
return {
parts,
bubbles,
displayText,
bubblesFromJson: filtered.fromFilterJson,
};
}
const parsed = parseMultiBubbleReply(raw, {
maxBubbles,
maxChunkChars,
maxStickers,
fallbackSplit: true,
expandLongBubbles: true,
});
const allowedSlugs = params.stickers.map((s) => s.slug);
// `raw` is only a safe fallback when it was not a recognised JSON envelope.
// Otherwise "the reply reduced to nothing" would be answered by sending
// `{"messages":[…]}` to the user verbatim — say nothing instead.
const rawFallback: ReplyPart[] =
!parsed.fromJson && raw ? [{ kind: "text" as const, text: raw }] : [];
let parts = parsed.parts.length > 0 ? parsed.parts : rawFallback;
// Drop invented / unknown sticker slugs (same rule as ReplyFilter path)
parts = dropDisallowedStickers(parts, allowedSlugs);
if (!parts.length) {
parts = rawFallback;
}
const bubbles =
parts.length > 0
? parts.map((p) =>
p.kind === "text" ? p.text : `[表情:${p.slug}]`,
)
: raw
? [raw]
: [];
const displayText =
parts.length > 0
? parts
.map((p) =>
p.kind === "text" ? p.text : `[表情:${p.slug}]`,
)
.join("\n")
: parsed.displayText || bubbles.join("\n");
return {
parts,
bubbles,
displayText,
bubblesFromJson: parsed.fromJson,
};
}
/** Swap in a vision client at runtime (settings reload). */
setVisionClient(client: LlmClient | null): void {
this.visionLlm = client;
}
/**
* Caption mode: describe each attached image with the vision endpoint and
* hand the roleplay model text instead of bytes.
*
* The returned attachments carry `caption` and have `dataUri` cleared the
* roleplay model must not receive image parts it cannot parse. Failures
* degrade to a notice-only attachment rather than failing the turn: a flaky
* captioner should cost the user a description, not their reply.
*
* Token cost is billed to the bot owner like any other call.
*/
private async captionAttachments(params: {
attachments: PromptAttachment[];
userText: string;
botAccountId: string;
ownerUserId?: string | null;
ownerUsername?: string;
botName?: string | null;
}): Promise<PromptAttachment[]> {
const vision = this.visionLlm;
const model = this.opts.visionModel?.trim();
if (!vision || !model) {
// Nothing can read the image — drop the bytes so the prompt tells the
// persona it cannot see it, instead of shipping an unusable data URI.
return params.attachments.map((a) =>
a.dataUri ? { ...a, dataUri: undefined } : a,
);
}
let promptTokens = 0;
let completionTokens = 0;
const out = await Promise.all(
params.attachments.map(async (a) => {
if (!a.dataUri) return a;
try {
const res = await vision.chatWithUsage(
buildImageCaptionMessages({
dataUri: a.dataUri,
userText: params.userText,
}),
{
model,
tools: [],
maxToolRounds: 0,
maxTokens: this.opts.visionCaptionMaxTokens,
},
);
promptTokens += res.promptTokens;
completionTokens += res.completionTokens;
const caption = res.text.trim();
return {
...a,
dataUri: undefined,
caption: caption || undefined,
} satisfies PromptAttachment;
} catch (err) {
console.error(
`[vision] caption failed bot=${params.botAccountId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return { ...a, dataUri: undefined };
}
}),
);
if (promptTokens > 0 || completionTokens > 0) {
await recordTokenUsage(this.db, {
userId: params.ownerUserId ?? undefined,
botId: params.botAccountId,
promptTokens,
completionTokens,
username: params.ownerUsername,
botName: params.botName ?? undefined,
});
}
return out;
}
async handleInbound(req: InboundChatRequest): Promise<InboundChatResult> {
const peer = await ensurePeer(this.db, req.botAccountId, req.peerId);
// NOTE: no upsertContextToken here — BotWorkerManager.handleJob already
// wrote the identical key/value, and it does so *before* the rate-limit
// early-return and the P2P intercept. Writing it again here would be a
// duplicate RTT on every message; removing the worker's copy instead would
// leave throttled / P2P peers with a stale token (breaking proactive and
// P2P delivery), so the worker keeps ownership of this write.
if (!peer.approved && !this.opts.allowUnapproved) {
return { kind: "reject", text: this.opts.unapprovedReply };
}
if (/^\s*\/角色/.test(req.text)) {
return {
kind: "reply",
text: "角色切换仅支持机器人主人在后台分配,暂不支持用户自助 /角色 命令。",
};
}
const persona = await resolvePersonaForPeer(
this.db,
req.botAccountId,
req.peerId,
);
if (!persona) {
return { kind: "reject", text: this.opts.noPersonaReply };
}
// Parallelize independent Redis reads (critical on remote Redis / Upstash)
const [systemPrompt, bot, history, memories] = await Promise.all([
getPublishedPrompt(this.db, persona.id),
getBotAccount(this.db, req.botAccountId),
listRecentMessages(
this.db,
req.botAccountId,
req.peerId,
this.opts.shortHistoryLimit,
persona.id,
),
listMemories(this.db, req.botAccountId, req.peerId, persona.id),
]);
if (!systemPrompt) {
return { kind: "reject", text: this.opts.noPersonaReply };
}
const botName = bot?.display_name?.trim() || "助手";
const owner = bot?.owner_user_id
? await getUser(this.db, bot.owner_user_id)
: undefined;
// Caption mode turns the image into text here, before anything else reads
// the attachments — so history, memory retrieval and the roleplay prompt all
// see the description rather than an opaque placeholder.
let attachments = req.attachments ?? [];
if (
this.opts.visionMode !== "direct" &&
attachments.some((a) => a.dataUri)
) {
attachments = await this.captionAttachments({
attachments,
userText: req.text,
botAccountId: req.botAccountId,
ownerUserId: bot?.owner_user_id,
ownerUsername: owner?.username,
botName: bot?.display_name,
});
}
// The media bytes are never persisted, so this described text is the only
// trace a later turn can see.
const historyText = describeAttachments(req.text, attachments);
const [userMsg, , stickers] = await Promise.all([
insertMessage(this.db, {
botAccountId: req.botAccountId,
peerId: req.peerId,
personaId: persona.id,
role: "user",
content: historyText,
contextToken: req.contextToken,
}),
// `peer` is already loaded — skip the read half of the read-modify-write
touchPeerActivityFrom(this.db, peer),
this.opts.stickersEnabled === false || !bot?.owner_user_id
? Promise.resolve([])
: listStickersForOwnerPrompt(this.db, bot.owner_user_id),
]);
// INCR reply from the insert above — no extra GET needed to decide on
// memory extraction further down.
const userMsgCount = userMsg.user_count ?? 0;
const queryText = [
historyText,
...history
.slice(-4)
.map((m) => m.content)
.filter(Boolean),
].join("\n");
const selectedMemories = selectMemoriesForPrompt(memories, queryText, {
topK: this.opts.memoryTopK,
fullInjectMax: this.opts.memoryFullInjectMax,
});
// `owner` was already resolved above the caption step (it bills the caption
// call), so there is no second lookup here.
let rawLlmText: string;
let promptTokens = 0;
let completionTokens = 0;
if (persona.mode === "chatflow") {
// Chatflow MVP: proactive path still uses prompt mode elsewhere;
// inbound uses graph. Disable proactive separately at scheduler.
const graph = await getPublishedGraph(this.db, persona.id);
const secret = this.opts.llmProviderSecret || "";
let upstream: LlmUpstream | null = null;
if (secret && persona.llm_provider_id) {
const resolved = await resolvePersonaUpstream(this.db, {
llmProviderId: persona.llm_provider_id,
ownerUserId: bot?.owner_user_id,
secret,
});
if (resolved) {
upstream = {
baseUrl: resolved.baseUrl,
apiKey: resolved.apiKey,
model: resolved.model,
};
}
}
const cf = await this.chatflow.run(graph, {
// The graph engine has no multimodal node, so a chatflow persona sees
// the `[图片]` placeholder rather than the image itself.
userText: historyText,
botName,
systemPrompt,
history: history.map((m) => ({ role: m.role, content: m.content })),
memories: selectedMemories.map((m) => m.content),
webSearchEnabled: Boolean(persona.web_search_enabled),
upstream,
});
rawLlmText = cf.text;
promptTokens = cf.promptTokens;
completionTokens = cf.completionTokens;
} else {
const messages = buildChatMessages({
systemPrompt,
memories: selectedMemories,
history,
userText: req.text,
botName,
multiBubbleJson: this.primaryMultiBubbleJson(),
stickers,
timeToolEnabled: this.opts.timeToolEnabled !== false,
attachments,
});
const { client: chatClient, callOpts } = await this.resolveChatClient({
persona,
ownerUserId: bot?.owner_user_id,
});
// Route the turn to the vision model only when the model actually gets an
// image; text turns must keep using the persona's normal model.
const visionModel = this.opts.visionModel?.trim();
if (visionModel && attachments.some((a) => a.dataUri)) {
callOpts.model = visionModel;
}
const usage = await chatClient.chatWithUsage(messages, callOpts);
rawLlmText = usage.text;
promptTokens = usage.promptTokens;
completionTokens = usage.completionTokens;
}
const finalized = await this.finalizeReplyParts({
rawLlmText,
stickers,
botAccountId: req.botAccountId,
ownerUserId: bot?.owner_user_id,
ownerUsername: owner?.username,
botName: bot?.display_name,
});
const { parts, bubbles, displayText, bubblesFromJson } = finalized;
// Everything left is independent bookkeeping — one wave, not four.
// This runs between "model produced text" and "first bubble sent", so each
// serialized round trip here is latency the user feels. Still awaited:
// the next message on this peer reads history to build its prompt, so a
// floating insertMessage would drop this turn out of context.
await Promise.all([
recordTokenUsage(this.db, {
userId: bot?.owner_user_id,
botId: req.botAccountId,
promptTokens,
completionTokens,
username: owner?.username,
botName: bot?.display_name,
}),
// Store plain bubbles text in history (never raw JSON wrapper)
insertMessage(this.db, {
botAccountId: req.botAccountId,
peerId: req.peerId,
personaId: persona.id,
role: "assistant",
content: displayText,
contextToken: req.contextToken,
}),
// Re-read here, unlike the pre-LLM touch: `peer` was loaded before the
// model call, so writing it back whole could revert an approve /
// proactive toggle the owner made during those seconds.
touchPeerActivity(this.db, req.botAccountId, req.peerId),
]);
if (
userMsgCount > 0 &&
userMsgCount % this.opts.memoryExtractEveryN === 0
) {
void this.extractMemory(req.botAccountId, req.peerId, persona.id).catch(
(err) => {
console.error("[memory] extract failed", err);
},
);
}
return {
kind: "reply",
text: displayText,
bubbles,
parts,
bubblesFromJson,
personaId: persona.id,
personaSlug: persona.slug,
ownerUserId: bot?.owner_user_id,
};
}
/**
* Generate a proactive message when the peer has been idle.
* Does not insert a user message. May return kind=skip if the model declines.
*/
async handleProactive(req: ProactiveChatRequest): Promise<InboundChatResult> {
const peer = await ensurePeer(this.db, req.botAccountId, req.peerId);
if (!peer.approved && !this.opts.allowUnapproved) {
return { kind: "reject", text: this.opts.unapprovedReply };
}
if (!peer.proactive_enabled) {
return { kind: "skip", skipReason: "peer_off" };
}
const persona = await resolvePersonaForPeer(
this.db,
req.botAccountId,
req.peerId,
);
if (!persona) {
return { kind: "reject", text: this.opts.noPersonaReply };
}
// Chatflow MVP: proactive outreach only supported for classic prompt mode
if (persona.mode === "chatflow") {
return { kind: "skip", skipReason: "chatflow_no_proactive" };
}
const [systemPrompt, bot, history, memories] = await Promise.all([
getPublishedPrompt(this.db, persona.id),
getBotAccount(this.db, req.botAccountId),
listRecentMessages(
this.db,
req.botAccountId,
req.peerId,
this.opts.shortHistoryLimit,
persona.id,
),
listMemories(this.db, req.botAccountId, req.peerId, persona.id),
]);
if (!systemPrompt) {
return { kind: "reject", text: this.opts.noPersonaReply };
}
const botName = bot?.display_name?.trim() || "助手";
const stickers =
this.opts.stickersEnabled === false || !bot?.owner_user_id
? []
: await listStickersForOwnerPrompt(this.db, bot.owner_user_id);
const proactiveQuery = history
.slice(-6)
.map((m) => m.content)
.filter(Boolean)
.join("\n");
const selectedMemories = selectMemoriesForPrompt(
memories,
proactiveQuery || "主动联系",
{
topK: this.opts.memoryTopK,
fullInjectMax: this.opts.memoryFullInjectMax,
},
);
const messages = buildProactiveMessages({
systemPrompt,
memories: selectedMemories,
history,
idleHours: req.idleHours,
botName,
multiBubbleJson: this.primaryMultiBubbleJson(),
stickers,
timeToolEnabled: this.opts.timeToolEnabled !== false,
});
const { client: chatClient, callOpts } = await this.resolveChatClient({
persona,
ownerUserId: bot?.owner_user_id,
});
// Proactive: keep tools minimal (no web search spam)
callOpts.tools = (callOpts.tools || []).filter((t) => t !== "web_search");
callOpts.webSearch = undefined;
const usage = await chatClient.chatWithUsage(messages, callOpts);
const owner = bot?.owner_user_id
? await getUser(this.db, bot.owner_user_id)
: undefined;
await recordTokenUsage(this.db, {
userId: bot?.owner_user_id,
botId: req.botAccountId,
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
username: owner?.username,
botName: bot?.display_name,
});
const skip = parseProactiveSkip(usage.text);
if (skip.skip) {
return {
kind: "skip",
skipReason: skip.reason ?? "model_skip",
personaId: persona.id,
personaSlug: persona.slug,
};
}
const finalized = await this.finalizeReplyParts({
rawLlmText: usage.text,
stickers,
botAccountId: req.botAccountId,
ownerUserId: bot?.owner_user_id,
ownerUsername: owner?.username,
botName: bot?.display_name,
});
const parts = finalized.parts;
const bubbles = finalized.bubbles;
const displayText = finalized.displayText.trim();
if (!displayText) {
return {
kind: "skip",
skipReason: "empty_reply",
personaId: persona.id,
personaSlug: persona.slug,
};
}
await insertMessage(this.db, {
botAccountId: req.botAccountId,
peerId: req.peerId,
personaId: persona.id,
role: "assistant",
content: displayText,
contextToken: req.contextToken,
});
await touchPeerActivity(this.db, req.botAccountId, req.peerId);
return {
kind: "reply",
text: displayText,
bubbles,
parts,
bubblesFromJson: finalized.bubblesFromJson,
personaId: persona.id,
personaSlug: persona.slug,
};
}
async extractMemory(
botAccountId: string,
peerId: string,
personaId: string,
): Promise<void> {
const history = await listRecentMessages(
this.db,
botAccountId,
peerId,
30,
personaId,
);
const existing = await listMemories(
this.db,
botAccountId,
peerId,
personaId,
);
const msgs = buildMemoryExtractMessages({ history, existing });
const raw = await this.llm.chatWithUsage(msgs);
const parsed = parseFactsJson(raw.text);
// Merge with existing so extraction can keep prior facts the model omitted
const merged = normalizeFactList(
[...existing.map((m) => m.content), ...parsed],
this.opts.memoryMaxItems ?? 100,
);
const bot = await getBotAccount(this.db, botAccountId);
await recordTokenUsage(this.db, {
userId: bot?.owner_user_id,
botId: botAccountId,
promptTokens: raw.promptTokens,
completionTokens: raw.completionTokens,
botName: bot?.display_name,
});
if (merged.length) {
await replaceMemories(this.db, botAccountId, peerId, personaId, merged, {
maxItems: this.opts.memoryMaxItems ?? 100,
});
await writeAudit(this.db, "memory_extracted", "system", {
botAccountId,
peerId,
personaId,
count: merged.length,
});
}
}
async resetMemory(
botAccountId: string,
peerId: string,
personaId?: string,
): Promise<void> {
await clearMemories(this.db, botAccountId, peerId, personaId);
await writeAudit(this.db, "memory_reset", "admin", {
botAccountId,
peerId,
personaId,
});
}
}
@@ -0,0 +1,39 @@
import type { ChatflowGraph } from "./types.js";
/** Default: start → llm → answer */
export function createDefaultChatflowGraph(): ChatflowGraph {
return {
version: 1,
nodes: [
{
id: "start",
type: "start",
label: "开始",
data: {},
},
{
id: "llm",
type: "llm",
label: "LLM",
data: {
system: "{{system_prompt}}",
prompt:
"对话历史:\n{{history}}\n\n相关记忆:\n{{memories}}\n\n用户:{{query}}",
temperature: 0.8,
},
},
{
id: "answer",
type: "answer",
label: "回复",
data: {
answer: "{{llm.text}}",
},
},
],
edges: [
{ id: "e1", source: "start", target: "llm" },
{ id: "e2", source: "llm", target: "answer" },
],
};
}
@@ -0,0 +1,317 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import type { AddressInfo } from "node:net";
import type { LlmClient } from "@wechat-ai/llm";
import { ChatflowEngine } from "./engine.js";
import { ChatflowError, type ChatflowGraph } from "./types.js";
/**
* The http node never touches the LLM, so a bare stub is enough constructing
* a real LlmClient would drag credentials into a unit test for no gain.
*/
const stubLlm = {} as LlmClient;
function httpGraph(url: string, extra: Record<string, unknown> = {}): ChatflowGraph {
return {
version: 1,
nodes: [
{ id: "start", type: "start", data: {} },
{ id: "call", type: "http", data: { url, method: "GET", ...extra } },
{ id: "answer", type: "answer", data: { answer: "{{call.text}}" } },
],
edges: [
{ id: "e1", source: "start", target: "call" },
{ id: "e2", source: "call", target: "answer" },
],
};
}
const RUN_INPUT = {
userText: "hi",
botName: "bot",
systemPrompt: "sys",
history: [],
memories: [],
};
/** Records what each request looked like so header handling can be asserted. */
interface Recorder {
server: http.Server;
port: number;
seen: Array<{ url: string; auth: string | undefined }>;
}
async function startServer(
handler: (req: http.IncomingMessage, res: http.ServerResponse, rec: Recorder) => void,
): Promise<Recorder> {
const rec: Recorder = { server: null as unknown as http.Server, port: 0, seen: [] };
rec.server = http.createServer((req, res) => {
rec.seen.push({ url: req.url ?? "", auth: req.headers.authorization });
handler(req, res, rec);
});
await new Promise<void>((resolve) => rec.server.listen(0, "127.0.0.1", resolve));
rec.port = (rec.server.address() as AddressInfo).port;
return rec;
}
async function stopServer(rec: Recorder | undefined): Promise<void> {
if (!rec) return;
await new Promise<void>((resolve) => rec.server.close(() => resolve()));
}
describe("chatflow http node: allowlist gating", () => {
it("blocks everything when the allowlist and tools host are both empty", async () => {
const engine = new ChatflowEngine({ platformLlm: stubLlm, httpAllowHosts: [] });
await assert.rejects(
() => engine.run(httpGraph("https://api.example.com/x"), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.equal(err.code, "http_blocked");
assert.match(err.message, /no TOOLS_BASE_URL/);
return true;
},
);
});
it("still refuses a host that is not listed", async () => {
const engine = new ChatflowEngine({
platformLlm: stubLlm,
httpAllowHosts: ["api.allowed.com"],
});
await assert.rejects(
() => engine.run(httpGraph("https://api.evil.com/x"), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.match(err.message, /not allowlisted/);
return true;
},
);
});
it("`*` does NOT open up internal space", async () => {
const engine = new ChatflowEngine({ platformLlm: stubLlm, httpAllowHosts: ["*"] });
for (const target of [
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://100.100.100.200/latest/meta-data/",
"http://127.0.0.1:8000/admin",
"http://10.0.0.5/",
"http://192.168.1.1/",
"http://[::1]/",
"http://2130706433/",
"http://metadata.google.internal/computeMetadata/v1/",
"http://redis/",
]) {
await assert.rejects(
() => engine.run(httpGraph(target), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError, `${target} should be a ChatflowError`);
assert.equal(err.code, "http_blocked", `${target} should be http_blocked`);
assert.match(err.message, /private host blocked/, target);
return true;
},
`expected ${target} to be refused`,
);
}
});
it("`*` still rejects non-http schemes", async () => {
const engine = new ChatflowEngine({ platformLlm: stubLlm, httpAllowHosts: ["*"] });
await assert.rejects(
() => engine.run(httpGraph("file:///etc/passwd"), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.match(err.message, /only allows http/);
return true;
},
);
});
});
describe("chatflow http node: redirects", () => {
let target: Recorder | undefined;
before(async () => {
target = await startServer((req, res) => {
if (req.url === "/ok") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("payload-body");
return;
}
if (req.url === "/to-metadata") {
res.writeHead(302, { Location: "http://169.254.169.254/latest/meta-data/" });
res.end();
return;
}
if (req.url === "/to-loopback") {
res.writeHead(302, { Location: "http://127.0.0.1:9/" });
res.end();
return;
}
if (req.url === "/loop") {
res.writeHead(302, { Location: "/loop" });
res.end();
return;
}
res.writeHead(404);
res.end("nope");
});
});
after(async () => {
await stopServer(target);
});
/** The tools host is exempt, which lets a loopback test server stand in for an allowed origin. */
function engineForLocal(): ChatflowEngine {
return new ChatflowEngine({
platformLlm: stubLlm,
toolsBaseUrl: `http://127.0.0.1:${target!.port}`,
httpAllowHosts: ["*"],
});
}
it("fetches an allowed host and exposes the body to the flow", async () => {
const out = await engineForLocal().run(
httpGraph(`http://127.0.0.1:${target!.port}/ok`),
RUN_INPUT,
);
assert.equal(out.text, "payload-body");
});
it("refuses a redirect into cloud metadata", async () => {
// The whole point: the guard sees only the first URL unless hops are
// re-checked, so without redirect: "manual" this would succeed.
await assert.rejects(
() =>
engineForLocal().run(
httpGraph(`http://127.0.0.1:${target!.port}/to-metadata`),
RUN_INPUT,
),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.equal(err.code, "http_blocked");
assert.match(err.message, /169\.254\.169\.254/);
return true;
},
);
});
it("refuses a redirect to a loopback port that is not the tools host", async () => {
await assert.rejects(
() =>
engineForLocal().run(
httpGraph(`http://127.0.0.1:${target!.port}/to-loopback`),
RUN_INPUT,
),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.equal(err.code, "http_blocked");
return true;
},
);
});
it("caps a redirect loop instead of spinning", async () => {
await assert.rejects(
() =>
engineForLocal().run(
httpGraph(`http://127.0.0.1:${target!.port}/loop`),
RUN_INPUT,
),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.match(err.message, /exceeded \d+ redirects/);
return true;
},
);
});
});
describe("chatflow http node: credential handling", () => {
let toolsSrv: Recorder | undefined;
let otherSrv: Recorder | undefined;
before(async () => {
otherSrv = await startServer((_req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("other-origin");
});
toolsSrv = await startServer((req, res) => {
if (req.url === "/hop") {
res.writeHead(302, { Location: `http://127.0.0.1:${otherSrv!.port}/landed` });
res.end();
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("tools-origin");
});
});
after(async () => {
await stopServer(toolsSrv);
await stopServer(otherSrv);
});
it("sends the tools key to the tools host", async () => {
const engine = new ChatflowEngine({
platformLlm: stubLlm,
toolsBaseUrl: `http://127.0.0.1:${toolsSrv!.port}`,
toolsApiKey: "secret-tools-key",
httpAllowHosts: ["*"],
});
await engine.run(httpGraph(`http://127.0.0.1:${toolsSrv!.port}/direct`), RUN_INPUT);
assert.equal(toolsSrv!.seen.at(-1)?.auth, "Bearer secret-tools-key");
});
it("cannot be redirected off the tools origin to leak the key", async () => {
// Only the exact tools host:port is exempt from the internal-space check,
// so a hop to any other loopback port dies before a socket is opened —
// which is also why the second server must see no request at all.
//
// (fetchHttpNode additionally strips Authorization whenever the origin
// changes. That path only matters for public->public redirects, which a
// unit test cannot exercise without real network egress; the assertion
// below is the reachable half of the same invariant.)
const engine = new ChatflowEngine({
platformLlm: stubLlm,
toolsBaseUrl: `http://127.0.0.1:${toolsSrv!.port}`,
toolsApiKey: "secret-tools-key",
httpAllowHosts: ["*"],
});
await assert.rejects(
() => engine.run(httpGraph(`http://127.0.0.1:${toolsSrv!.port}/hop`), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.equal(err.code, "http_blocked");
return true;
},
);
assert.deepEqual(
otherSrv!.seen,
[],
"the redirect target must never have been contacted",
);
});
it("refuses a sibling port on the tools machine", async () => {
// The regression this pins: the exemption used to match on hostname only,
// so with a loopback TOOLS_BASE_URL every other local port — including
// this service's own API — was reachable through an http node.
const engine = new ChatflowEngine({
platformLlm: stubLlm,
toolsBaseUrl: `http://127.0.0.1:${toolsSrv!.port}`,
toolsApiKey: "secret-tools-key",
httpAllowHosts: ["*"],
});
await assert.rejects(
() => engine.run(httpGraph(`http://127.0.0.1:${otherSrv!.port}/landed`), RUN_INPUT),
(err: unknown) => {
assert.ok(err instanceof ChatflowError);
assert.equal(err.code, "http_blocked");
assert.match(err.message, /loopback/);
return true;
},
);
});
});
+574
View File
@@ -0,0 +1,574 @@
import {
LlmClient,
WebSearchClient,
type LlmUpstream,
} from "@wechat-ai/llm";
import { createDefaultChatflowGraph } from "./default-graph.js";
import {
ChatflowError,
type ChatflowGraph,
type ChatflowNodeBase,
type ChatflowRunInput,
type ChatflowRunResult,
} from "./types.js";
import {
evalCondition,
renderTemplate,
validateChatflowGraph,
} from "./validate.js";
import {
ALLOW_ANY_HOST,
allowsAnyHost,
blockedHostReason,
blockedResolvedReason,
normalizeHost,
} from "./http-guard.js";
/** Wall clock for one http node. Tools gateway calls are the only sanctioned target. */
const HTTP_NODE_TIMEOUT_MS = Number(
process.env.CHATFLOW_HTTP_TIMEOUT_MS ?? "15000",
);
/** Redirect hops an http node may take. Every hop is re-checked by the guard. */
const HTTP_NODE_MAX_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
export interface ChatflowEngineOptions {
/** Platform LLM client (admin path; also used when no upstream). */
platformLlm: LlmClient;
toolsBaseUrl?: string;
toolsApiKey?: string;
webSearchEnabled?: boolean;
webSearchMaxResults?: number;
/** Wall clock for one tools-gateway search call */
toolsTimeoutMs?: number;
maxSteps?: number;
maxNodes?: number;
/**
* Hostnames allowed for http nodes (default: host of toolsBaseUrl only).
* Prevents bypass of AI gateway via arbitrary HTTP.
*/
httpAllowHosts?: string[];
timeZone?: string;
}
export class ChatflowEngine {
private opts: Required<
Pick<
ChatflowEngineOptions,
| "maxSteps"
| "maxNodes"
| "webSearchEnabled"
| "webSearchMaxResults"
| "timeZone"
>
> &
ChatflowEngineOptions;
constructor(opts: ChatflowEngineOptions) {
this.opts = {
...opts,
maxSteps: opts.maxSteps ?? 32,
maxNodes: opts.maxNodes ?? 40,
webSearchEnabled: opts.webSearchEnabled === true,
webSearchMaxResults: opts.webSearchMaxResults ?? 5,
timeZone: opts.timeZone || "Asia/Shanghai",
};
}
/**
* Merge admin-editable options in place (runtime settings reload).
* `platformLlm` is intentionally not settable here swap it via the
* owning service so every holder is updated together.
*/
applyOptions(patch: Partial<Omit<ChatflowEngineOptions, "platformLlm">>): void {
if (patch.toolsBaseUrl !== undefined) this.opts.toolsBaseUrl = patch.toolsBaseUrl;
if (patch.toolsApiKey !== undefined) this.opts.toolsApiKey = patch.toolsApiKey;
if (patch.toolsTimeoutMs !== undefined) {
this.opts.toolsTimeoutMs = patch.toolsTimeoutMs;
}
if (patch.webSearchEnabled !== undefined) {
this.opts.webSearchEnabled = patch.webSearchEnabled === true;
}
if (patch.webSearchMaxResults !== undefined) {
this.opts.webSearchMaxResults = patch.webSearchMaxResults;
}
if (patch.maxSteps !== undefined) this.opts.maxSteps = patch.maxSteps;
if (patch.maxNodes !== undefined) this.opts.maxNodes = patch.maxNodes;
if (patch.httpAllowHosts !== undefined) {
this.opts.httpAllowHosts = patch.httpAllowHosts;
}
if (patch.timeZone !== undefined) {
this.opts.timeZone = patch.timeZone || "Asia/Shanghai";
}
}
async run(
graphRaw: unknown | null | undefined,
input: ChatflowRunInput,
): Promise<ChatflowRunResult> {
const graph =
graphRaw && typeof graphRaw === "object"
? (graphRaw as ChatflowGraph)
: createDefaultChatflowGraph();
const validated = validateChatflowGraph(graph, {
maxNodes: this.opts.maxNodes,
});
const { graph: g, startId } = validated;
const byId = new Map(g.nodes.map((n) => [n.id, n]));
const historyText = (input.history || [])
.map((m) => `${m.role}: ${m.content}`)
.join("\n")
.slice(0, 12_000);
const memoriesText = (input.memories || []).join("\n") || "(无)";
const vars: Record<string, unknown> = {
query: input.userText,
user_input: input.userText,
bot_name: input.botName,
system_prompt: input.systemPrompt,
history: historyText,
memories: memoriesText,
};
let promptTokens = 0;
let completionTokens = 0;
const trace: string[] = [];
let steps = 0;
let current: string | null = startId;
let finalAnswer: string | null = null;
const outgoing = (nodeId: string, handle?: string | null) => {
const edges = g.edges.filter((e) => e.source === nodeId);
if (handle) {
const hit = edges.find(
(e) => (e.sourceHandle || "true") === handle,
);
if (hit) return hit.target;
}
const def = edges.find((e) => !e.sourceHandle || e.sourceHandle === "default");
if (def) return def.target;
return edges[0]?.target ?? null;
};
while (current) {
steps += 1;
if (steps > this.opts.maxSteps) {
throw new ChatflowError(
"max_steps",
`chatflow exceeded max steps (${this.opts.maxSteps})`,
);
}
const node = byId.get(current);
if (!node) {
throw new ChatflowError("node", `missing node ${current}`);
}
trace.push(`enter:${node.type}:${node.id}`);
if (node.type === "start") {
current = outgoing(node.id);
continue;
}
if (node.type === "memory") {
// Re-expose memories (already selected by caller); optional kind filter later
vars[node.id] = { text: memoriesText, items: input.memories };
vars.memories = memoriesText;
current = outgoing(node.id);
continue;
}
if (node.type === "if-else") {
const cond = String(
(node.data?.condition as string) ||
(node.data?.expr as string) ||
"",
);
const ok = evalCondition(renderTemplate(cond, vars), vars);
vars[node.id] = { result: ok };
current = outgoing(node.id, ok ? "true" : "false");
if (!current) {
current = outgoing(node.id, ok ? "false" : "true");
}
continue;
}
if (node.type === "search") {
const q = renderTemplate(
String((node.data?.query as string) || "{{query}}"),
vars,
);
if (!this.opts.webSearchEnabled || !input.webSearchEnabled) {
throw new ChatflowError(
"search_disabled",
"web search disabled (persona or WEB_SEARCH_ENABLED)",
);
}
if (!this.opts.toolsBaseUrl) {
throw new ChatflowError(
"search_disabled",
"TOOLS_BASE_URL required for search node",
);
}
const client = new WebSearchClient({
toolsBaseUrl: this.opts.toolsBaseUrl,
toolsApiKey: this.opts.toolsApiKey || "",
timeoutMs: this.opts.toolsTimeoutMs,
});
const maxR = Number(
node.data?.max_results ?? this.opts.webSearchMaxResults,
);
const hits = await client.search(q, maxR);
const text = hits
.map(
(h, i) =>
`${i + 1}. ${h.title}\n${h.url}\n${h.snippet}`,
)
.join("\n\n");
vars[node.id] = { text, hits, query: q };
vars.search = text;
current = outgoing(node.id);
continue;
}
if (node.type === "http") {
const urlTpl = String((node.data?.url as string) || "");
const url = renderTemplate(urlTpl, vars).trim();
await this.assertHttpAllowed(url);
const method = String(
(node.data?.method as string) || "POST",
).toUpperCase();
const headersRaw = (node.data?.headers as Record<string, string>) || {};
const headers: Record<string, string> = {
"Content-Type": "application/json",
"User-Agent": "WeChat-AI-Chatflow/1.0",
};
for (const [k, v] of Object.entries(headersRaw)) {
headers[k] = renderTemplate(String(v), vars);
}
if (this.opts.toolsApiKey && !headers.Authorization) {
// Only inject when targeting the tools gateway itself — host AND
// port, so a different service on the same machine never sees the key.
try {
const host = new URL(url).host.toLowerCase();
const toolsHostPort = this.toolsHostPort();
if (toolsHostPort && host === toolsHostPort) {
headers.Authorization = `Bearer ${this.opts.toolsApiKey}`;
}
} catch {
/* ignore */
}
}
let body: string | undefined;
if (method !== "GET" && method !== "HEAD") {
const bodyTpl =
(node.data?.body as string) ||
JSON.stringify({ query: "{{query}}" });
body = renderTemplate(bodyTpl, vars);
}
// Bounded: an unresponsive endpoint here would otherwise hold the
// reply-consumer slot and the bot:peer chain open indefinitely.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), HTTP_NODE_TIMEOUT_MS);
let resp: Response;
let respText: string;
try {
resp = await this.fetchHttpNode(
url,
{ method, headers, body },
ctrl.signal,
);
respText = await resp.text();
} catch (err: unknown) {
// A guard rejection — a redirect into internal space, say — is a hard
// stop. Letting it fall through to http_error would turn a blocked
// SSRF attempt into a branch the flow can quietly carry on from.
if (err instanceof ChatflowError) throw err;
const message = ctrl.signal.aborted
? `timeout after ${HTTP_NODE_TIMEOUT_MS}ms`
: err instanceof Error
? err.message
: String(err);
vars[node.id] = { status: 0, text: `http_error: ${message}`, json: null };
trace.push(`http_error:${message}`);
current = outgoing(node.id);
continue;
} finally {
clearTimeout(timer);
}
let json: unknown = null;
try {
json = JSON.parse(respText);
} catch {
json = null;
}
vars[node.id] = {
status: resp.status,
text: respText.slice(0, 50_000),
json,
};
if (!resp.ok) {
trace.push(`http_error:${resp.status}`);
}
current = outgoing(node.id);
continue;
}
if (node.type === "llm") {
const system = renderTemplate(
String(
(node.data?.system as string) ||
(node.data?.system_prompt as string) ||
"{{system_prompt}}",
),
vars,
);
const prompt = renderTemplate(
String(
(node.data?.prompt as string) ||
(node.data?.user as string) ||
"{{query}}",
),
vars,
);
const temperature =
typeof node.data?.temperature === "number"
? node.data.temperature
: 0.8;
const client = this.resolveLlmClient(input.upstream ?? null);
const messages = [
{ role: "system" as const, content: system },
{ role: "user" as const, content: prompt },
];
const usage = await client.chatWithUsage(messages, {
tools: [],
timeZone: this.opts.timeZone,
// force temperature via... LlmClient uses constructor temp; ok for MVP
});
// Note: LlmClient temperature is fixed at construct; forUserUpstream uses defaults.
void temperature;
promptTokens += usage.promptTokens;
completionTokens += usage.completionTokens;
const text = usage.text.trim();
vars[node.id] = { text, model: usage.model };
vars.llm_text = text;
current = outgoing(node.id);
continue;
}
if (node.type === "answer") {
const ans = renderTemplate(
String(
(node.data?.answer as string) ||
(node.data?.text as string) ||
"{{llm_text}}",
),
vars,
).trim();
finalAnswer = ans || finalAnswer;
vars[node.id] = { text: ans };
// Prefer first non-empty answer; stop graph
if (ans) break;
current = outgoing(node.id);
continue;
}
throw new ChatflowError("node", `unsupported node type ${node.type}`);
}
if (!finalAnswer?.trim()) {
throw new ChatflowError("no_answer", "chatflow produced no answer");
}
return {
text: finalAnswer.trim(),
trace,
steps,
promptTokens,
completionTokens,
};
}
private resolveLlmClient(upstream: LlmUpstream | null): LlmClient {
if (upstream) {
if (!this.opts.toolsBaseUrl || !this.opts.toolsApiKey) {
throw new ChatflowError(
"node",
"User custom LLM requires TOOLS_BASE_URL and TOOLS_API_KEY",
);
}
return LlmClient.forUserUpstream({
toolsBaseUrl: this.opts.toolsBaseUrl,
toolsApiKey: this.opts.toolsApiKey,
upstream,
});
}
return this.opts.platformLlm;
}
private toolsHost(): string | null {
const base = (this.opts.toolsBaseUrl || "").trim();
if (!base) return null;
try {
return new URL(base).hostname.toLowerCase();
} catch {
return null;
}
}
/**
* Tools gateway host *including port*, for the checks where the port is the
* whole point.
*
* The internal-space exemption and the Authorization injection must both
* match on host+port, not hostname. A local tools container is normally
* TOOLS_BASE_URL=http://127.0.0.1:7860; matching on hostname alone would
* exempt every other port on loopback too this service's own API among
* them and would hand the tools key to whatever is listening there.
*/
private toolsHostPort(): string | null {
const base = (this.opts.toolsBaseUrl || "").trim();
if (!base) return null;
try {
return new URL(base).host.toLowerCase();
} catch {
return null;
}
}
private async assertHttpAllowed(url: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new ChatflowError("http_blocked", "invalid http node URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new ChatflowError("http_blocked", "http node only allows http(s)");
}
// parsed.hostname already drops userinfo and normalises IP encodings, so
// http://[email protected]/ is judged on the address, not the label.
const host = normalizeHost(parsed.hostname);
const entries = (this.opts.httpAllowHosts || [])
.map((h) => h.trim().toLowerCase())
.filter(Boolean);
const anyHost = allowsAnyHost(entries);
const allow = new Set(
entries.filter((h) => h !== ALLOW_ANY_HOST).map((h) => normalizeHost(h)),
);
const toolsHost = this.toolsHost();
if (toolsHost) allow.add(toolsHost);
if (!anyHost) {
// Always allow loopback tools during local dev if tools points there
if (!allow.size) {
throw new ChatflowError(
"http_blocked",
"http node blocked: no TOOLS_BASE_URL / CHATFLOW_HTTP_ALLOWLIST",
);
}
if (!allow.has(host)) {
throw new ChatflowError(
"http_blocked",
`http node host not allowlisted: ${host}`,
);
}
}
// The tools container is a deliberate config, loopback or not — but only
// that exact host:port, not every port on the same machine.
const toolsHostPort = this.toolsHostPort();
if (toolsHostPort && parsed.host.toLowerCase() === toolsHostPort) return;
// Internal space stays blocked on both paths. With `*` this is the only
// barrier left, so it is a range check rather than a list of spellings,
// followed by a look at what the name actually resolves to.
const reason =
blockedHostReason(host) ?? (await blockedResolvedReason(host));
if (reason) {
throw new ChatflowError(
"http_blocked",
`http node private host blocked: ${host} (${reason})`,
);
}
}
/**
* Fetch an http node's URL, re-checking every redirect hop.
*
* `fetch` defaults to redirect: "follow", which would let an allowlisted
* host bounce the request to 169.254.169.254 the guard only ever sees the
* first URL. So hops are taken manually and each one goes back through
* assertHttpAllowed.
*/
private async fetchHttpNode(
startUrl: string,
init: { method: string; headers: Record<string, string>; body?: string },
signal: AbortSignal,
): Promise<Response> {
let url = startUrl;
let method = init.method;
let body = init.body;
let headers = { ...init.headers };
for (let hop = 0; ; hop++) {
await this.assertHttpAllowed(url);
const resp = await fetch(url, {
method,
headers,
body,
signal,
redirect: "manual",
});
if (!REDIRECT_STATUSES.has(resp.status)) return resp;
const location = resp.headers.get("location");
if (!location) return resp;
if (hop >= HTTP_NODE_MAX_REDIRECTS) {
throw new ChatflowError(
"http_blocked",
`http node exceeded ${HTTP_NODE_MAX_REDIRECTS} redirects`,
);
}
let next: URL;
try {
next = new URL(location, url);
} catch {
throw new ChatflowError(
"http_blocked",
`http node redirect to invalid URL: ${location}`,
);
}
// Never carry credentials across an origin change — the Authorization
// header is only ever injected for the tools host.
if (next.host !== new URL(url).host) delete headers.Authorization;
// Mirror fetch's own method rewriting: 303 always downgrades to GET,
// and 301/302 do so for anything that had a body.
if (resp.status === 303 || (body !== undefined && resp.status !== 307 && resp.status !== 308)) {
method = "GET";
body = undefined;
const { "Content-Type": _ct, ...rest } = headers;
headers = rest;
}
url = next.toString();
}
}
}
export function isChatflowGraph(raw: unknown): raw is ChatflowGraph {
try {
validateChatflowGraph(raw);
return true;
} catch {
return false;
}
}
export function summarizeGraph(graph: ChatflowGraph | null | undefined): {
nodeCount: number;
types: string[];
} {
if (!graph?.nodes?.length) return { nodeCount: 0, types: [] };
const types = [...new Set(graph.nodes.map((n: ChatflowNodeBase) => n.type))];
return { nodeCount: graph.nodes.length, types };
}
@@ -0,0 +1,289 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
ALLOW_ANY_HOST,
allowsAnyHost,
blockedHostReason,
blockedResolvedReason,
ipReason,
normalizeHost,
} from "./http-guard.js";
/** Reads better than `!== null` at every call site. */
function blocked(host: string): boolean {
return blockedHostReason(host) !== null;
}
describe("chatflow http guard: public hosts", () => {
it("allows ordinary public names and addresses", () => {
for (const host of [
"api.openai.com",
"example.com",
"sub.domain.example.co.uk",
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"wechat.smnet-ai.asia",
"2c2ch1u11-share-api-0.hf.space",
// 172.x outside 16-31 is public
"172.15.0.1",
"172.32.0.1",
// 100.x outside the CGNAT block is public
"100.63.255.255",
"100.128.0.1",
// octal-looking but normalises to 8.0.0.1
"010.0.0.1",
"[2606:4700::1111]",
]) {
assert.equal(blockedHostReason(host), null, `expected allowed: ${host}`);
}
});
});
describe("chatflow http guard: loopback and private ranges", () => {
it("blocks loopback in every encoding the URL parser emits", () => {
// The old string check only knew the literal "127.0.0.1".
for (const host of [
"127.0.0.1",
"127.0.0.2",
"127.1.2.3",
"2130706433",
"0x7f.0.0.1",
"0177.0.0.1",
"127.1",
"localhost",
"LOCALHOST",
"[::1]",
"[::ffff:127.0.0.1]",
"[::ffff:7f00:1]",
"[64:ff9b::7f00:1]",
]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("blocks RFC1918 space", () => {
for (const host of [
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.31.255.255",
"192.168.0.1",
"192.168.1.1",
]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("blocks the unspecified address", () => {
for (const host of ["0.0.0.0", "0", "[::]"]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
});
describe("chatflow http guard: cloud metadata", () => {
it("blocks the link-local metadata endpoints", () => {
// AWS / Azure IMDS, and GCP addressed by IP. None of these is a string the
// old blacklist matched.
for (const host of ["169.254.169.254", "169.254.170.2", "169.254.0.1"]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
assert.match(blockedHostReason("169.254.169.254")!, /metadata|link-local/);
});
it("blocks Alibaba Cloud metadata inside the CGNAT block", () => {
assert.ok(blocked("100.100.100.200"));
assert.ok(blocked("100.64.0.1"));
assert.ok(blocked("100.127.255.255"));
});
it("blocks metadata hostnames including the GCP one", () => {
for (const host of [
"metadata",
"metadata.google.internal",
"anything.internal",
]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
});
describe("chatflow http guard: intranet names", () => {
it("blocks single-label hosts such as docker service names", () => {
for (const host of ["wechat-ai-tools", "redis", "db", "kubelet"]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("blocks internal suffixes, trailing dot and case included", () => {
for (const host of [
"printer.local",
"EVIL.LOCAL.",
"host.localhost",
"svc.cluster.internal",
"nas.home.arpa",
"fileserver.lan",
"wiki.corp",
]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("blocks IPv6 unique-local and link-local", () => {
for (const host of ["[fd00::1]", "[fc00::1]", "[fe80::1]", "[ff02::1]"]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("blocks hosts it cannot parse rather than passing them through", () => {
for (const host of ["", " ", "a b", "[not:an:ip", "%%%"]) {
assert.ok(blocked(host), `expected blocked: ${JSON.stringify(host)}`);
}
});
});
describe("chatflow http guard: gaps found by the SSRF audit", () => {
it("blocks all of fe00::/8, not just fe80::/10", () => {
// Regression: the mask `(b[1] & 0xc0) === 0x80` matched fe80-febf only, so
// site-local fec0::/10 and the unassigned fe00-fe7f block passed through.
for (const host of [
"[fe00::1]",
"[fe40::1]",
"[fe7f::1]",
"[fec0::1]",
"[fedf::1]",
"[feff:ffff::1]",
"[fe80::1]",
]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
});
it("default-denies IPv6 outside global unicast 2000::/3", () => {
for (const host of ["[100::1]", "[0100::1]", "[4000::1]", "[1000::1]"]) {
assert.ok(blocked(host), `expected blocked: ${host}`);
}
// ...while real global unicast stays reachable.
for (const host of ["[2606:4700::1111]", "[2001:4860:4860::8888]", "[3fff::1]"]) {
assert.equal(blockedHostReason(host), null, `expected allowed: ${host}`);
}
});
it("blocks cloud metadata endpoints that use a public-looking name", () => {
// metadata.tencentyun.com is neither an internal suffix nor an IP range,
// and it serves CVM role credentials without a token step.
assert.ok(blocked("metadata.tencentyun.com"));
assert.ok(blocked("metadata.goog"));
assert.ok(blocked("instance-data"));
});
it("blocks Kubernetes .svc names", () => {
assert.ok(blocked("kubernetes.default.svc"));
assert.ok(blocked("redis.default.svc"));
});
it("ipReason judges v4 and v6 literals identically to the host check", () => {
assert.match(ipReason("127.0.0.1")!, /loopback/);
assert.match(ipReason("169.254.169.254")!, /metadata|link-local/);
assert.match(ipReason("100.100.100.200")!, /metadata|NAT/);
assert.match(ipReason("fec0::1")!, /fe00::\/8/);
assert.equal(ipReason("8.8.8.8"), null);
assert.equal(ipReason("2606:4700::1111"), null);
assert.equal(ipReason("not-an-ip"), null);
});
});
describe("chatflow http guard: DNS resolution", () => {
/** Fixed answers — a real resolver would make these tests measure the network. */
const fakeDns = (map: Record<string, string[]>) => async (host: string) => {
const hit = map[host];
if (!hit) throw new Error("ENOTFOUND");
return hit;
};
it("blocks a public name that resolves into internal space", async () => {
// Lexically these are ordinary .io names; only the answer gives them away.
// nip.io-style wildcard resolvers need no attacker infrastructure at all.
const dns = fakeDns({
"169-254-169-254.nip.io": ["169.254.169.254"],
"100-100-100-200.nip.io": ["100.100.100.200"],
"evil.example.com": ["10.0.0.5"],
"wechat-ai-tools.wechat-ai_default": ["172.17.0.3"],
});
const imds = await blockedResolvedReason("169-254-169-254.nip.io", dns);
assert.match(imds!, /169\.254\.169\.254/);
assert.match(imds!, /link-local|metadata/);
assert.match(
(await blockedResolvedReason("100-100-100-200.nip.io", dns))!,
/100\.100\.100\.200/,
);
assert.match(
(await blockedResolvedReason("evil.example.com", dns))!,
/10\.0\.0\.5/,
);
// Dotted Docker service name — the single-label rule cannot see this one.
assert.match(
(await blockedResolvedReason("wechat-ai-tools.wechat-ai_default", dns))!,
/172\.17\.0\.3/,
);
});
it("blocks when ANY answer is internal, not just the first", async () => {
const dns = fakeDns({ "split.example.com": ["93.184.216.34", "10.1.2.3"] });
assert.match(
(await blockedResolvedReason("split.example.com", dns))!,
/10\.1\.2\.3/,
);
});
it("blocks an IPv6 answer in internal space", async () => {
const dns = fakeDns({ "v6.example.com": ["fec0::1"] });
assert.match((await blockedResolvedReason("v6.example.com", dns))!, /fe00::\/8/);
});
it("allows a name that resolves to public addresses", async () => {
const dns = fakeDns({ "api.example.com": ["93.184.216.34", "2606:4700::1111"] });
assert.equal(await blockedResolvedReason("api.example.com", dns), null);
});
it("does not turn an unresolvable name into a block", async () => {
// Nothing can egress to a name that will not resolve, and a DNS blip must
// not abort the whole flow with a hard http_blocked.
const dns = fakeDns({});
assert.equal(await blockedResolvedReason("nope.example.com", dns), null);
});
it("skips resolution for literals, which were already judged", async () => {
let called = false;
const dns = async (_h: string) => {
called = true;
return ["10.0.0.1"];
};
assert.equal(await blockedResolvedReason("8.8.8.8", dns), null);
assert.equal(await blockedResolvedReason("127.0.0.1", dns), null);
assert.equal(await blockedResolvedReason("[::1]", dns), null);
assert.equal(called, false, "literals must not hit the resolver");
});
});
describe("chatflow http guard: helpers", () => {
it("normalizeHost unbrackets, lowercases and drops the trailing dot", () => {
assert.equal(normalizeHost(" EXAMPLE.COM. "), "example.com");
assert.equal(normalizeHost("[::1]"), "::1");
assert.equal(normalizeHost("evil.local..."), "evil.local");
});
it("allowsAnyHost only fires on the sentinel", () => {
assert.equal(allowsAnyHost([ALLOW_ANY_HOST]), true);
assert.equal(allowsAnyHost([" * "]), true);
assert.equal(allowsAnyHost(["api.example.com", "*"]), true);
assert.equal(allowsAnyHost(["api.example.com"]), false);
assert.equal(allowsAnyHost([]), false);
assert.equal(allowsAnyHost(undefined), false);
// Not a wildcard matcher — only the bare sentinel opens things up.
assert.equal(allowsAnyHost(["*.example.com"]), false);
});
});
+306
View File
@@ -0,0 +1,306 @@
import { lookup as dnsLookup } from "node:dns/promises";
/**
* Egress guard for chatflow `http` nodes.
*
* Split out of engine.ts because this is the only thing standing between a
* user-authored URL and a server-side fetch: chatflow graphs are written by
* persona owners (any registered user see the PUT graph route in
* apps/api/src/routes.ts), and the http node's response body is fed back into
* the flow's vars, so an unguarded fetch is a *readable* SSRF primitive, not a
* blind one. Worth unit-testing on its own.
*
* The old guard compared the hostname against a handful of literal strings
* ("localhost", "127.0.0.1", "0.0.0.0", "*.local"). That is sound only while an
* exact-match allowlist does the real work. Once CHATFLOW_HTTP_ALLOWLIST is
* opened up with `*`, this file becomes the barrier, so it checks address
* ranges instead of spellings 169.254.169.254 and 100.100.100.200 are the
* addresses that actually matter and neither is a string the old list caught.
*/
/**
* Allowlist entry meaning "any public host". Cannot collide with a real
* hostname: `*` is not a legal DNS label, and WHATWG URL parsing rejects it in
* an authority, so no reachable host can ever equal this sentinel.
*/
export const ALLOW_ANY_HOST = "*";
/**
* Suffixes that never resolve to anything public. `.internal` covers GCP's
* metadata.google.internal; `.local` is mDNS; the rest are conventional
* intranet suffixes that are not delegated TLDs.
*/
const INTERNAL_SUFFIXES = [
".local",
".localhost",
".internal",
".home.arpa",
".lan",
".intranet",
".corp",
".private",
// Kubernetes service DNS (kubernetes.default.svc, svc.cluster.local).
".svc",
];
/**
* Metadata endpoints that answer on a *public-looking* name, so neither the IP
* ranges nor the internal suffixes catch them. Tencent's is the one that
* matters here this deployment is China-facing, and metadata.tencentyun.com
* hands out CVM role credentials with no token step.
*/
const METADATA_HOSTS = new Set([
"metadata.tencentyun.com",
"metadata.google.internal",
"metadata.goog",
"instance-data",
]);
/**
* Lowercase, unbracket, and drop the trailing dot.
*
* All three matter: WHATWG returns IPv6 hosts wrapped in brackets
* (`new URL("http://[::1]/").hostname === "[::1]"`), and "evil.local." is the
* same name to DNS but not to endsWith(".local").
*/
export function normalizeHost(raw: string): string {
let h = (raw ?? "").trim().toLowerCase();
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
while (h.endsWith(".")) h = h.slice(0, -1);
return h;
}
/**
* Re-run a bare hostname through WHATWG URL parsing.
*
* This is what collapses the classic alternate IPv4 encodings 2130706433,
* 0x7f.0.0.1, 0177.1 all normalise to dotted-quad 127.x so the range checks
* below only ever have to understand one form. Reusing the platform parser
* beats hand-rolling inet_aton. Returns null when the host is unparseable,
* which callers must treat as "block".
*/
function canonicalizeHost(host: string): string | null {
if (!host) return null;
try {
const probe = new URL(`http://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}/`);
return normalizeHost(probe.hostname);
} catch {
return null;
}
}
/** Dotted-quad only — the sole IPv4 form WHATWG URL emits. */
function parseIpv4(host: string): number[] | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
if (!m) return null;
const parts = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
return parts.some((n) => n > 255) ? null : parts;
}
/** Returns 16 bytes, expanding `::` and any embedded IPv4 tail. */
function parseIpv6(host: string): number[] | null {
if (!host.includes(":")) return null;
// A zone id has no business in a URL; refuse rather than guess.
if (host.includes("%")) return null;
const halves = host.split("::");
if (halves.length > 2) return null;
const toGroups = (s: string): number[] | null => {
if (!s) return [];
const out: number[] = [];
for (const part of s.split(":")) {
if (part === "") return null;
if (part.includes(".")) {
const v4 = parseIpv4(part);
if (!v4) return null;
out.push((v4[0]! << 8) | v4[1]!, (v4[2]! << 8) | v4[3]!);
continue;
}
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
out.push(parseInt(part, 16));
}
return out;
};
const head = toGroups(halves[0] ?? "");
const tail = halves.length === 2 ? toGroups(halves[1] ?? "") : [];
if (!head || !tail) return null;
let groups: number[];
if (halves.length === 2) {
const fill = 8 - head.length - tail.length;
if (fill < 1) return null;
groups = [...head, ...new Array<number>(fill).fill(0), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return null;
const bytes: number[] = [];
for (const g of groups) bytes.push((g >> 8) & 0xff, g & 0xff);
return bytes;
}
/**
* Non-public IPv4 space. Ordered roughly by how likely each is to be the thing
* an attacker actually wants.
*/
function ipv4Reason(b: number[]): string | null {
const [a, c] = [b[0]!, b[1]!];
if (a === 127) return "loopback";
if (a === 10) return "private range 10/8";
if (a === 172 && c >= 16 && c <= 31) return "private range 172.16/12";
if (a === 192 && c === 168) return "private range 192.168/16";
// 169.254.169.254 (AWS/Azure IMDS, GCP by IP) lives here.
if (a === 169 && c === 254) return "link-local / cloud metadata";
// 100.64/10 is CGNAT and also holds Alibaba Cloud's 100.100.100.200.
if (a === 100 && c >= 64 && c <= 127) return "carrier-grade NAT / cloud metadata";
if (a === 0) return "unspecified range 0/8";
if (a === 192 && c === 0) return "IETF reserved 192.0/16";
if (a === 198 && (c === 18 || c === 19)) return "benchmarking range";
if (a >= 224) return "multicast / reserved";
return null;
}
function ipv6Reason(b: number[]): string | null {
const zeros = (upTo: number) => b.slice(0, upTo).every((x) => x === 0);
// ::ffff:a.b.c.d — judge the embedded IPv4, or ::ffff:7f00:1 slips through.
if (zeros(10) && b[10] === 0xff && b[11] === 0xff) {
return ipv4Reason(b.slice(12)) ?? null;
}
// NAT64 well-known prefix 64:ff9b::/96 also embeds IPv4.
if (
b[0] === 0x00 && b[1] === 0x64 && b[2] === 0xff && b[3] === 0x9b &&
b.slice(4, 12).every((x) => x === 0)
) {
return ipv4Reason(b.slice(12)) ?? null;
}
if (zeros(12)) {
const last = b.slice(12);
if (last.every((x) => x === 0)) return "unspecified address";
if (last[0] === 0 && last[1] === 0 && last[2] === 0 && last[3] === 1) {
return "loopback";
}
// IPv4-compatible ::a.b.c.d
return ipv4Reason(last) ?? null;
}
if ((b[0]! & 0xfe) === 0xfc) return "unique-local fc00::/7";
// All of fe00::/8, not just fe80::/10: the old `(b[1] & 0xc0) === 0x80` mask
// matched fe80-febf only, letting site-local fec0::/10 and the unassigned
// fe00-fe7f block straight through. None of fe00::/8 is globally routable.
if (b[0] === 0xfe) return "link-local / site-local fe00::/8";
if (b[0] === 0xff) return "multicast ff00::/8";
// Default-deny anything outside global unicast 2000::/3. Every publicly
// routable IPv6 address lives there, so this closes the whole long tail of
// reserved and special-purpose prefixes without enumerating them.
if ((b[0]! & 0xe0) !== 0x20) return "outside global unicast 2000::/3";
return null;
}
/**
* Range verdict for one already-literal address, v4 or v6.
*
* Shared by the lexical check and the post-resolution check so both judge
* addresses by exactly the same rules.
*/
export function ipReason(addr: string): string | null {
const host = normalizeHost(addr);
const v4 = parseIpv4(host);
if (v4) return ipv4Reason(v4);
const v6 = parseIpv6(host);
if (v6) return ipv6Reason(v6);
return null;
}
/**
* Why this host must not be fetched, or null when it looks publicly routable.
*
* Lexical only, and deliberately so: it stays synchronous and cheap, and it
* refuses every *direct* attempt at loopback, RFC1918 and the metadata
* endpoints in each encoding the URL parser can produce. Names that merely
* resolve somewhere internal are not its job pair it with
* blockedResolvedReason, which does the looking.
*/
export function blockedHostReason(rawHost: string): string | null {
const host = canonicalizeHost(normalizeHost(rawHost));
if (!host) return "unparseable host";
const v4 = parseIpv4(host);
if (v4) return ipv4Reason(v4);
const v6 = parseIpv6(host);
if (v6) return ipv6Reason(v6);
if (host === "localhost") return "loopback";
if (METADATA_HOSTS.has(host)) return "cloud metadata hostname";
// A public name needs a dot. Single labels are Docker service names, bare
// intranet hostnames, and "metadata" — never anything routable.
if (!host.includes(".")) return "single-label / intranet hostname";
for (const suffix of INTERNAL_SUFFIXES) {
if (host.endsWith(suffix)) return `internal suffix ${suffix}`;
}
return null;
}
/**
* Resolve the name and judge what it actually points at.
*
* The lexical pass above cannot see through DNS, and that is where the real
* evasions live: `169-254-169-254.nip.io` is a perfectly ordinary `.io` name
* that resolves to the IMDS address, an attacker's own A record can point at
* 10.0.0.5, and Docker's embedded DNS answers `service.network` with a bridge
* address. All three are lexically indistinguishable from a public host, so the
* only way to refuse them is to look.
*
* A resolution failure is deliberately NOT an error: if the name does not
* resolve here it will not resolve for fetch either, so nothing can egress, and
* turning transient DNS trouble into a hard `http_blocked` would abort the whole
* flow over a blip.
*
* Residual gap: this resolves, then fetch resolves again, so an attacker who
* controls authoritative DNS with a very short TTL can still rebind between the
* two lookups. Closing that needs the socket pinned to the address that was
* actually validated an undici Agent with a custom `connect.lookup`, which
* means taking on undici as a dependency. Documented in docs/chatflow.md.
*/
export async function blockedResolvedReason(
rawHost: string,
resolve: HostResolver = defaultResolver,
): Promise<string | null> {
const host = canonicalizeHost(normalizeHost(rawHost));
if (!host) return "unparseable host";
// Literals were already judged directly; resolving them adds nothing.
if (parseIpv4(host) || parseIpv6(host)) return null;
let addresses: string[];
try {
addresses = await resolve(host);
} catch {
return null;
}
for (const address of addresses) {
const reason = ipReason(address);
if (reason) return `${host} resolves to ${address} (${reason})`;
}
return null;
}
/**
* Injectable so the range logic can be tested without a network. Real DNS in a
* test would measure the resolver, not the code some sandboxes answer every
* query with a placeholder address.
*/
export type HostResolver = (host: string) => Promise<string[]>;
const defaultResolver: HostResolver = async (host) => {
const found = await dnsLookup(host, { all: true, verbatim: true });
return found.map((f) => f.address);
};
/** True when the allowlist entries opt into "any public host". */
export function allowsAnyHost(entries: readonly string[] | undefined): boolean {
return (entries ?? []).some((e) => e.trim() === ALLOW_ANY_HOST);
}
+79
View File
@@ -0,0 +1,79 @@
/** Chatflow graph types (Dify-like MVP). */
export type ChatflowNodeType =
| "start"
| "llm"
| "answer"
| "if-else"
| "http"
| "memory"
| "search";
export interface ChatflowNodeBase {
id: string;
type: ChatflowNodeType;
/** Display label in UI */
label?: string;
data?: Record<string, unknown>;
}
export interface ChatflowEdge {
id: string;
source: string;
target: string;
/** For if-else: "true" | "false" | omit for default */
sourceHandle?: string | null;
}
export interface ChatflowGraph {
version: 1;
nodes: ChatflowNodeBase[];
edges: ChatflowEdge[];
}
export interface ChatflowRunInput {
userText: string;
botName: string;
systemPrompt: string;
/** Recent history as role/content pairs */
history: Array<{ role: string; content: string }>;
/** Memory facts already selected for this turn */
memories: string[];
/**
* When true, persona allows search nodes / search tools.
* Still requires global webSearch + tools gateway on engine deps.
*/
webSearchEnabled?: boolean;
/** Persona llm_provider_id resolved upstream (via tools only). */
upstream?: {
baseUrl: string;
apiKey: string;
model: string;
} | null;
}
export interface ChatflowRunResult {
text: string;
/** Ordered intermediate logs for debugging (not sent to WeChat) */
trace: string[];
steps: number;
promptTokens: number;
completionTokens: number;
}
export class ChatflowError extends Error {
constructor(
public code:
| "invalid_graph"
| "max_steps"
| "no_answer"
| "http_blocked"
| "search_disabled"
| "node"
| "timeout",
message: string,
) {
super(message);
this.name = "ChatflowError";
}
}

Some files were not shown because too many files have changed in this diff Show More