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
+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/**/*"]
}