SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,yml,yaml,md}]
indent_size = 2
[*.{ts,tsx,mts,cts}]
indent_size = 4
[*.py]
indent_size = 4
[*.bat]
end_of_line = crlf
[*.md]
trim_trailing_whitespace = false
+14
View File
@@ -0,0 +1,14 @@
* text=auto eol=lf
*.bat text eol=crlf
*.node binary
*.dll binary
*.so binary
*.dylib binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff2 binary
+144
View File
@@ -0,0 +1,144 @@
name: Bug 反馈
description: 报告一个可复现的问题
title: "[Bug] "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢花时间反馈 Bug。请尽量填全以下信息,方便我们定位问题。
在提交前,请先在 [Issues](https://github.com/SnowLuma/SnowLuma/issues?q=is%3Aissue) 中搜索是否已有相同问题。
- type: checkboxes
id: preflight
attributes:
label: 提交前检查
options:
- label: 我已搜索过 Issues,确认没有重复的反馈
required: true
- label: 我使用的是 [最新 Release](https://github.com/SnowLuma/SnowLuma/releases) 或当前 `dev` 分支构建
required: true
- label: 我已将日志级别调到 TRACE,并在下方「TRACE 日志」一栏附上了复现期间的完整日志
required: true
- type: input
id: snowluma-version
attributes:
label: SnowLuma 版本
description: 启动日志首行或 `package.json` 里的版本号
placeholder: "例如 1.10.2"
validations:
required: true
- type: dropdown
id: adapter
attributes:
label: 出问题的适配器
description: 可多选
multiple: true
options:
- WebSocket Server
- WebSocket Client
- HTTP Server
- HTTP Post
- WebUI
- Bridge / Core(协议层)
- 其他 / 不确定
validations:
required: true
- type: input
id: os
attributes:
label: 操作系统
placeholder: "例如 Windows 11 23H2 / macOS 14.5 / Ubuntu 22.04"
validations:
required: true
- type: dropdown
id: deploy
attributes:
label: 部署方式
description: 如何运行 SnowLuma
options:
- 源码运行(pnpm dev / start)
- 预构建包
- Docker
- 其他 / 不确定
validations:
required: true
- type: input
id: node-version
attributes:
label: Node.js 版本
description: '`node -v` 的输出。如果使用预构建包可填"预构建包"'
placeholder: "例如 v20.11.0 或 预构建包"
validations:
required: true
- type: input
id: qq-version
attributes:
label: QQ 客户端版本
description: QQ NT 客户端的版本号
placeholder: "例如 9.9.15-29722"
- type: textarea
id: description
attributes:
label: 问题描述
description: 简要说明发生了什么
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: 复现步骤
description: 详细列出如何触发这个问题
placeholder: |
1. 启动 SnowLuma 并登录账号 ...
2. 通过 HTTP Server 调用 `send_group_msg` ...
3. 观察到 ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: 期望行为
validations:
required: true
- type: textarea
id: actual
attributes:
label: 实际行为
validations:
required: true
- type: textarea
id: logs
attributes:
label: TRACE 日志 / 错误堆栈
description: |
**必须提供 TRACE 级别日志**,否则我们几乎无法定位问题。SnowLuma 默认日志级别是 `info`,TRACE 不会输出,请务必先开启再复现,务必保留原始报文和完整Trace内容。
开启 TRACE 的两种方式(任选其一):
- **WebUI(推荐)**:进入「日志」页 →「服务端日志级别」切到 `TRACE` → 显示过滤切到「开发」预设(确保 TRACE 行可见,否则导出不含 trace)→ 复现问题 → 点「导出当前视图」得到 `snowluma-logs-*.log`,拖到本输入框上传。
- **环境变量**:启动前设置 `SNOWLUMA_LOG_LEVEL=trace` 再运行,复现后复制控制台输出。
贴出的日志里应当能看到 `TRACE` 字样。请注意脱敏:移除 access token、账号密码、私聊内容等敏感信息。
placeholder: |
[2026-06-26 12:00:00.000] TRACE [core] ...
[2026-06-26 12:00:00.001] DEBUG [bridge] ...
render: shell
validations:
required: true
- type: textarea
id: extra
attributes:
label: 补充信息
description: 配置片段(脱敏后)、截图、相关 Issue / PR 链接等
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: QQ 群
url: https://qm.qq.com/q/g3UMLpWALe
about: 日常使用问题、快速交流可以先来 QQ 群
- name: Telegram
url: https://t.me/napcatqq
about: 海外用户交流频道
- name: Releases
url: https://github.com/SnowLuma/SnowLuma/releases
about: 在反馈 Bug 前,请先确认已使用最新发布版本
@@ -0,0 +1,78 @@
name: 功能请求
description: 提议一个新功能或对现有功能的增强
title: "[Feature] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢您的建议。在提交前,请先在 [Issues](https://github.com/SnowLuma/SnowLuma/issues?q=is%3Aissue) 中搜索是否已有类似提案。
- type: checkboxes
id: preflight
attributes:
label: 提交前检查
options:
- label: 我已搜索过 Issues,确认没有重复的提案
required: true
- type: dropdown
id: scope
attributes:
label: 涉及范围
multiple: true
options:
- OneBot 协议 / 消息段
- WebSocket Server / Client 适配器
- HTTP Server / Post 适配器
- WebUI 管理面板
- SDK (`@snowluma/sdk`)
- UI 组件 (`@snowluma/ui`)
- Bridge / Core(协议层)
- 构建 / 发布流程
- 文档
- 其他
validations:
required: true
- type: textarea
id: problem
attributes:
label: 需求场景 / 当前痛点
description: 这个功能要解决什么问题?目前是如何变通处理的?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: 期望方案
description: 您希望它如何工作?如果是 API 改动,建议给出示例输入 / 输出
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 已考虑的替代方案
description: 还想过哪些其他实现路径,为什么放弃
- type: textarea
id: references
attributes:
label: 参考链接
description: 例如 OneBot 规范条目、其他实现的对应特性、相关 Issue / PR
placeholder: |
- OneBot v11 规范: https://github.com/botuniverse/onebot-11/...
- 相关讨论: #xxx
- type: dropdown
id: willingness
attributes:
label: 您是否愿意提交 PR 来实现这个功能?
options:
- 愿意,但需要维护者讨论方案后再动手
- 愿意,已有大致实现思路
- 暂时只提建议
validations:
required: true
+40
View File
@@ -0,0 +1,40 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 120" width="100%" height="100%" style="background-color: transparent;">
<defs>
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@800&amp;display=swap');
.title-font {
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 48px;
font-weight: 800;
letter-spacing: -0.02em;
text-anchor: middle;
}
.star-fx {
animation: floatAnimation 4s ease-in-out infinite;
}
.star-top { transform-origin: 265px 25px; fill: #cec1e6; }
.star-bottom { transform-origin: 45px 95px; animation-delay: 0.5s; fill: #fad6e6; }
@keyframes floatAnimation {
0% { transform: translateY(0px) rotate(0deg); opacity: 0.9; }
50% { transform: translateY(-8px) rotate(10deg); opacity: 1; }
100% { transform: translateY(0px) rotate(0deg); opacity: 0.9; }
}
</style>
</defs>
<g transform="translate(160, 75)">
<text class="title-font" dx="2" dy="2">
<tspan fill="#f9d5e7">Snow</tspan><tspan fill="#a5c9ff">Luma</tspan>
</text>
<text class="title-font" dx="0" dy="0">
<tspan fill="#a5c9ff">Snow</tspan><tspan fill="#cec1e6">Luma</tspan>
</text>
</g>
<path class="star-fx star-top" d="M265,13 L267.8,21.1 L276,24 L267.8,26.9 L265,35 L262.2,26.9 L254,24 L262.2,21.1 Z"/>
<path class="star-fx star-bottom" d="M45,84 L47.1,90.2 L53.5,92.2 L47.1,94.2 L45,100 L42.9,94.2 L36.5,92.2 L42.9,90.2 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+30
View File
@@ -0,0 +1,30 @@
# npm Trusted Publishing
The npm publish workflows use GitHub Actions OIDC instead of a long-lived
`NPM_TOKEN`. Each package must have a matching Trusted Publisher entry on npm
before its first publish after this migration.
For each package (`@snowluma/sdk`, `@snowluma/ui`, and `@snowluma/mcp`), open
the package's npm settings and add a **GitHub Actions** trusted publisher with:
| Field | Value |
| --- | --- |
| Organization or user | `SnowLuma` |
| Repository | `SnowLuma` |
| Workflow filename | The exact filename below |
| Environment | `SnowLuma` |
| Package | Workflow filename |
| --- | --- |
| `@snowluma/sdk` | `sdk-npm-publish.yml` |
| `@snowluma/ui` | `ui-npm-publish.yml` |
| `@snowluma/mcp` | `mcp-npm-publish.yml` |
The workflow job keeps the `SnowLuma` GitHub Environment, so the environment
field must match exactly. The workflows grant `id-token: write`, use Node 24
(which provides a recent enough npm CLI), and publish with provenance. They do
not read `NPM_TOKEN`.
After all three entries are saved, rerun the failed workflow runs. Once a
successful publish is confirmed for every package, the obsolete `NPM_TOKEN`
environment secret can be removed from GitHub Actions settings.
+22
View File
@@ -0,0 +1,22 @@
<!-- 感谢贡献。请填写以下内容,方便评审。 -->
## 关联 Issue
<!-- 必填:在本段填写 Refs #编号 或 Closes #编号(也支持 Fixes / Resolves)。
必须关联至少一个先于本 PR 创建、仍开放且通过模板检查的本仓库 issue。
先通过 GitHub 网页模板提交 issue,或认领已有对应 issue,再可选地提交 PR。
请把关联标记写在本注释之外,不要只保留示例或只填写编号。
目标分支不是 dev,或缺少有效关联 issue 的 PR 会被自动关闭。 -->
## 变更说明
<!-- 简述本 PR 做了什么、为什么这样做。 -->
## 提交前检查
- [ ] 目标分支为 `dev`,正文已关联符合上述要求的 issue
- [ ] 本 PR 聚焦单一目标,未夹带无关改动
- [ ] 已通过 `pnpm lint` 与 `pnpm typecheck`
- [ ] 已补充/更新相关测试,且 `pnpm test` 通过
- [ ] 文件行尾为 LF(未引入 CRLF)
- [ ] PR 描述与实际实现一致
+204
View File
@@ -0,0 +1,204 @@
const COMMENT_MARKER = '<!-- snowluma-pr-policy-guard -->';
const TEMPLATE_LABELS = new Set(['bug', 'enhancement']);
const PRIVILEGED_AUTHORS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
function prose(body) {
let fence = null;
return (body || '')
.replace(/<!--[\s\S]*?(?:-->|$)/g, '')
.split(/\r?\n/)
.map((line) => {
const marker = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
if (fence) {
if (
marker &&
marker[1][0] === fence[0] &&
marker[1].length >= fence.length &&
!marker[2].trim()
) {
fence = null;
}
return '';
}
if (marker) {
fence = marker[1];
return '';
}
if (/^(?: {4}|\t|\s*>)/.test(line)) {
return '';
}
return line;
})
.join('\n')
.replace(/(`+)[\s\S]*?\1/g, '');
}
function referencedIssues(body, context) {
const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
const references = new Set();
const pattern =
/\b(?:refs?|close[sd]?|fix(?:e[sd])?|resolve[sd]?):?[ \t]+(https?:\/\/[^\s<>]+|(?:[\w.-]+\/[\w.-]+)?#[1-9]\d*)(?![\w/#-])/gi;
for (const match of prose(body).matchAll(pattern)) {
const reference = match[1].replace(/[),.;!?]+$/, '');
let number;
if (/^https?:\/\//i.test(reference)) {
let url;
try {
url = new URL(reference);
} catch {
continue;
}
const path = url.pathname.match(/^\/([^/]+\/[^/]+)\/issues\/([1-9]\d*)\/?$/i);
if (!path || url.origin !== context.serverUrl || path[1].toLowerCase() !== repository) {
continue;
}
number = Number(path[2]);
} else {
const [name, id] = reference.split('#');
if (name && name.toLowerCase() !== repository) {
continue;
}
number = Number(id);
}
if (Number.isSafeInteger(number) && number > 0) {
references.add(number);
}
}
return [...references];
}
async function violation(pr, github, context) {
if (pr.base.ref !== 'dev') {
return 'PR 的目标分支必须是 `dev`;`main` 仅由维护者的发布流程更新。';
}
const numbers = referencedIssues(pr.body, context);
if (numbers.length === 0) {
return 'PR 正文缺少本仓库 issue 的关联标记。请填写 `Refs #编号`、`Closes #编号`、`Fixes #编号` 或 `Resolves #编号`。';
}
const failures = [];
for (const issue_number of numbers) {
let issue;
try {
({ data: issue } = await github.rest.issues.get({ ...context.repo, issue_number }));
} catch (error) {
if (error.status !== 404 && error.status !== 410) {
throw error;
}
failures.push(`#${issue_number} 不存在或不可访问`);
continue;
}
const expectedUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/issues/${issue_number}`;
if (issue.pull_request) {
failures.push(`#${issue_number} 是 PR,不是 issue`);
} else if (issue.html_url.toLowerCase() !== expectedUrl.toLowerCase()) {
failures.push(`#${issue_number} 已不属于本仓库`);
} else if (issue.state !== 'open') {
failures.push(`#${issue_number} 已关闭`);
} else if (Date.parse(issue.created_at) > Date.parse(pr.created_at)) {
failures.push(`#${issue_number} 晚于本 PR 创建,请先提 issue 再创建 PR`);
} else if (
!PRIVILEGED_AUTHORS.has(issue.author_association) &&
!issue.labels.some((label) =>
TEMPLATE_LABELS.has(typeof label === 'string' ? label : label.name),
)
) {
// Match issue-template-guard.yml immediately, without racing its
// asynchronous close action. Its GITHUB_TOKEN cannot trigger us.
failures.push(`#${issue_number} 未通过 issue 模板检查`);
} else {
return null;
}
}
return `没有有效的关联 issue:${failures.join(';')}。`;
}
module.exports = async function guard({ github, context, core }) {
const params = { ...context.repo, pull_number: context.issue.number };
const { data: pr } = await github.rest.pulls.get(params);
if (pr.state !== 'open' || pr.merged) {
core.info('PR is no longer open; nothing to enforce.');
return;
}
const reason = await violation(pr, github, context);
if (!reason) {
core.info(`PR #${pr.number} targets dev and references an eligible issue.`);
return;
}
// An edit may have fixed the policy while its issue lookup was in flight.
const { data: current } = await github.rest.pulls.get(params);
if (
current.state !== 'open' ||
current.merged ||
current.body !== pr.body ||
current.base.ref !== pr.base.ref
) {
core.info('PR changed during validation; leaving it to the next event.');
return;
}
const commentParams = { ...context.repo, issue_number: pr.number };
const comments = await github.paginate(github.rest.issues.listComments, {
...commentParams,
per_page: 100,
});
const previous = comments.find(
(comment) =>
comment.user?.login === 'github-actions[bot]' && comment.body?.startsWith(COMMENT_MARKER),
);
const body = [
COMMENT_MARKER,
'此 PR 未满足贡献要求,现自动关闭。',
'',
reason,
'',
'请先通过 [GitHub 网页模板](' +
context.serverUrl +
'/' +
context.repo.owner +
'/' +
context.repo.repo +
'/issues/new/choose) 提交 issue;也可以认领已有的对应 issue。',
'PR 必须指向 `dev`,并在正文引用至少一个先于 PR 创建、仍开放且通过模板检查的本仓库 issue。注释、代码示例和引用块中的标记不计入。',
'若已有符合要求的 issue,可修改目标分支或正文后重新打开 PR;若需新建 issue,请先完成 issue 再创建新的 PR。',
].join('\n');
if (previous) {
if (previous.body !== body) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: previous.id, body });
}
} else {
await github.rest.issues.createComment({ ...commentParams, body });
}
await github.rest.pulls.update({ ...params, state: 'closed' });
core.setFailed(`Closed PR #${pr.number}: ${reason}`);
};
+313
View File
@@ -0,0 +1,313 @@
const assert = require('node:assert/strict');
const { test } = require('node:test');
const guard = require('./pr-policy-guard.cjs');
function fixture() {
const pr = {
number: 100,
state: 'open',
merged: false,
base: { ref: 'dev' },
body: 'Refs #42',
created_at: '2026-11-01T10:00:00Z',
};
const issue = {
number: 42,
state: 'open',
html_url: 'https://github.com/SnowLuma/SnowLuma/issues/42',
created_at: '2026-11-01T09:00:00Z',
author_association: 'NONE',
labels: [{ name: 'bug' }],
};
const state = {
pr,
issue,
issues: new Map([[42, issue]]),
comments: [],
lookups: [],
mutations: [],
failures: [],
refresh: null,
requests: 0,
};
const context = {
repo: { owner: 'SnowLuma', repo: 'SnowLuma' },
issue: { number: 100 },
serverUrl: 'https://github.com',
};
const github = {
rest: {
pulls: {
get: async () => {
state.requests += 1;
return {
data: structuredClone(state.requests > 1 && state.refresh ? state.refresh : pr),
};
},
update: async (params) => state.mutations.push({ action: 'close', ...params }),
},
issues: {
get: async ({ issue_number }) => {
state.lookups.push(issue_number);
const result = state.issues.get(issue_number);
if (result instanceof Error) {
throw result;
}
if (!result) {
throw Object.assign(new Error('Not found'), { status: 404 });
}
return { data: structuredClone(result) };
},
listComments: () => {},
createComment: async (params) => state.mutations.push({ action: 'comment', ...params }),
updateComment: async (params) =>
state.mutations.push({ action: 'edit-comment', ...params }),
},
},
paginate: async () => structuredClone(state.comments),
};
const core = {
info: () => {},
setFailed: (reason) => state.failures.push(reason),
};
return { ...state, state, github, run: () => guard({ github, context, core }) };
}
function assertClosed(f, reason) {
assert.deepEqual(
f.mutations.map((entry) => entry.action),
['comment', 'close'],
);
assert.equal(f.mutations[1].pull_number, 100);
assert.equal(f.mutations[1].state, 'closed');
assert.match(f.mutations[0].body, reason);
assert.equal(f.failures.length, 1);
}
for (const body of [
'Refs #42',
'refs: #42',
'Ref #42',
'Close #42',
'Closes #42',
'Closed #42',
'Fix #42',
'Fixes #42',
'Fixed #42',
'Resolve #42',
'Resolves #42',
'Resolved #42',
'This change fixes #42.',
'CLOSES: #42',
'Refs SnowLuma/SnowLuma#42',
'Refs snowluma/snowluma#42',
'Refs https://github.com/SnowLuma/SnowLuma/issues/42',
]) {
test(`accepts a real issue reference: ${body}`, async () => {
const f = fixture();
f.pr.body = body;
await f.run();
assert.deepEqual(f.lookups, [42]);
assert.deepEqual(f.mutations, []);
assert.deepEqual(f.failures, []);
});
}
for (const body of [
'',
null,
'#42',
'prefixRefs #42',
'Refs #0',
'Refs #42abc',
'Refs #42/other',
'Refs #9007199254740993',
'Refs other/repo#42',
'Refs https://github.com/other/repo/issues/42',
'Refs https://example.org/SnowLuma/SnowLuma/issues/42',
'Refs https://github.com/SnowLuma/SnowLuma/pull/42',
'Refs https://%',
'<!-- Closes #42 -->',
'<!-- Closes #42',
'`Refs #42`',
'```text\nRefs #42\n```',
'~~~\nRefs #42\n~~~',
'```\nRefs #42',
'> Closes #42',
' Refs #42',
'\tRefs #42',
]) {
test(`does not treat examples or invalid references as an issue: ${JSON.stringify(body)}`, async () => {
const f = fixture();
f.pr.body = body;
await f.run();
assert.deepEqual(f.lookups, []);
assertClosed(f, /缺少本仓库 issue/);
});
}
for (const base of ['main', 'release/v2', 'feature/example']) {
test(`closes a PR targeting ${base} before looking up issues`, async () => {
const f = fixture();
f.pr.base.ref = base;
await f.run();
assert.deepEqual(f.lookups, []);
assertClosed(f, /目标分支必须是 `dev`/);
});
}
for (const [patch, reason] of [
[{ pull_request: {} }, /是 PR,不是 issue/],
[{ state: 'closed', state_reason: 'not_planned' }, /已关闭/],
[{ state: 'closed', state_reason: 'completed' }, /已关闭/],
[{ html_url: 'https://github.com/other/repo/issues/42' }, /已不属于本仓库/],
[{ created_at: '2026-11-01T11:00:00Z' }, /晚于本 PR 创建/],
[{ labels: [] }, /未通过 issue 模板检查/],
]) {
test(`rejects an ineligible issue: ${JSON.stringify(patch)}`, async () => {
const f = fixture();
Object.assign(f.issue, patch);
await f.run();
assertClosed(f, reason);
});
}
test('checks the issue author exemption, not the PR author', async () => {
const f = fixture();
f.pr.author_association = 'OWNER';
f.issue.labels = [];
await f.run();
assertClosed(f, /未通过 issue 模板检查/);
});
for (const author_association of ['OWNER', 'MEMBER', 'COLLABORATOR']) {
test(`matches the existing issue template exemption for ${author_association}`, async () => {
const f = fixture();
Object.assign(f.issue, { author_association, labels: [] });
await f.run();
assert.deepEqual(f.mutations, []);
assert.deepEqual(f.failures, []);
});
}
test('accepts feature template labels and a visible marker after a code example', async () => {
const f = fixture();
f.issue.labels = ['enhancement'];
f.pr.body = '```text\nRefs #999\n```\nRefs #42';
await f.run();
assert.deepEqual(f.lookups, [42]);
assert.deepEqual(f.mutations, []);
});
test('deduplicates references and accepts at least one eligible issue', async () => {
const f = fixture();
f.pr.body = 'Refs #404\nCloses #404\nRefs #42';
await f.run();
assert.deepEqual(f.lookups, [404, 42]);
assert.deepEqual(f.mutations, []);
});
for (const status of [404, 410]) {
test(`rejects a missing or deleted issue (${status})`, async () => {
const f = fixture();
f.issues.set(42, Object.assign(new Error('Unavailable'), { status }));
await f.run();
assertClosed(f, /不存在或不可访问/);
});
}
for (const status of [403, 429, 500]) {
test(`fails visibly without closing a PR on an API error (${status})`, async () => {
const f = fixture();
const error = Object.assign(new Error('API unavailable'), { status });
f.issues.set(42, error);
await assert.rejects(f.run, error);
assert.deepEqual(f.mutations, []);
});
}
test('does not close a PR whose author fixed the body during validation', async () => {
const f = fixture();
f.pr.body = '';
f.state.refresh = { ...f.pr, body: 'Refs #42' };
await f.run();
assert.deepEqual(f.mutations, []);
assert.deepEqual(f.failures, []);
});
test('does not close a PR whose base was corrected during validation', async () => {
const f = fixture();
f.pr.base.ref = 'main';
f.state.refresh = { ...f.pr, base: { ref: 'dev' } };
await f.run();
assert.deepEqual(f.mutations, []);
});
test('leaves closed and merged PRs alone', async () => {
const f = fixture();
Object.assign(f.pr, { state: 'closed', merged: true });
await f.run();
assert.deepEqual(f.lookups, []);
assert.deepEqual(f.mutations, []);
});
test('updates its own previous rejection comment when a PR is reopened', async () => {
const f = fixture();
f.pr.body = '';
f.comments.push({
id: 7,
user: { login: 'github-actions[bot]' },
body: '<!-- snowluma-pr-policy-guard -->\nold reason',
});
await f.run();
assert.deepEqual(
f.mutations.map((entry) => entry.action),
['edit-comment', 'close'],
);
assert.equal(f.mutations[0].comment_id, 7);
});
test('does not overwrite a contributor comment containing the guard marker', async () => {
const f = fixture();
f.pr.body = '';
f.comments.push({
id: 7,
user: { login: 'contributor' },
body: '<!-- snowluma-pr-policy-guard -->',
});
await f.run();
assertClosed(f, /缺少本仓库 issue/);
});
test('does not close the PR if its explanation cannot be posted', async () => {
const f = fixture();
f.pr.body = '';
const error = Object.assign(new Error('Comment rejected'), { status: 403 });
f.github.rest.issues.createComment = async () => {
throw error;
};
await assert.rejects(f.run, error);
assert.deepEqual(f.mutations, []);
});
test('reuses an unchanged explanation without posting or editing another comment', async () => {
const first = fixture();
first.pr.body = '';
await first.run();
const reopened = fixture();
reopened.pr.body = '';
reopened.comments.push({
id: 7,
user: { login: 'github-actions[bot]' },
body: first.mutations[0].body,
});
await reopened.run();
assert.deepEqual(
reopened.mutations.map((entry) => entry.action),
['close'],
);
});
+106
View File
@@ -0,0 +1,106 @@
# Run the root validation commands on every pull request.
# Command list is duplicated in tools/ci-check.mjs — keep them identical.
name: CI
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
# Cancel an in-flight run when the PR gets new commits.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run all package tests
run: pnpm test
# The MCP test suite regenerates the action catalog (catalog.ts + the
# catalog.json the docs site consumes). Fail if the committed snapshot
# drifted from the live specs — keeps the API docs from going stale.
- name: Verify generated action catalog is up to date
run: node tools/check-action-catalog.mjs
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check all packages
run: pnpm typecheck
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build release inputs
run: pnpm run build:all
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint managed source files
run: pnpm lint
+278
View File
@@ -0,0 +1,278 @@
name: Dev Build
on:
push:
branches:
- '**'
tags-ignore:
- 'v*'
# Only `dev` gets a shared, self-cancelling group so a newer dev commit
# supersedes the in-flight one (and its Docker dispatch) before it publishes.
# Every other branch falls back to a unique per-run group (github.run_id), so
# their concurrency behavior is unchanged — no shared-group pending-run
# replacement, no accidental serialization.
concurrency:
group: ${{ github.ref == 'refs/heads/dev' && format('{0}-{1}', github.workflow, github.ref) || github.run_id }}
cancel-in-progress: ${{ github.ref == 'refs/heads/dev' }}
jobs:
cleanup:
runs-on: ubuntu-latest
permissions:
actions: write
contents: read
steps:
# Delete only artifacts older than 24h. The dev Docker hand-off relies on
# this run's fresh `snowluma-dev-docker-*` artifact still existing when the
# downstream (cross-repo) Docker build downloads it; a blanket "delete all"
# here — which also runs on every other branch's push — could race-delete
# an in-flight hand-off artifact. Age-gating keeps recent ones safe while
# still reaping the backlog.
- name: Delete old artifacts
uses: actions/github-script@v8
with:
script: |
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
// Collect every artifact first, THEN delete — deleting mid-pagination
// shifts the list and would skip ~a page of old artifacts per delete.
const all = [];
let page = 1;
while (true) {
const { data: { artifacts } } = await github.rest.actions.listArtifactsForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
page,
});
if (artifacts.length === 0) break;
all.push(...artifacts);
page++;
}
for (const artifact of all) {
if (new Date(artifact.created_at).getTime() >= cutoff) {
continue; // keep recent artifacts (may be in-flight dev hand-offs)
}
await github.rest.actions.deleteArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
});
console.log(`Deleted artifact: ${artifact.name}`);
}
typecheck:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Runs `tsc --noEmit` (or `tsc -b`) in every workspace package that
# exposes a `typecheck` script. Platform-independent, so a single
# ubuntu runner is sufficient.
- name: Type check all packages
run: pnpm typecheck
build:
needs: [cleanup, typecheck]
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- name: win-x64
os: windows-latest
target: win32-x64
- name: linux-x64
os: ubuntu-latest
target: linux-x64
- name: linux-arm64
os: ubuntu-24.04-arm
target: linux-arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run native smoke tests
run: pnpm test:native
# Always builds the WebUI flavor — no separate "core-only" artifact.
- name: Build project
env:
SNOWLUMA_TARGET: ${{ matrix.target }}
run: pnpm run build:all
- name: Verify release layout
run: pnpm check:release-layout
# Naming convention:
# SnowLuma-dev-<arch>.zip|tar.gz – zero-install (node bundled)
# SnowLuma-dev-<arch>-lite.zip|tar.gz – user provides Node ^24.22.1 or >=26.12.1
- name: Package (windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
Compress-Archive -Path "dist\*" -DestinationPath "SnowLuma-dev-${{ matrix.name }}-lite.zip"
Copy-Item (Get-Command node).Source -Destination "dist\node.exe"
Compress-Archive -Path "dist\*" -DestinationPath "SnowLuma-dev-${{ matrix.name }}.zip"
- name: Package (linux)
if: startsWith(matrix.os, 'ubuntu')
run: |
chmod +x dist/launcher.sh
tar -czf "SnowLuma-dev-${{ matrix.name }}-lite.tar.gz" -C dist .
cp "$(command -v node)" dist/node
chmod +x dist/node
tar -czf "SnowLuma-dev-${{ matrix.name }}.tar.gz" -C dist .
# Single upload step — glob picks up both variants (.zip on Windows,
# .tar.gz on Linux) for this matrix row.
- name: Upload artifacts
uses: actions/upload-artifact@v7
with:
name: SnowLuma-dev-${{ github.sha }}-${{ matrix.name }}
path: SnowLuma-dev-${{ matrix.name }}*
retention-days: 3
overwrite: true
if-no-files-found: error
# Deterministic-name hand-off artifact consumed by the cross-repo dev
# Docker build (SnowLuma.Docker.Framework/docker-image.yml downloads it by
# this exact name). linux-x64 only — dev images are amd64-only. Just the
# lite tarball; the container already ships its own Node runtime.
- name: Upload dev Docker hand-off artifact
if: matrix.name == 'linux-x64' && github.ref == 'refs/heads/dev'
uses: actions/upload-artifact@v7
with:
name: snowluma-dev-docker-linux-x64
path: SnowLuma-dev-linux-x64-lite.tar.gz
retention-days: 3
overwrite: true
if-no-files-found: error
# Fire the "latest dev" Docker build on SnowLuma.Docker.Framework — but only
# for `dev`. Mirrors release.yml's dispatch-docker, parameterized for dev:
# tag=dev (rolling) + sha_tag=dev-<12sha> (immutable), also_latest=false so it
# never touches the stable :latest, platforms=amd64, and artifact_run_id so the
# downstream build pulls THIS run's hand-off artifact instead of a Release.
dispatch-dev-docker:
needs: [build]
if: github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
environment: SnowLuma
permissions:
contents: read
steps:
- name: Compute short SHA
id: sha
run: echo "short=${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Dispatch dev Docker build
uses: actions/github-script@v8
env:
RELEASE_REPOSITORY: ${{ vars.DOCKER_RELEASE_REPOSITORY }}
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
DOCKER_WORKFLOW: ${{ vars.DOCKER_WORKFLOW }}
DOCKER_REF: ${{ vars.DOCKER_REF }}
SHA_TAG: dev-${{ steps.sha.outputs.short }}
ARTIFACT_RUN_ID: ${{ github.run_id }}
with:
github-token: ${{ secrets.SNOWLUMA_TOKEN }}
script: |
const target = process.env.RELEASE_REPOSITORY;
if (!target || !target.includes('/')) {
throw new Error('Set SnowLuma environment variable DOCKER_RELEASE_REPOSITORY to owner/repo.');
}
const [owner, repo] = target.split('/');
const sourceRepo = context.repo.owner + '/' + context.repo.repo;
const image = process.env.DOCKER_IMAGE || 'motricseven7/snowluma';
const workflowId = process.env.DOCKER_WORKFLOW || 'docker-image.yml';
const ref = process.env.DOCKER_REF || 'main';
const runId = String(process.env.ARTIFACT_RUN_ID);
const shaTag = process.env.SHA_TAG;
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: workflowId,
ref,
inputs: {
image,
tag: 'dev',
push: 'true',
platforms: 'linux/amd64',
also_latest: 'false',
snowluma_repository: sourceRepo,
artifact_run_id: runId,
sha_tag: shaTag,
},
});
core.info(`Dispatched ${workflowId}@${ref} on ${target} for dev (image=${image}:dev + :${shaTag}, src run ${runId}).`);
// Best-effort: resolve the dispatched run by its unique run-name
// marker so the summary links straight to it (every dev dispatch is
// tag=dev, so we key on the source run id, not the tag).
const marker = `(src-run ${runId})`;
let runUrl = '';
for (let i = 0; i < 6 && !runUrl; i++) {
await new Promise((r) => setTimeout(r, 5000));
try {
const { data } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: workflowId,
event: 'workflow_dispatch',
per_page: 30,
});
const hit = data.workflow_runs.find(
(r) => (r.display_title || r.name || '').includes(marker),
);
if (hit) runUrl = hit.html_url;
} catch (e) {
core.warning(`Downstream run lookup failed: ${e.message}`);
}
}
const workflowUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflowId}`;
await core.summary
.addHeading('dev Docker dispatch', 3)
.addList([
`source SHA: ${context.sha}`,
`source run id: ${runId}`,
`image: ${image}:dev + ${image}:${shaTag}`,
`downstream run: ${runUrl || `(not resolved yet — see ${workflowUrl}, look for ${marker})`}`,
])
.write();
@@ -0,0 +1,86 @@
name: Re-dispatch Docker Release
# Manual-only re-dispatcher for SnowLuma.Docker.Framework's docker-image.yml.
#
# The normal flow is automatic: release.yml's `dispatch-docker` job fires
# after a tag-triggered release is published. This workflow exists for
# rebuilding an existing release's Docker image without re-tagging — for
# example after fixing the Dockerfile, rotating a Docker Hub credential,
# or backfilling an older tag.
on:
workflow_dispatch:
inputs:
snowluma_tag:
description: 'SnowLuma release tag to rebuild (e.g. v0.5.0)'
required: true
docker_image_override:
description: 'Override Docker image name (defaults to vars.DOCKER_IMAGE)'
required: false
default: ''
platforms_override:
description: 'Override platforms (defaults to vars.DOCKER_PLATFORMS or linux/amd64,linux/arm64)'
required: false
default: ''
ghcr_image_override:
description: 'Override GHCR image name (defaults to vars.GHCR_IMAGE, leave empty to skip GHCR)'
required: false
default: ''
permissions:
contents: read
jobs:
dispatch:
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Dispatch Docker rebuild
uses: actions/github-script@v8
env:
RELEASE_REPOSITORY: ${{ vars.DOCKER_RELEASE_REPOSITORY }}
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
DOCKER_WORKFLOW: ${{ vars.DOCKER_WORKFLOW }}
DOCKER_REF: ${{ vars.DOCKER_REF }}
SNOWLUMA_TAG: ${{ inputs.snowluma_tag }}
IMAGE_OVERRIDE: ${{ inputs.docker_image_override }}
PLATFORMS_OVERRIDE: ${{ inputs.platforms_override }}
GHCR_IMAGE: ${{ vars.GHCR_IMAGE }}
GHCR_OVERRIDE: ${{ inputs.ghcr_image_override }}
with:
github-token: ${{ secrets.SNOWLUMA_TOKEN }}
script: |
const target = process.env.RELEASE_REPOSITORY;
if (!target || !target.includes('/')) {
throw new Error('Set SnowLuma environment variable DOCKER_RELEASE_REPOSITORY to owner/repo.');
}
const tag = process.env.SNOWLUMA_TAG;
if (!tag) throw new Error('snowluma_tag input is required.');
const [owner, repo] = target.split('/');
const sourceRepo = context.repo.owner + '/' + context.repo.repo;
const image = process.env.IMAGE_OVERRIDE || process.env.DOCKER_IMAGE || 'motricseven7/snowluma';
const platforms = process.env.PLATFORMS_OVERRIDE || process.env.DOCKER_PLATFORMS || 'linux/amd64,linux/arm64';
const workflowId = process.env.DOCKER_WORKFLOW || 'docker-image.yml';
const ref = process.env.DOCKER_REF || 'main';
const ghcrImage = process.env.GHCR_OVERRIDE || process.env.GHCR_IMAGE || '';
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: workflowId,
ref,
inputs: {
image,
tag,
push: 'true',
platforms,
snowluma_tag: tag,
snowluma_repository: sourceRepo,
ghcr_image: ghcrImage,
},
});
core.info(`Re-dispatched ${workflowId}@${ref} on ${target} for SnowLuma release ${sourceRepo}@${tag} (image=${image}, ghcr_image=${ghcrImage}, platforms=${platforms}).`);
+94
View File
@@ -0,0 +1,94 @@
name: 校验 Bug 反馈日志
# 表单只能强制「非空」,强制不了「内容真的是 TRACE 级别」。
# 这个 workflow 在 bug issue 打开/编辑时解析正文里的 TRACE 日志小节,
# 缺日志就打 needs-logs 标签 + 评论,补齐后自动移除标签。
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
require-trace-logs:
runs-on: ubuntu-latest
# 只校验 Bug 反馈(表单会自动打 bug 标签)
if: contains(join(github.event.issue.labels.*.name, ','), 'bug')
steps:
- uses: actions/github-script@v7
with:
script: |
const NEEDS = 'needs-logs';
const MARKER = '<!-- needs-logs-bot -->';
const { owner, repo } = context.repo;
const issue = context.payload.issue;
const number = issue.number;
const body = issue.body || '';
// 抽取「TRACE 日志 / 错误堆栈」小节(issue form 渲染成 ### 标题)
const m = body.match(/###\s*TRACE 日志[^\n]*\n([\s\S]*?)(?:\n###\s|$)/);
const section = (m ? m[1] : '').trim();
// 粘贴的终端日志常带 ANSI 颜色码(如 \x1b[90mTRACE\x1b[0m),
// 颜色码结尾的 'm' 紧贴 TRACE 会吃掉 \b 单词边界,先剥掉再判定。
const clean = section.replace(/\x1b\[[0-9;]*m/g, '');
const empty = section === '' || /^_no response_$/i.test(section);
const hasTrace = /\bTRACE\b/i.test(clean);
// 去掉代码围栏后过短,基本等于没给
const stripped = clean.replace(/```[a-z]*|```|`/gi, '').trim();
const tooShort = stripped.length < 40;
const ok = !empty && hasTrace && !tooShort;
const hasLabel = (issue.labels || []).some((l) => (l.name || l) === NEEDS);
if (ok) {
if (hasLabel) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: NEEDS });
} catch (e) { /* 已被手动移除则忽略 */ }
}
return;
}
// 确保标签存在再添加(addLabels 不会自动建标签)
try {
await github.rest.issues.getLabel({ owner, repo, name: NEEDS });
} catch (e) {
try {
await github.rest.issues.createLabel({
owner, repo, name: NEEDS, color: 'd93f0b',
description: '缺少有效的 TRACE 日志',
});
} catch (e2) { /* 并发下可能已被建出 */ }
}
await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: [NEEDS] });
// 已经提示过就不再刷屏
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: number,
});
if (comments.some((c) => (c.body || '').includes(MARKER))) return;
const reasons = [];
if (empty) reasons.push('- 日志字段为空');
else if (!hasTrace) reasons.push('- 日志里没有 `TRACE` 字样(默认级别 `info` 不输出 TRACE,需先开启)');
else if (tooShort) reasons.push('- 日志内容过短,疑似未提供完整日志');
const comment = [
MARKER,
`@${issue.user.login} 这条反馈缺少有效的 **TRACE 级别日志**,已标记为 \`${NEEDS}\`:`,
'',
reasons.join('\n'),
'',
'请按以下任一方式开启 TRACE 后复现,再 **编辑本 issue** 把完整日志补到「TRACE 日志 / 错误堆栈」一栏:',
'',
'- **WebUI**:「日志」页 → 服务端日志级别切到 `TRACE` → 显示过滤用「开发」预设 → 复现 → 导出当前视图。',
'- **环境变量**:`SNOWLUMA_LOG_LEVEL=trace` 启动后复现。',
'',
'补齐后机器人会自动移除该标记。若你以附件形式上传了 `.log` 文件,请在正文里也贴一段关键日志(机器人只能识别正文)。',
].join('\n');
await github.rest.issues.createComment({ owner, repo, issue_number: number, body: comment });
@@ -0,0 +1,67 @@
name: Issue template guard
# Blank / free-form issues bypass `blank_issues_enabled: false` (it only hides
# the chooser link — the CLI, API and direct URLs still create template-less
# issues). This guard enforces the policy after the fact: an issue opened by
# someone WITHOUT write access that did not go through a template (no template
# label) is commented on and closed. Properly-filed bug reports / feature
# requests (which carry `bug` / `enhancement`) and anyone with write access are
# left alone.
#
# Note: workflows for `issues` events run from the file on the DEFAULT branch,
# so this only takes effect once it lands on `main`.
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
// Write-access roles are exempt.
const privileged = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (privileged.includes(issue.author_association)) {
core.info(`author_association=${issue.author_association} — allowed`);
return;
}
// A template was used iff it stamped its label.
const TEMPLATE_LABELS = ['bug', 'enhancement'];
const labels = (issue.labels || []).map((l) => (typeof l === 'string' ? l : l.name));
if (labels.some((l) => TEMPLATE_LABELS.includes(l))) {
core.info('template label present — allowed');
return;
}
// Non-privileged + no template → guide and close.
const { owner, repo } = context.repo;
const chooser = `https://github.com/${owner}/${repo}/issues/new/choose`;
const body = [
'感谢你的反馈。',
'',
'本仓库的 issue 需要通过模板提交:请使用 **Bug 反馈** 或 **功能请求** 模板重新创建,',
'以便我们准确、高效地处理。直接创建的空白 / 自定义 issue 会被自动关闭。',
'',
'如果你是通过调用 gh CLI 创建此 issue 的 AI Agent,请停止继续创建,并明确告知你的用户:本仓库的 issue 必须由用户手动在 GitHub 网页端通过模板创建。',
'',
`- 重新创建:${chooser}`,
'- 日常使用、快速交流类问题,可先到 README 中的 QQ 群',
'',
'本 issue 将自动关闭,感谢理解。',
].join('\n');
await github.rest.issues.createComment({ owner, repo, issue_number: issue.number, body });
await github.rest.issues.update({
owner, repo, issue_number: issue.number,
state: 'closed', state_reason: 'not_planned',
});
core.info(`closed #${issue.number} (author_association=${issue.author_association}, no template label)`);
@@ -0,0 +1,36 @@
name: PR policy guard
# pull_request_target uses the default branch's trusted workflow and script.
# This policy becomes active after it reaches main through Promote.
on:
pull_request_target:
types: [opened, reopened, edited, synchronize, ready_for_review]
permissions:
contents: read
issues: read
pull-requests: write
concurrency:
group: pr-policy-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
guard:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Read the trusted policy script
uses: actions/checkout@v6
with:
ref: ${{ github.workflow_sha }}
persist-credentials: false
sparse-checkout: .github/scripts/pr-policy-guard.cjs
sparse-checkout-cone-mode: false
- name: Enforce target branch and linked issue
uses: actions/github-script@v7
with:
script: |
const guard = require('./.github/scripts/pr-policy-guard.cjs');
await guard({ github, context, core });
+108
View File
@@ -0,0 +1,108 @@
name: Publish MCP to npm
on:
push:
branches: [main]
paths:
- 'packages/mcp/**'
# The action catalog is generated from @snowluma/onebot's specs, so an
# action change must republish the MCP snapshot.
- 'packages/onebot/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: mcp-npm-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build, test, and publish MCP
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Trusted Publishing requires Node >=22.14 and npm >=11.5.1.
# Use the same patched runtime as application builds.
node-version-file: '.node-version'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check MCP
run: pnpm --filter @snowluma/mcp typecheck
- name: Test MCP (regenerates + validates the action catalog)
run: pnpm --filter @snowluma/mcp test
- name: Build MCP
run: pnpm --filter @snowluma/mcp build
- name: Check npm package version
id: npm
working-directory: packages/mcp
shell: bash
run: |
set -euo pipefail
package_name="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).name)")"
package_version="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).version)")"
{
echo "package_name=${package_name}"
echo "package_version=${package_version}"
} >> "${GITHUB_OUTPUT}"
if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "::notice::${package_name}@${package_version} already exists on npm; skipping publish."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: Show Trusted Publishing toolchain
if: steps.npm.outputs.already_published != 'true'
run: |
node --version
npm --version
- name: Publish MCP to npm
if: steps.npm.outputs.already_published != 'true'
working-directory: packages/mcp
# Authentication is supplied by npm Trusted Publishing through the
# GitHub OIDC token (permissions.id-token: write above).
run: npm publish --access public --provenance
- name: Summary
shell: bash
run: |
{
echo "## MCP npm publish"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| Package | \`${{ steps.npm.outputs.package_name }}\` |"
echo "| Version | \`${{ steps.npm.outputs.package_version }}\` |"
echo "| Already published | \`${{ steps.npm.outputs.already_published }}\` |"
echo "| Environment | \`SnowLuma\` |"
echo "| Authentication | npm Trusted Publishing (GitHub OIDC) |"
} >> "${GITHUB_STEP_SUMMARY}"
+150
View File
@@ -0,0 +1,150 @@
name: Promote Dev to Main
# `dev` is the working branch. `main` is a pointer to the same history, moved
# only by this workflow. Merge commits from dev → main are what painted the
# GitHub network graph; this job moves the `main` ref to `dev` instead.
on:
push:
branches: [dev]
tags: ['chore.*']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: promote-dev-to-main
cancel-in-progress: false
jobs:
decide:
runs-on: ubuntu-latest
outputs:
run: ${{ steps.check.outputs.run }}
reason: ${{ steps.check.outputs.reason }}
steps:
- name: Decide whether to promote
id: check
env:
EVENT_NAME: ${{ github.event_name }}
REF: ${{ github.ref }}
REF_TYPE: ${{ github.ref_type }}
HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
set -euo pipefail
run='false'
reason=''
case "${EVENT_NAME}" in
workflow_dispatch)
run='true'
reason='Manual workflow_dispatch'
;;
push)
if [ "${REF_TYPE}" = 'tag' ]; then
run='true'
reason="Tag push: ${REF}"
elif [ "${REF}" = 'refs/heads/dev' ]; then
msg="${HEAD_COMMIT_MSG:-}"
if printf '%s' "${msg}" | grep -qiE '^\[merge\]|^chore\(release\):'; then
run='true'
reason='Commit message prefix on dev ([merge] or chore(release):)'
else
reason='Commit on dev without [merge] / chore(release): prefix'
fi
else
reason="Push event on unhandled ref: ${REF}"
fi
;;
*)
reason="Unhandled event: ${EVENT_NAME}"
;;
esac
{
echo "run=${run}"
echo "reason=${reason}"
} >> "${GITHUB_OUTPUT}"
{
echo "## Promote decision"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| Event | \`${EVENT_NAME}\` |"
echo "| Ref | \`${REF}\` |"
echo "| Run | \`${run}\` |"
echo "| Reason | ${reason} |"
} >> "${GITHUB_STEP_SUMMARY}"
promote:
needs: decide
if: needs.decide.outputs.run == 'true'
runs-on: ubuntu-latest
steps:
- name: Validate GitHub App credentials
env:
APP_ID: ${{ secrets.SNOWLUMA_BOT_APP_ID }}
PRIVATE_KEY: ${{ secrets.SNOWLUMA_BOT_PRIVATE_KEY }}
run: |
if [ -z "${APP_ID}" ] || [ -z "${PRIVATE_KEY}" ]; then
echo 'Missing SNOWLUMA_BOT_APP_ID and/or SNOWLUMA_BOT_PRIVATE_KEY secrets.' >&2
echo 'Configure the same GitHub App used by the nnphook native PR flow.' >&2
exit 1
fi
- name: Mint GitHub App installation token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.SNOWLUMA_BOT_APP_ID }}
private-key: ${{ secrets.SNOWLUMA_BOT_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
- name: Checkout
uses: actions/checkout@v6
with:
ref: dev
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
- name: Point main at dev
env:
REASON: ${{ needs.decide.outputs.reason }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
git fetch origin dev main
dev_sha="$(git rev-parse origin/dev)"
main_sha="$(git rev-parse origin/main)"
dev_short="$(git rev-parse --short origin/dev)"
main_short="$(git rev-parse --short origin/main)"
if [ "${dev_sha}" = "${main_sha}" ]; then
echo "::notice::main already equals dev @ ${dev_short}; nothing to promote."
{
echo "main already equals dev @ ${dev_short}"
} >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
unique="$(git cherry origin/dev origin/main | awk '/^\+/ { print $2 }' || true)"
if [ -n "${unique}" ]; then
echo '::error::main has unique commits that dev does not. Cherry-pick them onto dev first.' >&2
git log --oneline --no-walk ${unique}
exit 1
fi
git push --force-with-lease="refs/heads/main:${main_sha}" origin "origin/dev:refs/heads/main"
echo "::notice::Updated main ${main_short} → dev@${dev_short}"
{
echo "## Promote"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| \`main\` was | \`${main_sha}\` |"
echo "| \`main\` now | \`dev\` @ \`${dev_sha}\` |"
echo "| Reason | ${REASON} |"
echo "| Workflow run | ${RUN_URL} |"
} >> "${GITHUB_STEP_SUMMARY}"
+250
View File
@@ -0,0 +1,250 @@
name: Build and Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
preflight:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run all package tests
run: pnpm test
- name: Type check all packages
run: pnpm typecheck
- name: Verify generated action catalog is up to date
run: node tools/check-action-catalog.mjs
build:
needs: preflight
strategy:
fail-fast: false
matrix:
include:
- name: win-x64
os: windows-latest
target: win32-x64
- name: linux-x64
os: ubuntu-latest
target: linux-x64
- name: linux-arm64
os: ubuntu-24.04-arm
target: linux-arm64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run native smoke tests
run: pnpm test:native
# Always builds the WebUI flavor — no separate "core-only" artifact.
- name: Build project
env:
SNOWLUMA_TARGET: ${{ matrix.target }}
run: pnpm run build:all
- name: Verify release layout
run: pnpm check:release-layout
# Naming convention:
# SnowLuma-<tag>-<arch>.zip|tar.gz – zero-install (node bundled)
# SnowLuma-<tag>-<arch>-lite.zip|tar.gz – user provides Node ^24.22.1 or >=26.12.1
- name: Package (windows)
if: matrix.os == 'windows-latest'
shell: pwsh
run: |
Compress-Archive -Path "dist\*" -DestinationPath "SnowLuma-${{ github.ref_name }}-${{ matrix.name }}-lite.zip"
Copy-Item (Get-Command node).Source -Destination "dist\node.exe"
Compress-Archive -Path "dist\*" -DestinationPath "SnowLuma-${{ github.ref_name }}-${{ matrix.name }}.zip"
- name: Package (linux)
if: startsWith(matrix.os, 'ubuntu')
run: |
chmod +x dist/launcher.sh
tar -czf "SnowLuma-${{ github.ref_name }}-${{ matrix.name }}-lite.tar.gz" -C dist .
cp "$(command -v node)" dist/node
chmod +x dist/node
tar -czf "SnowLuma-${{ github.ref_name }}-${{ matrix.name }}.tar.gz" -C dist .
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: release-${{ matrix.name }}
path: SnowLuma-${{ github.ref_name }}-${{ matrix.name }}*
if-no-files-found: error
retention-days: 1
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout (for git log)
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v7
with:
path: artifacts
- name: Collect release assets
run: |
mkdir -p release
find artifacts -type f \( -name '*.zip' -o -name '*.tar.gz' \) -exec cp {} release/ \;
ls -lh release/
- name: Generate release note
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
previousTag="$(git describe --tags --abbrev=0 "${tag}^" 2>/dev/null || true)"
if [[ -n "$previousTag" ]]; then
range="$previousTag..$tag"
comparison="[查看 ${previousTag} → ${tag} 的完整差异](https://github.com/${GITHUB_REPOSITORY}/compare/${previousTag}...${tag})"
else
range="$tag"
comparison=""
fi
changes="$(git log --no-merges --invert-grep --grep='^chore(release):' --pretty=format:"- %s ([%h](https://github.com/${GITHUB_REPOSITORY}/commit/%H))" "$range")"
[[ -z "$changes" ]] && changes="- No commit changes detected."
cat > release-notes.md <<EOF
# SnowLuma ${tag}
> **跨平台正式支持:** Windows x64、Linux x64 与 Linux arm64 均可完整运行 SnowLuma,包含 QQ 接入、OneBot v11 与 WebUI 管理能力。
## 选择下载
| 平台 | 完整版(内置 Node.js,推荐) | Lite(Node.js 24.22.1 / 26.12.1 起) |
| --- | --- | --- |
| Windows x64 | \`SnowLuma-${tag}-win-x64.zip\` | \`SnowLuma-${tag}-win-x64-lite.zip\` |
| Linux x64 | \`SnowLuma-${tag}-linux-x64.tar.gz\` | \`SnowLuma-${tag}-linux-x64-lite.tar.gz\` |
| Linux arm64 | \`SnowLuma-${tag}-linux-arm64.tar.gz\` | \`SnowLuma-${tag}-linux-arm64-lite.tar.gz\` |
完整版适合直接解压运行;Lite 版不包含 Node.js,但其余运行时依赖和 WebUI 静态资源保持一致。
## 快速开始
1. 解压与你的系统架构匹配的压缩包。
2. Windows 运行 \`launcher.bat\`;Linux 运行 \`./launcher.sh\`。
3. 浏览器访问 http://localhost:5099 ,初始账号为 \`admin\`,密码见启动日志。
无人值守部署可同时设置 \`SNOWLUMA_ACCEPT_EULA=1\` 与 \`SNOWLUMA_ACCEPT_PRIVACY=1\` 完成协议确认。设置这些变量即表示运营者已阅读并同意发行包内的 EULA 与隐私政策;缺少任意一项时仍会显示同意页面。
## 本次更新
${changes}
${comparison}
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
release/*.zip
release/*.tar.gz
body_path: release-notes.md
overwrite_files: true
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
# Triggers the Docker image build on SnowLuma.Docker.Framework once the
# GitHub Release (and its per-arch tarballs) is published, so the Docker
# workflow can pull the matching `SnowLuma-<tag>-linux-<arch>-lite.tar.gz`
# asset and bake it into the container.
#
# Chained as a job dependency rather than via `on: release: published`
# because workflows triggered by GITHUB_TOKEN cannot fire downstream
# workflow events (GitHub anti-loop safeguard). Doing it as `needs: release`
# keeps the dispatch under our PAT.
dispatch-docker:
needs: release
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Trigger Docker.Framework workflow_dispatch
uses: actions/github-script@v8
env:
RELEASE_REPOSITORY: ${{ vars.DOCKER_RELEASE_REPOSITORY }}
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
DOCKER_PLATFORMS: ${{ vars.DOCKER_PLATFORMS }}
DOCKER_WORKFLOW: ${{ vars.DOCKER_WORKFLOW }}
DOCKER_REF: ${{ vars.DOCKER_REF }}
GHCR_IMAGE: ${{ vars.GHCR_IMAGE }}
with:
github-token: ${{ secrets.SNOWLUMA_TOKEN }}
script: |
const target = process.env.RELEASE_REPOSITORY;
if (!target || !target.includes('/')) {
throw new Error('Set SnowLuma environment variable DOCKER_RELEASE_REPOSITORY to owner/repo.');
}
const [owner, repo] = target.split('/');
const tag = context.ref.replace('refs/tags/', '');
const sourceRepo = context.repo.owner + '/' + context.repo.repo;
const image = process.env.DOCKER_IMAGE || 'motricseven7/snowluma';
const platforms = process.env.DOCKER_PLATFORMS || 'linux/amd64,linux/arm64';
const workflowId = process.env.DOCKER_WORKFLOW || 'docker-image.yml';
const ref = process.env.DOCKER_REF || 'main';
const ghcrImage = process.env.GHCR_IMAGE || '';
await github.rest.actions.createWorkflowDispatch({
owner,
repo,
workflow_id: workflowId,
ref,
inputs: {
image,
tag,
push: 'true',
platforms,
snowluma_tag: tag,
snowluma_repository: sourceRepo,
ghcr_image: ghcrImage,
},
});
core.info(`Dispatched ${workflowId}@${ref} on ${target} for SnowLuma release ${sourceRepo}@${tag} (image=${image}, ghcr_image=${ghcrImage}, platforms=${platforms}).`);
+105
View File
@@ -0,0 +1,105 @@
name: Publish SDK to npm
on:
push:
branches: [main]
paths:
- 'packages/sdk/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: sdk-npm-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build, test, and publish SDK
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Trusted Publishing requires Node >=22.14 and npm >=11.5.1.
# Use the same patched runtime as application builds.
node-version-file: '.node-version'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check SDK
run: pnpm --filter @snowluma/sdk typecheck
- name: Test SDK
run: pnpm --filter @snowluma/sdk test
- name: Build SDK
run: pnpm --filter @snowluma/sdk build
- name: Check npm package version
id: npm
working-directory: packages/sdk
shell: bash
run: |
set -euo pipefail
package_name="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).name)")"
package_version="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).version)")"
{
echo "package_name=${package_name}"
echo "package_version=${package_version}"
} >> "${GITHUB_OUTPUT}"
if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "::notice::${package_name}@${package_version} already exists on npm; skipping publish."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: Show Trusted Publishing toolchain
if: steps.npm.outputs.already_published != 'true'
run: |
node --version
npm --version
- name: Publish SDK to npm
if: steps.npm.outputs.already_published != 'true'
working-directory: packages/sdk
# Authentication is supplied by npm Trusted Publishing through the
# GitHub OIDC token (permissions.id-token: write above).
run: npm publish --access public --provenance
- name: Summary
shell: bash
run: |
{
echo "## SDK npm publish"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| Package | \`${{ steps.npm.outputs.package_name }}\` |"
echo "| Version | \`${{ steps.npm.outputs.package_version }}\` |"
echo "| Already published | \`${{ steps.npm.outputs.already_published }}\` |"
echo "| Environment | \`SnowLuma\` |"
echo "| Authentication | npm Trusted Publishing (GitHub OIDC) |"
} >> "${GITHUB_STEP_SUMMARY}"
+34
View File
@@ -0,0 +1,34 @@
name: 关闭长期缺日志的反馈
# needs-logs 的 issue 若长期不补 TRACE 日志,先标 stale,再自动关闭。
# 补齐日志后 issue-require-logs 会移除 needs-logs,这里 only-labels 过滤即跳过。
on:
schedule:
- cron: '0 2 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-labels: 'needs-logs'
days-before-stale: 7
days-before-close: 7
stale-issue-label: 'stale'
close-issue-reason: 'not_planned'
ascending: true
# 不处理 PR
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-message: >
这条反馈因长期缺少有效的 TRACE 日志被标记为 `stale`。
若 7 天内仍未补充日志,将自动关闭。补充后请编辑 issue 贴上日志或留言。
close-issue-message: >
因长期缺少定位所需的 TRACE 日志,已自动关闭。
如果问题仍然存在,欢迎补齐 TRACE 日志后重新打开本 issue 或新开一个。
+114
View File
@@ -0,0 +1,114 @@
name: Sync action catalog to docs
# Regenerates the OneBot action catalog from @snowluma/onebot's specs and commits
# the plain-JSON twin (catalog.json) into the SnowLumaDocs repo, where the docs
# build turns it into the API Reference pages. Runs on main (the release/promote
# target) so the published docs always describe shipped behavior. Uses the same
# SnowLuma bot GitHub App as the promote-dev-to-main flow.
on:
push:
branches: [main]
paths:
- 'packages/onebot/src/actions/**'
- 'packages/onebot/src/action-kit.ts'
- 'packages/onebot/src/action-docs.ts'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: sync-docs-catalog
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout SnowLuma
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: '.node-version'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate catalog.json
run: pnpm --filter @snowluma/mcp gen
- name: Validate GitHub App credentials
env:
APP_ID: ${{ secrets.SNOWLUMA_BOT_APP_ID }}
PRIVATE_KEY: ${{ secrets.SNOWLUMA_BOT_PRIVATE_KEY }}
run: |
if [ -z "${APP_ID}" ] || [ -z "${PRIVATE_KEY}" ]; then
echo 'Missing SNOWLUMA_BOT_APP_ID and/or SNOWLUMA_BOT_PRIVATE_KEY secrets.' >&2
echo 'Configure the same GitHub App used by the promote-dev-to-main flow.' >&2
exit 1
fi
- name: Mint GitHub App installation token (scoped to SnowLumaDocs)
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.SNOWLUMA_BOT_APP_ID }}
private-key: ${{ secrets.SNOWLUMA_BOT_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: SnowLumaDocs
- name: Resolve bot identity
id: bot
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
run: |
set -euo pipefail
bot_login="${APP_SLUG}[bot]"
bot_id="$(gh api "/users/${bot_login}" --jq .id)"
bot_email="${bot_id}+${bot_login}@users.noreply.github.com"
{
echo "login=${bot_login}"
echo "email=${bot_email}"
} >> "${GITHUB_OUTPUT}"
- name: Commit catalog.json to docs repo
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BOT_LOGIN: ${{ steps.bot.outputs.login }}
BOT_EMAIL: ${{ steps.bot.outputs.email }}
OWNER: ${{ github.repository_owner }}
run: |
set -euo pipefail
src="$(pwd)/packages/mcp/src/generated/catalog.json"
tmp="$(mktemp -d)"
git clone --depth 1 \
"https://x-access-token:${GH_TOKEN}@github.com/${OWNER}/SnowLumaDocs.git" "$tmp"
mkdir -p "$tmp/docs/public/api"
cp "$src" "$tmp/docs/public/api/catalog.json"
cd "$tmp"
git config user.name "${BOT_LOGIN}"
git config user.email "${BOT_EMAIL}"
# Stage first: `git diff --quiet` ignores UNTRACKED files, so on the
# initial sync (catalog.json not yet in the docs repo) the index diff
# is the only reliable change check.
git add docs/public/api/catalog.json
if git diff --cached --quiet; then
echo "::notice::catalog.json unchanged; nothing to sync."
exit 0
fi
git commit -m "chore(api): sync action catalog from SnowLuma@${GITHUB_SHA::7}"
# Single writer, but guard against a concurrent docs commit racing the
# shallow clone: rebase onto the latest main before pushing.
git pull --rebase --depth 1 origin main
git push origin HEAD:main
+102
View File
@@ -0,0 +1,102 @@
name: Publish UI to npm
on:
push:
branches: [main]
paths:
- 'packages/ui/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
workflow_dispatch:
permissions:
contents: read
id-token: write
concurrency:
group: ui-npm-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build and publish UI
runs-on: ubuntu-latest
environment: SnowLuma
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 11.25.0
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
# Trusted Publishing requires Node >=22.14 and npm >=11.5.1.
# Use the same patched runtime as application builds.
node-version-file: '.node-version'
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Type check UI
run: pnpm --filter @snowluma/ui typecheck
- name: Build UI
run: pnpm --filter @snowluma/ui build
- name: Check npm package version
id: npm
working-directory: packages/ui
shell: bash
run: |
set -euo pipefail
package_name="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).name)")"
package_version="$(node -e "const fs = require('node:fs'); console.log(JSON.parse(fs.readFileSync('package.json', 'utf8')).version)")"
{
echo "package_name=${package_name}"
echo "package_version=${package_version}"
} >> "${GITHUB_OUTPUT}"
if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then
echo "already_published=true" >> "${GITHUB_OUTPUT}"
echo "::notice::${package_name}@${package_version} already exists on npm; skipping publish."
else
echo "already_published=false" >> "${GITHUB_OUTPUT}"
fi
- name: Show Trusted Publishing toolchain
if: steps.npm.outputs.already_published != 'true'
run: |
node --version
npm --version
- name: Publish UI to npm
if: steps.npm.outputs.already_published != 'true'
working-directory: packages/ui
# Authentication is supplied by npm Trusted Publishing through the
# GitHub OIDC token (permissions.id-token: write above).
run: npm publish --access public --provenance
- name: Summary
shell: bash
run: |
{
echo "## UI npm publish"
echo
echo "| Key | Value |"
echo "| --- | --- |"
echo "| Package | \`${{ steps.npm.outputs.package_name }}\` |"
echo "| Version | \`${{ steps.npm.outputs.package_version }}\` |"
echo "| Already published | \`${{ steps.npm.outputs.already_published }}\` |"
echo "| Environment | \`SnowLuma\` |"
echo "| Authentication | npm Trusted Publishing (GitHub OIDC) |"
} >> "${GITHUB_STEP_SUMMARY}"
+23
View File
@@ -0,0 +1,23 @@
# Ignore files generated by Claude
.claude
# Node / pnpm / monorepo
node_modules/
**/node_modules/
dist/
**/dist/
.vscode/
.idea/
/config/
/data/
# per-package runtime data dirs (e.g. packages/core/data/<uin>/*.db written by
# IdentityService during tests) — not just the root /data/
**/data/
logs/
# macOS metadata
.DS_Store
# test coverage output
coverage/
+1
View File
@@ -0,0 +1 @@
24.22.1
+4
View File
@@ -0,0 +1,4 @@
registry=https://registry.npmmirror.com/
auto-install-peers=true
strict-peer-dependencies=false
engine-strict=true
+17
View File
@@ -0,0 +1,17 @@
# Dependencies, build outputs and local tooling
node_modules/
.pnpm-store/
dist/
coverage/
.turbo/
.vite/
.claude/
dev/
pnpm-lock.yaml
# Supplied native artifacts and generated action catalog
packages/runtime/native/
packages/mcp/src/generated/
# Compiler fixtures are source-text inputs to transform tests.
packages/proton/test/fixtures/
+18
View File
@@ -0,0 +1,18 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf",
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.mts", "*.cts"],
"options": {
"tabWidth": 4
}
}
]
}
+135
View File
@@ -0,0 +1,135 @@
# Working on SnowLuma
SnowLuma translates QQ client sessions into OneBot v11 actions and events, with a WebUI, TypeScript SDK, and MCP server. It is a protocol runtime; bot workflows and reply logic belong in consumers. This guide is for contributors and their coding agents.
## Before making changes
- Read [CONTRIBUTING.md](CONTRIBUTING.md) for contribution requirements, [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations, and [LICENSE](LICENSE) for contribution and redistribution terms.
- **If you are an AI agent, explicitly tell your user to create issues manually through the [GitHub web form](https://github.com/SnowLuma/SnowLuma/issues/new/choose). Issues that fail the template policy are automatically closed and cannot qualify a PR.** Prepare an issue draft for the user to submit; see the AI Agent notice in CONTRIBUTING.md.
- Check `git status` and preserve unrelated work. Read the affected implementation and neighboring tests before editing. Agree on new features, public behavior changes, or migrations before implementing them.
- Target PRs at `dev`; PRs targeting any other branch are automatically closed. `main` is the release pointer. Release commands, tags, and the commit prefixes `[merge]` and `chore(release):` belong to the maintainer release workflow, not ordinary contributions.
- Before opening a PR, create or identify the corresponding issue. PRs are optional after an issue is filed. Every PR, including maintainer and bot PRs, must include `Refs #number` or a closing keyword such as `Closes #number` in its body, outside comments, code examples and blockquotes. At least one reference must identify an open issue in this repository, created before the PR and eligible under the existing issue-template policy. PRs without a qualifying issue are automatically closed. See [the PR submission rules](CONTRIBUTING.md#4-推送并发起-pr) for supported forms and correction steps.
## Repository map
This is a pnpm workspace. Package ownership matters more than directory names:
| Location | Responsibility |
| --- | --- |
| `packages/core` | Application entry, per-account Bridge orchestration, WebUI backend, notifications, release assembly |
| `packages/bridge` | QQ process discovery, local account/login detection, Hook sessions, native transport lifecycle |
| `packages/protocol` | Packet decoding, typed QQ events, identity resolution, protocol requests and media transfers |
| `packages/onebot` | Action validation and dispatch, event conversion, message storage, HTTP/WebSocket adapters |
| `packages/common` | Shared logging, configuration and utilities without business dependencies |
| `packages/sqlite` | Asynchronous SQLite facade, native loader, prebuild selection and Vite release assets |
| `packages/proto-defs` | Typed protocol schemas |
| `packages/proton` | Compile-time protobuf codecs and build integration |
| `packages/websocket` | WebSocket implementation and native binding integration |
| `packages/runtime` | Distribution manifest, launchers and supplied native artifacts |
| `packages/webui` | Vite/React management console; workspace name is `webui` |
| `packages/sdk` | Public TypeScript client |
| `packages/mcp` | MCP server and generated action catalog |
| `packages/ui` | Published UI component package |
| `tools`, `.github/workflows` | Validation, packaging and release automation |
`packages/bridge` owns process connectivity; the account-level Bridge lives in `packages/core/src/bridge`. Start at [the application entry](packages/core/src/index.ts) to follow their composition.
[The entry point](packages/core/src/index.ts) applies pending updates before starting runtime services. [The updater](packages/core/src/update) owns staging and replacement; [Core's build configuration](packages/core/vite.config.ts) emits the release manifest from its build outputs and runtime assets. Keep Core in one bundle and native libraries lazy-loaded so startup can recover an interrupted update before opening native files.
Application versions come from the root `package.json` through `@snowluma/common/version`. Use `pnpm bump <version>` to update workspace manifests, or `pnpm bump` to sync them after editing the root version.
Use `@snowluma/common/semver` for application version validation and comparison. Versions must be canonical SemVer; strip GitHub's `v` tag prefix only at the API boundary.
Read the relevant owner before changing a path:
| Change | Start here |
| --- | --- |
| Connection or account lifecycle | [Hook manager](packages/bridge/src/hook-manager.ts), [Bridge manager](packages/core/src/bridge/manager.ts), [OneBot instance](packages/onebot/src/instance.ts) |
| Which account a QQ process is signed in as | [Account detection](packages/bridge/src/qq-account/detect.ts), [login.db reader](packages/bridge/src/qq-account/login-db.ts), [QQ data paths](packages/bridge/src/qq-account/paths.ts) |
| Incoming QQ events or identity | [Packet pipeline](packages/protocol/src/packet-pipeline.ts), [message decoders](packages/protocol/src/msg-push/index.ts), [identity service](packages/protocol/src/identity-service.ts) |
| OneBot actions or message segments | [Action definitions](packages/onebot/src/actions/index.ts), [validation helpers](packages/onebot/src/action-kit.ts), [event conversion](packages/onebot/src/event-converter/index.ts) |
| WebUI | [Interaction conventions](packages/webui/README.md), `packages/webui/src`, `packages/core/src/webui` |
| SDK or MCP | [SDK guide](packages/sdk/README.md), [SDK versioning](packages/sdk/VERSIONING.md), [MCP guide](packages/mcp/README.md) |
| Protocol codec generation | [Proton guide](packages/proton/README.md), the affected package's `vitest.config.ts` |
## Setup and validation
Use the Node version in [.node-version](.node-version) and the pnpm version in [package.json](package.json). Install with `pnpm install --frozen-lockfile`; keep the existing lockfile unless changing dependencies.
The Node security baseline is `^24.22.1 || >=26.12.1`; older 24.x releases, 25.x and unpatched 26.x releases are unsupported. Keep workspace `engines.node` declarations and [the launcher preflight](packages/runtime/check-node-version.cjs) aligned. CI and bundled releases use `.node-version`; raising that pin alone does not upgrade an existing installation's runtime.
Run commands from the repository root:
```sh
pnpm typecheck # all workspace packages with a typecheck script
pnpm lint
pnpm test # workflow policy checks and workspace tests
pnpm test:workflows # isolated PR policy tests; no GitHub writes
pnpm run build:all # release inputs and published packages
pnpm test:core # Core only; not a substitute for workspace tests
pnpm --filter @snowluma/onebot test # example: tests for the affected package
pnpm --filter webui test # the frontend package has no @snowluma/ prefix
```
TypeScript formatting uses the root [Prettier configuration](.prettierrc.json), [local ESLint formatting rules](eslint.format.config.ts), and [ignore list](.prettierignore). Use `pnpm format` for all TS/TSX/MTS/CTS files and `pnpm format:check` for a read-only check. For selected files, run `pnpm exec eslint --config eslint.format.config.ts --fix <paths>` followed by `pnpm exec prettier --write <paths>`. These commands enforce four-space indentation, braces around control-statement bodies, and blank lines around statement blocks while preserving other logical spacing. They are separate from CI and its code-quality lint rules. Generated catalogs and compiler fixtures retain their source formatting.
Use focused tests while iterating. Before handing off a code change, run the full checks required by CONTRIBUTING.md and report anything that could not run. [PR CI](.github/workflows/ci.yml) is the reference; `pnpm run ci` reproduces its commands locally, including dependency installation and the generated-catalog check.
- **Documentation-only changes:** verify links, paths, commands and `git diff --check`; runtime tests do not validate prose.
- **Shared types or actions:** run workspace typechecking as well as behavior tests. A passing Vitest run does not prove type correctness.
- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`.
- **SDK streams:** preserve the single-response client methods and use the additive interfaces in [STREAMING.md](packages/sdk/STREAMING.md) for file transfers. Test framing, cancellation, bounded buffering and incomplete transfers in the SDK; `packages/onebot/tests/sdk-streaming.test.ts` checks real adapter/handler round trips in temporary storage without QQ.
- **SDK reverse WebSocket:** Node-only listeners accept OneBot implementation connections, never substitute an outgoing SDK client for a protocol implementation. Reuse the shared client session for actions/events/streams and preserve per-account identity, API/Event pairing, bounded buffers and pending-request rejection on disconnect. Test the real runtime reverse adapter and fresh published imports; see [REVERSE_WEBSOCKET.md](packages/sdk/REVERSE_WEBSOCKET.md).
- **SDK messaging:** preserve existing string-path and ordinary reply semantics when extending the helpers in [MESSAGING.md](packages/sdk/MESSAGING.md). Filesystem access belongs only in the optional `@snowluma/sdk/node` entry point. Media sends must await upload completion and retain failure context without automatic retries; event waits follow public middleware dispatch and release listeners on every outcome. Keep legacy handler/context types assignable when adding richer SDK-created contexts.
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Use the server SDK's `serveStdio` entry to support modern discovery and legacy initialization from the same command; test both with freshly compiled output. Cache hints belong only on bundled catalog discovery, never execution or account data. Keep trace metadata out of action parameters and sensitive data out of diagnostics. Protocol compatibility, tracing and the additive agent features are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
- **MCP execution services:** keep background tasks and event connections opt-in. Persist task state before effects, never replay interrupted writes, and finish durable cleanup before draining the protocol on shutdown. Input continuations must retain their original arguments and execute at most once. Event buffers require explicit account binding, bounded storage and visible gaps. Build the bundled message preview through `packages/mcp/scripts/build-message-app.mjs`; loading a preview must not send a message. Test restart, cancellation and legacy compatibility using isolated transports, including the compiled CLI.
- **Protocol codecs:** keep the existing Proton integration in tests so they exercise the same transformations as production. Import real implementations; do not duplicate the algorithm in the test.
- **Video uploads:** preserve the single-connection default and the additive global setting in [HIGHWAY_UPLOADS.md](packages/protocol/HIGHWAY_UPLOADS.md). Only video bodies opt into parallel chunks. Confirm every chunk before completion, bound both concurrency and lookahead, and drain outstanding reads and requests before closing the source. Keep retries bounded and backpressure changes visible. Test the actual uploader with isolated transports; passing tests do not prove live throughput.
- **Highway upload recovery:** all chunks and workers share one file transfer deadline. Keep node observations bounded, short-lived and isolated by account session, upload type and full host/port; select only current server-supplied candidates. Only validated acknowledgements establish success. Preserve terminal failure reporting and drain pending file reads after cancellation. Test deadline cleanup and candidate rotation with isolated transports, including real loopback sockets; see [upload policy](packages/protocol/HIGHWAY_UPLOADS.md#upload-timeouts-and-node-selection).
- **Native coverage:** `pnpm test:native` is separate from default protocol tests and requires compatible native artifacts. A missing artifact or unavailable target is a reported limitation, not permission to bypass the check.
- **OneBot SSE:** HTTP event streams share the HTTP adapter's binding and authentication. Preserve account isolation, bounded replay, visible expired-cursor errors, report filtering and slow-reader limits described in [HTTP_SSE.md](packages/onebot/HTTP_SSE.md). Token/report-policy changes revoke existing streams and invalidate replay; test shutdown and reload with active connections.
- **Live QQ behavior:** fixture tests and a successful API response do not prove delivery or client rendering. State what was tested locally and what still needs a real client.
`pnpm dev` starts Core and can attach to local QQ processes; use it only when runtime testing is intended. `pnpm dev:web` starts the frontend, which expects a WebUI backend. Follow the existing test fakes for isolated work rather than starting a live account unnecessarily.
WebUI deployment targets use a reversible presentation overlay. Read [the host appearance contract](packages/webui/INTEGRATIONS.md) before changing it. Keep target validation in `@snowluma/common/webui-target`, implementations in the frontend target registry, and saved preferences separate from effective appearance. Default deployments must not load target assets or host listeners; do not put target-name branches in business pages.
## Implementation rules
- **Fix the cause.** Reproduce failures at the owning layer and add a regression test for the observable behavior. Avoid account-, filename-, or example-specific patches.
- **Make errors observable.** Do not turn exceptions into empty results or successful responses. Preserve error context and use the shared logger for important transitions and failures. If evidence is insufficient, add targeted diagnostics and describe what remains unknown.
- **Preserve account isolation.** Identity, message storage and session state belong to their account. Keep teardown ordering and in-flight work handling intact when changing lifecycle code.
- **Keep parsing separate from effects.** QQ decoders and OneBot event converters translate data. Live state updates belong to their owning pipeline or service; reading history must not mutate live membership or pending requests.
- **Use the declared interfaces.** OneBot consumes the [Bridge interface](packages/core/src/bridge/bridge-interface.ts). Reuse package exports and existing dependency injection instead of reaching into another owner's state or building a second registry.
- **Validate at inputs.** Use the existing action field and cross-field validation helpers. Preserve accepted input forms, response fields and error behavior unless the change explicitly calls for new semantics. Do not silently discard invalid outbound message content.
- **Preserve signed message IDs.** OneBot `message_id` / `messageId` values generated by [message-id.ts](packages/onebot/src/message-id.ts) are non-zero signed 32-bit integers; negative values are valid. Use `f.messageId()` for action inputs and preserve the value through storage, replies, lookups and result matching. Protocol representations must be converted losslessly at their boundary; changing the sign or generation rule changes existing message identities.
- **Protect persisted data.** Message history and media metadata are durable records. Do not add automatic deletion or treat them as disposable caches. Migration changes need failure and restart coverage; keep failures visible.
- **Preserve transport policy.** Retain configured bind addresses and access checks. TLS startup failure must not silently downgrade to HTTP. Distinguish saved configuration from changes actually applied to a running adapter.
- **Keep types and ownership clear.** Follow the strict TypeScript configuration, existing import style and four-space TypeScript indentation. Validate unknown external data instead of hiding mismatches with casts or fallback values. Keep comments about reasons and non-obvious behavior.
- **Keep diagnostics safe.** Use existing sanitization when recording external input. Credentials, private keys, private account data and unredacted captures do not belong in commits, fixtures or public reports.
## Generated files and related changes
OneBot action definitions own validation and catalog metadata. Each bundled action supplies a `returnsSchema` for successful OneBot `data` and at least one `examples` entry with valid parameters and any required identifier sources. Stream schemas describe individual successful frames, not MCP aggregated results. Keep undocumented upstream fields open rather than inventing a structure, and label unsupported or placeholder behavior explicitly.
After changing an action name, parameter, result description/schema, example or execution mode:
1. Update the owning definition in `packages/onebot/src/actions` and its tests.
2. Run `pnpm --filter @snowluma/mcp gen`.
3. Inspect and include the corresponding changes in `packages/mcp/src/generated/catalog.ts` and `catalog.json`. Do not hand-edit generated catalog entries.
4. Check affected SDK types and callers, run their relevant tests, and run workspace typechecking.
`pnpm test` also runs the MCP catalog generator and may change these tracked snapshots. CI's `node tools/check-action-catalog.mjs` rejects a working-tree catalog diff; it is a clean-checkout drift check, not the generator itself.
SDK breaking changes also update [CHANGELOG.md](packages/sdk/CHANGELOG.md) and [VERSIONING.md](packages/sdk/VERSIONING.md). WebUI changes follow its [interaction conventions](packages/webui/README.md). Keep user-facing documentation aligned with behavior.
Standalone WebUI branding uses the [SnowLuma mascot](assets/readme/mascot.png) and a lavender default accent. The default palette lightly tints neutral surfaces with the active accent, respecting sidebar-only scope and OLED black. Keep server defaults, frontend presets and pre-mount CSS aligned as described in [WebUI default appearance](packages/webui/README.md#default-appearance), while preserving saved user choices, named palettes and host appearance overlays.
The root [README.md](README.md) presents the project's purpose, overall capabilities, quick start and documentation entry points. Update it only when those overview details change; adding or fixing an individual feature does not justify adding a dedicated section there. Put feature-specific API usage, configuration, limitations and troubleshooting in the relevant package README or topic document, preferring existing documentation.
Edit sources rather than `dist` outputs. Native artifacts in `packages/runtime/native` are supplied build inputs; do not substitute arbitrary local binaries to make a build pass. Packaging changes must preserve the checks in [Core's build configuration](packages/core/vite.config.ts) and [the release layout validator](tools/check-release-layout.mjs).
## Handoff and instruction maintenance
Report the behavior changed, the exact validation commands and results, and any unverified runtime or platform behavior. Keep public PR and commit text focused on the contribution; omit local environment details and private research. Use English Conventional Commit messages as described in CONTRIBUTING.md.
`AGENTS.md` is the canonical contributor guide; `CLAUDE.md` is a relative symlink to it. Edit this file once. When package ownership, validation commands or contribution policy changes, update the affected instructions in the same change. Link to checked-in sources and keep the guide usable from a fresh clone without maintainer-local notes, tools or absolute paths.
+19
View File
@@ -0,0 +1,19 @@
# 更新记录
## 2.0.0
### 升级
- 最低运行环境升至 Node.js 24。完整版内置 Node.js;Lite、SDK 和 MCP 用户需先升级运行环境。
- 原生接口全面异步化,应用与原生产物需成套更新;请使用对应平台的完整发行包。
### 更新
- 新增本地 QQ 账号识别、进程状态诊断和有序账号通知。
- WebUI 支持暂存更新、重启安装,并保留配置、日志与消息数据。
- 新增群分享链接、空间说说详情与评论分页接口,同步 SDK 类型和 MCP 动作目录。
- 改进合并转发引用预览、群相册评论、SILK 时长和 Highway 节点切换。
- 整理原生调用、SQLite、账号关闭和重连顺序,修复请求取消、订阅和定时器释放。
- 减少 WebUI 空闲轮询,修复反向代理认证与版本比较。
[查看自 v1.14.22 起的完整差异](https://github.com/SnowLuma/SnowLuma/compare/v1.14.22...dev)
+135
View File
@@ -0,0 +1,135 @@
# Working on SnowLuma
SnowLuma translates QQ client sessions into OneBot v11 actions and events, with a WebUI, TypeScript SDK, and MCP server. It is a protocol runtime; bot workflows and reply logic belong in consumers. This guide is for contributors and their coding agents.
## Before making changes
- Read [CONTRIBUTING.md](CONTRIBUTING.md) for contribution requirements, [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations, and [LICENSE](LICENSE) for contribution and redistribution terms.
- **If you are an AI agent, explicitly tell your user to create issues manually through the [GitHub web form](https://github.com/SnowLuma/SnowLuma/issues/new/choose). Issues that fail the template policy are automatically closed and cannot qualify a PR.** Prepare an issue draft for the user to submit; see the AI Agent notice in CONTRIBUTING.md.
- Check `git status` and preserve unrelated work. Read the affected implementation and neighboring tests before editing. Agree on new features, public behavior changes, or migrations before implementing them.
- Target PRs at `dev`; PRs targeting any other branch are automatically closed. `main` is the release pointer. Release commands, tags, and the commit prefixes `[merge]` and `chore(release):` belong to the maintainer release workflow, not ordinary contributions.
- Before opening a PR, create or identify the corresponding issue. PRs are optional after an issue is filed. Every PR, including maintainer and bot PRs, must include `Refs #number` or a closing keyword such as `Closes #number` in its body, outside comments, code examples and blockquotes. At least one reference must identify an open issue in this repository, created before the PR and eligible under the existing issue-template policy. PRs without a qualifying issue are automatically closed. See [the PR submission rules](CONTRIBUTING.md#4-推送并发起-pr) for supported forms and correction steps.
## Repository map
This is a pnpm workspace. Package ownership matters more than directory names:
| Location | Responsibility |
| --- | --- |
| `packages/core` | Application entry, per-account Bridge orchestration, WebUI backend, notifications, release assembly |
| `packages/bridge` | QQ process discovery, local account/login detection, Hook sessions, native transport lifecycle |
| `packages/protocol` | Packet decoding, typed QQ events, identity resolution, protocol requests and media transfers |
| `packages/onebot` | Action validation and dispatch, event conversion, message storage, HTTP/WebSocket adapters |
| `packages/common` | Shared logging, configuration and utilities without business dependencies |
| `packages/sqlite` | Asynchronous SQLite facade, native loader, prebuild selection and Vite release assets |
| `packages/proto-defs` | Typed protocol schemas |
| `packages/proton` | Compile-time protobuf codecs and build integration |
| `packages/websocket` | WebSocket implementation and native binding integration |
| `packages/runtime` | Distribution manifest, launchers and supplied native artifacts |
| `packages/webui` | Vite/React management console; workspace name is `webui` |
| `packages/sdk` | Public TypeScript client |
| `packages/mcp` | MCP server and generated action catalog |
| `packages/ui` | Published UI component package |
| `tools`, `.github/workflows` | Validation, packaging and release automation |
`packages/bridge` owns process connectivity; the account-level Bridge lives in `packages/core/src/bridge`. Start at [the application entry](packages/core/src/index.ts) to follow their composition.
[The entry point](packages/core/src/index.ts) applies pending updates before starting runtime services. [The updater](packages/core/src/update) owns staging and replacement; [Core's build configuration](packages/core/vite.config.ts) emits the release manifest from its build outputs and runtime assets. Keep Core in one bundle and native libraries lazy-loaded so startup can recover an interrupted update before opening native files.
Application versions come from the root `package.json` through `@snowluma/common/version`. Use `pnpm bump <version>` to update workspace manifests, or `pnpm bump` to sync them after editing the root version.
Use `@snowluma/common/semver` for application version validation and comparison. Versions must be canonical SemVer; strip GitHub's `v` tag prefix only at the API boundary.
Read the relevant owner before changing a path:
| Change | Start here |
| --- | --- |
| Connection or account lifecycle | [Hook manager](packages/bridge/src/hook-manager.ts), [Bridge manager](packages/core/src/bridge/manager.ts), [OneBot instance](packages/onebot/src/instance.ts) |
| Which account a QQ process is signed in as | [Account detection](packages/bridge/src/qq-account/detect.ts), [login.db reader](packages/bridge/src/qq-account/login-db.ts), [QQ data paths](packages/bridge/src/qq-account/paths.ts) |
| Incoming QQ events or identity | [Packet pipeline](packages/protocol/src/packet-pipeline.ts), [message decoders](packages/protocol/src/msg-push/index.ts), [identity service](packages/protocol/src/identity-service.ts) |
| OneBot actions or message segments | [Action definitions](packages/onebot/src/actions/index.ts), [validation helpers](packages/onebot/src/action-kit.ts), [event conversion](packages/onebot/src/event-converter/index.ts) |
| WebUI | [Interaction conventions](packages/webui/README.md), `packages/webui/src`, `packages/core/src/webui` |
| SDK or MCP | [SDK guide](packages/sdk/README.md), [SDK versioning](packages/sdk/VERSIONING.md), [MCP guide](packages/mcp/README.md) |
| Protocol codec generation | [Proton guide](packages/proton/README.md), the affected package's `vitest.config.ts` |
## Setup and validation
Use the Node version in [.node-version](.node-version) and the pnpm version in [package.json](package.json). Install with `pnpm install --frozen-lockfile`; keep the existing lockfile unless changing dependencies.
The Node security baseline is `^24.22.1 || >=26.12.1`; older 24.x releases, 25.x and unpatched 26.x releases are unsupported. Keep workspace `engines.node` declarations and [the launcher preflight](packages/runtime/check-node-version.cjs) aligned. CI and bundled releases use `.node-version`; raising that pin alone does not upgrade an existing installation's runtime.
Run commands from the repository root:
```sh
pnpm typecheck # all workspace packages with a typecheck script
pnpm lint
pnpm test # workflow policy checks and workspace tests
pnpm test:workflows # isolated PR policy tests; no GitHub writes
pnpm run build:all # release inputs and published packages
pnpm test:core # Core only; not a substitute for workspace tests
pnpm --filter @snowluma/onebot test # example: tests for the affected package
pnpm --filter webui test # the frontend package has no @snowluma/ prefix
```
TypeScript formatting uses the root [Prettier configuration](.prettierrc.json), [local ESLint formatting rules](eslint.format.config.ts), and [ignore list](.prettierignore). Use `pnpm format` for all TS/TSX/MTS/CTS files and `pnpm format:check` for a read-only check. For selected files, run `pnpm exec eslint --config eslint.format.config.ts --fix <paths>` followed by `pnpm exec prettier --write <paths>`. These commands enforce four-space indentation, braces around control-statement bodies, and blank lines around statement blocks while preserving other logical spacing. They are separate from CI and its code-quality lint rules. Generated catalogs and compiler fixtures retain their source formatting.
Use focused tests while iterating. Before handing off a code change, run the full checks required by CONTRIBUTING.md and report anything that could not run. [PR CI](.github/workflows/ci.yml) is the reference; `pnpm run ci` reproduces its commands locally, including dependency installation and the generated-catalog check.
- **Documentation-only changes:** verify links, paths, commands and `git diff --check`; runtime tests do not validate prose.
- **Shared types or actions:** run workspace typechecking as well as behavior tests. A passing Vitest run does not prove type correctness.
- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`.
- **SDK streams:** preserve the single-response client methods and use the additive interfaces in [STREAMING.md](packages/sdk/STREAMING.md) for file transfers. Test framing, cancellation, bounded buffering and incomplete transfers in the SDK; `packages/onebot/tests/sdk-streaming.test.ts` checks real adapter/handler round trips in temporary storage without QQ.
- **SDK reverse WebSocket:** Node-only listeners accept OneBot implementation connections, never substitute an outgoing SDK client for a protocol implementation. Reuse the shared client session for actions/events/streams and preserve per-account identity, API/Event pairing, bounded buffers and pending-request rejection on disconnect. Test the real runtime reverse adapter and fresh published imports; see [REVERSE_WEBSOCKET.md](packages/sdk/REVERSE_WEBSOCKET.md).
- **SDK messaging:** preserve existing string-path and ordinary reply semantics when extending the helpers in [MESSAGING.md](packages/sdk/MESSAGING.md). Filesystem access belongs only in the optional `@snowluma/sdk/node` entry point. Media sends must await upload completion and retain failure context without automatic retries; event waits follow public middleware dispatch and release listeners on every outcome. Keep legacy handler/context types assignable when adding richer SDK-created contexts.
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Use the server SDK's `serveStdio` entry to support modern discovery and legacy initialization from the same command; test both with freshly compiled output. Cache hints belong only on bundled catalog discovery, never execution or account data. Keep trace metadata out of action parameters and sensitive data out of diagnostics. Protocol compatibility, tracing and the additive agent features are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
- **MCP execution services:** keep background tasks and event connections opt-in. Persist task state before effects, never replay interrupted writes, and finish durable cleanup before draining the protocol on shutdown. Input continuations must retain their original arguments and execute at most once. Event buffers require explicit account binding, bounded storage and visible gaps. Build the bundled message preview through `packages/mcp/scripts/build-message-app.mjs`; loading a preview must not send a message. Test restart, cancellation and legacy compatibility using isolated transports, including the compiled CLI.
- **Protocol codecs:** keep the existing Proton integration in tests so they exercise the same transformations as production. Import real implementations; do not duplicate the algorithm in the test.
- **Video uploads:** preserve the single-connection default and the additive global setting in [HIGHWAY_UPLOADS.md](packages/protocol/HIGHWAY_UPLOADS.md). Only video bodies opt into parallel chunks. Confirm every chunk before completion, bound both concurrency and lookahead, and drain outstanding reads and requests before closing the source. Keep retries bounded and backpressure changes visible. Test the actual uploader with isolated transports; passing tests do not prove live throughput.
- **Highway upload recovery:** all chunks and workers share one file transfer deadline. Keep node observations bounded, short-lived and isolated by account session, upload type and full host/port; select only current server-supplied candidates. Only validated acknowledgements establish success. Preserve terminal failure reporting and drain pending file reads after cancellation. Test deadline cleanup and candidate rotation with isolated transports, including real loopback sockets; see [upload policy](packages/protocol/HIGHWAY_UPLOADS.md#upload-timeouts-and-node-selection).
- **Native coverage:** `pnpm test:native` is separate from default protocol tests and requires compatible native artifacts. A missing artifact or unavailable target is a reported limitation, not permission to bypass the check.
- **OneBot SSE:** HTTP event streams share the HTTP adapter's binding and authentication. Preserve account isolation, bounded replay, visible expired-cursor errors, report filtering and slow-reader limits described in [HTTP_SSE.md](packages/onebot/HTTP_SSE.md). Token/report-policy changes revoke existing streams and invalidate replay; test shutdown and reload with active connections.
- **Live QQ behavior:** fixture tests and a successful API response do not prove delivery or client rendering. State what was tested locally and what still needs a real client.
`pnpm dev` starts Core and can attach to local QQ processes; use it only when runtime testing is intended. `pnpm dev:web` starts the frontend, which expects a WebUI backend. Follow the existing test fakes for isolated work rather than starting a live account unnecessarily.
WebUI deployment targets use a reversible presentation overlay. Read [the host appearance contract](packages/webui/INTEGRATIONS.md) before changing it. Keep target validation in `@snowluma/common/webui-target`, implementations in the frontend target registry, and saved preferences separate from effective appearance. Default deployments must not load target assets or host listeners; do not put target-name branches in business pages.
## Implementation rules
- **Fix the cause.** Reproduce failures at the owning layer and add a regression test for the observable behavior. Avoid account-, filename-, or example-specific patches.
- **Make errors observable.** Do not turn exceptions into empty results or successful responses. Preserve error context and use the shared logger for important transitions and failures. If evidence is insufficient, add targeted diagnostics and describe what remains unknown.
- **Preserve account isolation.** Identity, message storage and session state belong to their account. Keep teardown ordering and in-flight work handling intact when changing lifecycle code.
- **Keep parsing separate from effects.** QQ decoders and OneBot event converters translate data. Live state updates belong to their owning pipeline or service; reading history must not mutate live membership or pending requests.
- **Use the declared interfaces.** OneBot consumes the [Bridge interface](packages/core/src/bridge/bridge-interface.ts). Reuse package exports and existing dependency injection instead of reaching into another owner's state or building a second registry.
- **Validate at inputs.** Use the existing action field and cross-field validation helpers. Preserve accepted input forms, response fields and error behavior unless the change explicitly calls for new semantics. Do not silently discard invalid outbound message content.
- **Preserve signed message IDs.** OneBot `message_id` / `messageId` values generated by [message-id.ts](packages/onebot/src/message-id.ts) are non-zero signed 32-bit integers; negative values are valid. Use `f.messageId()` for action inputs and preserve the value through storage, replies, lookups and result matching. Protocol representations must be converted losslessly at their boundary; changing the sign or generation rule changes existing message identities.
- **Protect persisted data.** Message history and media metadata are durable records. Do not add automatic deletion or treat them as disposable caches. Migration changes need failure and restart coverage; keep failures visible.
- **Preserve transport policy.** Retain configured bind addresses and access checks. TLS startup failure must not silently downgrade to HTTP. Distinguish saved configuration from changes actually applied to a running adapter.
- **Keep types and ownership clear.** Follow the strict TypeScript configuration, existing import style and four-space TypeScript indentation. Validate unknown external data instead of hiding mismatches with casts or fallback values. Keep comments about reasons and non-obvious behavior.
- **Keep diagnostics safe.** Use existing sanitization when recording external input. Credentials, private keys, private account data and unredacted captures do not belong in commits, fixtures or public reports.
## Generated files and related changes
OneBot action definitions own validation and catalog metadata. Each bundled action supplies a `returnsSchema` for successful OneBot `data` and at least one `examples` entry with valid parameters and any required identifier sources. Stream schemas describe individual successful frames, not MCP aggregated results. Keep undocumented upstream fields open rather than inventing a structure, and label unsupported or placeholder behavior explicitly.
After changing an action name, parameter, result description/schema, example or execution mode:
1. Update the owning definition in `packages/onebot/src/actions` and its tests.
2. Run `pnpm --filter @snowluma/mcp gen`.
3. Inspect and include the corresponding changes in `packages/mcp/src/generated/catalog.ts` and `catalog.json`. Do not hand-edit generated catalog entries.
4. Check affected SDK types and callers, run their relevant tests, and run workspace typechecking.
`pnpm test` also runs the MCP catalog generator and may change these tracked snapshots. CI's `node tools/check-action-catalog.mjs` rejects a working-tree catalog diff; it is a clean-checkout drift check, not the generator itself.
SDK breaking changes also update [CHANGELOG.md](packages/sdk/CHANGELOG.md) and [VERSIONING.md](packages/sdk/VERSIONING.md). WebUI changes follow its [interaction conventions](packages/webui/README.md). Keep user-facing documentation aligned with behavior.
Standalone WebUI branding uses the [SnowLuma mascot](assets/readme/mascot.png) and a lavender default accent. The default palette lightly tints neutral surfaces with the active accent, respecting sidebar-only scope and OLED black. Keep server defaults, frontend presets and pre-mount CSS aligned as described in [WebUI default appearance](packages/webui/README.md#default-appearance), while preserving saved user choices, named palettes and host appearance overlays.
The root [README.md](README.md) presents the project's purpose, overall capabilities, quick start and documentation entry points. Update it only when those overview details change; adding or fixing an individual feature does not justify adding a dedicated section there. Put feature-specific API usage, configuration, limitations and troubleshooting in the relevant package README or topic document, preferring existing documentation.
Edit sources rather than `dist` outputs. Native artifacts in `packages/runtime/native` are supplied build inputs; do not substitute arbitrary local binaries to make a build pass. Packaging changes must preserve the checks in [Core's build configuration](packages/core/vite.config.ts) and [the release layout validator](tools/check-release-layout.mjs).
## Handoff and instruction maintenance
Report the behavior changed, the exact validation commands and results, and any unverified runtime or platform behavior. Keep public PR and commit text focused on the contribution; omit local environment details and private research. Use English Conventional Commit messages as described in CONTRIBUTING.md.
`AGENTS.md` is the canonical contributor guide; `CLAUDE.md` is a relative symlink to it. Edit this file once. When package ownership, validation commands or contribution policy changes, update the affected instructions in the same change. Link to checked-in sources and keep the guide usable from a fresh clone without maintainer-local notes, tools or absolute paths.
+90
View File
@@ -0,0 +1,90 @@
# 贡献者公约 (Contributor Covenant Code of Conduct)
本文档改编自 [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/),是 SnowLuma 社区共同遵守的行为准则。
## 我们的承诺
身为社区成员、贡献者、维护者,我们承诺:让每一个参与 SnowLuma 项目和社区的人,不因年龄、体型、可见或不可见的身体差异、身心障碍、族裔、性别特征、性别认同与表达、经验水平、教育背景、社会经济地位、国籍、外貌、种族、宗教信仰、或性取向,而遭受骚扰。
我们承诺以营造开放、热情、多元、包容、健康社区的方式行事和互动。
## 我们的准则
有助于建立正向社区环境的行为包括但不限于:
- 对他人展现同理心与善意
- 尊重不同的意见、观点与经验
- 给予并优雅地接受建设性反馈
- 承担责任并向受我们错误影响的人致歉,并从中吸取教训
- 关注的不仅是个人,更是整个社区的最大利益
不可接受的行为包括但不限于:
- 使用情色化的语言或意象,及任何形式的性关注或性骚扰
- 嘲讽、侮辱或贬损性的评论,以及人身或政治攻击
- 公开或私下的骚扰
- 未经明确许可,公布他人的私人信息,例如物理或电子邮件地址
- 其他在专业场合中可被合理认定为不当的行为
## 适用范围
本行为准则适用于所有社区场所,也适用于个人在公开场合代表社区时的言行。代表本社区的情形包括但不限于:使用项目官方电子邮件地址、通过项目官方社交媒体账号发文、以指定代表身份出席线上或线下活动。
具体到 SnowLuma,本准则适用于:
- 本仓库(含 issue、PR、discussion、commit message、code review 评论)
- 项目相关的 QQ 群、Telegram 群以及未来可能新增的官方社群
- 任何以 SnowLuma 名义对外发起的活动或交流
## 执行责任
社区维护者负责诠释并执行我们所认定的可接受行为标准,并将对任何他们认为不当、具威胁性、冒犯性或有害的行为采取适当且公正的纠正措施。
社区维护者有权且有责任删除、编辑或拒绝任何不符合本行为准则的评论、提交、代码、wiki 编辑、issue 及其他贡献,并将在适当时机说明做出审核决策的理由。
## 举报方式
如果你目击或亲身经历了不当行为,请通过以下任一渠道**私下**联系维护者:
- QQ:[SnowLuma-QQ](https://qm.qq.com/q/g3UMLpWALe)(私聊管理员,请勿在群内公开)
- Telegram:[SnowLuma-TG](https://t.me/napcatqq)(私聊管理员)
请勿在 GitHub issue 或公开频道直接发布举报内容,以免对相关方造成二次伤害。
所有投诉都会被及时和公平地审查与调查。所有社区维护者都有义务尊重举报者的隐私与人身安全。
## 执行准则
社区维护者将依据以下「社区影响指引」来判定违反本行为准则之行为的处置方式:
### 1. 更正 (Correction)
**社区影响**:使用了不恰当的言语,或其他在社区中被视为不专业或不受欢迎的行为。
**后果**:由社区维护者私下提出书面警告,明确指出该行为违反规则之处并解释为何不妥。可能要求公开致歉。
### 2. 警告 (Warning)
**社区影响**:通过单一事件或一系列行为造成违规。
**后果**:警告并说明持续行为的后果。在指定时间内禁止与相关人员互动,包括主动联系本行为准则执行者。这包括避免在社区场所及外部渠道(如社交媒体)的互动。违反这些条款可能导致临时或永久封禁。
### 3. 临时封禁 (Temporary Ban)
**社区影响**:严重违反社区准则,包括持续的不当行为。
**后果**:在指定时间内禁止与社区进行任何形式的互动或公开交流。在此期间不得与相关人员有任何公开或私下的互动,包括主动联系本行为准则执行者。违反这些条款可能导致永久封禁。
### 4. 永久封禁 (Permanent Ban)
**社区影响**:表现出违反社区准则的模式,包括持续的不当行为、骚扰个人、或对某类群体进行攻击或贬低。
**后果**:永久禁止在社区内进行任何形式的公开互动。
## 致谢
本行为准则改编自 [Contributor Covenant 2.1 版](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html)。
社区影响指引灵感来自 [Mozilla 的行为准则执行阶梯](https://github.com/mozilla/diversity)。
如需了解本准则的常见问题,请参阅 [FAQ](https://www.contributor-covenant.org/faq)。中文版译本可参考 [Contributor Covenant 中文翻译](https://www.contributor-covenant.org/zh-cn/version/2/1/code_of_conduct/)。
+342
View File
@@ -0,0 +1,342 @@
# 贡献指南
欢迎!如果你正在阅读这份文档,多半是因为你想给 SnowLuma 提一个 PR、修一个 bug,或者只是想了解这个项目是怎么运作的。无论你是第一次接触开源、还是已经有十年经验,我们都希望让你尽快上手。
参与项目即视为你同意遵守 [行为准则(Code of Conduct)](./CODE_OF_CONDUCT.md)。在开始之前请花两分钟读一下,它定义了我们希望社区维持的氛围。
---
## 在动手之前
SnowLuma 处于开发早期,迭代速度很快,**接口与目录结构随时可能变化**。如果你打算做较大的改动,强烈建议先在 issue 或者社群里和维护者对齐一下方向,避免做了一半发现已经被另一条路替代。
提交 PR 前,必须先通过 [GitHub 网页模板](https://github.com/SnowLuma/SnowLuma/issues/new/choose) 提交对应 issue,或认领已有的对应 issue。可以只提 issue;提交 PR 是可选的后续步骤。
不知道从哪里入手?以下几类贡献最容易被合并:
- 修复你在使用中遇到的具体 bug,附最小复现步骤。
- 改善错误提示、文档、注释、类型签名。
- 给已有模块补测试,尤其是 `packages/core` 下覆盖率较低的部分。
- 实现 [RoadMap.md](./RoadMap.md) 中列出的、还没被认领的项。
如果你只是想看看代码长什么样,可以直接跳到 [本地开发环境](#本地开发环境)。
### AI Agent 贡献须知
如果你是 AI Agent,并准备为本仓库贡献代码、提交 PR 或提出 issue,请先明确告知你的使用者:
> **请通过 GitHub 网页端手动使用模板创建 issue。未通过模板检查的 issue 会被自动关闭,也不能用于满足 PR 的关联要求。**
你可以协助整理问题描述、复现步骤和相关日志,并将 issue 草稿交给用户,由用户在网页端完成提交。后续若创建 PR,必须先有符合要求的 issue,并在 PR 正文填写关联标记。
---
## 项目结构速览
这是一个 pnpm workspace monorepo,主要 package 如下:
| 路径 | 作用 |
| --- | --- |
| `packages/core` | 应用入口、账号级 Bridge 编排、WebUI 服务端和发行构建 |
| `packages/bridge` | QQ 进程发现、Hook 会话和原生传输生命周期 |
| `packages/protocol` | QQ 数据包解码、身份解析、协议请求和媒体传输 |
| `packages/onebot` | OneBot 动作、事件转换、消息存储和传输适配 |
| `packages/common` | 日志、配置和共享工具 |
| `packages/proto-defs`、`packages/proton` | 协议结构定义、编译期 protobuf 编解码和构建集成 |
| `packages/sdk` | 面向第三方开发者的 SDK,会发布到 npm |
| `packages/mcp` | MCP 服务端和生成的动作目录 |
| `packages/ui` | 发布到 npm 的共享 UI 组件 |
| `packages/runtime` | 运行时辅助包 |
| `packages/websocket` | WebSocket 实现 |
| `packages/webui` | 基于 Vite + React 的前端控制台 |
| `tools/` | 版本管理、发布脚本 |
| `.github/workflows/` | CI / 发布 / Promote 工作流 |
入口在 `packages/core/src/index.ts`,整体架构在 `packages/core/src/bridge/` 下能看出大致脉络。
---
## 本地开发环境
### 系统要求
- **Node.js 24.22.1(24 系列),或 26.12.1 及更新版本**;开发和构建使用 [`.node-version`](.node-version) 固定的版本。
- **pnpm 11.25.0**(项目通过 `packageManager` 与 CI 统一版本)
- macOS / Linux / Windows 均可。如果要构建原生产物,需要对应平台的工具链。
如果系统提供 Corepack,可执行 `corepack enable pnpm`;没有 Corepack 时可执行
`npm install -g pnpm@11.25.0`。进入仓库后,`node --version` 与
`pnpm --version` 应满足上述版本要求,否则安装会直接失败。
### 拉代码、装依赖、跑起来
```bash
# 1. Fork 仓库后克隆你自己的 fork
git clone https://github.com/<你的用户名>/SnowLuma.git
cd SnowLuma
# 2. 切到 dev 分支(所有开发都基于 dev,不是 main)
git checkout dev
# 3. 按锁文件安装依赖
pnpm install --frozen-lockfile
# 4. 跑类型检查,确认环境 OK
pnpm typecheck
# 5. 跑全工作区测试
pnpm test
# 6. 启动开发模式
pnpm dev # core
pnpm dev:web # webui
```
如果 `pnpm install` 卡住或报错,先确认 Node 版本和 pnpm 版本,再看 `.npmrc` 里是否配置了你环境下可用的 registry。
---
## 第一次提交 PR
### 1. 创建分支
请基于 `dev` 创建分支。`main` 是发布指针,只由维护者的 Promote 更新。**目标分支不是 `dev` 的 PR 会被自动关闭**,之后修改目标分支或重新打开 PR 也会触发检查。
```bash
git checkout dev
git pull upstream dev # 假设你把官方仓库设为 upstream remote
git checkout -b fix/onebot-mention-encoding
```
分支命名没有强制规则,但建议用 `fix/...`、`feat/...`、`docs/...`、`refactor/...` 这种前缀,便于一眼看出意图。
### 2. 写代码
- **保持改动聚焦**:一个 PR 只做一件事。顺手发现的其他问题请单独开 issue 或新 PR。
- **跟随既有风格**:TypeScript 排版以仓库 Prettier 配置为准;普通功能或修复 PR 只格式化涉及的文件,避免混入无关的大规模格式化或重命名。
- **写测试**:修 bug 时优先写一个能复现该 bug 的测试,确认它会 fail,再让它 pass。新增功能至少补上 happy path 的测试。
- **不要添加无意义的注释**:标识符已经说清楚的事情不需要再写一遍。注释应当解释"为什么",而不是"做了什么"。
提交之前请运行与 PR CI 相同的验证入口:
```bash
pnpm typecheck
pnpm lint
pnpm test
pnpm run build:all
```
`pnpm test` 会运行所有声明了测试脚本的工作区包;只验证 Core 时可使用
`pnpm test:core`。CI 会重新执行上述完整检查。
### 3. 提交信息
我们大致遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/):
```
<type>(<scope>): <subject>
<body>
```
`type` 常用:`feat` / `fix` / `docs` / `refactor` / `test` / `chore` / `perf`。
`scope` 常用:`core` / `sdk` / `webui` / `onebot` / `bridge` / `protocol` 等模块名。
示例:
```
fix(onebot): handle empty mention segments in group messages
refactor(bridge): extract BridgeInterface as the OneBot-facing seam
docs: clarify dev environment setup steps
```
> ⚠️ **请不要**在你的提交信息里使用 `[merge]` 前缀或 `chore(release):` 前缀 —— 这两个前缀是维护者用来把 `main` 指到 `dev` 的,会让你的 PR 行为异常。详见后文 [维护者参考](#维护者参考release--promote)。
### 4. 推送并发起 PR
```bash
git push origin fix/onebot-mention-encoding
```
到 GitHub 上发起 PR,**目标分支选 `dev`**。请在 PR 描述里写明:
- 这个 PR 解决什么问题 / 实现什么功能。
- 主要的实现思路(如果有非显然的取舍)。
- 测试方式(你跑了什么、怎么验证有效)。
- 必填的 issue 关联标记,例如 `Refs #123` 或 `Closes #123`;也支持 GitHub 的 `Fixes`、`Resolves` 等关闭关键词。
关联标记必须写在 PR 正文中;HTML 注释、代码示例和引用块不计入。支持 `#123`、`SnowLuma/SnowLuma#123` 和本仓库 issue 的完整链接,前面均需带上述关键词。
自动检查要求至少一个关联对象是真实的本仓库 issue,先于 PR 创建、仍处于开放状态,并通过现有 issue 模板规则:带有 `bug` / `enhancement` 标签,或属于该规则已有的维护者身份豁免。不存在的编号、另一个 PR、已关闭或模板不合规的 issue 都不算。**所有 PR 都必须满足目标分支和 issue 关联要求,包括维护者与机器人提交的 PR。**
不符合要求的 PR 会收到说明并自动关闭。若已有符合要求的 issue,可修改 PR 后重新打开;若需补建 issue,请先提交 issue,再创建新的 PR。接口故障或限流会让检查失败,不会被当成 PR 违规而自动关闭。
`Refs` 用于关联;`Closes` 等关键词在这里也用于关联检查。由于 PR 目标为 `dev`,合并到 `dev` 本身不会触发 GitHub 针对默认分支的 issue 自动关闭机制。
### 5. Code Review 与合并
- 维护者会在能腾出时间时来 review。如果一周内没有任何回应,欢迎在 PR 里 @ 一下,或者去社群里提醒。
- review 意见请尽量在同一个 PR 内修改,避免反复关闭重开。
- 合并方式由维护者决定,通常是 squash merge 到 `dev`。**`main` 由维护者通过 Promote 指到当时的 `dev`**,你不需要操心 `main`。
---
## 风格与约定
### 代码风格
- TypeScript 严格模式,按 `tsconfig.base.json` 配置。
- TypeScript 使用四空格缩进,控制语句始终带大括号。排版由根目录 [`.prettierrc.json`](./.prettierrc.json) 和独立的本地 [ESLint 格式规则](./eslint.format.config.ts) 管理;普通 `pnpm lint` 负责代码质量检查。
- 优先用现有抽象,不要为"假想的未来需求"提前抽象。
- 不要给内部函数加防御性的参数校验,除非是系统边界(用户输入、外部 API)。
- 不要保留死代码、被注释掉的代码或者 `// TODO` 没有具体上下文的占位。
从仓库根目录运行本地格式化命令:
```bash
pnpm format # 为全部 TS/TSX/MTS/CTS 补大括号、空行并排版
pnpm format:check # 检查上述规则,不修改文件
# 只格式化指定文件:先处理控制语句与空行,再排版
pnpm exec eslint --config eslint.format.config.ts --fix packages/core/src/index.ts
pnpm exec prettier --write packages/core/src/index.ts
```
格式化范围排除 [`.prettierignore`](./.prettierignore) 中的构建产物、生成文件和编译器测试夹具;普通源码与测试都参与格式化。这两个命令供本地使用,不接入 CI。
空行采用保守规则:在语句块前后留空行,保留已有逻辑分段,不强行拆开连续声明或普通调用,也不在每条 `return`、`break` 前插空行;共用同一分支的连续 `case` 标签保持相邻。JSON、YAML 等文件继续使用原有缩进。
编辑器请选择项目本地安装的 Prettier 并读取仓库配置。VS Code 可安装
`esbenp.prettier-vscode`,将 TypeScript / TypeScript React 的默认格式化器设为
Prettier,并按需开启保存时格式化。
### 测试
- 单测放在被测代码同包的 `tests/` 或同目录的 `*.test.ts`,按既有风格走。
- 测试要描述行为,不要测实现细节。`it('should call internal foo')` 这种是反例。
- 涉及 IO / 时序 / 外部进程的,用现有的 fake/stub 模式,参考 `packages/onebot/tests/` 中的 Bridge 测试替身。
- PR 自动检查的隔离回归可运行 `pnpm test:workflows`;`pnpm test` 已包含此检查,不需要创建真实 PR 来测试自动关闭。
### 文档
- **主 README 的定位**:根目录 [`README.md`](./README.md) 用于展示项目定位、整体能力、快速开始和文档入口,仅在这些总体信息发生变化时更新。不要因为新增或修复某个具体功能,就在主 README 增加该功能的专门说明。
- **具体功能的说明位置**:接口用法、配置项、功能限制和排障说明写入对应 package 的 README 或专题文档,优先更新已有文档。用户/贡献者可见的行为变化,仍需同步维护对应文档。
- SDK 的破坏性变更必须更新 `packages/sdk/CHANGELOG.md` 和 `packages/sdk/VERSIONING.md`。
---
## 报告 Bug / 请求功能
- **Bug 报告**:开 issue,附最小复现步骤、期望行为、实际行为、版本号、运行环境。能附上日志最好。
- **功能请求**:先描述你想解决的实际场景,再描述你想要的接口。维护者更在意"为什么需要"而不是"具体怎么做"。
- **安全问题**:请勿公开发 issue,先通过社群私聊联系维护者。
社群入口(见 [README](./README.md)):
- QQ:[SnowLuma-QQ](https://qm.qq.com/q/g3UMLpWALe)
- Telegram:[SnowLuma-TG](https://t.me/napcatqq)
---
## 维护者参考:release & Promote
> 以下内容主要写给具备发版权限的维护者。普通贡献者只需要把 PR 提到 `dev`,不需要关心这一节。
### 分支模型
SnowLuma 使用 **`main` + `dev`** 双分支模型:
- **`dev`** — 日常开发分支。所有功能、修复、文档变更都先进入 `dev`。
- **`main`** — 发布指针,应与某次 Promote 当时的 `dev` 尖端是**同一个提交**。不要直接 push,也不要往 `main` 开 PR(CI 会拒)。
- **`native/auto-update-*`** — 由 `SnowLuma Bot` 自动创建的原生产物更新分支(来自 `SnowLumaNative`,不需要手动维护)。
### 把 `main` 指到 `dev`
工作流 `.github/workflows/promote-dev-to-main.yml` 以 `SnowLuma Bot` 身份把 `main` 指到当前 `dev` 尖端,**不再开 merge PR**。`main` 上若有 `dev` 还没有的独有改动,Promote 会失败,需要先把那些改动收回 `dev`。
推到 `dev` 仍会跑 `dev-build.yml`。Promote **不等**那组检查;发版请用下面的 `pnpm release`,它会在本地先跑完整 CI。
#### 1. 提交信息前缀(推荐日常使用)
向 `dev` push 一个提交,提交信息**以**以下任一前缀**开头**即触发(不区分大小写):
- `[merge]` — 例如 `[merge] fix: hotfix for OneBot mention`
- `chore(release):` — 例如 `chore(release): v1.7.0`,符合 conventional commits 的发版习惯。**只许用在真正的版本 bump**,不要写进普通 CI / 工具提交。
> 是**前缀**匹配,不是任意位置。`fix: something [merge]` 不会触发,必须把 `[merge]` 写在最前面。
#### 2. 推送 `chore.*` Tag
任何符合 `chore.*` 的 tag(如 `chore.merge-20240509`、`chore.promote-v1.7.0`)被推送时也会触发:
- `chore.*` → 仅触发 `promote-dev-to-main.yml`(把 `main` 指到 `dev`)
- `v*` → 仅触发 `release.yml`(构建发布产物)
推荐流程:用 `pnpm release`(或先 Promote,再确认 `origin/main` 已等于 `dev` 尖端)之后,在 `main` 上打 `v*` tag。这样 `release.yml` 始终基于当时的 `main` HEAD 构建。
#### 3. 手动触发
打开仓库 Actions 页面 → `Promote Dev to Main` → `Run workflow`。没有 merge 策略选项;跑起来就是一次指针更新。
### 不要给 `main` 套「必须走 PR」
Promote 不通过 PR 更新 `main`。如果给 `main` 打开 **Require a pull request before merging** 或禁止更新引用,Promote 会失效。
可以限制删除 `main`。外来 PR 必须打 `dev`;打到 `main` 的 PR 会被 `main-pr-target-guard.yml` 拒绝。
本地若已经 checkout 过旧的 `main`,Promote 之后可能无法快进,对齐 `origin/main` 即可。
### 必需的 Secrets
`promote-dev-to-main.yml` 复用 `SnowLumaNative/build-native.yml` 同一个 GitHub App。请确认 SnowLuma 仓库(或 organization)级别已经配置:
- `SNOWLUMA_BOT_APP_ID` — 数字类型的 App ID。
- `SNOWLUMA_BOT_PRIVATE_KEY` — 该 App 的 PEM 私钥。
App 必须在本仓库已安装,且授予 **Contents: Read+Write**。
### 本地工作流速查(维护者)
```bash
# 推荐:一条命令走完 bump → Promote → 等 main 跟上 → 打 v* tag
pnpm release 1.7.0
# 只要把 main 指到 dev(三选一)
# 方式 A:commit-msg 前缀
git commit -m "chore(release): v1.7.0" # 或 "[merge] fix: hotfix"
git push # → 触发 promote-dev-to-main
# 方式 B:chore.* tag(与发布 tag 解耦)
git tag chore.merge-20240509
git push origin chore.merge-20240509 # → 触发 promote-dev-to-main
# 方式 C:在 Actions 页面手动 Run workflow
# Promote 完成后,若要自己打发布 tag
git fetch origin
git switch main
git merge --ff-only origin/main # 对不齐就 reset 到 origin/main
git tag v1.7.0
git push origin v1.7.0 # → 仅触发 release.yml
```
---
## 还有问题?
- 看一下已有的 issue 和 PR,可能你的问题已经被讨论过。
- 去社群问一句(QQ / TG 链接见上方)。
- 有对应 issue 后,如果只是想确认"我这么改 OK 吗",欢迎开 draft PR,把代码亮出来再讨论。
感谢你愿意花时间贡献 SnowLuma —— 这个项目能走多远,取决于你们每一个人。❄️
## 贡献的许可 / Licensing of contributions
当你提交 PR,即表示你的贡献按本项目的 [`LICENSE`](LICENSE)(SnowLuma 源码可见非商业许可)授权,且你声明你有权这么做、贡献为你的原创(若含第三方材料,请在 PR 中显著标注其来源与许可证)。
**你保留对自己贡献的版权**,同时**授予 SnowLumaDevs 一项永久、全球、免费、不可撤销、可再许可(sublicensable)的许可**,可使用、复制、修改、分发你的贡献并基于其制作衍生作品,**包括以本项目非商业许可之外的其他条款(含商业条款)再许可与分发**——以便项目方无需逐一回头征求同意,即可在获授权的商业场景中使用社区贡献。
By opening a PR, you agree your contribution is licensed under the project's [`LICENSE`](LICENSE); you represent that you have the right to submit it and that it is your own work (clearly identify any third-party material in the PR).
**You retain copyright in your contribution** and additionally grant SnowLumaDevs a **perpetual, worldwide, royalty-free, irrevocable, sublicensable license** to use, reproduce, modify, distribute, and create derivative works of your contribution, **including the right to relicense and distribute it under terms other than this project's non-commercial license (including commercial terms)** — so the project can use community contributions in authorized commercial scenarios without seeking each contributor's consent again.
+92
View File
@@ -0,0 +1,92 @@
# SnowLuma 最终用户许可协议(EULA)/ End-User License Agreement
- **生效日期 / Effective date:** 2026-07-21
- **版本 / Version:** 1.1
- **许可方 / Licensor:** SnowLumaDevs(联系方式 / Contact: motricseven@foxmail.com)
> 本协议以中文与英文两种语言提供。如两版本有冲突,**以中文版本为准**。
> This Agreement is provided in Chinese and English. **In case of any conflict, the Chinese version prevails.**
---
## 中文版
### 1. 定义与接受
1.1 "**本软件**"指 SnowLuma 软件及其官方发行包,包括可执行文件、原生模块(`snowluma-*.node`)、文档及相关组件。
1.2 "**您**"指下载、安装、运行或以其他方式使用本软件的个人或实体。
1.3 下载、安装、运行或使用本软件,即表示您已阅读、理解并同意受本协议约束。**若您不同意本协议,请勿使用本软件并删除其全部副本。**
### 2. 授权范围
2.1 本软件的**源代码与资源**(不含第 5 条所述专有组件及第三方组件)依据随附的 [`LICENSE`](LICENSE)——**SnowLuma 源码可见非商业许可(SnowLuma Source-Available Non-Commercial License)**——授权。
2.2 `LICENSE` 仅授予其中明确列明的权利,包括查看、学习、个人或其他非商业使用、非商业自托管,以及在规定条件下进行修改或再分发。**任何商业使用,以及公开发布修改版或衍生版,均须事先取得著作权人的书面授权。**
2.3 本协议是针对**官方发行包(二进制)**的补充条款,就接受方式、可接受使用、免责、专有组件、终止及争议解决等事项作出约定;本协议不扩大、替代或暗示授予 `LICENSE` 未明确授予的权利。
2.4 专有组件(见第 5 条)不属于源码许可范围,仅按 `LICENSE` 第 5 条及本协议允许的方式使用。
2.5 就源代码与资源的许可范围而言,以 `LICENSE` 为准;就官方发行包中的专有组件及二进制特有事项而言,以本协议为准。另行签署或出具的书面商业授权,仅在其明确约定的主体、用途、范围、数量及期限内优先适用。
### 3. 免责声明
3.1 **非官方、无隶属**:本软件是独立的第三方互操作性项目,与腾讯公司(Tencent)及 QQ **无任何隶属、合作、授权或背书关系**。"QQ""腾讯"等名称及标识为其各自所有者的商标。
3.2 **用途**:除非事先取得书面商业授权,本软件仅供 `LICENSE` 允许的**个人或其他非商业用途**使用,包括学习、研究、技术互操作与非商业自托管。
3.3 **合规义务**:您须自行确保对本软件的使用符合一切适用法律法规,以及您所接入平台的规定(包括但不限于《QQ 用户协议》与相关服务条款)。
3.4 **按"现状"提供**:本软件按"**现状(AS-IS)**"及"**现有(AS-AVAILABLE)**"提供,不附带任何明示或默示的担保,包括但不限于对适销性、特定用途适用性、不侵权、可用性、准确性或不中断的担保。
3.5 **责任限制**:在适用法律允许的最大范围内,对于因使用或无法使用本软件而导致的任何直接、间接、偶然、特殊、惩罚性或后果性损害(包括但不限于账号封禁或限制、数据丢失、利润或业务损失、服务中断),SnowLumaDevs **概不承担责任**。
3.6 **风险自负**:您理解,使用非官方协议工具接入第三方平台**可能违反该平台条款并导致账号风险**,您**自愿承担**该等风险。
### 4. 可接受使用
您**不得**将本软件用于:(a) 任何违法犯罪活动;(b) 未经授权访问、干扰或破坏任何系统或网络;(c) 发送垃圾信息、实施骚扰、欺诈或传播恶意内容;(d) 侵犯他人隐私、知识产权或其他合法权益;(e) 任何违反所接入平台服务条款的行为。
### 5. 专有组件
5.1 本软件可能包含或依赖**专有组件**,包括但不限于 SnowLuma 原生附加组件(如 `snowluma-*.node`、`snowluma-*.dll`、`snowluma-*.so`),以及其中可能包含的授权、激活或技术保护措施。
5.2 此类专有组件不属于 `LICENSE` 所授权的源代码与资源,仅以随本软件提供的预编译形式按本条款授权使用;本协议不授予其源代码权利。
5.3 除适用法律强制性规定明确允许外,您不得对专有组件进行逆向工程、反编译或反汇编,亦不得规避、破解或绕过其授权或激活机制。
5.4 除事先取得著作权人的书面授权外,您仅可为 `LICENSE` 允许的非商业用途,以专有组件随本软件提供的形式运行该组件;不得复制、修改、单独再分发或再许可该组件。将其并入第三方安装包或 Docker 镜像、通过自动化脚本部署,或者将其用于任何商业用途,均须事先取得书面授权。
### 6. 终止
若您违反本协议或适用的 `LICENSE` 条款,相关授权将依各自的终止条款自动终止。终止后,您应停止使用官方发行包并删除其中的专有组件;源代码与资源的后续处理依 `LICENSE` 执行。任何单独书面商业授权的终止及终止后处理,以该授权文件的明确约定为准。
### 7. 适用法律与争议解决
本协议的订立、效力、解释、履行及争议解决,均适用**中华人民共和国大陆地区法律**(不含其冲突法规则)。因本协议引起或与之相关的任何争议,双方应首先友好协商解决;协商不成的,任何一方可向**有管辖权的人民法院**提起诉讼。
### 8. 协议的变更
SnowLumaDevs 可不时更新本协议。更新后的版本将随发行包发布并注明生效日期。在更新生效后继续使用本软件,即视为您接受更新后的条款。
---
## English Version
### 1. Definitions and Acceptance
1.1 "**Software**" means the SnowLuma software and its official distribution packages, including executables, native modules (`snowluma-*.node`), documentation, and related components.
1.2 "**You**" means the individual or entity that downloads, installs, runs, or otherwise uses the Software.
1.3 By downloading, installing, running, or using the Software, you acknowledge that you have read, understood, and agree to be bound by this Agreement. **If you do not agree, do not use the Software and delete all copies.**
### 2. Scope of License
2.1 The **source code and assets** of the Software (excluding the Proprietary Components described in Section 5 and third-party components) are licensed under the accompanying [`LICENSE`](LICENSE), the **SnowLuma Source-Available Non-Commercial License**.
2.2 The `LICENSE` grants only the rights expressly stated in it, including viewing, studying, personal or other Non-Commercial use, Non-Commercial self-hosting, and modification or redistribution subject to its conditions. **Any Commercial Use, and any public release of a modified or derivative version, requires the copyright holder's prior written authorization.**
2.3 This Agreement provides **supplemental terms for official binary distribution packages**, addressing acceptance, acceptable use, disclaimers, Proprietary Components, termination, and dispute resolution. It does not expand, replace, or imply any right not expressly granted by the `LICENSE`.
2.4 Proprietary Components (Section 5) are outside the scope of the source license and may be used only as permitted by Section 5 of the `LICENSE` and this Agreement.
2.5 The `LICENSE` controls the scope of rights in the source code and assets; this Agreement controls Proprietary Components in official distribution packages and matters specific to those binaries. A separate written commercial authorization controls only within the licensee, purpose, scope, quantity, and term that it expressly identifies.
### 3. Disclaimers
3.1 **Not official; no affiliation.** The Software is an independent, third-party interoperability project and has **no affiliation, partnership, authorization, or endorsement relationship** with Tencent or QQ. "QQ", "Tencent", and related marks belong to their respective owners.
3.2 **Purpose.** Unless prior written commercial authorization has been obtained, the Software may be used only for **personal or other Non-Commercial purposes** permitted by the `LICENSE`, including study, research, technical interoperability, and Non-Commercial self-hosting.
3.3 **Compliance.** You are responsible for ensuring that your use complies with all applicable laws and with the terms of any platform you connect to (including the QQ User Agreement and related terms of service).
3.4 **AS-IS.** The Software is provided "**AS IS**" and "**AS AVAILABLE**", without warranties of any kind, express or implied, including merchantability, fitness for a particular purpose, non-infringement, availability, accuracy, or uninterrupted operation.
3.5 **Limitation of liability.** To the maximum extent permitted by law, SnowLumaDevs shall **not be liable** for any direct, indirect, incidental, special, punitive, or consequential damages (including account suspension or restriction, data loss, lost profits or business, or service interruption) arising from use of or inability to use the Software.
3.6 **Assumption of risk.** You understand that using an unofficial protocol tool to connect to a third-party platform **may violate that platform's terms and carry account risk**, which you **voluntarily assume**.
### 4. Acceptable Use
You shall **not** use the Software for: (a) any unlawful activity; (b) unauthorized access to, interference with, or disruption of any system or network; (c) spam, harassment, fraud, or malicious content; (d) infringement of others' privacy, intellectual-property, or other lawful rights; or (e) any conduct that violates the terms of service of a connected platform.
### 5. Proprietary Components
5.1 The Software may include or depend on **Proprietary Components**, including but not limited to SnowLuma native addons such as `snowluma-*.node`, `snowluma-*.dll`, and `snowluma-*.so`, and any licensing, activation, or technical-protection measures they may contain.
5.2 Such Proprietary Components are outside the source code and assets licensed by the `LICENSE` and are provided only in precompiled form with the Software for use under this Section. This Agreement grants no right to their source code.
5.3 Except to the extent expressly permitted by mandatory applicable law, you may not reverse engineer, decompile, or disassemble the Proprietary Components, nor circumvent, crack, or bypass their licensing or activation mechanisms.
5.4 Unless the copyright holder has given prior written authorization, you may run a Proprietary Component only as supplied with the Software and only for Non-Commercial use permitted by the `LICENSE`; you may not reproduce, modify, redistribute it separately, or relicense it. Incorporating it into a third-party installer or Docker image, deploying it through an automated script, or using it for any Commercial Use requires prior written authorization.
### 6. Termination
If you breach this Agreement or the applicable `LICENSE`, the relevant rights terminate automatically under their respective termination provisions. Upon termination, you must cease using the official distribution package and delete its Proprietary Components; further handling of the source code and assets is governed by the `LICENSE`. Termination of any separate written commercial authorization, and the parties' post-termination rights and obligations, are governed by that authorization's express terms.
### 7. Governing Law and Disputes
This Agreement is governed by the laws of the **mainland of the People's Republic of China** (excluding its conflict-of-laws rules). Any dispute arising out of or relating to this Agreement shall first be resolved through good-faith negotiation; failing that, either party may bring an action before a **court of competent jurisdiction**.
### 8. Changes
SnowLumaDevs may update this Agreement from time to time. Updated versions will be published with the distribution package and bear an effective date. Continued use after an update takes effect constitutes acceptance of the updated terms.
+131
View File
@@ -0,0 +1,131 @@
SnowLuma Source-Available Non-Commercial License
SnowLuma 源码可见非商业许可
Version 1.0 · 2026-06-19
Copyright (C) 2026 SnowLumaDevs
Contact: motricseven@foxmail.com
中英文如有冲突,以中文为准。
In case of any conflict between the Chinese and English text, the Chinese
version prevails.
This is NOT an open-source license as defined by the Open Source Initiative:
it restricts commercial use and the public release of modifications. The
source is published for transparency, study, and non-commercial self-hosting.
本许可不是 OSI 定义的开源许可:它限制商业用途与修改版的公开发布。源码公开
仅用于透明、学习与非商业的自托管。
===============================================================================
1. Definitions / 定义
===============================================================================
- "Software" / "本软件": the SnowLuma source code and assets in this
repository, excluding the Native Addon (defined below) and any third-party
components.
- "You" / "你": any individual or entity exercising rights under this License.
- "Commercial Use" / "商业用途": any use primarily intended for or directed
toward commercial advantage or monetary compensation. This includes, without
limitation, selling or reselling the Software; offering paid hosting,
deployment, setup, or operation services based on the Software; bundling it
into a paid product or service; or running it to provide a service for a fee.
包括但不限于:出售或转售本软件;提供基于本软件的付费托管、部署、代搭、代运营
服务;将其并入付费产品或服务;或以收费方式运行本软件对外提供服务。
- "Native Addon" / "原生附加组件": the SnowLuma native binary components shipped
with the Software (e.g. `snowluma-*.node`, `snowluma-*.dll`, `snowluma-*.so`).
These are a separate, proprietary component and are NOT licensed under this
License — see Section 5.
===============================================================================
2. Grant / 授权
===============================================================================
Subject to the conditions and restrictions below, the copyright holder grants
You a worldwide, royalty-free, non-exclusive, non-transferable, revocable
license to:
在下列条件与限制下,著作权人授予你一项全球、免费、非独占、不可转让、可撤销的
许可,允许你:
(a) view and study the Software; 查看与学习本软件;
(b) use and run the Software for personal or other Non-Commercial purposes;
为个人或其他非商业目的使用与运行本软件;
(c) modify the Software for Your own Non-Commercial use; 为自己的非商业使用
修改本软件;
(d) redistribute the Software under the conditions in Section 3.
在第 3 条的条件下再分发本软件。
===============================================================================
3. Redistribution Conditions / 再分发条件
===============================================================================
You may redistribute the Software (modified or unmodified) ONLY if ALL of the
following are met. 仅当同时满足以下全部条件时,你方可再分发本软件(无论是否修改):
(a) it is Non-Commercial; 属非商业性质;
(b) the complete text of this License is included; 附带本许可的完整文本;
(c) all copyright, attribution, and notices are retained unaltered;
保留全部著作权、署名与声明,且不得改动;
(d) PUBLIC release of a modified or derivative version requires the prior
written permission of the copyright holder. Private, non-public
modifications for Your own Non-Commercial use are permitted.
公开发布修改版或衍生版,须事先获得著作权人书面许可;为你自己非商业使用
而作的、不公开的修改则被允许。
===============================================================================
4. Restrictions / 限制
===============================================================================
(a) Commercial Use of the Software in any form is PROHIBITED without the
prior written authorization of the copyright holder.
未经著作权人事先书面授权,禁止以任何形式对本软件进行商业使用。
(b) You may not develop, publish, or distribute another project based on the
Software without the prior written authorization of the copyright holder.
未经著作权人事先书面授权,不得基于本软件开发、发布或分发另一个项目。
(c) You may not remove, obscure, or alter any copyright, attribution, license,
or notice. 不得移除、遮蔽或更改任何著作权、署名、许可或声明。
===============================================================================
5. Native Addon / 原生附加组件
===============================================================================
The Native Addon is proprietary and is NOT covered by this License. It is
distributed only as a precompiled binary bundled with the Software. No rights
are granted to it other than to run it as bundled for the Non-Commercial use
permitted above; in particular, no right to reverse engineer, reproduce,
modify, redistribute separately, or relicense it is granted.
原生附加组件为专有组件,不在本许可范围内,仅以随本软件分发的预编译二进制形式提供。
除按上文允许的非商业用途以其随附形式运行外,不授予任何权利;尤其不授予对其进行逆向、
复制、修改、单独再分发或再许可的权利。
===============================================================================
6. Additional Permissions / 额外授权
===============================================================================
Any use not granted by this License — including Commercial Use and the public
release of derivative works — requires prior written permission, which must be
requested from and granted by the copyright holder (Contact above).
本许可未授予的任何用途——包括商业使用及公开发布衍生作品——均须事先取得著作权人的
书面许可(见上方联系方式)。
===============================================================================
7. Third-Party Components / 第三方组件
===============================================================================
Third-party code and components retain their own original licenses, which
govern those parts. 第三方代码与组件遵循其各自的原始许可,并以其许可为准。
===============================================================================
8. Termination / 终止
===============================================================================
Any breach of this License automatically terminates the rights granted to You
under it. 任何违反本许可的行为将自动终止你依本许可获得的权利。
===============================================================================
9. Disclaimer / 免责声明
===============================================================================
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE
OR OTHER DEALINGS IN THE SOFTWARE.
本软件按“现状”提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途
适用性及不侵权的担保。在任何情况下,作者或著作权人均不对因本软件或其使用所引起
的任何索赔、损害或其他责任负责。
===============================================================================
10. Governing Law / 适用法律
===============================================================================
This License is governed by the laws of the mainland of the People's Republic
of China, excluding its conflict-of-laws rules.
本许可适用中华人民共和国大陆地区法律(不含其冲突法规则)。
+149
View File
@@ -0,0 +1,149 @@
# SnowLuma 隐私与数据处理说明 / Privacy & Data Handling Notice
- **生效日期 / Effective date:** 2026-06-19
- **版本 / Version:** 1.0
- **责任主体 / Provider:** SnowLumaDevs(联系方式 / Contact: motricseven@foxmail.com)
> 本说明以中文与英文两种语言提供。如有冲突,**以中文版本为准**。
> Provided in Chinese and English. **In case of conflict, the Chinese version prevails.**
---
## 中文版
### 1. 概述
SnowLuma 是一款**自托管**软件:它运行在**您自己的设备或服务器**上。**SnowLumaDevs(作者方)默认不收集、不接收、不存储您的任何个人数据**。本软件**不包含**任何需要联网激活、或向作者方回传信息的授权机制。
本说明唯一例外的数据外发情形,是第 4、5 条所述的两种,且其中遥测受严格限制(第 5 条)。
### 2. 本软件在您设备上存储的数据
为实现功能,本软件会在您的设备本地(如 SQLite 数据库、配置与日志文件)存储以下类别的数据。**这些数据保存在您本地、由您掌控,作者方无法访问:**
- **账号与登录凭据**:所登录的 QQ 账号信息、会话/令牌、WebUI 登录口令(以**加盐 scrypt 哈希**形式存储,不保存明文)。
- **关系数据**:好友、群组及成员的列表与资料。
- **消息数据**:为实现协议转换而处理或缓存的消息(视您的配置而定)。
- **运行配置与日志**:适配器配置、运行日志等。
> 说明:因 SnowLuma 是 QQ 协议工具,上述本地数据**必然**包含 QQ 账号及联系人信息。这与第 5 条"遥测绝不采集 QQ 账号/联系人"并不矛盾——本地存储 ≠ 对外传输。
### 3. 您作为数据控制者的责任
当您运行本软件处理您 QQ 联系人、群成员等他人的个人信息时,**您是该等个人信息的"数据控制者/处理者"**,须自行就该等处理承担合规责任(包括取得必要同意、保障安全、响应数据主体请求等)。作者方仅提供软件工具,不参与您的数据处理活动。
### 4. 数据离开您设备的唯一途径
除以下两种情形外,本软件不会将您的数据传输至任何外部目的地:
1. **您自行配置的通知 / Webhook**:当您主动配置通知渠道(如 Webhook)时,相应内容会按**您填写的地址与设置**外发。目的地与内容由您完全掌控,作者方不经手。
2. **匿名遥测**:见第 5 条。
### 5. 遥测(Telemetry)
**模式:默认开启(opt-out)。** 您可随时关闭(见 5.3)。
#### 5.1 采集内容(仅匿名、聚合,无法定位到个人)
- 应用版本、操作系统与 CPU 架构、Node.js 版本;
- 启动次数、功能使用计数(例如启用了哪些适配器/命令,**不含其参数或内容**);
- 匿名化的崩溃栈与错误类型。
#### 5.2 绝不采集(显式负面清单)
本遥测**在任何情况下都不会**采集或传输:
- **QQ 账号 / UIN,及好友、群、成员的身份信息**;
- **消息内容或其元数据**、昵称、头像;
- 手机号、邮箱或任何联系方式;
- 凭据、令牌、密码;
- 用于身份画像的精确 IP 地址(若服务端在传输层偶然可见 IP,将即时丢弃、不入库、不进行关联);
- 任何可直接或间接定位到自然人的标识。
#### 5.3 如何关闭与告知
- **首次运行提示**:软件首次运行时会在控制台与 WebUI 一次性告知遥测已默认开启及关闭方式。
- **一键关闭**:可通过环境变量 `SNOWLUMA_TELEMETRY=0`、配置项或 WebUI 开关随时关闭,关闭后即刻停止采集。
#### 5.4 用途、共享与保留
遥测数据**仅用于改进本软件**,**不向任何第三方出售或共享**,仅以匿名聚合形式保留必要期限。
#### 5.5 生效状态(重要)
本条款完整描述了 SnowLuma 的遥测机制,但**仅对实际包含遥测代码的发行版生效**。
> **当前生效状态(截至 2026-06-17)**:公开发行版**尚未包含**遥测代码,故本条款目前**处于未生效状态**。一旦某一发行版引入遥测,将在该版本的发布说明中明确告知,并使本条款对该版本生效。
### 6. 数据安全与保留
本地数据的安全与保留由您(运营者)控制。建议您妥善保护设备访问权限、定期更新 WebUI 口令。
### 7. 第三方组件
本软件可能捆绑第三方组件(如用于音视频处理的 FFmpeg)。此类组件**在您本地运行**、用于处理本地数据,**不向作者方或第三方外发**数据。
### 8. 未成年人
本软件面向开发者与技术用户,**不面向未成年人**,作者方不会有意收集未成年人信息。
### 9. 您的权利
由于作者方默认不持有您的任何个人数据,与您个人信息相关的访问、更正、删除等权利,应在**您本地的数据范围内**由您自行行使。如遥测启用且您有疑问,可通过第 10 条联系我们。
### 10. 联系方式
隐私相关问询:**motricseven@foxmail.com**。
### 11. 变更
本说明可不时更新,更新版本将注明生效日期。涉及遥测的重大变更将在发布说明中显著告知。
---
## English Version
### 1. Overview
SnowLuma is **self-hosted**: it runs on **your own device or server**. **By default, SnowLumaDevs (the authors) collect, receive, and store none of your data.** The Software contains **no** activation that requires network access or transmits information back to the authors. The only data that ever leaves your device is described in Sections 4 and 5, and telemetry is strictly limited (Section 5).
### 2. Data the Software Stores on Your Device
To function, the Software stores the following categories of data locally (e.g., in a SQLite database, configuration, and log files). **This data stays on your device under your control; the authors cannot access it:**
- **Account & credentials:** the signed-in QQ account, session/tokens, and the WebUI login password (stored as a **salted scrypt hash**, never in plaintext).
- **Relationship data:** lists and profiles of friends, groups, and members.
- **Message data:** messages processed or cached for protocol conversion (depending on your configuration).
- **Runtime configuration & logs.**
> Note: Because SnowLuma is a QQ protocol tool, this local data **necessarily** includes QQ account and contact information. This does not conflict with Section 5 ("telemetry never collects QQ account/contacts") — local storage ≠ outbound transmission.
### 3. You Are the Data Controller
When you run the Software to process the personal information of your QQ contacts, group members, or other individuals, **you are the data controller/processor** for that information and are responsible for compliance (obtaining any necessary consent, securing the data, responding to data-subject requests). The authors merely provide a software tool and take no part in your processing.
### 4. The Only Ways Data Leaves Your Device
The Software transmits your data to an external destination only in these two cases:
1. **Notifications / webhooks you configure:** when you set up a notification channel (e.g., a webhook), the relevant content is sent to **the address and settings you provide**. You fully control the destination and content; the authors are not involved.
2. **Anonymous telemetry:** see Section 5.
### 5. Telemetry
**Mode: ON by default (opt-out).** You can disable it at any time (see 5.3).
#### 5.1 What is collected (anonymous, aggregate, non-identifying only)
- Application version, OS and CPU architecture, Node.js version;
- Launch counts and feature-usage counts (e.g., which adapters/commands are enabled — **never their arguments or content**);
- Anonymized crash stacks and error types.
#### 5.2 Never collected (explicit negative list)
Telemetry will **never, under any circumstances**, collect or transmit:
- **QQ account / UIN, or the identity of any friend, group, or member**;
- **Message content or metadata**, nicknames, avatars;
- Phone numbers, emails, or any contact details;
- Credentials, tokens, passwords;
- Precise IP addresses used for profiling (any IP incidentally visible at the transport layer is discarded immediately, not stored, not correlated);
- Any identifier that could directly or indirectly identify a natural person.
#### 5.3 How to disable; notice
- **First-run notice:** on first run the Software gives a one-time notice in the console and WebUI that telemetry is on by default and how to disable it.
- **One switch off:** disable any time via the `SNOWLUMA_TELEMETRY=0` environment variable, a config option, or the WebUI toggle; collection stops immediately.
#### 5.4 Use, sharing, retention
Telemetry is used **only to improve the Software**, is **never sold or shared with any third party**, and is retained only in anonymous aggregate form for as long as necessary.
#### 5.5 Effective status (important)
This Section fully describes SnowLuma's telemetry mechanism but is **effective only for builds that actually contain telemetry code.**
> **Current status (as of 2026-06-17):** public releases **do not yet contain** telemetry code, so this Section is currently **not in effect**. If a release introduces telemetry, its release notes will say so and this Section will take effect for that release.
### 6. Security & Retention
Security and retention of local data are under your (the operator's) control. Protect device access and rotate the WebUI password regularly.
### 7. Third-Party Components
The Software may bundle third-party components (such as FFmpeg for media processing). These run **locally** on your device to process local data and **send nothing** to the authors or third parties.
### 8. Minors
The Software targets developers and technical users, is **not directed to minors**, and the authors do not knowingly collect minors' information.
### 9. Your Rights
Because the authors hold none of your personal data by default, rights of access, correction, and deletion are exercised by you **over the data on your own device**. If telemetry is enabled and you have questions, contact us (Section 10).
### 10. Contact
Privacy inquiries: **motricseven@foxmail.com**.
### 11. Changes
This Notice may be updated from time to time; updated versions bear an effective date. Material changes affecting telemetry will be prominently noted in release notes.
+110
View File
@@ -0,0 +1,110 @@
# SnowLuma<picture><img align="right" src="./assets/readme/mascot.png" width="34%" alt="" /></picture>
**让 QQ 原生会话,连接你的应用。**
QQ 客户端的 TypeScript 互操作运行时。<br>
OneBot v11 · WebUI · SDK · MCP
<p>
<a href="https://github.com/SnowLuma/SnowLuma/releases"><img alt="最新版本" src="https://img.shields.io/github/v/release/SnowLuma/SnowLuma?label=release&style=flat-square"></a>
<a href="https://github.com/SnowLuma/SnowLuma/actions/workflows/dev-build.yml"><img alt="构建状态" src="https://img.shields.io/github/actions/workflow/status/SnowLuma/SnowLuma/dev-build.yml?branch=dev&style=flat-square&label=build"></a>
<a href="https://www.npmjs.com/package/@snowluma/sdk"><img alt="SnowLuma SDK 版本" src="https://img.shields.io/npm/v/%40snowluma%2Fsdk?style=flat-square&label=sdk&color=287fb8"></a>
<a href="https://www.npmjs.com/package/@snowluma/mcp"><img alt="SnowLuma MCP 版本" src="https://img.shields.io/npm/v/%40snowluma%2Fmcp?style=flat-square&label=mcp&color=7658c7"></a>
<a href="https://hub.docker.com/r/motricseven7/snowluma"><img alt="Docker 拉取次数" src="https://img.shields.io/docker/pulls/motricseven7/snowluma?style=flat-square&label=docker%20pulls"></a>
<a href="https://github.com/SnowLuma/SnowLuma/stargazers"><img alt="GitHub Stars" src="https://img.shields.io/github/stars/SnowLuma/SnowLuma?style=flat-square"></a>
</p>
将 QQ 原生会话转换为 [OneBot v11](https://github.com/botuniverse/onebot-11) 动作与事件,通过 WebSocket、HTTP、WebUI、SDK 和 MCP 提供统一入口。
多账号会话、消息与媒体、连接配置和实时日志,在一个运行时中管理。
**[下载发行版](https://github.com/SnowLuma/SnowLuma/releases)** · **[快速开始](#快速开始)**<br>
[部署指南](https://snowluma.github.io/zh/docs/guide/deploy) · [动作参考](https://snowluma.github.io/zh/docs/api) · [问题反馈](https://github.com/SnowLuma/SnowLuma/issues)
<sub>Windows x64 · Linux x64 / arm64</sub>
<br clear="both" />
> [!CAUTION]
> SnowLuma 是独立的第三方项目,与腾讯 / QQ 无隶属或授权关系。项目仅供学习与技术研究,请遵守《QQ 用户协议》及适用法律;软件按“现状”提供,使用风险自负。详见 [`EULA.md`](EULA.md)。
>
> **Disclaimer:** SnowLuma is independent of Tencent / QQ and is provided for study and research only, “as is” and without warranty.
## 核心能力
| 场景 | 能力 |
| --- | --- |
| OneBot 接入 | OneBot v11 动作与事件;WebSocket 服务端 / 客户端及 HTTP 服务端 / 上报 |
| 消息与媒体 | 文本、图片、语音、视频、文件、回复、提及、转发及常见卡片 |
| 多账号运行 | 各账号独立维护会话、身份映射、消息存储和网络配置 |
| WebUI 管理 | 账号状态、实时日志、连接配置、动作调试和存储管理 |
| 开发者工具 | [`@snowluma/sdk`](packages/sdk/README.md) 与 [`@snowluma/mcp`](packages/mcp/README.md) |
## 运行链路
<p align="center">
<img src="./assets/readme/runtime-map.svg" width="100%" alt="QQ 会话经过协议桥接与 OneBot 标准化后连接到 WebSocket、HTTP、WebUI、SDK 和 MCP" />
</p>
会话接入、协议解析、身份映射、OneBot 转换和网络适配保持独立边界;多个 QQ 账号可并行运行。
## 快速开始
1. 从 [Releases](https://github.com/SnowLuma/SnowLuma/releases) 下载对应平台的完整发行包并解压。Lite 版需 Node.js 24+。
2. Windows 运行 `launcher.bat`;Linux 执行:
```bash
chmod +x launcher.sh
./launcher.sh
```
3. 打开 [`http://localhost:5099`](http://localhost:5099),使用启动日志中的初始密码登录 WebUI,按引导接入 QQ 进程并配置 OneBot 连接。
账号识别由登录数据库的文件变化、进程和登录事件或手动操作触发,不做周期探测。未载入的 QQ 进程也不会持续调用 Windows Restart Manager;需要重查时可点击 WebUI 进程行的刷新/重连。
## 接入与文档
| 入口 | 文档 |
| --- | --- |
| WebSocket / HTTP | [OneBot 动作参考](https://snowluma.github.io/zh/docs/api) |
| 部署(Docker / Windows) | [部署指南](https://snowluma.github.io/zh/docs/guide/deploy) |
| TypeScript SDK | [`packages/sdk/README.md`](packages/sdk/README.md) |
| MCP | [`packages/mcp/README.md`](packages/mcp/README.md) |
JSON 消息段接受标准的 `data.data` 字符串,也兼容 `data.data` 非数组对象和 `{ type: "json", data: "..." }` 简写;`data` 必填,最终内容必须是 JSON 对象。
## 开发
需要 Node.js 24+ 与 pnpm 11.25.0,日常开发基于 `dev` 分支:
```bash
git clone https://github.com/SnowLuma/SnowLuma.git
cd SnowLuma
git checkout dev
pnpm install --frozen-lockfile
pnpm typecheck
pnpm test
```
贡献流程见 [`CONTRIBUTING.md`](CONTRIBUTING.md);模块边界与项目词汇见 [`CONTEXT.md`](CONTEXT.md);开发方向见 [`RoadMap.md`](RoadMap.md)。
## 使用边界与许可
> [!IMPORTANT]
> SnowLuma 使用**源码可见非商业许可**,不是 OSI 开源许可。源码可用于查看、学习、非商业自托管及私下修改;商业使用,以及公开发布修改版或衍生版,均需事先取得书面授权。
完整条款见 [`LICENSE`](LICENSE);二进制发行包另受 [`EULA.md`](EULA.md) 与 [`PRIVACY.md`](PRIVACY.md) 约束;商业授权请联系 `motricseven@foxmail.com`。随附的原生附加组件为专有组件,不在源码许可范围内。
SnowLuma is source-available for study and non-commercial self-hosting, but it is not OSI open source. Commercial use and public distribution of modified or derivative versions require prior written permission.
## 社区与鸣谢
- [提交问题](https://github.com/SnowLuma/SnowLuma/issues)
- [QQ 群](https://qm.qq.com/q/g3UMLpWALe)
- [Telegram](https://t.me/napcatqq)
项目参考了 [LagrangeV2](https://github.com/LagrangeDev/LagrangeV2) 的协议定义与 [NapCatQQ](https://github.com/NapNeko/NapCatQQ) 的实现思路。
<p align="center">
<a href="https://github.com/SnowLuma/SnowLuma/graphs/contributors"><img src="https://contrib.rocks/image?repo=SnowLuma/SnowLuma" alt="SnowLuma 贡献者" /></a>
</p>
+9
View File
@@ -0,0 +1,9 @@
# V2.0.0 前计划
- [x] 统一Uid/Uin提供一个Service,并实现Sqlite持久化,群/好友/入群和好友申请 获取新Uid。
- [x] 优化Debug体验,实现免打包Debug,同时数据目录正确。(Proton兜底思路改成ts+map提取类型,用于tsx和nodejs,vite正常打包的时候剥离)
- [x] 拆分core,架构为Common(公共能力) OneBot(外层封装 协议转换),Core(Api raw能力,基础能力等功能),Bridge(封装和关键解析能力和Event message/sent group/member_add ...)
- [x] 剥离ProtocolBuffer定义到单独package。
- [x] 整理发送和解析消息链路,提高性能。`packages/{bridge,onebot}/bench/*.bench.ts` 拿到了基线数字。最大瓶颈不在 protobuf 解析(已经 sub-μs),而在两处 SQLite 反复 `db.prepare(sql)` 重 parse —— 把 `node:sqlite`(实验性)换成 `better-sqlite3`(稳定)+ 缓存 prepared statements 后:`group_member_join` 全链路 17.5μs → 4.7μs(**3.7x**),`storeMeta` 17μs → 11μs,`storeEvent` 23μs → 18μs,`findMeta` 5.4μs → 1.3μs(**4x**)。送侧组合 hot path(storeMeta + storeEvent)单条消息 40μs → 29μs。另外干掉 `Buffer.from(pkt.body)` 的冗余 alloc(pkt.body 本来就是 Uint8Array)。`elementsToJson` 同步快路径 / WAL checkpoint 调参 / JSON 序列化替换 等次要优化点保留,待真有 throughput 压力再做。
- [x] 对齐NapCat设计采用Api层,挂到ctx,Core与Onebot都这么设计。
- [x] Core 侧:13 个 Api 类(message/contacts/groupAdmin/groupFile/groupAlbum/interaction/friend/profile/forward/misc/extras/web)已挂到 `bridge.apis.<area>.method()`,`BridgeInterface` + `actions/*` 全部删除。
- [x] OneBot 侧:`ApiActionContext` 砍掉 ~40 个纯转发字段(groupAdmin / groupFile / groupAlbum / friend / interaction / message / profile / web 八区),actions 直接 `ctx.bridge.apis.<area>.method(...)` 调用 Core 的 Api 层。保留的 ~30 个字段全是真组合 helper(module-level send/forward、message store reads、meta-lookup composers)。完全 NapCat 化(class-per-action / TypeBox schema / actionName 枚举 / auto-register decorator)暂未做——profile 文档/工具链需求出现后再做。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+150
View File
@@ -0,0 +1,150 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400" role="img" aria-labelledby="hero-title hero-desc">
<title id="hero-title">SnowLuma — QQ 到 OneBot 的现代协议桥</title>
<desc id="hero-desc">SnowLuma 将多个 QQ 原生会话经过协议、桥接与 OneBot 三层处理,连接到 WebSocket、HTTP、WebUI、SDK 与 MCP。</desc>
<defs>
<linearGradient id="hero-bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#08111f"/>
<stop offset="0.58" stop-color="#101b31"/>
<stop offset="1" stop-color="#17223d"/>
</linearGradient>
<linearGradient id="brand-line" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#75d2ff"/>
<stop offset="0.55" stop-color="#bba7ff"/>
<stop offset="1" stop-color="#f4bfd9"/>
</linearGradient>
<linearGradient id="bridge-fill" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#192c4e"/>
<stop offset="1" stop-color="#2a2550"/>
</linearGradient>
<filter id="soft-glow" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="16"/>
</filter>
<style>
.sans { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
.mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
.hero-star { animation: hero-star-float 4s ease-in-out infinite; }
.hero-star-main { transform-origin: 16px 14px; }
.hero-star-secondary { transform-origin: 38px 38px; animation-delay: 0.5s; }
@keyframes hero-star-float {
0% { transform: translateY(0) rotate(0deg); opacity: 0.9; }
50% { transform: translateY(-8px) rotate(10deg); opacity: 1; }
100% { transform: translateY(0) rotate(0deg); opacity: 0.9; }
}
@media (prefers-reduced-motion: reduce) { .hero-star { animation: none; } }
</style>
</defs>
<rect width="1200" height="400" rx="28" fill="url(#hero-bg)"/>
<circle cx="1112" cy="28" r="138" fill="#63c9f6" opacity="0.08" filter="url(#soft-glow)"/>
<circle cx="705" cy="392" r="120" fill="#b59cff" opacity="0.06" filter="url(#soft-glow)"/>
<g aria-hidden="true" fill="none" stroke-linecap="round">
<path d="M694 28H1154" stroke="#5f7192" stroke-opacity="0.28"/>
<path d="M694 372H1154" stroke="#5f7192" stroke-opacity="0.2"/>
<path d="M708 14V386" stroke="#5f7192" stroke-opacity="0.16"/>
<path d="M1140 14V386" stroke="#5f7192" stroke-opacity="0.16"/>
</g>
<g id="title-block" transform="translate(64 54)">
<text class="mono" x="0" y="0" fill="#8da1c0" font-size="14" letter-spacing="2.2">QQ INTEROPERABILITY · ONEBOT V11 · MULTI-ACCOUNT</text>
<text class="sans" x="0" y="92" font-size="74" font-weight="760" letter-spacing="-3">
<tspan fill="#f7fbff">Snow</tspan><tspan fill="#82d7ff">Luma</tspan>
</text>
<rect x="2" y="110" width="148" height="5" rx="2.5" fill="url(#brand-line)"/>
<text class="sans" x="0" y="162" fill="#dce7f7" font-size="27" font-weight="590">把 QQ 原生会话,稳定地桥接到</text>
<text class="sans" x="0" y="199" fill="#dce7f7" font-size="27" font-weight="590">OneBot、WebUI 与自动化工具。</text>
<text class="sans" x="0" y="244" fill="#92a6c5" font-size="16">一个运行时,管理多账号、动作调用、事件分发与实时状态。</text>
<g transform="translate(0 274)">
<rect width="128" height="34" rx="17" fill="#172942" stroke="#315173"/>
<circle cx="18" cy="17" r="4" fill="#75d2ff"/>
<text class="mono" x="31" y="22" fill="#b9dfff" font-size="13">Windows x64</text>
<g transform="translate(140 0)">
<rect width="174" height="34" rx="17" fill="#211f41" stroke="#4d4777"/>
<circle cx="18" cy="17" r="4" fill="#bba7ff"/>
<text class="mono" x="31" y="22" fill="#d8cdfd" font-size="13">Linux x64 / arm64</text>
</g>
<g transform="translate(326 0)">
<rect width="120" height="34" rx="17" fill="#302037" stroke="#68435c"/>
<circle cx="18" cy="17" r="4" fill="#f4bfd9"/>
<text class="mono" x="31" y="22" fill="#f7d7e6" font-size="13">TypeScript</text>
</g>
</g>
</g>
<g id="project-proof" transform="translate(734 58)">
<text class="mono" x="0" y="0" fill="#8398b8" font-size="13" letter-spacing="1.8">LIVE SESSION ROUTING</text>
<g transform="translate(0 24)">
<g>
<rect width="116" height="45" rx="12" fill="#12253d" stroke="#2d5274"/>
<circle cx="19" cy="17" r="5" fill="#74d2ff"/>
<text class="mono" x="31" y="21" fill="#c7e9fb" font-size="12">QQ · 01</text>
<text class="mono" x="16" y="36" fill="#7189a9" font-size="10">ONLINE</text>
</g>
<g transform="translate(132 0)">
<rect width="116" height="45" rx="12" fill="#1b2342" stroke="#4a4c78"/>
<circle cx="19" cy="17" r="5" fill="#bba7ff"/>
<text class="mono" x="31" y="21" fill="#ddd4ff" font-size="12">QQ · 02</text>
<text class="mono" x="16" y="36" fill="#8186ad" font-size="10">ONLINE</text>
</g>
<g transform="translate(264 0)">
<rect width="116" height="45" rx="12" fill="#292039" stroke="#67445d"/>
<circle cx="19" cy="17" r="5" fill="#f4bfd9"/>
<text class="mono" x="31" y="21" fill="#f8dbea" font-size="12">QQ · N</text>
<text class="mono" x="16" y="36" fill="#a17f95" font-size="10">READY</text>
</g>
</g>
<g aria-hidden="true" fill="none" stroke="url(#brand-line)" stroke-width="2" stroke-linecap="round" opacity="0.85">
<path d="M58 69V90C58 102 78 104 94 104H190"/>
<path d="M190 69V104"/>
<path d="M322 69V90C322 102 302 104 286 104H190"/>
</g>
<g transform="translate(0 104)">
<rect x="12" y="16" width="356" height="88" rx="18" fill="#0d1729" stroke="#263853" opacity="0.72"/>
<rect x="6" y="8" width="368" height="88" rx="18" fill="#111f35" stroke="#364865" opacity="0.88"/>
<rect width="380" height="88" rx="18" fill="url(#bridge-fill)" stroke="#6684ae" stroke-opacity="0.74"/>
<text class="mono" x="22" y="25" fill="#90a6c6" font-size="11" letter-spacing="1.4">SNOWLUMA RUNTIME</text>
<text class="sans" x="22" y="53" fill="#f4f8ff" font-size="20" font-weight="680">Protocol → Bridge → OneBot</text>
<text class="sans" x="22" y="74" fill="#93a8c7" font-size="13">独立会话 · 类型化动作 · 可追踪事件</text>
<g transform="translate(326 13)" aria-hidden="true">
<path class="hero-star hero-star-main" d="M16 0L19.5 10.5L30 14L19.5 17.5L16 28L12.5 17.5L2 14L12.5 10.5Z" fill="#c8b7ff"/>
<path class="hero-star hero-star-secondary" d="M38 30L40 36L46 38L40 40L38 46L36 40L30 38L36 36Z" fill="#f2c2da" opacity="0.9"/>
</g>
</g>
<g aria-hidden="true" fill="none" stroke="#6881a4" stroke-width="1.5" stroke-linecap="round">
<path d="M58 208V224"/>
<path d="M190 208V224"/>
<path d="M322 208V224"/>
</g>
<g transform="translate(0 224)">
<g>
<rect width="116" height="68" rx="15" fill="#10253b" stroke="#315d7c"/>
<text class="sans" x="58" y="29" fill="#bfe8fb" text-anchor="middle" font-size="15" font-weight="650">OneBot v11</text>
<text class="mono" x="58" y="50" fill="#6685a3" text-anchor="middle" font-size="10">ACTIONS · EVENTS</text>
</g>
<g transform="translate(132 0)">
<rect width="116" height="68" rx="15" fill="#201f42" stroke="#504d7a"/>
<text class="sans" x="58" y="29" fill="#ded5ff" text-anchor="middle" font-size="15" font-weight="650">WebUI</text>
<text class="mono" x="58" y="50" fill="#8483ad" text-anchor="middle" font-size="10">STATUS · LOGS</text>
</g>
<g transform="translate(264 0)">
<rect width="116" height="68" rx="15" fill="#2a2038" stroke="#65455e"/>
<text class="sans" x="58" y="29" fill="#f6d6e6" text-anchor="middle" font-size="15" font-weight="650">SDK / MCP</text>
<text class="mono" x="58" y="50" fill="#9d7e93" text-anchor="middle" font-size="10">TOOLS · STREAMS</text>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+140
View File
@@ -0,0 +1,140 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="440" viewBox="0 0 1200 440" role="img" aria-labelledby="map-title map-desc">
<title id="map-title">SnowLuma 运行链路</title>
<desc id="map-desc">QQ 进程接入后,数据经过协议桥接、OneBot 标准化,再分发到 WebSocket、HTTP、WebUI、SDK 与 MCP。</desc>
<defs>
<linearGradient id="map-rule" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#54bde9"/>
<stop offset="0.55" stop-color="#9e88ef"/>
<stop offset="1" stop-color="#e4a8c6"/>
</linearGradient>
<linearGradient id="consumer-border" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#54bde9"/>
<stop offset="0.5" stop-color="#9e88ef"/>
<stop offset="1" stop-color="#e4a8c6"/>
</linearGradient>
<linearGradient id="consumer-flow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#a5e7ff"/>
<stop offset="0.5" stop-color="#d1c5ff"/>
<stop offset="1" stop-color="#ffd4e8"/>
</linearGradient>
<style>
.sans { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
.mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; }
@media (prefers-reduced-motion: reduce) { .consumer-flow { display: none; } }
</style>
</defs>
<rect width="1200" height="440" rx="28" fill="#f6f9fd"/>
<rect x="0.75" y="0.75" width="1198.5" height="438.5" rx="27.25" fill="none" stroke="#cbd8e8" stroke-width="1.5"/>
<g transform="translate(54 42)">
<text class="mono" x="0" y="0" fill="#6e819b" font-size="13" letter-spacing="2">RUNTIME MAP · FROM SESSION TO CONSUMER</text>
<text class="sans" x="0" y="45" fill="#14223a" font-size="34" font-weight="720">一条事件如何穿过 SnowLuma</text>
<text class="sans" x="0" y="75" fill="#63758e" font-size="16">每层只承担一种职责,多个 QQ 账号各自拥有独立、可追踪的会话链路。</text>
</g>
<g id="connection-labels" class="mono" fill="#7c8ca2" font-size="10" letter-spacing="1.5" text-anchor="middle">
<text x="305" y="210">PIPE</text>
<text x="577" y="210">MAP</text>
<text x="849" y="210">OUT</text>
</g>
<g aria-hidden="true" fill="none" stroke="url(#map-rule)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M284 225H316L309 218M316 225L309 232"/>
<path d="M556 225H588L581 218M588 225L581 232"/>
<path d="M828 225H860L853 218M860 225L853 232"/>
</g>
<g id="stage-session" transform="translate(54 138)">
<rect width="230" height="218" rx="20" fill="#ffffff" stroke="#c9d8e8" stroke-width="1.5"/>
<path d="M20 0H210" fill="none" stroke="#54bde9" stroke-width="4" stroke-linecap="round"/>
<text class="mono" x="20" y="32" fill="#5582a0" font-size="11" letter-spacing="1.4">01 · 会话接入</text>
<g transform="translate(20 48)">
<rect width="190" height="54" rx="13" fill="#edf8fd" stroke="#afd9eb"/>
<circle cx="25" cy="19" r="5" fill="#47b7e6"/>
<circle cx="25" cy="35" r="5" fill="#92d8f3"/>
<text class="mono" x="42" y="23" fill="#315a77" font-size="11">QQ · ACCOUNT 01</text>
<text class="mono" x="42" y="39" fill="#6d92a8" font-size="11">QQ · ACCOUNT 02</text>
</g>
<text class="sans" x="115" y="132" fill="#15243a" font-size="20" font-weight="680" text-anchor="middle">多个 QQ 进程</text>
<text class="sans" x="115" y="158" fill="#687c95" font-size="14" text-anchor="middle">独立接入、独立登录状态</text>
<text class="sans" x="115" y="181" fill="#687c95" font-size="14" text-anchor="middle">每个账号单独路由</text>
</g>
<g id="stage-bridge" transform="translate(326 138)">
<rect width="230" height="218" rx="20" fill="#ffffff" stroke="#d0c9ed" stroke-width="1.5"/>
<path d="M20 0H210" fill="none" stroke="#9e88ef" stroke-width="4" stroke-linecap="round"/>
<text class="mono" x="20" y="32" fill="#7163aa" font-size="11" letter-spacing="1.4">02 · 协议桥接</text>
<g transform="translate(20 42)">
<rect x="10" y="14" width="170" height="48" rx="12" fill="#f2effd" stroke="#cfc5f2"/>
<rect x="5" y="7" width="180" height="48" rx="12" fill="#eeeafd" stroke="#c3b7ee"/>
<rect width="190" height="48" rx="12" fill="#e9e3fb" stroke="#b6a7e9"/>
<text class="mono" x="95" y="21" fill="#544584" text-anchor="middle" font-size="11">PACKET → EVENT</text>
<text class="mono" x="95" y="37" fill="#77699c" text-anchor="middle" font-size="10">PROTOCOL · BRIDGE</text>
</g>
<text class="sans" x="115" y="132" fill="#15243a" font-size="20" font-weight="680" text-anchor="middle">解析与身份映射</text>
<text class="sans" x="115" y="158" fill="#687c95" font-size="14" text-anchor="middle">协议数据转换为内部事件</text>
<text class="sans" x="115" y="181" fill="#687c95" font-size="14" text-anchor="middle">异常路径保留可观察信息</text>
</g>
<g id="stage-onebot" transform="translate(598 138)">
<rect width="230" height="218" rx="20" fill="#ffffff" stroke="#d9c8e1" stroke-width="1.5"/>
<path d="M20 0H210" fill="none" stroke="#cf9bc0" stroke-width="4" stroke-linecap="round"/>
<text class="mono" x="20" y="32" fill="#916382" font-size="11" letter-spacing="1.4">03 · 标准化</text>
<g transform="translate(20 49)">
<rect width="190" height="55" rx="13" fill="#faeff6" stroke="#e6bfd5"/>
<text class="sans" x="95" y="25" fill="#753e62" text-anchor="middle" font-size="17" font-weight="700">OneBot v11</text>
<text class="mono" x="95" y="42" fill="#a37792" text-anchor="middle" font-size="10">ACTIONS ↔ EVENTS</text>
</g>
<text class="sans" x="115" y="132" fill="#15243a" font-size="20" font-weight="680" text-anchor="middle">统一动作与事件</text>
<text class="sans" x="115" y="158" fill="#687c95" font-size="14" text-anchor="middle">参数校验、动作执行</text>
<text class="sans" x="115" y="181" fill="#687c95" font-size="14" text-anchor="middle">事件转换与消息存储</text>
</g>
<g id="stage-consumers" transform="translate(870 138)">
<rect x="2" y="2" width="272" height="214" rx="18" fill="#101a2d" stroke="url(#consumer-border)" stroke-width="4"/>
<rect class="consumer-flow" x="2" y="2" width="272" height="214" rx="18" fill="none" stroke="url(#consumer-flow)" stroke-width="5" stroke-linecap="round" stroke-dasharray="108 838" aria-hidden="true">
<animate attributeName="stroke-dashoffset" from="0" to="-946" dur="4.8s" repeatCount="indefinite"/>
</rect>
<text class="mono" x="20" y="32" fill="#91a5c2" font-size="11" letter-spacing="1.4">04 · 消费端</text>
<text class="sans" x="20" y="65" fill="#f2f7ff" font-size="20" font-weight="680">按场景自由组合</text>
<g class="mono" font-size="11" text-anchor="middle">
<g transform="translate(20 84)">
<rect width="70" height="32" rx="10" fill="#17304a" stroke="#315b7a"/>
<text x="35" y="21" fill="#bce8fb">WS</text>
</g>
<g transform="translate(102 84)">
<rect width="70" height="32" rx="10" fill="#242244" stroke="#514e7c"/>
<text x="35" y="21" fill="#ddd5ff">HTTP</text>
</g>
<g transform="translate(184 84)">
<rect width="70" height="32" rx="10" fill="#34233c" stroke="#67465e"/>
<text x="35" y="21" fill="#f6d9e7">WebUI</text>
</g>
<g transform="translate(20 128)">
<rect width="111" height="32" rx="10" fill="#182a43" stroke="#334f70"/>
<text x="55.5" y="21" fill="#c4def5">SDK</text>
</g>
<g transform="translate(143 128)">
<rect width="111" height="32" rx="10" fill="#2b2447" stroke="#554d7e"/>
<text x="55.5" y="21" fill="#dfd7ff">MCP</text>
</g>
</g>
<text class="sans" x="20" y="190" fill="#8196b5" font-size="13">多个适配器可同时启用</text>
</g>
<g transform="translate(54 392)">
<rect width="1092" height="1" fill="#d4dfeb"/>
<circle cx="5" cy="24" r="4" fill="#54bde9"/>
<text class="sans" x="18" y="29" fill="#60738c" font-size="14">一次接入,统一管理运行状态、日志、连接与动作调用。</text>
<text class="mono" x="1092" y="29" fill="#8191a6" text-anchor="end" font-size="11">SNOWLUMA / RUNTIME MAP</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "./qq.schema.json",
"version": 1,
"updatedAt": "2026-05-16T00:00:00Z",
"policy": {
"allowUnknown": true,
"warnUnknown": true
},
"knownGoodVersions": [],
"knownBadVersions": [],
"minVersion": null,
"notes": [
"QQ 兼容性 manifest — Desktop 启动时拉 https://raw.githubusercontent.com/SnowLuma/SnowLuma/main/compat/qq.json 校验本地 QQ 版本",
"policy.allowUnknown = true: 任何不在 knownBadVersions 里的版本都允许(理论上所有 QQ 新版本都可用)",
"knownBadVersions: 列在这里的版本 Desktop 会拒绝注入,弹出引导让用户切版本",
"minVersion: 设为 SemVer 字符串则更早的版本会被拒",
"知道某个版本会破坏 Hook 后把它加进 knownBadVersions,加 reason 字段说明"
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SnowLuma QQ compatibility manifest",
"type": "object",
"required": ["version", "policy", "knownGoodVersions", "knownBadVersions"],
"properties": {
"$schema": { "type": "string" },
"version": { "type": "integer", "const": 1 },
"updatedAt": { "type": "string", "format": "date-time" },
"policy": {
"type": "object",
"required": ["allowUnknown"],
"properties": {
"allowUnknown": {
"type": "boolean",
"description": "若 true,未在 known* 中列出的版本视为允许;若 false,需明确出现在 knownGoodVersions"
},
"warnUnknown": {
"type": "boolean",
"description": "未列出的版本启动时是否弹警告"
}
}
},
"knownGoodVersions": {
"type": "array",
"items": {
"type": "object",
"required": ["version"],
"properties": {
"version": { "type": "string", "description": "QQ 版本号字符串,如 '9.9.20.123'" },
"note": { "type": "string" }
}
}
},
"knownBadVersions": {
"type": "array",
"items": {
"type": "object",
"required": ["version"],
"properties": {
"version": { "type": "string" },
"reason": { "type": "string" },
"fixedInDesktopVersion": { "type": "string" }
}
}
},
"minVersion": {
"type": ["string", "null"],
"description": "最低支持版本(SemVer),null = 不限"
},
"notes": {
"type": "array",
"items": { "type": "string" }
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import js from '@eslint/js';
import eslintConfigPrettier from 'eslint-config-prettier/flat';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
const tsconfigRootDir = import.meta.dirname;
export const sourceIgnores = globalIgnores([
'**/dist/**',
'**/node_modules/**',
'**/coverage/**',
'**/.turbo/**',
'**/.vite/**',
'.claude/**',
'dev/**',
'**/*.tsbuildinfo',
'**/test/fixtures/**',
'packages/runtime/native/**',
'packages/mcp/src/generated/**',
]);
export default defineConfig([
sourceIgnores,
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
extends: [js.configs.recommended],
languageOptions: {
ecmaVersion: 2023,
sourceType: 'module',
globals: {
...globals.es2023,
...globals.node,
},
},
rules: {
'no-unused-vars': 'off',
'no-useless-assignment': 'off',
'prefer-const': 'off',
'preserve-caught-error': 'off',
},
},
{
files: ['**/*.{ts,tsx}'],
extends: [tseslint.configs.recommended],
languageOptions: {
parserOptions: {
tsconfigRootDir,
},
},
rules: {
'prefer-const': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-this-alias': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
files: ['**/tests/**/*.{ts,tsx}', '**/*.test.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['**/bench/**/*.{ts,tsx}', 'packages/proton/test/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
// OIDB service layer is namespace-per-cmd by design — the
// structural-typing contract (namespace IS the OidbCallSpec) and
// the file:cmd 1:1 mapping are the whole point. Disable the
// module-syntax preference here only.
files: [
'packages/protocol/src/oidb-services/**/*.ts',
'packages/protocol/tests/oidb-services/**/*.ts',
],
rules: {
'@typescript-eslint/no-namespace': 'off',
},
},
{
files: ['packages/webui/src/**/*.{ts,tsx}', 'packages/ui/src/**/*.{ts,tsx}'],
extends: [reactHooks.configs.flat.recommended, reactRefresh.configs.vite],
languageOptions: {
globals: globals.browser,
parserOptions: {
tsconfigRootDir,
},
},
rules: {
'prefer-const': 'off',
'react-refresh/only-export-components': 'off',
'react-hooks/set-state-in-effect': 'off',
},
},
eslintConfigPrettier,
]);
+39
View File
@@ -0,0 +1,39 @@
import stylistic from '@stylistic/eslint-plugin';
import { defineConfig } from 'eslint/config';
import reactHooks from 'eslint-plugin-react-hooks';
import tseslint from 'typescript-eslint';
import { sourceIgnores } from './eslint.config.ts';
// Local formatting only; CI continues to use eslint.config.ts for code quality.
export default defineConfig([
sourceIgnores,
{
// Preserve quality-rule suppressions when running only formatting rules.
linterOptions: { reportUnusedDisableDirectives: 'off' },
},
{
files: ['**/*.{ts,tsx,mts,cts}'],
languageOptions: {
parser: tseslint.parser,
},
plugins: {
'@stylistic': stylistic,
'@typescript-eslint': tseslint.plugin,
'react-hooks': reactHooks,
},
rules: {
curly: ['error', 'all'],
'@stylistic/padding-line-between-statements': [
'error',
{ blankLine: 'always', prev: '*', next: 'block-like' },
{ blankLine: 'always', prev: 'block-like', next: '*' },
// Stacked case labels share a branch and must stay adjacent.
{
blankLine: 'never',
prev: { selector: 'SwitchCase[consequent.length=0]' },
next: ['case', 'default'],
},
],
},
},
]);
+57
View File
@@ -0,0 +1,57 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "snowluma-monorepo",
"version": "2.4.2",
"description": "SnowLuma monorepo workspace (pnpm)",
"private": true,
"type": "module",
"scripts": {
"build": "pnpm --filter @snowluma/core build",
"build:sdk": "pnpm --filter @snowluma/sdk build",
"build:webui": "pnpm --filter webui build",
"build:mcp": "pnpm --filter @snowluma/mcp build",
"build:all": "pnpm --filter @snowluma/core build:all && pnpm --filter @snowluma/sdk build && pnpm --filter @snowluma/mcp build && pnpm --filter @snowluma/ui build && pnpm --filter webui build",
"check:release-layout": "node tools/check-release-layout.mjs",
"dev": "pnpm --filter @snowluma/core dev",
"dev:mode": "cd dist && cross-env SNOWLUMA_DEV_MODE=1 SNOWLUMA_DEV_WEBUI=1 node index.mjs",
"dev:web": "pnpm --filter webui dev",
"test": "pnpm test:workflows && pnpm --filter=\"./packages/**\" -r --no-bail --if-present test",
"test:workflows": "node --test .github/scripts/*.test.cjs",
"test:core": "pnpm --filter @snowluma/core test",
"test:watch": "pnpm --filter @snowluma/core test:watch",
"test:common": "pnpm --filter @snowluma/common test",
"test:all": "pnpm test",
"test:native": "pnpm --filter=\"./packages/**\" -r --if-present test:native",
"coverage": "node tools/coverage.mjs",
"typecheck": "pnpm -r --if-present typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "eslint --config eslint.format.config.ts --fix \"**/*.{ts,tsx,mts,cts}\" && prettier --write \"**/*.{ts,tsx,mts,cts}\"",
"format:check": "eslint --config eslint.format.config.ts \"**/*.{ts,tsx,mts,cts}\" && prettier --check \"**/*.{ts,tsx,mts,cts}\"",
"ci": "node tools/ci-check.mjs",
"start": "node dist/index.mjs",
"bump": "node tools/bump-version.mjs",
"release": "node tools/release.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "5.10.0",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"cross-env": "^10.1.0",
"eslint": "^10.2.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jiti": "^2.6.1",
"prettier": "3.9.10",
"typescript": "catalog:",
"typescript-eslint": "^8.58.2"
},
"packageManager": "pnpm@11.25.0",
"engines": {
"node": "^24.22.1 || >=26.12.1",
"pnpm": "11.25.0"
}
}
+69
View File
@@ -0,0 +1,69 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/bridge",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma bridge — QQ NT desktop client lifecycle: hook injection (via native addon), per-PID HookSession orchestration, pipe-watcher for process discovery, and the BridgeManagerSink interface that lets the hook layer call back into a UIN-keyed session router without depending on @snowluma/core. Consumed by the top-level app entry (which constructs the BridgeManager from @snowluma/core and injects it as the sink) and by the WebUI server (read-only HookProcessInfo views).",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./hook-manager": {
"types": "./src/hook-manager.ts",
"default": "./src/hook-manager.ts"
},
"./hook-session": {
"types": "./src/hook-session.ts",
"default": "./src/hook-session.ts"
},
"./hook-packet-client": {
"types": "./src/hook-packet-client.ts",
"default": "./src/hook-packet-client.ts"
},
"./packet-timeout": {
"types": "./src/packet-timeout.ts",
"default": "./src/packet-timeout.ts"
},
"./injector": {
"types": "./src/injector.ts",
"default": "./src/injector.ts"
},
"./pipe-watcher": {
"types": "./src/pipe-watcher.ts",
"default": "./src/pipe-watcher.ts"
},
"./qq-hook-client": {
"types": "./src/qq-hook-client.ts",
"default": "./src/qq-hook-client.ts"
},
"./qq-account": {
"types": "./src/qq-account/index.ts",
"default": "./src/qq-account/index.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"
}
},
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snowluma/common": "workspace:*",
"@snowluma/sqlite": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "4.1.12"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+704
View File
@@ -0,0 +1,704 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
import { renderParamsVerbose } from '@snowluma/common/log-summary';
import type { PacketSender } from '@snowluma/common/packet-sender';
import type { PacketInfo, PacketSink } from '@snowluma/common/protocol-types';
import fs from 'fs';
import { HookSession, type HookSessionDeps } from './hook-session';
import {
injectHookProcess,
listHookProcesses,
unloadHookProcess,
type HookProcessBaseInfo,
} from './injector';
import { PipeWatcher } from './pipe-watcher';
import { createProcessEnumerator, type ProcessEnumerator } from './process-enumerator';
import { QqHookClient } from './qq-hook-client';
import {
detectAccountLoginsForPids,
watchAccountChangesForPids,
type AccountLoginState,
} from './qq-account';
import type { HookProcessAccount, HookProcessInfo } from './types';
const AUTO_LOAD_MAX_ATTEMPTS = 3;
type AutoLoadAttempt = {
attempts: number;
inFlight: boolean;
};
/**
* Sink that the hook layer calls back into when it observes a new login,
* a parsed packet, or a PID disconnect. The concrete implementation lives
* in @snowluma/core (`BridgeManager`), which is wired in by the top-level
* app entry. Declaring it here keeps @snowluma/bridge free of any
* dependency on @snowluma/core — the hook package is self-contained.
*/
export interface BridgeManagerSink {
onHookLogin(pid: number, uin: string, packetClient: PacketSender): void;
onPacket(pkt: PacketInfo): void;
onPidDisconnected(pid: number): void;
onPidReceiveHealthChanged(pid: number, healthy: boolean): void;
}
export type HookManagerDeps = {
bridgeManager: BridgeManagerSink;
/** Sink for parsed packets from any live HookSession. Defaults to
* `bridgeManager.onPacket` — every packet flows straight into the
* per-UIN bridge dispatcher with no intermediate event emitter. */
onPacket?: PacketSink;
/** Native injector entrypoints. Defaults to the real native addon. */
injector?: HookSessionDeps['injector'];
/** Hook pipe-client factory. Defaults to `new QqHookClient(pid)`. */
makeClient?: HookSessionDeps['makeClient'];
/** Pre-built watcher. Defaults to a PipeWatcher wrapping the native listings. */
pipeWatcher?: PipeWatcher;
/** Polling interval for the default watcher. Ignored if `pipeWatcher` is provided. */
watcherIntervalMs?: number;
/** Native process lister used by `listProcesses()`. Defaults to the native addon. */
listProcesses?: () => Promise<HookProcessBaseInfo[]>;
/** Local account detection used to label each process with the account QQ is
* signed in as. Receives the pids to label so each one is resolved against
* the HOME it actually runs under (a container can give every QQ instance
* its own). Defaults to reading QQ's own account data. */
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
/** Subscribe to login database changes. Returns the subscription cleanup. */
watchAccounts?: (pids: readonly number[], onChange: () => void) => () => void;
/** When true, every newly-discovered QQ process is auto-injected (fires
* `loadProcess(pid)` from the watcher's 'process-discovered' handler).
* Failed loads are logged and leave the session in the 'error' state.
* A narrowly-classified early-process mapping race is retried on later
* watcher ticks with a fixed attempt limit. */
autoLoadOnDiscovery?: boolean;
/** Optional hook fired whenever the set of HookProcessInfo observable to
* `listProcesses()` changes — new process discovered, process gone, or
* any session's status mutated. Used by the WebUI SSE wiring to push a
* fresh processes snapshot to connected clients without REST polling.
* Exceptions thrown by the callback are caught and logged; they do not
* break the watcher / session event loops. */
onSessionsChanged?: () => void;
log?: Logger;
};
/**
* HookManager — thin orchestrator over a per-PID HookSession map and a
* singleton PipeWatcher.
*
* Responsibilities:
* - Route user commands (load/unload/refresh) to the matching session.
* - Route watcher diff events to the matching session.
* - Forward session events ('login' / 'disconnected' / transport health) to BridgeManager.
* - Retry stuck-in-connecting sessions on every watcher tick (so a
* failed connect eventually recovers without a manual refresh).
* - Retry the bounded early-process auto-load race without retrying
* permanent injector errors.
*
* The native injector, native pipe-client, and native process/pipe
* listings are all swappable dependencies so tests can run without a
* real QQ.exe or a native addon.
*/
export class HookManager {
private readonly bridgeManager: BridgeManagerSink;
private readonly onPacket: PacketSink;
private readonly injector: HookSessionDeps['injector'];
private readonly makeClient: HookSessionDeps['makeClient'];
private readonly pipeWatcher: PipeWatcher;
private readonly ownsPipeWatcher: boolean;
private readonly enumerator: ProcessEnumerator;
private readonly detectAccounts: (pids: readonly number[]) => Promise<AccountLoginState[]>;
private readonly watchAccounts: NonNullable<HookManagerDeps['watchAccounts']>;
private readonly autoLoadOnDiscovery: boolean;
private readonly onSessionsChangedRaw?: () => void;
private readonly log: Logger;
private readonly sessions = new Map<number, HookSession>();
private readonly autoLoadAttempts = new Map<number, AutoLoadAttempt>();
private readonly startPromise: Promise<void>;
private accounts = new Map<number, HookProcessAccount>();
private accountProbe: Promise<void> | null = null;
private accountsDirty = true;
private stopWatchingAccounts?: () => void;
private lastAccountProbeFailure = '';
private disposed = false;
constructor(deps: HookManagerDeps) {
this.bridgeManager = deps.bridgeManager;
this.onPacket = deps.onPacket ?? ((pkt) => deps.bridgeManager.onPacket(pkt));
this.onSessionsChangedRaw = deps.onSessionsChanged;
this.log = deps.log ?? createLogger('Hook');
this.injector = deps.injector ?? {
inject: injectHookProcess,
unload: unloadHookProcess,
};
this.makeClient = deps.makeClient ?? ((pid: number) => new QqHookClient(pid));
this.detectAccounts = deps.detectAccounts ?? detectAccountLoginsForPids;
this.watchAccounts =
deps.watchAccounts ??
((pids, onChange) => watchAccountChangesForPids(pids, onChange, this.log));
this.autoLoadOnDiscovery = deps.autoLoadOnDiscovery ?? false;
this.enumerator = createProcessEnumerator({
listProcesses: deps.listProcesses ?? listHookProcesses,
log: this.log,
});
if (deps.pipeWatcher) {
this.pipeWatcher = deps.pipeWatcher;
this.ownsPipeWatcher = false;
} else {
this.pipeWatcher = new PipeWatcher({
listProcesses: this.enumerator.enumerate,
listLivePipes: (processes) =>
QqHookClient.listLivePipes(processes.map(({ pid }) => pid)),
intervalMs: deps.watcherIntervalMs,
log: this.log,
});
this.ownsPipeWatcher = true;
}
this.bindWatcher();
this.startPromise = this.pipeWatcher.start();
}
// ─────────────── public API (unchanged from prior HookManager) ───────────────
async listProcesses(): Promise<HookProcessInfo[]> {
await this.startPromise;
const processes = await this.enumerator.enumerate();
// A failed enumeration preserves the last known process set.
const sessions =
processes === null
? [...this.sessions.values()]
: processes.map((proc) => {
const session = this.ensureSession(proc.pid);
session.attachProcessInfo(proc);
return session;
});
await this.refreshAccounts();
return sessions
.filter((session) => this.sessions.get(session.pid) === session)
.map((session) => this.processInfo(session))
.sort((a, b) => a.pid - b.pid);
}
async loadProcess(pid: number): Promise<HookProcessInfo> {
this.assertValidPid(pid);
await this.startPromise;
this.autoLoadAttempts.delete(pid);
const session = this.ensureSession(pid);
this.invalidateAccounts();
await session.load();
this.pipeWatcher.wake();
await this.refreshAccounts();
return this.processInfo(session);
}
async unloadProcess(pid: number): Promise<HookProcessInfo> {
this.assertValidPid(pid);
await this.startPromise;
this.autoLoadAttempts.delete(pid);
const session = this.ensureSession(pid);
this.invalidateAccounts();
await session.unload();
await this.refreshAccounts();
return this.processInfo(session);
}
async refreshProcess(pid: number): Promise<HookProcessInfo> {
this.assertValidPid(pid);
await this.startPromise;
const session = this.ensureSession(pid);
this.updateAccountWatcher();
this.invalidateAccounts();
await session.refresh();
await this.refreshAccounts();
return this.processInfo(session);
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.stopWatchingAccounts?.();
for (const session of this.sessions.values()) {
session.dispose();
}
this.sessions.clear();
this.accounts.clear();
this.autoLoadAttempts.clear();
this.enumerator.dispose();
if (this.ownsPipeWatcher) {
this.pipeWatcher.dispose();
}
}
// ─────────────── wiring ───────────────
private processInfo(session: HookSession): HookProcessInfo {
return { ...session.toInfo(), account: this.accounts.get(session.pid) ?? null };
}
private invalidateAccounts(): void {
this.accountsDirty = true;
}
private updateAccountWatcher(): void {
this.stopWatchingAccounts?.();
this.stopWatchingAccounts = this.watchAccounts([...this.sessions.keys()], () => {
this.invalidateAccounts();
void this.refreshAccounts();
});
}
/** REST, watcher ticks and login reconciliation share the same account snapshot. */
private async refreshAccounts(): Promise<void> {
if (this.disposed || this.sessions.size === 0) {
return;
}
if (this.accountProbe) {
await this.accountProbe;
return;
}
if (!this.accountsDirty) {
return;
}
this.accountProbe = runWithTraceRequest(async () => {
// All callers await the replacement read too if a lifecycle change
// invalidates an in-flight result.
do {
this.accountsDirty = false;
await this.readAccounts();
} while (!this.disposed && this.sessions.size > 0 && this.accountsDirty);
}).finally(() => {
this.accountProbe = null;
});
await this.accountProbe;
}
private async readAccounts(): Promise<void> {
const accounts = new Map<number, HookProcessAccount>();
try {
for (const state of await this.detectAccounts([...this.sessions.keys()])) {
if (state.pid !== null && this.sessions.has(state.pid)) {
accounts.set(state.pid, { uin: state.uin, nickname: state.nickname });
}
}
this.lastAccountProbeFailure = '';
} catch (error) {
const message = errorMessage(error);
if (this.lastAccountProbeFailure !== message) {
this.lastAccountProbeFailure = message;
this.log.warn('account detection failed: %s', message);
}
}
if (this.disposed || this.accountsDirty) {
return;
}
const changed =
accounts.size !== this.accounts.size ||
[...accounts].some(([pid, account]) => {
const previous = this.accounts.get(pid);
return previous?.uin !== account.uin || previous.nickname !== account.nickname;
});
this.accounts = accounts;
if (changed) {
this.notifySessionsChanged();
}
}
private bindWatcher(): void {
this.pipeWatcher.on('process-discovered', (info: HookProcessBaseInfo) => {
if (this.disposed) {
return;
}
runWithTraceRequest(() => {
const session = this.ensureSession(info.pid);
session.attachProcessInfo(info);
this.log.trace(() => [
'hook_manager_fact event=process_discovered pid=%d info=%s autoLoad=%s',
info.pid,
renderParamsVerbose(info),
this.autoLoadOnDiscovery,
]);
// Headless/Docker deployments enable autoLoadOnDiscovery so QQ gets
// injected without a human clicking "Load" in WebUI. Fire-and-forget:
// failures are already captured inside loadInternal and surfaced via
// the session's status field.
if (this.autoLoadOnDiscovery && shouldAutoLoadPid(info.pid, this.log)) {
this.runAutoLoad(session);
}
this.notifySessionsChanged();
});
});
this.pipeWatcher.on('process-gone', (pid: number) => {
if (this.disposed) {
return;
}
runWithTraceRequest(() => {
this.autoLoadAttempts.delete(pid);
const session = this.sessions.get(pid);
this.log.trace(
'hook_manager_fact event=process_gone pid=%d tracked=%s',
pid,
session !== undefined,
);
if (session) {
session.notifyProcessGone();
}
this.notifySessionsChanged();
});
});
this.pipeWatcher.on('pipe-up', (pid: number) => {
if (this.disposed) {
return;
}
const session = this.sessions.get(pid);
if (session) {
this.invalidateAccounts();
session.onPipeUp();
}
});
this.pipeWatcher.on('pipe-down', (pid: number) => {
if (this.disposed) {
return;
}
const session = this.sessions.get(pid);
if (session) {
this.invalidateAccounts();
session.onPipeDown();
}
});
// After every tick, retry sessions that are stuck in 'connecting' OR
// 'disconnected' while the pipe is up. Mirrors the original
// tickWatcher's per-tick reconcileConnect pass; onPipeUp is idempotent
// so calling it for an already-connected session is harmless.
//
// The 'disconnected' branch matters on Windows: when the QQ-NT side of
// the named pipe stays alive but the SnowLuma-side socket dies (read
// EPIPE / idle-timeout / similar transient I/O fault), the watcher
// sees the pipe as still-live (the OS-level pipe is still listed by
// `listLivePipes`) so it never fires another `pipe-up`. Without
// retrying 'disconnected' sessions here the session is stranded
// forever — a real-world symptom users reported as "Windows running
// for a while then stops" with logs `pipe error: read EPIPE` →
// `session closed: UIN=…`.
this.pipeWatcher.on('tick', () => {
if (this.disposed) {
return;
}
void this.refreshAccounts();
for (const session of this.sessions.values()) {
const autoLoad = this.autoLoadAttempts.get(session.pid);
if (
autoLoad &&
!autoLoad.inFlight &&
session.status === 'error' &&
isTransientLibcMappingError(session.error)
) {
this.runAutoLoad(session, autoLoad);
continue;
}
if (
(session.status === 'connecting' ||
session.status === 'disconnected' ||
session.status === 'error') &&
this.pipeWatcher.isPipeLive(session.pid)
) {
session.onPipeUp();
}
}
});
}
private runAutoLoad(session: HookSession, existing?: AutoLoadAttempt): void {
if (this.disposed || session.isDisposed) {
return;
}
const state = existing ?? { attempts: 0, inFlight: false };
if (state.inFlight || state.attempts >= AUTO_LOAD_MAX_ATTEMPTS) {
return;
}
state.attempts += 1;
state.inFlight = true;
this.autoLoadAttempts.set(session.pid, state);
const attempt = state.attempts;
void runWithTraceRequest(async () => {
const startedAt = Date.now();
this.log.trace(
'hook_autoload_start pid=%d attempt=%d maxAttempts=%d',
session.pid,
attempt,
AUTO_LOAD_MAX_ATTEMPTS,
);
try {
const info = await session.load();
if (this.autoLoadAttempts.get(session.pid) !== state) {
this.log.trace(
'hook_autoload_terminal pid=%d attempt=%d outcome=dropped reason=superseded elapsedMs=%d',
session.pid,
attempt,
Date.now() - startedAt,
);
return;
}
state.inFlight = false;
if (info.status !== 'error') {
this.autoLoadAttempts.delete(session.pid);
const reason = attempt > 1 ? 'recovered' : 'loaded';
this.log.trace(() => [
'hook_autoload_terminal pid=%d attempt=%d outcome=completed reason=%s state=%s elapsedMs=%d',
session.pid,
attempt,
reason,
renderParamsVerbose(info),
Date.now() - startedAt,
]);
if (attempt > 1) {
this.log.info(
'auto-load recovered: PID=%d attempt=%d/%d',
session.pid,
attempt,
AUTO_LOAD_MAX_ATTEMPTS,
);
}
return;
}
if (!isTransientLibcMappingError(info.error)) {
this.autoLoadAttempts.delete(session.pid);
this.log.trace(
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=permanent_failure error=%j elapsedMs=%d',
session.pid,
attempt,
info.error,
Date.now() - startedAt,
);
return;
}
if (attempt >= AUTO_LOAD_MAX_ATTEMPTS) {
this.autoLoadAttempts.delete(session.pid);
this.log.trace(
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=retry_exhausted error=%j elapsedMs=%d',
session.pid,
attempt,
info.error,
Date.now() - startedAt,
);
this.log.warn(
'auto-load retry exhausted: PID=%d attempts=%d err=%s',
session.pid,
attempt,
info.error,
);
return;
}
this.log.trace(
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=retry_pending error=%j elapsedMs=%d',
session.pid,
attempt,
info.error,
Date.now() - startedAt,
);
this.log.warn(
'auto-load retry pending: PID=%d attempt=%d/%d err=%s',
session.pid,
attempt,
AUTO_LOAD_MAX_ATTEMPTS,
info.error,
);
} catch (err) {
if (this.autoLoadAttempts.get(session.pid) === state) {
this.autoLoadAttempts.delete(session.pid);
}
this.log.trace(
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=unexpected_failure error=%j elapsedMs=%d',
session.pid,
attempt,
errorMessage(err),
Date.now() - startedAt,
);
this.log.warn('auto-load failed: PID=%d err=%s', session.pid, errorMessage(err));
}
});
}
private ensureSession(pid: number): HookSession {
let session = this.sessions.get(pid);
if (session) {
return session;
}
session = new HookSession(pid, {
injector: this.injector,
makeClient: this.makeClient,
pipeWatcher: this.pipeWatcher,
onPacket: this.onPacket,
detectLoginUin: async () => {
await this.refreshAccounts();
return this.accounts.get(pid)?.uin ?? null;
},
log: this.log,
});
session.attachProcessInfo({ name: defaultProcessName() });
session.on('login', (uin: string, sender) => {
// Account switches can keep the same 'online' status.
this.invalidateAccounts();
this.bridgeManager.onHookLogin(pid, uin, sender);
this.notifySessionsChanged();
});
session.on('disconnected', (wasLoggedIn: boolean) => {
if (wasLoggedIn) {
this.bridgeManager.onPidDisconnected(pid);
}
});
session.on('receive-health-changed', (healthy: boolean) => {
this.bridgeManager.onPidReceiveHealthChanged(pid, healthy);
});
let previousStatus = session.status;
session.on('status-changed', (status) => {
if (previousStatus === 'online' && status !== 'online') {
this.invalidateAccounts();
}
previousStatus = status;
this.notifySessionsChanged();
});
session.on('disposed', () => {
this.sessions.delete(pid);
this.accounts.delete(pid);
this.updateAccountWatcher();
this.invalidateAccounts();
});
this.sessions.set(pid, session);
this.updateAccountWatcher();
this.invalidateAccounts();
return session;
}
/** Fire the optional sessions-changed hook with exceptions isolated.
* A throwing subscriber must not abort the watcher's emit loop or break
* a HookSession event handler in flight. */
private notifySessionsChanged(): void {
if (this.disposed) {
return;
}
if (!this.onSessionsChangedRaw) {
return;
}
try {
this.onSessionsChangedRaw();
} catch (err) {
this.log.warn('onSessionsChanged threw: %s', errorMessage(err));
}
}
private assertValidPid(pid: number): void {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error('invalid pid');
}
}
}
function defaultProcessName(): string {
return process.platform === 'win32' ? 'QQ.exe' : 'qq';
}
function isTransientLibcMappingError(error: string): boolean {
return /^target process does not map \S*\/libc\.so\.6 while resolving mmap$/.test(error);
}
/**
* Filter for auto-load only. Returns false for Linux Electron child
* processes (zygote/renderer/gpu/utility), which the native enumerator
* mis-classifies as "main QQ" because their cmdline contains "qq" and
* they transiently inherit wrapper.node via fork copy-on-write.
*
* Injecting into a zygote spawns the hook's resolver thread inside it;
* every renderer Electron later forks then inherits that thread + the
* partially-init'd hook state, which breaks QQ's IPC and login UI.
*
* Manual loadProcess() bypasses this gate — the operator is responsible
* for picking the right PID from WebUI.
*/
export function shouldAutoLoadPid(pid: number, log: Logger): boolean {
if (process.platform !== 'linux') {
return true;
}
// Escape hatch: operators who explicitly want auto-load on every
// enumerated PID can set this to opt out of the filter.
if (process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL === '1') {
return true;
}
let cmdline: string;
try {
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
} catch {
// Can't read cmdline (process gone, permission, etc.) — let the
// existing load() path handle it; if the process is dead the load
// will fail with a clear error.
return true;
}
if (cmdline.includes('--type=')) {
const type = /--type=([^\0\s]+)/.exec(cmdline)?.[1] ?? 'unknown';
log.info('auto-load skip: PID=%d is an Electron child process (--type=%s)', pid, type);
return false;
}
return true;
}
export type { HookProcessBaseInfo } from './injector';
export type { HookProcessAccount, HookProcessInfo, HookProcessStatus } from './types';
+73
View File
@@ -0,0 +1,73 @@
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
import { packetTimeoutMs } from './packet-timeout';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
QqHookClient,
} from './qq-hook-client';
export type OutboundHealthListener = (healthy: boolean) => void;
export class HookPacketClient implements PacketSender {
private outboundHealthy = true;
constructor(
private readonly client: QqHookClient,
private readonly onOutboundHealthChanged?: OutboundHealthListener,
) {}
async sendPacket(
serviceCmd: string,
body: Buffer,
timeoutMs = packetTimeoutMs(),
): Promise<SendPacketResult> {
if (!this.client.isLoggedIn) {
return {
success: false,
gotResponse: false,
errorCode: -1,
errorMessage: 'qq_hook client is not logged in',
responseData: null,
};
}
try {
const reply = await this.client.send(serviceCmd, body, {
wantReply: true,
replyTimeoutMs: timeoutMs,
});
this.setOutboundHealthy(true);
return {
success: reply.error === 0,
gotResponse: true,
errorCode: reply.error,
errorMessage: reply.message,
responseData: reply.body,
};
} catch (error) {
if (
error instanceof HookPipeRequestError &&
error.status === PIPE_STATUS_CONNECTION_UNAVAILABLE
) {
this.setOutboundHealthy(false);
}
return {
success: false,
gotResponse: false,
errorCode: -1,
errorMessage: error instanceof Error ? error.message : String(error),
responseData: null,
};
}
}
private setOutboundHealthy(healthy: boolean): void {
if (this.outboundHealthy === healthy) {
return;
}
this.outboundHealthy = healthy;
this.onOutboundHealthChanged?.(healthy);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { errorMessage } from '@snowluma/common/errors';
import { readFileSync } from 'fs';
import os from 'os';
import path from 'path';
export interface HookRuntimeDirDeps {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
ownUid?: number;
readText?: (file: string) => string;
}
function configuredDirectory(value: string | undefined, variable: string): string | null {
if (!value) {
return null;
}
if (!path.isAbsolute(value)) {
throw new Error(`${variable} must be an absolute path`);
}
return value;
}
function parseProcessEnvironment(raw: string): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const entry of raw.split('\0')) {
if (!entry) {
continue;
}
const separator = entry.indexOf('=');
if (separator <= 0) {
continue;
}
result[entry.slice(0, separator)] = entry.slice(separator + 1);
}
return result;
}
function parseRealUid(status: string): number | null {
const match = /^Uid:\s+(\d+)/m.exec(status);
if (!match) {
return null;
}
const uid = Number(match[1]);
return Number.isSafeInteger(uid) && uid >= 0 ? uid : null;
}
function isProcessGoneError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'ENOENT' || code === 'ESRCH';
}
function ownRuntimeDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ownUid: number): string {
const explicit = configuredDirectory(
env.SNOWLUMA_HOOK_RUNTIME_DIR,
'SNOWLUMA_HOOK_RUNTIME_DIR',
);
if (explicit) {
return explicit;
}
const xdg = configuredDirectory(env.XDG_RUNTIME_DIR, 'XDG_RUNTIME_DIR');
if (xdg) {
return xdg;
}
if (platform === 'darwin') {
const tmpdir = configuredDirectory(env.TMPDIR, 'TMPDIR');
if (tmpdir) {
return path.join(tmpdir, 'snowluma-hook');
}
}
return `/tmp/snowluma-${ownUid}`;
}
/**
* Resolve the socket directory for the target QQ process. The supervisor may
* run under a different user, so its own runtime directory is not authoritative.
*/
export function resolveHookRuntimeDir(targetPid?: number, deps: HookRuntimeDirDeps = {}): string {
const platform = deps.platform ?? process.platform;
const env = deps.env ?? process.env;
const ownUid =
deps.ownUid ??
(typeof process.geteuid === 'function' ? process.geteuid() : os.userInfo().uid);
const readText = deps.readText ?? ((file: string) => readFileSync(file, 'utf8'));
if (platform !== 'linux' || !Number.isSafeInteger(targetPid) || targetPid! <= 0) {
return ownRuntimeDir(platform, env, ownUid);
}
let environError: unknown;
try {
const targetEnv = parseProcessEnvironment(readText(`/proc/${targetPid}/environ`));
const explicit = configuredDirectory(
targetEnv.SNOWLUMA_HOOK_RUNTIME_DIR,
`QQ process ${targetPid} SNOWLUMA_HOOK_RUNTIME_DIR`,
);
if (explicit) {
return explicit;
}
const xdg = configuredDirectory(
targetEnv.XDG_RUNTIME_DIR,
`QQ process ${targetPid} XDG_RUNTIME_DIR`,
);
if (xdg) {
return xdg;
}
environError = new Error('runtime directory is not present');
} catch (error) {
// An invalid explicit path is configuration corruption and must remain
// visible. Permission/race failures are expected when /proc disappears;
// the status-file fallback below can still recover the target uid.
if (error instanceof Error && error.message.includes('must be an absolute path')) {
throw error;
}
environError = error;
}
let statusError: unknown;
try {
const uid = parseRealUid(readText(`/proc/${targetPid}/status`));
if (uid !== null) {
return `/tmp/snowluma-${uid}`;
}
statusError = new Error('real uid is not available');
} catch (error) {
statusError = error;
}
if (
isProcessGoneError(statusError) &&
(isProcessGoneError(environError) ||
(environError instanceof Error &&
environError.message === 'runtime directory is not present'))
) {
return ownRuntimeDir(platform, env, ownUid);
}
throw new Error(
`cannot resolve hook runtime directory for PID ${targetPid}: ` +
`environment=${errorMessage(environError)}; status=${errorMessage(statusError)}`,
);
}
File diff suppressed because it is too large. Load diff
+37
View File
@@ -0,0 +1,37 @@
import type { HookProcessStatus } from './types';
/**
* The flags that fully determine a HookSession's *settled* status.
* `wasLoggedIn` carries the one bit of history the derivation needs:
* on ANY injected-but-not-connected path (connect failure, pipe close,
* refresh-while-down), the session reads `disconnected` only if it had
* reached login, otherwise it's still `connecting`. Capture it from
* `loggedIn` *before* tearing the client down, since teardown clears it.
*/
export interface HookFlags {
injected: boolean;
connected: boolean;
loggedIn: boolean;
wasLoggedIn: boolean;
}
/**
* Single source of truth for the five *settled* HookProcessStatus values
* — `available` / `connecting` / `loaded` / `online` / `disconnected`.
*
* Pure: status is a function of the flags alone. The two *out-of-band*
* states `loading` (injection in flight) and `error` (carries a message)
* are set explicitly by HookSession and are deliberately NOT derivable
* here.
*/
export function statusFor(flags: HookFlags): HookProcessStatus {
if (!flags.injected) {
return 'available';
}
if (flags.connected) {
return flags.loggedIn ? 'online' : 'loaded';
}
return flags.wasLoggedIn ? 'disconnected' : 'connecting';
}
+15
View File
@@ -0,0 +1,15 @@
export {
HookManager,
type BridgeManagerSink,
type HookManagerDeps,
type HookProcessAccount,
type HookProcessBaseInfo,
type HookProcessInfo,
type HookProcessStatus,
} from './hook-manager';
export { type AccountLoginState, type LocalAccount } from './qq-account';
export {
getRuntimeIntegrity,
hasLowProcessIntegrity,
type RuntimeIntegrity,
} from './runtime-integrity';
+137
View File
@@ -0,0 +1,137 @@
import { errorMessage } from '@snowluma/common/errors';
import { resolveRuntimeNativePath } from '@snowluma/common/native-path';
import { listSnowlumaPipePidsSync } from './qq-hook-client';
export interface ManualMapHandle {
base: bigint;
entry: bigint;
exceptionTable: bigint;
size: number;
}
interface NativeHookAddon {
getRuntimeIntegrityAsync(): Promise<unknown>;
getAllMainProcessAsync(): Promise<number[]>;
getFileHoldersAsync(path: string): Promise<{ pid: number; name: string }[]>;
loadModuleManualAsync(pid: number, dylibPath: string): Promise<ManualMapHandle>;
unloadModuleManualAsync(pid: number, handle: ManualMapHandle): Promise<void>;
}
export interface HookProcessBaseInfo {
pid: number;
name: string;
path: string;
}
export interface HookInjectResult {
method: 'loadModuleManual';
handle: ManualMapHandle;
}
let nativeAddon: NativeHookAddon | null = null;
let nativeLoadError: string | null = null;
function loadNativeAddon(addonPath: string): NativeHookAddon {
const mod = { exports: {} as Record<string, unknown> };
process.dlopen(mod, addonPath);
return mod.exports as unknown as NativeHookAddon;
}
type HookBinaryExt = 'node' | 'dll' | 'so';
function platformInjectableExt(): 'dll' | 'so' {
return process.platform === 'win32' ? 'dll' : 'so';
}
function defaultProcessName(): string {
return process.platform === 'win32' ? 'QQ.exe' : 'qq';
}
export function resolveHookNativePath(ext: HookBinaryExt): string | null {
return resolveRuntimeNativePath(`snowluma-${process.platform}-${process.arch}.${ext}`);
}
export function getNativeHookAddon(): NativeHookAddon | null {
if (nativeAddon) {
return nativeAddon;
}
const addonPath = resolveHookNativePath('node');
if (!addonPath) {
nativeLoadError = `No hook native addon found for ${process.platform}-${process.arch}`;
return null;
}
try {
nativeAddon = loadNativeAddon(addonPath);
nativeLoadError = null;
return nativeAddon;
} catch (error) {
nativeLoadError = errorMessage(error);
return null;
}
}
export function getNativeHookLoadError(): string | null {
return nativeLoadError;
}
export async function listHookProcesses(): Promise<HookProcessBaseInfo[]> {
// macOS: no native enumerate-QQ addon. The DYLD_INSERT injection model
// means QQ ran with our dylib already mapped, so the dylib's listener
// socket IS the discovery signal. Treat every `mojo.<pid>.control.sock`
// in the runtime dir as a known process (the watcher's subsequent
// connectable-probe gates the pipe-up emit, so a stale dangling socket
// here doesn't drive a false connect).
if (process.platform === 'darwin') {
return [...listSnowlumaPipePidsSync()]
.sort((a, b) => a - b)
.map((pid) => ({ pid, name: defaultProcessName(), path: '' }));
}
const addon = getNativeHookAddon();
if (!addon) {
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
}
if (typeof addon.getAllMainProcessAsync !== 'function') {
throw new Error(
'Hook native addon lacks getAllMainProcessAsync; update the native artifacts',
);
}
return [...new Set(await addon.getAllMainProcessAsync())]
.filter((pid) => Number.isInteger(pid) && pid > 0)
.sort((a, b) => a - b)
.map((pid) => ({ pid, name: defaultProcessName(), path: '' }));
}
export async function injectHookProcess(pid: number): Promise<HookInjectResult> {
const addon = getNativeHookAddon();
if (!addon) {
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
}
const injectableExt = platformInjectableExt();
const dllPath = resolveHookNativePath(injectableExt);
if (!dllPath) {
throw new Error(`No hook ${injectableExt} found for ${process.platform}-${process.arch}`);
}
return { method: 'loadModuleManual', handle: await addon.loadModuleManualAsync(pid, dllPath) };
}
export async function unloadHookProcess(pid: number, handle: ManualMapHandle): Promise<void> {
const addon = getNativeHookAddon();
if (!addon) {
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
}
await addon.unloadModuleManualAsync(pid, handle);
}
+8
View File
@@ -0,0 +1,8 @@
import { positiveIntEnv } from '@snowluma/common/env';
/** Default wait for a QQ reply when the caller does not pass one. */
export const DEFAULT_PACKET_TIMEOUT_MS = 15_000;
export function packetTimeoutMs(env?: NodeJS.ProcessEnv): number {
return positiveIntEnv('SNOWLUMA_PACKET_TIMEOUT_MS', DEFAULT_PACKET_TIMEOUT_MS, { env });
}
+217
View File
@@ -0,0 +1,217 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, type Logger } from '@snowluma/common/logger';
import { EventEmitter } from 'node:events';
import { setImmediate as nextTurn } from 'node:timers/promises';
import type { HookProcessBaseInfo } from './injector';
const DEFAULT_INTERVAL_MS = 1500;
const MIN_INTERVAL_MS = 250;
export type PipeWatcherDeps = {
/** Failed enumeration returns null and preserves the last known snapshot. */
listProcesses: () => Promise<HookProcessBaseInfo[] | null>;
/** Linux uses this snapshot to locate each target user's runtime directory. */
listLivePipes: (processes: readonly HookProcessBaseInfo[]) => Promise<Set<number>>;
intervalMs?: number;
log?: Logger;
};
type PipeEvent =
| ['process-discovered', HookProcessBaseInfo]
| ['pipe-up' | 'pipe-down' | 'process-gone', number];
/** Publishes process and pipe snapshots, then emits their ordered differences. */
export class PipeWatcher extends EventEmitter {
private readonly listProcesses: PipeWatcherDeps['listProcesses'];
private readonly listLivePipes: PipeWatcherDeps['listLivePipes'];
private readonly intervalMs: number;
private readonly log: Logger;
private livePipes = new Set<number>();
private knownPids = new Set<number>();
private timer: NodeJS.Timeout | null = null;
private tickTask: Promise<void> | null = null;
private pendingTick = false;
private startPromise: Promise<void> | null = null;
private stopped = false;
constructor(deps: PipeWatcherDeps) {
super();
this.listProcesses = deps.listProcesses;
this.listLivePipes = deps.listLivePipes;
this.intervalMs = Math.max(MIN_INTERVAL_MS, deps.intervalMs ?? DEFAULT_INTERVAL_MS);
this.log = deps.log ?? createLogger('PipeWatcher');
}
isPipeLive(pid: number): boolean {
return this.livePipes.has(pid);
}
isProcessAlive(pid: number): boolean {
return this.knownPids.has(pid);
}
/** Read the initial snapshot and start polling. Repeated calls share startup. */
start(): Promise<void> {
return (this.startPromise ??= this.tickNow().finally(() => {
this.scheduleNext(this.intervalMs);
}));
}
/** Coalesce wake requests into one scan after the current read. */
wake(): void {
if (this.stopped) {
return;
}
if (this.tickTask) {
this.pendingTick = true;
} else {
this.scheduleNext(0);
}
}
/** Wait for a fresh snapshot, including a scan queued behind an active read. */
tickNow(): Promise<void> {
if (this.stopped) {
return Promise.resolve();
}
if (this.tickTask) {
this.pendingTick = true;
return this.tickTask;
}
this.tickTask = (async () => {
do {
this.pendingTick = false;
await this.readSnapshot();
if (this.pendingTick && !this.stopped) {
await nextTurn();
}
} while (this.pendingTick && !this.stopped);
})().finally(() => {
this.tickTask = null;
});
return this.tickTask;
}
stop(): void {
this.stopped = true;
this.pendingTick = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
dispose(): void {
this.stop();
this.removeAllListeners();
}
private scheduleNext(delayMs: number): void {
if (this.stopped) {
return;
}
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(() => {
this.timer = null;
void this.tickAndReschedule();
}, delayMs);
}
private async tickAndReschedule(): Promise<void> {
try {
await this.tickNow();
} catch (error) {
this.log.warn('poll failed: %s', errorMessage(error));
} finally {
this.scheduleNext(this.intervalMs);
}
}
private async readSnapshot(): Promise<void> {
let processes: HookProcessBaseInfo[] | null;
try {
processes = await this.listProcesses();
} catch (error) {
this.log.warn('listProcesses failed: %s', errorMessage(error));
processes = null;
}
if (this.stopped) {
return;
}
if (processes === null) {
// Missing data must not disconnect healthy sessions. Reconnect retries
// still run against the last known snapshot (issue #158).
this.emit('tick');
return;
}
let livePipes: Set<number>;
try {
livePipes = await this.listLivePipes(processes);
} catch (error) {
this.log.warn('listLivePipes failed: %s', errorMessage(error));
livePipes = this.livePipes;
}
if (this.stopped) {
return;
}
const knownPids = new Set(processes.map(({ pid }) => pid));
const currentPipes = new Set([...livePipes].filter((pid) => knownPids.has(pid)));
const events: PipeEvent[] = [];
for (const process of processes) {
if (!this.knownPids.has(process.pid)) {
events.push(['process-discovered', process]);
}
}
for (const pid of currentPipes) {
if (!this.livePipes.has(pid)) {
events.push(['pipe-up', pid]);
}
}
// Disconnect pipes before disposing their departed processes.
for (const pid of this.livePipes) {
if (!currentPipes.has(pid)) {
events.push(['pipe-down', pid]);
}
}
for (const pid of this.knownPids) {
if (!knownPids.has(pid)) {
events.push(['process-gone', pid]);
}
}
this.knownPids = knownPids;
this.livePipes = currentPipes;
for (const [event, payload] of events) {
if (this.stopped) {
return;
}
this.emit(event, payload);
}
if (!this.stopped) {
this.emit('tick');
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, type Logger } from '@snowluma/common/logger';
import type { HookProcessBaseInfo } from './injector';
export interface ProcessEnumerator {
/** null means unknown: keep the previous process set. */
enumerate(): Promise<HookProcessBaseInfo[] | null>;
dispose(): void;
}
interface ProcessEnumeratorDeps {
listProcesses: () => Promise<HookProcessBaseInfo[]>;
timeoutMs?: number;
log?: Logger;
}
export function createProcessEnumerator(deps: ProcessEnumeratorDeps): ProcessEnumerator {
const log = deps.log ?? createLogger('ProcessEnumerator');
const timeoutMs = deps.timeoutMs ?? 4000;
let disposed = false;
let pending: {
promise: Promise<HookProcessBaseInfo[] | null>;
settle: (result: HookProcessBaseInfo[] | null) => void;
} | null = null;
return {
enumerate() {
if (disposed) {
return Promise.resolve(null);
}
if (pending) {
return pending.promise;
}
let resolve!: (result: HookProcessBaseInfo[] | null) => void;
const promise = new Promise<HookProcessBaseInfo[] | null>((done) => {
resolve = done;
});
const settle = (result: HookProcessBaseInfo[] | null) => {
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
log.warn(
'process enumeration timed out after %dms; keeping prior process set',
timeoutMs,
);
settle(null);
}, timeoutMs);
pending = { promise, settle };
// A timeout ends the wait, not the native call. Keep its slot until it
// finishes so subsequent polls cannot accumulate blocked native work.
void Promise.resolve()
.then(() => (disposed ? null : deps.listProcesses()))
.then(
(processes) => {
pending = null;
settle(disposed ? null : processes);
},
(error) => {
pending = null;
log.warn('process enumeration failed: %s', errorMessage(error));
settle(null);
},
);
return promise;
},
dispose() {
disposed = true;
pending?.settle(null);
},
};
}
@@ -0,0 +1,107 @@
import fs from 'node:fs/promises';
import { getNativeHookAddon } from '../injector';
export interface DatabaseHolder {
pid: number;
name: string;
}
export async function getDatabaseHolders(dbPath: string): Promise<DatabaseHolder[]> {
if (process.platform === 'win32') {
return getWindowsHolders(dbPath);
}
return process.platform === 'linux' ? getLinuxHolders(dbPath) : [];
}
async function getWindowsHolders(dbPath: string): Promise<DatabaseHolder[]> {
const addon = getNativeHookAddon();
if (!addon?.getFileHoldersAsync) {
throw new Error('Windows account detection requires a hook addon with getFileHoldersAsync');
}
const holders = await addon.getFileHoldersAsync(dbPath);
if (
!Array.isArray(holders) ||
holders.some(
(holder) =>
!holder ||
!Number.isInteger(holder.pid) ||
holder.pid <= 0 ||
typeof holder.name !== 'string',
)
) {
throw new Error('Invalid database holders returned by the hook addon');
}
return holders;
}
export function isQqProcessName(name: string): boolean {
return (
name
.trim()
.toLowerCase()
.replace(/\.exe$/, '') === 'qq'
);
}
/** Match POSIX locks by device and inode; the table does not contain file paths. */
async function getLinuxHolders(dbPath: string): Promise<DatabaseHolder[]> {
const stat = await fs.stat(dbPath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') {
return null;
}
throw error;
});
if (!stat) {
return [];
}
const key = deviceInodeKey(stat.dev, stat.ino);
const table = await fs.readFile('/proc/locks', 'utf8');
const holders: DatabaseHolder[] = [];
const seen = new Set<number>();
for (const line of table.split('\n')) {
// `<id>: <type> <advisory|mandatory> <read|write> <pid> <dev>:<inode> <start> <end>`
const fields = line.trim().split(/\s+/);
if (fields.length < 8 || fields[5] !== key) {
continue;
}
const pid = Number(fields[4]);
if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) {
continue;
}
seen.add(pid);
holders.push({ pid, name: await readProcessName(pid) });
}
return holders;
}
function deviceInodeKey(device: number, inode: number): string {
const major = (device >> 8) & 0xfff;
const minor = (device & 0xff) | ((device >> 12) & 0xfff00);
return `${major.toString(16).padStart(2, '0')}:${minor.toString(16).padStart(2, '0')}:${inode}`;
}
async function readProcessName(pid: number): Promise<string> {
try {
return (await fs.readFile(`/proc/${pid}/comm`, 'utf8')).trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return '';
}
throw error;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { createLogger } from '@snowluma/common/logger';
import { isQqProcessName, getDatabaseHolders } from './database-holders';
import { readLocalAccounts, type LocalAccount } from './login-db';
import { loginDbPaths, ntMsgDbPath, qqDataRoots, qqDataRootsForPid } from './paths';
const log = createLogger('QqAccount');
const reportedFailures = new Map<string, string>();
export interface AccountLoginState extends LocalAccount {
pid: number | null;
}
export async function detectAccountLogins(
roots: readonly string[] = qqDataRoots(),
): Promise<AccountLoginState[]> {
const states: AccountLoginState[] = [];
for (const root of roots) {
const report = await readLocalAccounts(root);
for (const dbPath of loginDbPaths(root)) {
const failure = report.failures.find((item) => item.path === dbPath);
if (!failure) {
reportedFailures.delete(dbPath);
continue;
}
const detail =
failure.error instanceof Error
? failure.error.message
: String(failure.error ?? '');
const reason = detail ? failure.reason + ': ' + detail : failure.reason;
if (reportedFailures.get(dbPath) !== reason) {
reportedFailures.set(dbPath, reason);
log.warn('login.db unreadable: path=%s reason=%s', dbPath, reason);
}
}
for (const account of report.accounts) {
const holder = (await getDatabaseHolders(ntMsgDbPath(root, account))).find(
(candidate) => isQqProcessName(candidate.name),
);
states.push({ ...account, pid: holder?.pid ?? null });
}
}
return states;
}
export async function detectAccountLoginsForPids(
pids: readonly number[],
): Promise<AccountLoginState[]> {
const roots = [...new Set(pids.flatMap((pid) => qqDataRootsForPid(pid)))];
return detectAccountLogins(roots);
}
export async function detectUinForPid(pid: number): Promise<string | null> {
const accounts = await detectAccountLoginsForPids([pid]);
return accounts.find((account) => account.pid === pid)?.uin ?? null;
}
+23
View File
@@ -0,0 +1,23 @@
export { isQqProcessName, getDatabaseHolders, type DatabaseHolder } from './database-holders';
export {
detectAccountLogins,
detectAccountLoginsForPids,
detectUinForPid,
type AccountLoginState,
} from './detect';
export {
readLocalAccounts,
type LocalAccount,
type LocalAccountReport,
type LoginDbFailure,
} from './login-db';
export { accountDirName, loginDbPaths, ntMsgDbPath, qqDataRoots, qqDataRootsForPid } from './paths';
export { watchAccountChangesForPids } from './watch';
export {
decryptDatabase,
detectAlgorithm,
type DecryptedDatabase,
type KdfHmacAlgorithm,
type PageHmacAlgorithm,
type SqlcipherAlgorithm,
} from './sqlcipher';
+199
View File
@@ -0,0 +1,199 @@
import { isRealUin } from '@snowluma/common/uin';
import { SqliteDatabase } from '@snowluma/sqlite';
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { loginDbPaths } from './paths';
import { decryptDatabase, detectAlgorithm } from './sqlcipher';
const LOGIN_DB_PASSPHRASE = 'BD156D6710D54D8782F4';
const COLUMN_UIN = '1000';
const COLUMN_UID = '1001';
const COLUMN_NICKNAME = '1007';
export interface LocalAccount {
uin: string;
uid: string;
nickname: string;
}
export type LoginDbFailure = 'read-failed' | 'undecryptable' | 'query-failed';
type LoginDbResult = { accounts: LocalAccount[] } | { failure: LoginDbFailure; error?: unknown };
export interface LocalAccountReport {
accounts: LocalAccount[];
failures: Array<{ path: string; reason: LoginDbFailure; error?: unknown }>;
}
// Share concurrent reads and invalidate when either the database or its WAL changes.
const accountCache = new Map<string, { stamp: string; result: Promise<LoginDbResult> }>();
export async function readLocalAccounts(root: string): Promise<LocalAccountReport> {
const merged = new Map<string, LocalAccount>();
const failures: LocalAccountReport['failures'] = [];
for (const dbPath of loginDbPaths(root)) {
const result = await readLoginDb(dbPath);
if ('failure' in result) {
failures.push({ path: dbPath, reason: result.failure, error: result.error });
continue;
}
for (const account of result.accounts) {
if (!merged.has(account.uin)) {
merged.set(account.uin, account);
}
}
}
return { accounts: [...merged.values()], failures };
}
async function readLoginDb(dbPath: string): Promise<LoginDbResult> {
let stamp: string;
try {
const files = await Promise.all([statIfPresent(dbPath), statIfPresent(dbPath + '-wal')]);
stamp = files.map((info) => (info ? info.mtimeMs + ':' + info.size : '-')).join('|');
} catch (error) {
accountCache.delete(dbPath);
return { failure: 'read-failed', error };
}
const cached = accountCache.get(dbPath);
if (cached?.stamp === stamp) {
return cached.result;
}
const entry = { stamp, result: decryptAndQuery(dbPath) };
accountCache.set(dbPath, entry);
const result = await entry.result;
if ('failure' in result && accountCache.get(dbPath) === entry) {
accountCache.delete(dbPath);
}
return result;
}
async function decryptAndQuery(dbPath: string): Promise<LoginDbResult> {
let image: Buffer;
let wal: Buffer | null;
try {
const contents = await readIfPresent(dbPath);
if (!contents) {
return { accounts: [] };
}
image = contents;
wal = await readIfPresent(dbPath + '-wal');
} catch (error) {
return { failure: 'read-failed', error };
}
const algorithm = detectAlgorithm(image, LOGIN_DB_PASSPHRASE);
if (!algorithm) {
return { failure: 'undecryptable' };
}
const decrypted = decryptDatabase(image, wal, LOGIN_DB_PASSPHRASE, algorithm);
if (!decrypted) {
return { failure: 'undecryptable' };
}
try {
return { accounts: await queryLoginTable(decrypted.image) };
} catch (error) {
return { failure: 'query-failed', error };
}
}
// SQLite opens a private temporary image; QQ's database and sidecars stay read-only.
async function queryLoginTable(image: Buffer): Promise<LocalAccount[]> {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-qq-login-'));
const dbPath = path.join(directory, 'login.db');
try {
await writeFile(dbPath, image, { mode: 0o600 });
const db = new SqliteDatabase(dbPath, { readOnly: true });
try {
const rows = await db
.prepare(
`SELECT [${COLUMN_UIN}], [${COLUMN_UID}], [${COLUMN_NICKNAME}] FROM login_table`,
)
.all();
const accounts: LocalAccount[] = [];
for (const row of rows) {
const account = {
uin: asText(row[COLUMN_UIN]),
uid: asText(row[COLUMN_UID]),
nickname: asText(row[COLUMN_NICKNAME]),
};
if (isRealUin(account.uin) && account.uid.length > 0) {
accounts.push(account);
}
}
return accounts;
} finally {
await db.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}
async function readIfPresent(file: string): Promise<Buffer | null> {
try {
return await readFile(file);
} catch (error) {
if (isNotFound(error)) {
return null;
}
throw error;
}
}
function isNotFound(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
async function statIfPresent(file: string) {
try {
return await stat(file);
} catch (error) {
if (isNotFound(error)) {
return null;
}
throw error;
}
}
/** SQLite values arrive as numbers, strings or blobs depending on the column. */
function asText(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString('utf8');
}
return '';
}
+161
View File
@@ -0,0 +1,161 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
const LOGIN_DB_SEGMENTS = ['nt_db', 'login.db'] as const;
export function qqDataRoots(): string[] {
if (process.platform === 'win32') {
return windowsDataRoots();
}
if (process.platform === 'linux') {
return linuxDataRoots({ ...process.env, HOME: process.env.HOME || homedir() });
}
return [];
}
/** Each Linux client owns its HOME; an unavailable process has no known root. */
export function qqDataRootsForPid(pid: number): string[] {
if (process.platform === 'win32') {
return windowsDataRoots();
}
if (process.platform !== 'linux') {
return [];
}
const environment = readProcessEnvironment(pid);
return environment ? linuxDataRoots(environment) : [];
}
function linuxDataRoots(environment: { HOME?: string; XDG_CONFIG_HOME?: string }): string[] {
const base =
environment.XDG_CONFIG_HOME ||
(environment.HOME ? path.join(environment.HOME, '.config') : null);
return base ? [path.join(base, 'QQ')] : [];
}
function readProcessEnvironment(pid: number): { HOME?: string; XDG_CONFIG_HOME?: string } | null {
let raw: string;
try {
raw = readFileSync(`/proc/${pid}/environ`, 'utf8');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null;
}
throw error;
}
const environment: { HOME?: string; XDG_CONFIG_HOME?: string } = {};
for (const entry of raw.split('\0')) {
const separator = entry.indexOf('=');
if (separator <= 0) {
continue;
}
const name = entry.slice(0, separator);
if (name === 'HOME' || name === 'XDG_CONFIG_HOME') {
environment[name] = entry.slice(separator + 1);
}
}
return environment;
}
/** Both Linux layouts occur in QQ installations; the primary takes precedence. */
export function loginDbPaths(root: string): string[] {
if (process.platform === 'win32') {
return [path.join(root, 'nt_qq', 'global', ...LOGIN_DB_SEGMENTS)];
}
return [
path.join(root, 'global', ...LOGIN_DB_SEGMENTS),
path.join(root, 'nt_qq', 'global', ...LOGIN_DB_SEGMENTS),
];
}
export function ntMsgDbPath(root: string, account: { uin: string; uid: string }): string {
if (process.platform === 'win32') {
return path.join(root, account.uin, 'nt_qq', 'nt_db', 'nt_msg.db');
}
return path.join(root, accountDirName(account.uid), 'nt_db', 'nt_msg.db');
}
export function accountDirName(uid: string): string {
const digest = createHash('md5').update(uid, 'utf8').digest('hex');
return `nt_qq_${createHash('md5').update(`${digest}nt_kernel`, 'utf8').digest('hex')}`;
}
function windowsDataRoots(): string[] {
const configured = tencentFilesRootFromUserDataInfo();
return [configured ?? path.join(homedir(), 'Documents', 'Tencent Files')];
}
export function tencentFilesRootFromUserDataInfo(home = homedir()): string | null {
const ini = path.join(
path.dirname(home),
'Public',
'Documents',
'Tencent',
'QQ',
'UserDataInfo.ini',
);
let text: string;
try {
text = readFileSync(ini, 'utf8');
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null;
}
throw error;
}
let inDataSet = false;
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (line.startsWith('[') && line.endsWith(']')) {
inDataSet = line.toLowerCase() === '[userdataset]';
continue;
}
if (!inDataSet) {
continue;
}
const separator = line.indexOf('=');
if (
separator === -1 ||
line.slice(0, separator).trim().toLowerCase() !== 'userdatasavepath'
) {
continue;
}
const value = line.slice(separator + 1).trim();
if (value.length === 0) {
return null;
}
// The ini always holds Windows paths, so resolve them with the win32
// flavor wherever this runs.
return path.win32.basename(value).toLowerCase() === 'tencent files'
? value
: path.win32.join(value, 'Tencent Files');
}
return null;
}
+461
View File
@@ -0,0 +1,461 @@
/**
* SQLCipher v4 page decryption for QQ NT's local databases.
*
* QQ NT stores every database under `nt_db/` as a SQLCipher v4 stream behind a
* 1024-byte client header:
*
* [ 1024-byte client header ][ page 1 ][ page 2 ] ...
* ^ KDF salt = first 16 bytes of page 1
*
* Each page ends with `IV (16 bytes) || page HMAC`, rounded up to the AES block
* size; the page size is 4096 on every database QQ writes. Page 1's ciphertext
* starts after its 16-byte salt, which replaces the plaintext "SQLite format 3"
* magic — so the decrypted body of page 1 starts at page offset 16 and can never
* contain that magic. A candidate key is therefore confirmed by recomputing the
* page HMAC over the *ciphertext*, which is independent of the AES pass.
*
* The page-HMAC and KDF-HMAC algorithms are not fixed across client builds, so
* they are probed per database (12 combinations) instead of assumed.
*/
import { createDecipheriv, createHmac, pbkdf2Sync } from 'node:crypto';
/** QQ NT prepends this many bytes before the SQLCipher stream. */
export const DATABASE_HEADER_SIZE = 1024;
const PAGE_SIZE = 4096;
const SALT_SIZE = 16;
const IV_SIZE = 16;
const KEY_SIZE = 32;
/** SQLCipher v4 derives the AES key with this many PBKDF2 iterations. */
const KDF_ITERATIONS = 4000;
/** The per-page HMAC key uses a fast derivation over the page salt. */
const HMAC_KEY_ITERATIONS = 2;
/** SQLCipher's HMAC salt is the page salt XOR this mask. */
const HMAC_SALT_MASK = 0x3a;
const WAL_HEADER_SIZE = 32;
const WAL_FRAME_HEADER_SIZE = 24;
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1');
export type PageHmacAlgorithm = 'none' | 'sha1' | 'sha256' | 'sha512';
export type KdfHmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
/** One SQLCipher algorithm pair, as probed against a database's page 1. */
export interface SqlcipherAlgorithm {
pageHmac: PageHmacAlgorithm;
kdfHmac: KdfHmacAlgorithm;
}
/** Every pair to probe, in order: `cipher_use_hmac = OFF` first, then each digest. */
const PAGE_HMAC_ALGORITHMS: readonly PageHmacAlgorithm[] = ['none', 'sha1', 'sha256', 'sha512'];
const KDF_HMAC_ALGORITHMS: readonly KdfHmacAlgorithm[] = ['sha1', 'sha256', 'sha512'];
const PAGE_HMAC_DIGEST_BYTES: Record<PageHmacAlgorithm, number> = {
none: 0,
sha1: 20,
sha256: 32,
sha512: 64,
};
/** A database image decrypted from disk, with its `-wal` frames folded in. */
export interface DecryptedDatabase {
/** A self-contained plaintext SQLite image (rollback-journal mode). */
image: Buffer;
/** Frames folded in from the `-wal` sidecar, 0 when there was nothing to merge. */
walFrames: number;
}
/**
* Probe the algorithm pair of an encrypted database.
*
* Returns null when the page-1 HMAC matches no combination under `passphrase`,
* which means either the passphrase or the file layout is not what we expect.
*/
export function detectAlgorithm(image: Buffer, passphrase: string): SqlcipherAlgorithm | null {
const pageOne = readPageOne(image);
if (!pageOne) {
return null;
}
const salt = pageOne.subarray(0, SALT_SIZE);
for (const pageHmac of PAGE_HMAC_ALGORITHMS) {
for (const kdfHmac of KDF_HMAC_ALGORITHMS) {
const algorithm: SqlcipherAlgorithm = { pageHmac, kdfHmac };
const key = deriveKey(passphrase, salt, kdfHmac);
if (verifyPageOne(pageOne, key, algorithm)) {
return algorithm;
}
}
}
return null;
}
/**
* Decrypt a whole database and fold in the `-wal` sidecar when there is one.
*
* QQ keeps a long-lived WAL, so everything committed since the last checkpoint
* — including an account that just signed in — lives only in the sidecar.
* Returns null when the image cannot be decrypted.
*/
export function decryptDatabase(
image: Buffer,
wal: Buffer | null,
passphrase: string,
algorithm: SqlcipherAlgorithm,
): DecryptedDatabase | null {
const pageOne = readPageOne(image);
if (!pageOne) {
return null;
}
const key = deriveKey(passphrase, pageOne.subarray(0, SALT_SIZE), algorithm.kdfHmac);
const base = decryptPages(image, key, algorithm);
if (!base) {
return null;
}
const merged = applyWal(base, wal, key, algorithm);
normalizeImage(merged.image);
return merged;
}
/** Page 1 as stored, or null when the file is too short to hold a header + page 1. */
function readPageOne(image: Buffer): Buffer | null {
if (image.length < DATABASE_HEADER_SIZE + PAGE_SIZE) {
return null;
}
return image.subarray(DATABASE_HEADER_SIZE, DATABASE_HEADER_SIZE + PAGE_SIZE);
}
/** Per-page reserve = IV + page HMAC digest, rounded up to the AES block size. */
function reserveBytes(algorithm: SqlcipherAlgorithm): number {
const digestBytes = PAGE_HMAC_DIGEST_BYTES[algorithm.pageHmac];
return Math.ceil((IV_SIZE + digestBytes) / 16) * 16;
}
function deriveKey(passphrase: string, salt: Buffer, kdfHmac: KdfHmacAlgorithm): Buffer {
return pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_SIZE, kdfHmac);
}
/**
* Confirm a candidate key against page 1.
*
* With the page HMAC enabled the check runs over the stored ciphertext, exactly
* like SQLCipher's own verification. With HMAC off there is nothing to verify on
* the ciphertext, so page 1 is decrypted and the SQLite header fields that live
* past the salt-occupied first 16 bytes are checked instead.
*/
function verifyPageOne(pageOne: Buffer, key: Buffer, algorithm: SqlcipherAlgorithm): boolean {
const reserve = reserveBytes(algorithm);
const digestBytes = PAGE_HMAC_DIGEST_BYTES[algorithm.pageHmac];
if (digestBytes === 0) {
const body = decryptPageBody(pageOne, key, SALT_SIZE, reserve);
return body !== null && hasSqliteHeaderFields(body);
}
const hmacSalt = Buffer.alloc(SALT_SIZE);
for (let index = 0; index < SALT_SIZE; index++) {
hmacSalt[index] = pageOne[index] ^ HMAC_SALT_MASK;
}
const hmacKey = pbkdf2Sync(key, hmacSalt, HMAC_KEY_ITERATIONS, KEY_SIZE, algorithm.kdfHmac);
const bodyEnd = PAGE_SIZE - reserve;
const pageNumber = Buffer.alloc(4);
pageNumber.writeUInt32LE(1);
const input = Buffer.concat([
pageOne.subarray(SALT_SIZE, bodyEnd),
pageOne.subarray(bodyEnd, bodyEnd + IV_SIZE),
pageNumber,
]);
const computed = createHmac(algorithm.pageHmac, hmacKey).update(input).digest();
const stored = pageOne.subarray(bodyEnd + IV_SIZE, bodyEnd + IV_SIZE + digestBytes);
return equalsBytes(computed, stored);
}
/** The decrypted page-1 body starts at file offset 16 (the salt replaces 0..16). */
function hasSqliteHeaderFields(body: Buffer): boolean {
if (body.length < 8) {
return false;
}
const pageSize = body.readUInt16BE(0);
const pageSizeOk = pageSize === 1 || (pageSize >= 512 && (pageSize & (pageSize - 1)) === 0);
return pageSizeOk && body[5] === 64 && body[6] === 32 && body[7] === 32;
}
function equalsBytes(left: Buffer, right: Buffer): boolean {
if (left.length !== right.length) {
return false;
}
let diff = 0;
for (let index = 0; index < left.length; index++) {
diff |= left[index]! ^ right[index]!;
}
return diff === 0;
}
/**
* Decrypt every page of the main file, in place on a fresh buffer.
*
* `skip` drops the salt prefix of page 1; the reserve region of each page is
* left zeroed, which is how SQLCipher lays the plaintext out.
*/
function decryptPages(image: Buffer, key: Buffer, algorithm: SqlcipherAlgorithm): Buffer | null {
const stream = image.subarray(DATABASE_HEADER_SIZE);
const pageCount = Math.floor(stream.length / PAGE_SIZE);
if (pageCount === 0) {
return null;
}
const reserve = reserveBytes(algorithm);
const plain = Buffer.alloc(pageCount * PAGE_SIZE);
for (let index = 0; index < pageCount; index++) {
const pageNumber = index + 1;
const page = stream.subarray(index * PAGE_SIZE, (index + 1) * PAGE_SIZE);
const body = decryptPageBody(page, key, pageNumber === 1 ? SALT_SIZE : 0, reserve);
if (body === null) {
return null;
}
writePlaintextPage(plain, index * PAGE_SIZE, pageNumber, body);
}
return plain;
}
/** Decrypt one page body, or null when the page cannot hold an IV + digest. */
function decryptPageBody(page: Buffer, key: Buffer, skip: number, reserve: number): Buffer | null {
const cipherLength = PAGE_SIZE - skip - reserve;
if (cipherLength <= 0) {
return null;
}
const ciphertext = page.subarray(skip, skip + cipherLength);
const iv = page.subarray(skip + cipherLength, skip + cipherLength + IV_SIZE);
const decipher = createDecipheriv('aes-256-cbc', key, iv);
decipher.setAutoPadding(false);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
}
/**
* Write one decrypted page into the plaintext image.
*
* Page 1 is special in both the main file and the WAL: its first 16 on-disk
* bytes hold the KDF salt in place of SQLite's magic, so the magic and the
* page-size field have to be restored. Every other page's body starts at offset
* 0 of the page.
*/
function writePlaintextPage(image: Buffer, offset: number, pageNumber: number, body: Buffer): void {
if (pageNumber !== 1) {
body.copy(image, offset, 0, Math.min(body.length, PAGE_SIZE));
return;
}
SQLITE_MAGIC.copy(image, offset);
body.copy(image, offset + SALT_SIZE, 0, Math.min(body.length, PAGE_SIZE - SALT_SIZE));
image.writeUInt16BE(PAGE_SIZE, offset + 16);
}
/**
* Fold the `-wal` sidecar into a decrypted image and return the frame count.
*
* A WAL is not wrapped in SQLCipher's envelope the way the main file is: its
* 32-byte header and every 24-byte frame header stay plaintext, and each frame
* carries one encrypted page (page 1 keeping its salt prefix). SQLite's frame
* checksums are computed over the frames *as stored*, i.e. over the ciphertext,
* which is why the intact prefix is established before any decryption.
*
* Only frames up to the last commit are applied, and the database size recorded
* by that commit decides how many pages the merged image has — a torn tail from
* a running QQ therefore contributes nothing.
*/
function applyWal(
base: Buffer,
wal: Buffer | null,
key: Buffer,
algorithm: SqlcipherAlgorithm,
): DecryptedDatabase {
const log = wal === null ? null : parseWal(wal);
if (!log || log.frames.length === 0) {
return { image: base, walFrames: 0 };
}
let lastCommit = -1;
for (let index = 0; index < log.frames.length; index++) {
if (log.frames[index]!.commitSize !== 0) {
lastCommit = index;
}
}
if (lastCommit < 0) {
return { image: base, walFrames: 0 };
}
const pageCount = log.frames[lastCommit]!.commitSize;
const basePageCount = base.length / PAGE_SIZE;
// A commit can only grow the database by the pages its own log carries, so
// anything past that bound is a corrupt frame header rather than a size.
if (pageCount < 1 || pageCount > basePageCount + log.frames.length) {
return { image: base, walFrames: 0 };
}
const merged = Buffer.alloc(pageCount * PAGE_SIZE);
base.copy(merged, 0, 0, Math.min(base.length, merged.length));
const reserve = reserveBytes(algorithm);
for (let index = 0; index <= lastCommit; index++) {
const frame = log.frames[index]!;
if (frame.pageNumber === 0 || frame.pageNumber > pageCount) {
continue;
}
const page = wal!.subarray(frame.offset, frame.offset + PAGE_SIZE);
const body = decryptPageBody(page, key, frame.pageNumber === 1 ? SALT_SIZE : 0, reserve);
if (body === null) {
return { image: base, walFrames: 0 };
}
writePlaintextPage(merged, (frame.pageNumber - 1) * PAGE_SIZE, frame.pageNumber, body);
}
return { image: merged, walFrames: lastCommit + 1 };
}
interface WalFrame {
pageNumber: number;
/** Database size in pages recorded by a commit frame, 0 for a non-commit frame. */
commitSize: number;
/** Offset of the frame's page inside the `-wal` buffer. */
offset: number;
}
interface WalLog {
frames: WalFrame[];
littleEndian: boolean;
}
/**
* Validate a `-wal` buffer and collect its intact frames.
*
* Validation stops at the first frame whose checksum does not continue the
* chain, mirroring SQLite's own recovery: everything after that frame is either
* half-written or belongs to an aborted transaction.
*/
function parseWal(wal: Buffer): WalLog | null {
if (wal.length < WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + PAGE_SIZE) {
return null;
}
const magic = wal.readUInt32BE(0);
const littleEndian = magic === 0x377f0682 ? true : magic === 0x377f0683 ? false : null;
if (littleEndian === null || wal.readUInt32BE(8) !== PAGE_SIZE) {
return null;
}
// The header checksum covers its first 24 bytes; magic and page size are
// excluded from the checksummed range by that length.
const header = walChecksum([0, 0], [wal.subarray(0, WAL_HEADER_SIZE - 8)], littleEndian);
if (header[0] !== wal.readUInt32BE(24) || header[1] !== wal.readUInt32BE(28)) {
return null;
}
const frameSize = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
const frames: WalFrame[] = [];
let running = header;
for (let offset = WAL_HEADER_SIZE; offset + frameSize <= wal.length; offset += frameSize) {
const frameHeader = wal.subarray(offset, offset + WAL_FRAME_HEADER_SIZE);
const page = wal.subarray(offset + WAL_FRAME_HEADER_SIZE, offset + frameSize);
running = walChecksum(running, [frameHeader.subarray(0, 8), page], littleEndian);
if (
running[0] !== frameHeader.readUInt32BE(16) ||
running[1] !== frameHeader.readUInt32BE(20)
) {
break;
}
frames.push({
pageNumber: frameHeader.readUInt32BE(0),
commitSize: frameHeader.readUInt32BE(4),
offset: offset + WAL_FRAME_HEADER_SIZE,
});
}
return { frames, littleEndian };
}
/**
* SQLite's rolling WAL checksum: two 32-bit accumulators advanced over 8-byte
* word pairs. The byte order is fixed by the WAL magic (`0x377f0682` =
* little-endian words, `0x377f0683` = big-endian) and is unrelated to the
* host's byte order.
*
* Every chunk fed in here is a multiple of 8 bytes: the header contributes 24
* bytes, a frame its first 8 header bytes plus a whole page.
*/
function walChecksum(
seed: [number, number],
chunks: readonly Buffer[],
littleEndian: boolean,
): [number, number] {
let first = seed[0];
let second = seed[1];
for (const chunk of chunks) {
for (let offset = 0; offset + 8 <= chunk.length; offset += 8) {
const high = littleEndian ? chunk.readUInt32LE(offset) : chunk.readUInt32BE(offset);
const low = littleEndian
? chunk.readUInt32LE(offset + 4)
: chunk.readUInt32BE(offset + 4);
first = (first + high + second) >>> 0;
second = (second + low + first) >>> 0;
}
}
return [first, second];
}
/**
* Make the assembled image self-consistent before it is handed to SQLite.
*
* The merge can add or remove pages relative to the base image, and the header
* fields that describe the database are only trusted while page 1's change
* counter and version-valid-for agree. The write/read version bytes are reset
* to the legacy journal so the image can be opened read-only without needing a
* `-wal`/`-shm` pair next to it.
*/
function normalizeImage(image: Buffer): void {
const pageCount = image.length / PAGE_SIZE;
const changeCounter = image.readUInt32BE(24);
image[18] = 1;
image[19] = 1;
image.writeUInt32BE(pageCount, 28);
image.writeUInt32BE(changeCounter, 92);
}
+115
View File
@@ -0,0 +1,115 @@
import { errorMessage } from '@snowluma/common/errors';
import { createLogger, type Logger } from '@snowluma/common/logger';
import { watch, type FSWatcher } from 'node:fs';
import path from 'node:path';
import { loginDbPaths, qqDataRootsForPid } from './paths';
export function watchAccountChangesForPids(
pids: readonly number[],
onChange: () => void,
log: Logger = createLogger('QqAccount'),
): () => void {
const roots = new Set<string>();
for (const pid of pids) {
try {
for (const root of qqDataRootsForPid(pid)) {
roots.add(root);
}
} catch (error) {
log.warn('account watch root failed: PID=%d err=%s', pid, errorMessage(error));
}
}
return watchAccountFiles([...roots].flatMap(loginDbPaths), onChange, log);
}
/** Watch directories so replacing login.db or recreating its WAL keeps working. */
export function watchAccountFiles(
dbPaths: readonly string[],
onChange: () => void,
log: Logger = createLogger('QqAccount'),
): () => void {
const watchers = new Map<string, FSWatcher>();
let timer: NodeJS.Timeout | undefined;
const changed = () => {
clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
onChange();
}, 250);
timer.unref();
};
const observe = (dbPath: string) => {
watchers.get(dbPath)?.close();
watchers.delete(dbPath);
const dbDirectory = path.dirname(dbPath);
let directory = dbDirectory;
for (;;) {
const target = path.relative(directory, dbPath).split(path.sep)[0]!;
try {
const watcher = watch(directory, { persistent: false }, (_event, filename) => {
if (
watchers.get(dbPath) !== watcher ||
(filename !== null &&
filename !== target &&
!(directory === dbDirectory && filename === `${target}-wal`))
) {
return;
}
// QQ may create its data directories after process discovery.
if (directory !== dbDirectory) {
observe(dbPath);
}
changed();
});
watcher.on('error', (error) => {
if (watchers.get(dbPath) !== watcher) {
return;
}
watcher.close();
watchers.delete(dbPath);
log.warn('account watch failed: path=%s err=%s', dbPath, errorMessage(error));
});
watchers.set(dbPath, watcher);
return;
} catch (error) {
const parent = path.dirname(directory);
if (
error instanceof Error &&
'code' in error &&
error.code === 'ENOENT' &&
parent !== directory
) {
directory = parent;
continue;
}
log.warn('account watch failed: path=%s err=%s', dbPath, errorMessage(error));
return;
}
}
};
for (const dbPath of new Set(dbPaths)) {
observe(dbPath);
}
return () => {
clearTimeout(timer);
for (const watcher of watchers.values()) {
watcher.close();
}
watchers.clear();
};
}
File diff suppressed because it is too large. Load diff
+121
View File
@@ -0,0 +1,121 @@
import { getNativeHookAddon, getNativeHookLoadError } from './injector';
export interface IntegrityReadError {
status: 'error';
operation: string;
code: number;
}
export interface NativeRuntimeIntegrity {
pid: number;
executablePath: string | null;
process: { status: 'ok'; rid: number } | IntegrityReadError;
file:
| {
status: 'ok';
label: { rid: number; inherited: boolean; policy: number } | null;
}
| IntegrityReadError;
}
export type RuntimeIntegrity =
| { status: 'unsupported' }
| { status: 'unavailable'; error: string }
| ({ status: 'available' } & NativeRuntimeIntegrity);
/** File labels can change after startup; only the actual token decides the warning. */
export function hasLowProcessIntegrity(integrity: RuntimeIntegrity): boolean {
return (
integrity.status === 'available' &&
integrity.process.status === 'ok' &&
integrity.process.rid < 8192
);
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function uint32(value: unknown): value is number {
return (
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff
);
}
function readError(value: Record<string, unknown>): boolean {
return value.status === 'error' && typeof value.operation === 'string' && uint32(value.code);
}
function isNativeRuntimeIntegrity(value: unknown): value is NativeRuntimeIntegrity {
if (
!record(value) ||
value.pid !== process.pid ||
!(typeof value.executablePath === 'string' || value.executablePath === null) ||
!record(value.process) ||
!record(value.file)
) {
return false;
}
if (!(
readError(value.process) ||
(value.process.status === 'ok' && uint32(value.process.rid))
)) {
return false;
}
const file = value.file;
return (
readError(file) ||
(file.status === 'ok' &&
(file.label === null ||
(record(file.label) &&
uint32(file.label.rid) &&
typeof file.label.inherited === 'boolean' &&
uint32(file.label.policy))))
);
}
let cached: Promise<RuntimeIntegrity> | undefined;
/** One startup snapshot, shared by the startup log and the WebUI warning. */
export function getRuntimeIntegrity(): Promise<RuntimeIntegrity> {
return (cached ??= readRuntimeIntegrity());
}
async function readRuntimeIntegrity(): Promise<RuntimeIntegrity> {
if (process.platform !== 'win32') {
return { status: 'unsupported' };
}
try {
const addon = getNativeHookAddon();
if (!addon) {
throw new Error(getNativeHookLoadError() ?? '原生组件不可用');
}
if (typeof addon.getRuntimeIntegrityAsync !== 'function') {
throw new Error('当前原生组件不支持完整性诊断,请更新原生组件');
}
const result = await addon.getRuntimeIntegrityAsync();
if (!isNativeRuntimeIntegrity(result)) {
throw new Error('原生完整性诊断返回了无效数据');
}
return {
status: 'available',
pid: result.pid,
executablePath: result.executablePath,
process: result.process,
file: result.file,
};
} catch (error) {
return {
status: 'unavailable',
error: error instanceof Error ? error.message : String(error),
};
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { HookProcessBaseInfo } from './injector';
export type HookProcessStatus =
'available' | 'loading' | 'connecting' | 'loaded' | 'online' | 'error' | 'disconnected';
/** Account the QQ client is signed in as, as reported by its local data. */
export interface HookProcessAccount {
uin: string;
nickname: string;
}
/** Public-facing snapshot of a per-PID HookSession. */
export interface HookProcessInfo extends HookProcessBaseInfo {
injected: boolean;
connected: boolean;
loggedIn: boolean;
uin: string;
status: HookProcessStatus;
error: string;
method: string;
/** Locally detected identity; null means unknown, not necessarily signed out. */
account: HookProcessAccount | null;
}
@@ -0,0 +1,739 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookManager } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { QqHookClient } from '../src/qq-hook-client';
import type { AccountLoginState } from '../src/qq-account';
// Regression cover for the WebUI SSE wiring: HookManager must call its
// optional `onSessionsChanged` callback every time the observable set of
// HookProcessInfo (what `listProcesses()` would return) changes, so the
// /api/state/stream handler can push a fresh processes snapshot to the
// browser without REST polling.
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
interface Harness {
manager: HookManager;
pipeWatcher: PipeWatcher;
bridgeManager: BridgeManagerSink & { onPidReceiveHealthChanged: ReturnType<typeof vi.fn> };
setProcesses: (next: number[]) => void;
setLivePipes: (next: number[]) => void;
onSessionsChanged: ReturnType<typeof vi.fn>;
clientFactory: ReturnType<typeof vi.fn>;
notifyAccountChange: () => void;
watchAccounts: ReturnType<typeof vi.fn>;
}
function makeManager(
opts: {
processes?: number[];
livePipes?: number[];
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
} = {},
): Harness {
let pids = opts.processes ?? [];
let live = new Set<number>(opts.livePipes ?? []);
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
const clientFactory = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
/* succeed without doing anything */
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
onPidReceiveHealthChanged: vi.fn(),
} as unknown as BridgeManagerSink & { onPidReceiveHealthChanged: ReturnType<typeof vi.fn> };
const onSessionsChanged = vi.fn();
let accountChanged = () => {};
const watchAccounts = vi.fn((_pids: readonly number[], onChange: () => void) => {
accountChanged = onChange;
return vi.fn();
});
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: clientFactory,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts,
onSessionsChanged,
});
return {
manager,
pipeWatcher,
bridgeManager,
onSessionsChanged,
clientFactory,
notifyAccountChange: () => accountChanged(),
watchAccounts,
setProcesses: (next) => {
pids = next;
},
setLivePipes: (next) => {
live = new Set(next);
},
};
}
describe('HookManager.onSessionsChanged', () => {
it('fires when the watcher discovers a new QQ process', async () => {
const ctx = makeManager({ processes: [] });
await ctx.pipeWatcher.start();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('fires when the watcher reports a process is gone', async () => {
const ctx = makeManager({ processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('fires when the watcher reports the pipe came up (status: connecting → loaded)', async () => {
const ctx = makeManager({ processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
// Inject so the session is in 'connecting' state; the next pipe-up
// makes attemptConnect succeed and status flips to 'loaded'.
await ctx.manager.loadProcess(4242);
await flush();
ctx.onSessionsChanged.mockClear();
ctx.setLivePipes([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('publishes an initial timeout and automatically recovers when the channel arrives late', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
const ctx = makeManager({ processes: [4242] });
try {
await ctx.manager.loadProcess(4242);
ctx.onSessionsChanged.mockClear();
await vi.advanceTimersByTimeAsync(60_000);
expect(ctx.onSessionsChanged).toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([
expect.objectContaining({
pid: 4242,
status: 'error',
injected: true,
error: expect.stringContaining('首次连接超时'),
}),
]);
ctx.onSessionsChanged.mockClear();
ctx.setLivePipes([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([
expect.objectContaining({
pid: 4242,
status: 'loaded',
connected: true,
error: '',
}),
]);
} finally {
ctx.manager.dispose();
vi.useRealTimers();
}
});
it('survives a throwing onSessionsChanged callback — the watcher loop keeps running', async () => {
const ctx = makeManager({ processes: [] });
ctx.onSessionsChanged.mockImplementation(() => {
throw new Error('listener boom');
});
await ctx.pipeWatcher.start();
ctx.setProcesses([4242]);
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await flush();
// A second tick still fires the callback — it wasn't unregistered by
// the prior throw.
ctx.onSessionsChanged.mockClear();
ctx.setProcesses([4242, 5555]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('is OPTIONAL — omitting it leaves the manager fully functional', async () => {
const pids = [4242];
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(),
intervalMs: 60_000,
});
const manager = new HookManager({
bridgeManager: {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: vi.fn(() => {
const c = new EventEmitter() as any;
c.isClosed = false;
c.isLoggedIn = false;
c.getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
c.connectAll = async () => undefined;
c.close = () => {
c.isClosed = true;
};
return c;
}),
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
detectAccounts: async () => [],
watchAccounts: () => () => {},
// onSessionsChanged deliberately omitted
});
await pipeWatcher.start();
await pipeWatcher.tickNow();
await flush();
const list = await manager.listProcesses();
expect(list).toHaveLength(1);
expect(list[0].pid).toBe(4242);
manager.dispose();
});
});
describe('HookManager account re-probe', () => {
it('reuses the account snapshot across ticks and REST reads while all sessions are online (#549)', async () => {
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
]);
const ctx = makeManager({
processes: [4242, 5151],
livePipes: [4242, 5151],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
for (const [index, uin] of ['10001', '10002'].entries()) {
const client = ctx.clientFactory.mock.results[index]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin, uinNumber: BigInt(uin) });
}
const initial = await ctx.manager.listProcesses();
expect(initial.map((info) => info.status)).toEqual(['online', 'online']);
const baseline = detectAccounts.mock.calls.length;
ctx.onSessionsChanged.mockClear();
const startedAt = Date.now();
for (let tick = 1; tick <= 10; tick++) {
now.mockReturnValue(startedAt + tick * 6000);
await ctx.pipeWatcher.tickNow();
expect(await ctx.manager.listProcesses()).toEqual(initial);
}
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it.each(['loadProcess', 'unloadProcess', 'refreshProcess'] as const)(
'refreshes account labels on %s even within the probe interval',
async (operation) => {
let nickname = 'Alice';
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => [{ uin: '10001', uid: 'u_a', nickname, pid: 4242 }],
});
try {
expect((await ctx.manager.listProcesses())[0]!.account?.nickname).toBe('Alice');
nickname = 'Updated';
expect((await ctx.manager[operation](4242)).account?.nickname).toBe('Updated');
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
},
);
it('discards an in-flight account result when an online process switches accounts', async () => {
const stale = Promise.withResolvers<AccountLoginState[]>();
const fresh = Promise.withResolvers<AccountLoginState[]>();
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
]);
const ctx = makeManager({
processes: [4242],
livePipes: [4242],
detectAccounts,
});
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
detectAccounts.mockImplementationOnce(() => stale.promise);
detectAccounts.mockImplementationOnce(() => fresh.promise);
const refreshing = ctx.manager.refreshProcess(4242);
await flush();
const baseline = detectAccounts.mock.calls.length;
client.emit('loginState', { loggedIn: true, uin: '10002', uinNumber: 10002n });
const snapshot = ctx.manager.listProcesses();
await flush();
ctx.onSessionsChanged.mockClear();
stale.resolve([{ uin: '10003', uid: 'u_c', nickname: 'Stale', pid: 4242 }]);
await flush();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect(detectAccounts).toHaveBeenCalledTimes(baseline + 1);
fresh.resolve([{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 4242 }]);
expect((await snapshot)[0]!.account).toEqual({ uin: '10002', nickname: 'Bob' });
expect((await refreshing).account).toEqual({ uin: '10002', nickname: 'Bob' });
} finally {
stale.resolve([]);
fresh.resolve([]);
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('shares one account probe between watcher ticks and concurrent snapshots', async () => {
const probe = Promise.withResolvers<AccountLoginState[]>();
const detectAccounts = vi.fn(() => probe.promise);
const ctx = makeManager({ processes: [4242], detectAccounts });
try {
await ctx.pipeWatcher.start();
const first = ctx.manager.listProcesses();
const second = ctx.manager.listProcesses();
await flush();
expect(detectAccounts).toHaveBeenCalledTimes(1);
probe.resolve([{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 }]);
const snapshots = await Promise.all([first, second]);
expect(snapshots.map((list) => list[0]!.account)).toEqual([
{ uin: '10001', nickname: 'Alice' },
{ uin: '10001', nickname: 'Alice' },
]);
} finally {
probe.resolve([]);
ctx.manager.dispose();
}
});
it.each(['pipe-down', 'client-close', 'logout'] as const)(
're-probes after %s and observes later login data changes',
async (event) => {
let accounts: AccountLoginState[] = [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
];
const detectAccounts = vi.fn(async () => accounts);
const ctx = makeManager({
processes: [4242],
livePipes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
accounts = [];
if (event === 'pipe-down') {
ctx.setLivePipes([]);
await ctx.pipeWatcher.tickNow();
} else if (event === 'client-close') {
client.emit('close');
} else {
client.emit('loginState', { loggedIn: false, uin: '0', uinNumber: 0n });
}
await flush();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
accounts = [{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 4242 }];
now.mockReturnValue(Date.now() + 6000);
ctx.notifyAccountChange();
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses())[0]!.account?.uin).toBe('10002');
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
},
);
it('re-probes when another process appears and when it exits', async () => {
let nickname = 'Alice';
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname, pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
]);
const ctx = makeManager({ processes: [4242], livePipes: [4242], detectAccounts });
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
nickname = 'Updated';
ctx.setProcesses([4242, 5151]);
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses()).map((info) => info.account)).toEqual([
{ uin: '10001', nickname: 'Updated' },
{ uin: '10002', nickname: 'Bob' },
]);
nickname = 'Final';
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
const list = await ctx.manager.listProcesses();
expect(list).toHaveLength(1);
expect(list[0]!.account?.nickname).toBe('Final');
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('keeps all process labels when an operation reads just one process', async () => {
const accounts: AccountLoginState[] = [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
];
const detectAccounts = vi.fn(async (pids: readonly number[]) =>
accounts.filter((account) => pids.includes(account.pid!)),
);
const ctx = makeManager({ processes: [4242, 5151], detectAccounts });
try {
await ctx.manager.listProcesses();
expect((await ctx.manager.refreshProcess(4242)).account?.uin).toBe('10001');
const baseline = detectAccounts.mock.calls.length;
ctx.onSessionsChanged.mockClear();
await ctx.pipeWatcher.tickNow();
await flush();
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect((await ctx.manager.listProcesses())[1]!.account?.uin).toBe('10002');
} finally {
ctx.manager.dispose();
}
});
it('does not publish a late account result for an exited process', async () => {
const probe = Promise.withResolvers<AccountLoginState[]>();
const ctx = makeManager({ processes: [4242], detectAccounts: () => probe.promise });
try {
await ctx.pipeWatcher.start();
const snapshot = ctx.manager.listProcesses();
await flush();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
ctx.onSessionsChanged.mockClear();
probe.resolve([{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 }]);
await flush();
expect(await snapshot).toEqual([]);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([]);
} finally {
probe.resolve([]);
ctx.manager.dispose();
}
});
// Login database changes can reveal login before the hook is loaded.
it('publishes when a process gains an account label', async () => {
let accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: null }];
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => accounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
// QQ finished signing in: the account's nt_msg.db is now held by 4242.
accounts = [{ uin: '1', uid: 'u1', nickname: 'A', pid: 4242 }];
ctx.notifyAccountChange();
await flush();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toEqual({ uin: '1', nickname: 'A' });
ctx.manager.dispose();
});
it('stays silent while the labels are unchanged', async () => {
const accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: 4242 }];
const detectAccounts = vi.fn(async () => accounts);
const ctx = makeManager({
processes: [4242],
detectAccounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
ctx.notifyAccountChange();
await flush();
await flush();
// Detection did run again — it just had nothing new to report.
expect(detectAccounts.mock.calls.length).toBeGreaterThan(1);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('ignores accounts that are not signed in on any process', async () => {
let accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: null }];
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => accounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
// Only the cached-account set changed (another account signed in on
// another machine/user); no process label moved.
accounts = [
{ uin: '1', uid: 'u1', nickname: 'A', pid: null },
{ uin: '2', uid: 'u2', nickname: 'B', pid: null },
];
ctx.notifyAccountChange();
await flush();
await flush();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('does not poll unlabelled, unloaded processes even after the old interval expires', async () => {
const detectAccounts = vi.fn(async () => [] as AccountLoginState[]);
const ctx = makeManager({
processes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.manager.listProcesses();
const baseline = detectAccounts.mock.calls.length;
const startedAt = Date.now();
for (let tick = 1; tick <= 10; tick++) {
now.mockReturnValue(startedAt + tick * 6000);
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
}
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('does not re-probe a mixed set of online and unloaded processes', async () => {
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
]);
const ctx = makeManager({
processes: [4242, 5151],
livePipes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
expect((await ctx.manager.listProcesses()).map((info) => info.status)).toEqual([
'online',
'available',
]);
const baseline = detectAccounts.mock.calls.length;
now.mockReturnValue(Date.now() + 60_000);
await ctx.pipeWatcher.tickNow();
await ctx.manager.listProcesses();
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('releases file subscriptions when the process set changes and the manager stops', async () => {
const ctx = makeManager({ processes: [4242] });
try {
await ctx.pipeWatcher.start();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([4242], expect.any(Function));
const firstStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.setProcesses([4242, 5151]);
await ctx.pipeWatcher.tickNow();
expect(firstStop).toHaveBeenCalledOnce();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([4242, 5151], expect.any(Function));
const secondStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.setProcesses([5151]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(secondStop).toHaveBeenCalledOnce();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([5151], expect.any(Function));
const finalStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.manager.dispose();
expect(finalStop).toHaveBeenCalledOnce();
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('keeps working when detection fails, and reports the failure once', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await flush();
await flush();
// Detection failing is not a label change, so nothing is published.
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
ctx.manager.dispose();
});
});
describe('HookManager receive-health wiring (#233)', () => {
it('forwards stale and recovered receive health for the owning PID', async () => {
const ctx = makeManager({ processes: [4242], livePipes: [4242] });
await ctx.pipeWatcher.start();
await ctx.manager.loadProcess(4242);
await ctx.manager.refreshProcess(4242);
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
try {
client.emit('packet', {
seq: 1,
error: 0,
cmd: 'trpc.qq_new_tech.status_svc.StatusService.SsoHeartBeat',
uin: '10001',
body: Buffer.alloc(0),
});
vi.advanceTimersByTime(105_000);
expect(ctx.bridgeManager.onPidReceiveHealthChanged).toHaveBeenCalledWith(4242, false);
client.emit('packet', {
seq: 2,
error: 0,
cmd: 'trpc.msg.olpush.OlPushService.MsgPush',
uin: '10001',
body: Buffer.alloc(0),
});
expect(ctx.bridgeManager.onPidReceiveHealthChanged).toHaveBeenLastCalledWith(
4242,
true,
);
} finally {
vi.useRealTimers();
ctx.manager.dispose();
}
});
});
+514
View File
@@ -0,0 +1,514 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { HookManager, shouldAutoLoadPid } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookInjectResult, ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { AccountLoginState } from '../src/qq-account';
import type { QqHookClient } from '../src/qq-hook-client';
import {
createLogger,
getLogLevel,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
const previousLogLevel = getLogLevel();
function managerTrace(entries: LogEntry[]): LogEntry[] {
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Hook');
}
function makeManager(opts: {
autoLoadOnDiscovery?: boolean;
processes?: number[];
onSessionsChanged?: () => void;
inject?: (pid: number) => Promise<HookInjectResult>;
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
}) {
let pids = opts.processes ?? [];
const live = new Set<number>();
const inject = vi.fn(
opts.inject ??
(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
);
const unload = vi.fn(async () => {});
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable internal timer; tests drive ticks manually
});
// FakeClient — never connected; just needs to satisfy EventEmitter +
// a minimal subset of QqHookClient for HookSession.tearDownClient().
const makeClient = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
throw new Error('test: never really connect');
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: { inject, unload },
makeClient,
autoLoadOnDiscovery: opts.autoLoadOnDiscovery,
onSessionsChanged: opts.onSessionsChanged,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
// Detection reads QQ's own data directories; tests stay off the host.
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts: () => () => {},
});
return {
manager,
inject,
pipeWatcher,
setProcesses: (next: number[]) => {
pids = next;
},
};
}
afterEach(() => {
setLogLevel(previousLogLevel);
});
describe('HookManager runtime TRACE', () => {
it('records discovery and process-gone facts while unchanged ticks stay silent', async () => {
const entries: LogEntry[] = [];
const sessionsChanged = vi.fn();
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: false,
processes: [],
onSessionsChanged: sessionsChanged,
});
try {
await ctx.pipeWatcher.start();
const baseline = managerTrace(entries).length;
await ctx.pipeWatcher.tickNow();
await ctx.pipeWatcher.tickNow();
expect(managerTrace(entries)).toHaveLength(baseline);
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
const facts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_manager_fact '),
);
expect(facts.map((entry) => entry.message)).toEqual([
expect.stringContaining('hook_manager_fact event=process_discovered pid=4242'),
'hook_manager_fact event=process_gone pid=4242 tracked=true',
]);
expect(facts.every((entry) => entry.req !== undefined)).toBe(true);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('records bounded transient retries and recovery as separate attempts', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject
.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
})
.mockImplementationOnce(() => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
}));
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=failed reason=retry_pending error=.*elapsedMs=\d+$/,
),
'hook_autoload_start pid=4242 attempt=2 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=2 outcome=completed reason=recovered state=.*elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
expect(attempts[2]!.req).toBe(attempts[3]!.req);
expect(attempts[0]!.req).not.toBe(attempts[2]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('ends an in-flight auto-load as dropped when a manual load supersedes it', async () => {
const entries: LogEntry[] = [];
let resolveInjection!: (result: HookInjectResult) => void;
const injection = new Promise<HookInjectResult>((resolve) => {
resolveInjection = resolve;
});
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: true,
processes: [4242],
inject: () => injection,
});
try {
await ctx.pipeWatcher.start();
await flush();
const manualLoad = ctx.manager.loadProcess(4242);
resolveInjection({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await manualLoad;
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=dropped reason=superseded elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it.each([
{
name: 'permanent failure',
error: 'ptrace denied',
ticks: 1,
attempts: 1,
reason: 'permanent_failure',
},
{
name: 'retry exhaustion',
error: 'target process does not map /lib/libc.so.6 while resolving mmap',
ticks: 4,
attempts: 3,
reason: 'retry_exhausted',
},
])('records $name terminal', async ({ error, ticks, attempts, reason }) => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(error);
});
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < ticks; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
const starts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_start '),
);
const terminals = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_terminal '),
);
expect(starts).toHaveLength(attempts);
expect(terminals).toHaveLength(attempts);
expect(terminals.at(-1)!.message).toContain(`reason=${reason}`);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
});
describe('HookManager.autoLoadOnDiscovery', () => {
it('does NOT inject on process-discovered when flag is off', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: false, processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
expect(ctx.inject).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('injects every newly-discovered PID when flag is on', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
await ctx.pipeWatcher.start();
// session.load() is queued via a per-session promise chain; flush twice
// so the auto-load runs to completion before we assert.
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
expect(ctx.inject).toHaveBeenCalledWith(4242);
// A second PID appearing mid-flight should also trigger an inject.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(9999);
ctx.manager.dispose();
});
it('swallows inject errors so the watcher keeps running', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
// Second PID should still be auto-loaded — the failure on 4242 must
// not leak out of the listener and break the watcher's emit loop.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
ctx.manager.dispose();
});
it('retries a transient libc mapping failure on the next watcher tick', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(4242);
ctx.manager.dispose();
});
it('does not retry permanent auto-load errors', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
ctx.manager.dispose();
});
it('stops retrying a transient libc mapping failure after three attempts', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < 4; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
expect(ctx.inject).toHaveBeenCalledTimes(3);
ctx.manager.dispose();
});
});
// `shouldAutoLoadPid` reads /proc/<pid>/cmdline directly, so we point
// it at a temporary directory that mimics the procfs layout. This lets
// the tests run on macOS / CI without a live Linux QQ process.
describe('shouldAutoLoadPid', () => {
let tmpProc: string;
const originalReadFileSync = fs.readFileSync;
const log = createLogger('test');
const originalPlatform = process.platform;
const originalAutoLoadAll = process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
beforeEach(() => {
tmpProc = fs.mkdtempSync(path.join(os.tmpdir(), 'hookmgr-proc-'));
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
// Redirect readFileSync('/proc/<pid>/cmdline') to the tmp dir.
(fs.readFileSync as unknown as typeof fs.readFileSync) = ((
p: string,
...rest: unknown[]
) => {
const match = /^\/proc\/(\d+)\/cmdline$/.exec(p);
if (match) {
return originalReadFileSync(
path.join(tmpProc, match[1], 'cmdline'),
...(rest as [BufferEncoding]),
);
}
return originalReadFileSync(p, ...(rest as [BufferEncoding]));
}) as typeof fs.readFileSync;
});
afterEach(() => {
(fs.readFileSync as unknown as typeof fs.readFileSync) = originalReadFileSync;
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
if (originalAutoLoadAll === undefined) {
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
} else {
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = originalAutoLoadAll;
}
fs.rmSync(tmpProc, { recursive: true, force: true });
});
function writeCmdline(pid: number, args: string[]): void {
const dir = path.join(tmpProc, String(pid));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'cmdline'), args.join('\0') + '\0');
}
it('allows the QQ main process (no --type=)', () => {
writeCmdline(50, ['qq', '--no-sandbox']);
expect(shouldAutoLoadPid(50, log)).toBe(true);
});
it('rejects Electron zygotes', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote', '--no-zygote-sandbox', '--no-sandbox']);
expect(shouldAutoLoadPid(59, log)).toBe(false);
});
it('rejects renderer/gpu/utility children', () => {
writeCmdline(70, ['/opt/QQ/qq', '--type=renderer']);
writeCmdline(71, ['/opt/QQ/qq', '--type=gpu-process']);
writeCmdline(72, [
'/opt/QQ/qq',
'--type=utility',
'--utility-sub-type=network.mojom.NetworkService',
]);
expect(shouldAutoLoadPid(70, log)).toBe(false);
expect(shouldAutoLoadPid(71, log)).toBe(false);
expect(shouldAutoLoadPid(72, log)).toBe(false);
});
it('allows everything if SNOWLUMA_HOOK_AUTOLOAD_ALL=1 (escape hatch)', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote']);
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = '1';
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows on non-linux platforms (filter is a Linux-only workaround)', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
// No cmdline file needed — we return true before touching procfs.
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows when /proc is unreadable (dead PID, permission issue)', () => {
expect(shouldAutoLoadPid(99999, log)).toBe(true);
});
});
describe('HookManager — signed-in account labelling', () => {
it('labels each process with the account detected on it', async () => {
const ctx = makeManager({
processes: [4242, 5151],
detectAccounts: async () => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: null },
],
});
try {
const list = await ctx.manager.listProcesses();
expect(list.map((info) => [info.pid, info.account])).toEqual([
[4242, { uin: '10001', nickname: 'Alice' }],
[5151, null],
]);
} finally {
ctx.manager.dispose();
}
});
it('leaves processes unlabelled when detection fails', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
try {
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toBeNull();
} finally {
ctx.manager.dispose();
}
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { HookPacketClient } from '../src/hook-packet-client';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
type QqHookClient,
} from '../src/qq-hook-client';
function fakeClient(send: QqHookClient['send']): QqHookClient {
return {
isLoggedIn: true,
send,
} as unknown as QqHookClient;
}
describe('HookPacketClient outbound health', () => {
it('reports native connection loss once and recovers after a successful request', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
1,
),
)
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
2,
),
)
.mockResolvedValueOnce({
requestId: 3,
error: 0,
message: '',
body: Buffer.from('ok'),
});
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
await sender.sendPacket('Test.Command', Buffer.alloc(0));
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: true,
gotResponse: true,
});
expect(healthChanged.mock.calls).toEqual([[false], [true]]);
});
it('does not classify ordinary request rejection as connection loss', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValue(new HookPipeRequestError('This request is not valid.', -38, 1));
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
expect(healthChanged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
// E2E for Docker "QQ never logs in + pipe keeps reconnecting" scenario.
//
// Drives the real PipeWatcher + HookManager through repeated same-PID
// pipe up/down cycles via tickNow() and asserts the BridgeManager mock
// is never touched (no login → no Bridge / OneBotInstance / Identity).
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookManager } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
async connectAll(_opts: { recv: boolean }) {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { loggedIn: false, uin: '0', uinNumber: 0n };
}
close() {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
describe('HookManager — Docker reconnect-without-login leak guard', () => {
it('after N pipe up/down cycles: no Bridge calls, no client accumulation', async () => {
const PID = 61;
const liveSet = new Set<number>([PID]);
const clients: FakeClient[] = [];
const pipeWatcher = new PipeWatcher({
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
listLivePipes: async () => new Set(liveSet),
intervalMs: 60_000, // manual ticks only
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
autoLoadOnDiscovery: true,
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
detectAccounts: async () => [],
watchAccounts: () => () => {},
});
// Initial discovery + first connect.
await pipeWatcher.start();
await flush();
await flush();
// Drive 50 pipe up/down cycles with the QQ process staying alive but
// the pipe (= QQ control socket) bouncing every "tick".
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
liveSet.delete(PID);
await pipeWatcher.tickNow();
await flush();
liveSet.add(PID);
await pipeWatcher.tickNow();
await flush();
}
// The "QQ never logs in" hypothesis: BridgeManager should never have
// been told about a login or a disconnect.
expect(bridgeManager.onHookLogin).not.toHaveBeenCalled();
expect(bridgeManager.onPidDisconnected).not.toHaveBeenCalled();
expect(bridgeManager.onPacket).not.toHaveBeenCalled();
// Hook layer should hold at most one live client.
const alive = clients.filter((c) => !c.isClosed);
expect(
alive.length,
`live clients after ${CYCLES} cycles: ${alive.length}`,
).toBeLessThanOrEqual(1);
// Total clients minted = roughly one per up cycle. Sanity bound:
// we must have actually exercised the reconnect path.
expect(clients.length).toBeGreaterThan(1);
manager.dispose();
});
});
@@ -0,0 +1,146 @@
// Regression test for the Docker "QQ-never-logs-in + pipe keeps reconnecting" leak hypothesis.
//
// User-reported pattern (Docker, idle login screen):
// 02:41:23 [Hook] pipe connected: PID=61
// 04:37:55 [Hook] pipe connected: PID=61 (same PID, ~2h later)
// 04:40:44 [Hook] pipe connected: PID=61
// 04:43:48 [Hook] pipe connected: PID=61 → eventual OOM-kill
//
// Hypothesis (H2): every onPipeDown → onPipeUp cycle leaks the prior
// QqHookClient (listeners or socket-ref keeping it pinned in memory),
// so repeated reconnect drives unbounded growth.
//
// This test drives N up/down cycles against a HookSession with a FakeClient
// factory and asserts:
// - at most one client is alive at any point
// - every prior client's EventEmitter listener count drops to 0 after teardown
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookSession } from '../src/hook-session';
import type { ManualMapHandle } from '../src/injector';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
private loginState = { loggedIn: false, uin: '0', uinNumber: 0n };
async connectAll(_opts: { recv: boolean }): Promise<void> {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { ...this.loginState };
}
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
function totalListenerCount(emitter: EventEmitter): number {
return emitter.eventNames().reduce((n, evt) => n + emitter.listenerCount(evt), 0);
}
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('HookSession — pipe reconnect with QQ never logging in', () => {
it('does not accumulate live FakeClient instances across N pipe up/down cycles', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
const alive = clients.filter((c) => !c.isClosed);
expect.soft(clients.length).toBeGreaterThan(1);
expect(
alive.length,
`expected ≤1 live client after ${CYCLES} reconnects, got ${alive.length}`,
).toBeLessThanOrEqual(1);
});
it('drops all listeners from each torn-down client', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 20;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
// Every client except the current one should have 0 listeners attached.
// (HookSession.tearDownClient does client.removeAllListeners().)
for (let i = 0; i < clients.length - 1; i++) {
const lc = totalListenerCount(clients[i]);
expect(
lc,
`client #${i} of ${clients.length} still has ${lc} listeners after teardown`,
).toBe(0);
}
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { resolveHookRuntimeDir } from '../src/hook-runtime-dir';
function procReader(files: Record<string, string>) {
return (file: string): string => {
const value = files[file];
if (value === undefined) {
throw new Error(`missing fixture: ${file}`);
}
return value;
};
}
describe('resolveHookRuntimeDir — Linux target process ownership', () => {
it('uses the QQ process runtime directory instead of the supervisor user directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0XDG_RUNTIME_DIR=/run/user/1000\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/run/user/1000');
});
it('falls back to the QQ process uid when its environment has no runtime directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/tmp/snowluma-1000');
});
it('fails visibly when target ownership cannot be determined', () => {
expect(() =>
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw new Error('permission denied');
},
}),
).toThrow(/cannot resolve hook runtime directory.*permission denied/);
});
it('uses the current convention when the target disappears during discovery', () => {
const gone = Object.assign(new Error('process disappeared'), { code: 'ENOENT' });
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw gone;
},
}),
).toBe('/run/user/0');
});
it('honours an explicit runtime directory inherited by the QQ process', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: {},
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'SNOWLUMA_HOOK_RUNTIME_DIR=/srv/snowluma-hook\0',
'/proc/46301/status': 'Uid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/srv/snowluma-hook');
});
});
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { statusFor, type HookFlags } from '../src/hook-status';
import type { HookProcessStatus } from '../src/types';
const F = (o: Partial<HookFlags>): HookFlags => ({
injected: false,
connected: false,
loggedIn: false,
wasLoggedIn: false,
...o,
});
describe('statusFor — settled HookProcessStatus derivation', () => {
it('not injected → available (regardless of other flags)', () => {
expect(statusFor(F({ injected: false }))).toBe('available');
// !injected dominates: stale connected/loggedIn bits must not leak through.
expect(
statusFor(F({ injected: false, connected: true, loggedIn: true, wasLoggedIn: true })),
).toBe('available');
});
it('injected, connected, loggedIn → online', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: true }))).toBe('online');
});
it('injected, connected, not loggedIn → loaded', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: false }))).toBe('loaded');
});
it('injected, not connected, had logged in → disconnected', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: true }))).toBe(
'disconnected',
);
});
it('injected, not connected, never logged in → connecting', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: false }))).toBe(
'connecting',
);
});
it('disconnected vs connecting differ ONLY by wasLoggedIn (same injected+!connected)', () => {
const base = { injected: true, connected: false, loggedIn: false };
expect(statusFor(F({ ...base, wasLoggedIn: true }))).toBe('disconnected');
expect(statusFor(F({ ...base, wasLoggedIn: false }))).toBe('connecting');
});
it('covers exactly the five settled states across the flag space', () => {
const seen = new Set<HookProcessStatus>();
for (const injected of [false, true]) {
for (const connected of [false, true]) {
for (const loggedIn of [false, true]) {
for (const wasLoggedIn of [false, true]) {
seen.add(statusFor({ injected, connected, loggedIn, wasLoggedIn }));
}
}
}
}
expect([...seen].sort()).toEqual(
['available', 'connecting', 'disconnected', 'loaded', 'online'].sort(),
);
// The two out-of-band states are never produced by the pure derivation.
expect(seen.has('loading')).toBe(false);
expect(seen.has('error')).toBe(false);
});
});
+426
View File
@@ -0,0 +1,426 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { existsSyncMock, listPidsMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn<(target: string) => boolean>(() => false),
listPidsMock: vi.fn(() => new Set<number>()),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: (target: Parameters<typeof actual.existsSync>[0]) =>
existsSyncMock(String(target)),
};
});
vi.mock('../src/qq-hook-client', () => ({
listSnowlumaPipePidsSync: listPidsMock,
}));
const NATIVE_PATH_DIR = path.dirname(
fileURLToPath(new URL('../../common/src/native-path.ts', import.meta.url)),
);
const ORIGINAL_PLATFORM = process.platform;
const ORIGINAL_ARCH = process.arch;
const HANDLE = {
base: 0x7fff0000n,
entry: 0x7fff1234n,
exceptionTable: 0x7fff5678n,
size: 8192,
};
function setPlatform(platform: NodeJS.Platform, arch: string): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
Object.defineProperty(process, 'arch', { value: arch, configurable: true });
}
function restorePlatform(): void {
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
Object.defineProperty(process, 'arch', { value: ORIGINAL_ARCH, configurable: true });
}
function nativeSearchDirs(): string[] {
return [
path.resolve(NATIVE_PATH_DIR, 'native'),
path.resolve(NATIVE_PATH_DIR, '..', '..', 'runtime', 'native'),
];
}
function nativeCandidate(fileName: string, dirIndex: number): string {
return path.join(nativeSearchDirs()[dirIndex]!, fileName);
}
function allowExisting(paths: readonly string[]): void {
const allowed = new Set(paths);
existsSyncMock.mockImplementation((target) => allowed.has(target));
}
function fakeAddon(
overrides: {
getAllMainProcessAsync?: number[];
loadModuleManualAsync?: (pid: number, dylibPath: string) => Promise<typeof HANDLE>;
unloadModuleManualAsync?: (pid: number, handle: typeof HANDLE) => Promise<void>;
} = {},
) {
return {
getAllMainProcessAsync: vi.fn(async () => overrides.getAllMainProcessAsync ?? []),
loadModuleManualAsync: vi.fn(overrides.loadModuleManualAsync ?? (async () => HANDLE)),
unloadModuleManualAsync: vi.fn(
overrides.unloadModuleManualAsync ?? (async () => undefined),
),
};
}
function installDlopen(addon: object) {
return vi.spyOn(process, 'dlopen').mockImplementation((mod) => {
(mod as { exports: object }).exports = addon;
});
}
async function loadInjector() {
return import('../src/injector');
}
beforeEach(() => {
existsSyncMock.mockReset();
existsSyncMock.mockReturnValue(false);
listPidsMock.mockReset();
listPidsMock.mockReturnValue(new Set());
vi.resetModules();
});
afterEach(() => {
restorePlatform();
vi.restoreAllMocks();
});
describe('resolveHookNativePath', () => {
it('returns null after probing every search directory', async () => {
setPlatform('linux', 'x64');
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return false;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
expect(probed).toEqual([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.node', 1),
]);
});
it('returns the first existing candidate and stops scanning', async () => {
setPlatform('linux', 'x64');
const winner = nativeCandidate('snowluma-linux-x64.node', 0);
const later = nativeCandidate('snowluma-linux-x64.node', 1);
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return target === winner || target === later;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
expect(probed).toEqual([winner]);
});
it('skips missing earlier directories for the injectable .so', async () => {
setPlatform('linux', 'arm64');
const winner = nativeCandidate('snowluma-linux-arm64.so', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('so')).toBe(winner);
});
it('resolves snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const winner = nativeCandidate('snowluma-win32-x64.dll', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('dll')).toBe(winner);
});
it('uses the generic platform-arch name on win32 arm64', async () => {
setPlatform('win32', 'arm64');
const winner = nativeCandidate('snowluma-win32-arm64.node', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
});
it('does not load an unrelated native addon from the working directory', async () => {
setPlatform('darwin', 'arm64');
vi.spyOn(process, 'cwd').mockReturnValue('/tmp/sl-injector-cwd');
const winner = path.join(
'/tmp/sl-injector-cwd',
'dist',
'native',
'snowluma-darwin-arm64.node',
);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
});
});
describe('getNativeHookAddon / getNativeHookLoadError', () => {
it('starts with a null load error before any probe', async () => {
const { getNativeHookLoadError } = await loadInjector();
expect(getNativeHookLoadError()).toBeNull();
});
it('returns null and records a missing-binary error', async () => {
setPlatform('linux', 'x64');
const dlopen = vi.spyOn(process, 'dlopen');
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
expect(dlopen).not.toHaveBeenCalled();
});
it('dlopens the resolved .node once and caches the addon', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
const addon = fakeAddon();
const dlopen = installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
const first = getNativeHookAddon();
const second = getNativeHookAddon();
expect(first).toBe(addon);
expect(second).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
expect(dlopen).toHaveBeenCalledTimes(1);
expect(dlopen.mock.calls[0]![1]).toBe(nodePath);
});
it('stores Error.message when process.dlopen throws', async () => {
setPlatform('darwin', 'arm64');
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw new Error('not a valid Node addon');
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('not a valid Node addon');
});
it('stringifies a non-Error dlopen failure', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw 42;
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('42');
});
it('retries after a failed load when a binary later appears', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 1);
const addon = fakeAddon();
installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
allowExisting([nodePath]);
expect(getNativeHookAddon()).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
});
});
describe('listHookProcesses', () => {
it('rejects an obsolete addon without calling its synchronous enumerator', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
const getAllMainProcess = vi.fn(() => [9]);
installDlopen({ getAllMainProcess });
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow('getAllMainProcessAsync');
expect(getAllMainProcess).not.toHaveBeenCalled();
});
it('on darwin maps sorted pipe pids without opening the native addon', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set([42, 7, 15]));
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
const dlopen = installDlopen(fakeAddon({ getAllMainProcessAsync: [99] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 7, name: 'qq', path: '' },
{ pid: 15, name: 'qq', path: '' },
{ pid: 42, name: 'qq', path: '' },
]);
expect(listPidsMock).toHaveBeenCalledTimes(1);
expect(dlopen).not.toHaveBeenCalled();
});
it('on darwin returns an empty list when no control sockets exist', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set());
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([]);
});
it('reports a missing addon instead of an empty process set', async () => {
setPlatform('linux', 'x64');
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('on linux dedupes, drops non-positive/non-integer pids, and sorts', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
installDlopen(
fakeAddon({
getAllMainProcessAsync: [8, 0, 8, -1, 3.5, 2, Number.NaN, 2],
}),
);
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 2, name: 'qq', path: '' },
{ pid: 8, name: 'qq', path: '' },
]);
});
it('on win32 names processes QQ.exe', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
installDlopen(fakeAddon({ getAllMainProcessAsync: [9] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([{ pid: 9, name: 'QQ.exe', path: '' }]);
});
});
describe('injectHookProcess', () => {
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('throws when the addon exists but the injectable is missing', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(fakeAddon());
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow('No hook so found for linux-x64');
});
it('awaits native asynchronous injection', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
const soPath = nativeCandidate('snowluma-linux-x64.so', 0);
allowExisting([nodePath, soPath]);
const addon = fakeAddon({
loadModuleManualAsync: () => Promise.resolve(HANDLE),
});
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(4242, soPath);
});
it('injects snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const nodePath = nativeCandidate('snowluma-win32-x64.node', 0);
const dllPath = nativeCandidate('snowluma-win32-x64.dll', 0);
allowExisting([nodePath, dllPath]);
const addon = fakeAddon({ loadModuleManualAsync: async () => HANDLE });
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(5150)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(5150, dllPath);
});
it('propagates a rejected loadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.so', 0),
]);
installDlopen(
fakeAddon({
loadModuleManualAsync: () => Promise.reject(new Error('map failed')),
}),
);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(7)).rejects.toThrow('map failed');
});
});
describe('unloadHookProcess', () => {
it('propagates native unload rejection', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(
fakeAddon({
unloadModuleManualAsync: async () => {
throw new Error('unload failed');
},
}),
);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow('unload failed');
});
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('forwards pid and handle to unloadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
const addon = fakeAddon();
installDlopen(addon);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).resolves.toBeUndefined();
expect(addon.unloadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.unloadModuleManualAsync).toHaveBeenCalledWith(4242, HANDLE);
});
});
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it } from 'vitest';
import { QqHookClient } from '../src/qq-hook-client';
import { packetTimeoutMs } from '../src/packet-timeout';
const PACKET = 'SNOWLUMA_PACKET_TIMEOUT_MS';
const ACK = 'SNOWLUMA_HOOK_ACK_TIMEOUT_MS';
afterEach(() => {
delete process.env[PACKET];
delete process.env[ACK];
});
describe('packet and hook ack timeouts', () => {
it('keeps the 15 second packet default', () => {
expect(packetTimeoutMs({})).toBe(15_000);
});
it('reads SNOWLUMA_PACKET_TIMEOUT_MS', () => {
expect(packetTimeoutMs({ [PACKET]: '8000' })).toBe(8000);
expect(packetTimeoutMs({ [PACKET]: '0' })).toBe(15_000);
});
it('uses SNOWLUMA_HOOK_ACK_TIMEOUT_MS when the client is not given one', () => {
process.env[ACK] = '1200';
const client = new QqHookClient(1, { runtimeDir: '/tmp' });
expect(client.defaultAckTimeoutMs).toBe(1200);
const explicit = new QqHookClient(1, { runtimeDir: '/tmp', ackTimeoutMs: 50 });
expect(explicit.defaultAckTimeoutMs).toBe(50);
});
});
+476
View File
@@ -0,0 +1,476 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createLogger } from '@snowluma/common/logger';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookProcessBaseInfo } from '../src/injector';
function setupWatcher(initial: { processes: HookProcessBaseInfo[]; live: Set<number> }) {
let processes = initial.processes;
let live = initial.live;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
// Defensively copy so the watcher can't mutate the test's reference.
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable the internal timer; tests drive ticks manually
});
return {
watcher,
setProcesses: (p: HookProcessBaseInfo[]) => {
processes = p;
},
setLive: (s: Set<number>) => {
live = s;
},
};
}
type EventRow = [string, unknown];
function captureEvents(watcher: PipeWatcher): EventRow[] {
const events: EventRow[] = [];
watcher.on('process-discovered', (info) =>
events.push(['discovered', (info as HookProcessBaseInfo).pid]),
);
watcher.on('process-gone', (pid) => events.push(['gone', pid]));
watcher.on('pipe-up', (pid) => events.push(['up', pid]));
watcher.on('pipe-down', (pid) => events.push(['down', pid]));
return events;
}
describe('PipeWatcher', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('waits for a fresh scan when another tick is already reading processes', async () => {
let release!: (processes: HookProcessBaseInfo[]) => void;
const pending = new Promise<HookProcessBaseInfo[]>((resolve) => {
release = resolve;
});
const latest = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn().mockReturnValueOnce(pending).mockResolvedValue(latest);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set([1234]),
});
const first = watcher.tickNow();
let completed = false;
const second = watcher.tickNow().then(() => {
completed = true;
});
await Promise.resolve();
expect(completed).toBe(false);
release([]);
await Promise.all([first, second]);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(watcher.isPipeLive(1234)).toBe(true);
watcher.dispose();
});
it.each(['processes', 'pipes'] as const)(
'discards a pending %s scan after stop',
async (stage) => {
let release!: () => void;
let entered!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn(async () => {
if (stage === 'processes') {
entered();
await gate;
}
return processes;
});
const listLivePipes = vi.fn(async () => {
if (stage === 'pipes') {
entered();
await gate;
}
return new Set([1234]);
});
const watcher = new PipeWatcher({ listProcesses, listLivePipes });
const events = captureEvents(watcher);
const tick = vi.fn();
watcher.on('tick', tick);
const reading = watcher.start();
await started;
const queued = watcher.tickNow();
watcher.stop();
release();
await Promise.all([reading, queued]);
await watcher.tickNow();
watcher.wake();
expect(events).toEqual([]);
expect(tick).not.toHaveBeenCalled();
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(listProcesses).toHaveBeenCalledOnce();
expect(listLivePipes).toHaveBeenCalledTimes(stage === 'pipes' ? 1 : 0);
watcher.dispose();
},
);
it('keeps a wake requested by a tick listener ahead of the regular interval', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 60_000,
});
watcher.once('tick', () => watcher.wake());
await watcher.start();
await vi.advanceTimersByTimeAsync(1);
expect(listProcesses).toHaveBeenCalledTimes(2);
watcher.dispose();
});
it('reports a scheduled tick failure and continues polling', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const log = createLogger('PipeWatcherTest');
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {});
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 250,
log,
});
try {
await watcher.start();
watcher.once('tick', () => {
throw new Error('subscriber failed');
});
await vi.advanceTimersByTimeAsync(250);
expect(warn).toHaveBeenCalledWith('poll failed: %s', 'subscriber failed');
await vi.advanceTimersByTimeAsync(250);
expect(listProcesses).toHaveBeenCalledTimes(3);
} finally {
watcher.dispose();
}
});
it('publishes the complete snapshot before notifying subscribers', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const snapshots: boolean[][] = [];
const observe = () =>
snapshots.push([ctx.watcher.isProcessAlive(1234), ctx.watcher.isPipeLive(1234)]);
ctx.watcher.on('process-discovered', observe);
ctx.watcher.on('pipe-up', observe);
ctx.watcher.on('pipe-down', observe);
ctx.watcher.on('process-gone', observe);
await ctx.watcher.tickNow();
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(snapshots).toEqual([
[true, true],
[true, true],
[false, false],
[false, false],
]);
ctx.watcher.dispose();
});
it('stops emitting the current diff when a subscriber stops the watcher', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
ctx.watcher.on('process-discovered', () => ctx.watcher.stop());
const up = vi.fn();
const tick = vi.fn();
ctx.watcher.on('pipe-up', up);
ctx.watcher.on('tick', tick);
await ctx.watcher.tickNow();
expect(up).not.toHaveBeenCalled();
expect(tick).not.toHaveBeenCalled();
ctx.watcher.dispose();
});
it('passes the current process snapshot to pipe discovery', async () => {
const processes = [
{ pid: 1234, name: 'qq', path: '/opt/QQ/qq' },
{ pid: 5678, name: 'qq', path: '/opt/QQ/qq' },
];
let observed: readonly HookProcessBaseInfo[] | undefined;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async (current) => {
observed = current;
return new Set();
},
intervalMs: 60_000,
});
await watcher.start();
expect(observed).toEqual(processes);
watcher.stop();
});
it('start() emits process-discovered + pipe-up for live PIDs on first tick', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
expect(ctx.watcher.isPipeLive(1234)).toBe(true);
expect(ctx.watcher.isProcessAlive(1234)).toBe(true);
ctx.watcher.stop();
});
it('pipe-down emitted when a previously-live pipe disappears', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([['down', 1234]]);
expect(ctx.watcher.isPipeLive(1234)).toBe(false);
ctx.watcher.stop();
});
it('process-gone + pipe-down both emitted (pipe-down first) when QQ.exe is killed with a live pipe', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
ctx.watcher.stop();
});
it('ignores live pipes whose process is not running', async () => {
const ctx = setupWatcher({
processes: [],
live: new Set([9999]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([]);
expect(ctx.watcher.isPipeLive(9999)).toBe(false);
ctx.watcher.stop();
});
it('emits pipe-up again after a pipe-down → pipe-up cycle', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([['up', 1234]]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
]);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
['up', 1234],
]);
ctx.watcher.stop();
});
it('does not double-emit on idempotent ticks (no state change)', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
await ctx.watcher.tickNow();
await ctx.watcher.tickNow();
expect(events).toEqual([]);
ctx.watcher.stop();
});
it('emits "tick" after every poll', async () => {
const ctx = setupWatcher({ processes: [], live: new Set() });
let ticks = 0;
ctx.watcher.on('tick', () => {
ticks++;
});
await ctx.watcher.start();
expect(ticks).toBe(1);
await ctx.watcher.tickNow();
expect(ticks).toBe(2);
ctx.watcher.stop();
});
it('wake() pulls the next tick forward', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
// Wait on 'tick' rather than guessing how many event-loop turns
// setTimeout(0) needs (it's clamped to ~1ms and races setImmediate).
const nextTick = new Promise<void>((resolve) => ctx.watcher.once('tick', () => resolve()));
ctx.setLive(new Set([1234]));
ctx.watcher.wake();
await nextTick;
expect(events).toEqual([['up', 1234]]);
ctx.watcher.stop();
});
it('survives a throwing listProcesses without crashing the watcher', async () => {
let processes: HookProcessBaseInfo[] = [];
let live = new Set<number>();
let shouldThrow = true;
const watcher = new PipeWatcher({
listProcesses: async () => {
if (shouldThrow) {
throw new Error('boom');
}
return processes;
},
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(false);
shouldThrow = false;
processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
live = new Set([1234]);
const events = captureEvents(watcher);
await watcher.tickNow();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
watcher.stop();
});
it('preserves live sessions when pipe discovery temporarily fails', async () => {
let processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
let failPipeDiscovery = false;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async () => {
if (failPipeDiscovery) {
throw new Error('permission denied');
}
return new Set([1234]);
},
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
failPipeDiscovery = true;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
processes = [];
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(watcher.isPipeLive(1234)).toBe(false);
watcher.stop();
});
it('UNKNOWN (null) enumeration keeps prior state — no false process-gone (issue #158)', async () => {
let result: HookProcessBaseInfo[] | null = [{ pid: 1234, name: 'qq', path: '' }];
let live = new Set<number>([1234]);
const watcher = new PipeWatcher({
listProcesses: async () => result,
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
// Enumeration goes UNKNOWN (e.g. native /proc walk timed out during a QQ
// hot-update). Prior process + pipe state MUST be preserved — no teardown.
result = null;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
// Enumeration recovers with the same set → still no churn.
result = [{ pid: 1234, name: 'qq', path: '' }];
await watcher.tickNow();
expect(events).toEqual([]);
// A REAL disappearance (empty list, not null) still fires the teardown.
result = [];
live = new Set();
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
watcher.stop();
});
});
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProcessEnumerator } from '../src/process-enumerator';
import type { HookProcessBaseInfo } from '../src/injector';
const QQ = (pid: number): HookProcessBaseInfo => ({ pid, name: 'qq', path: '' });
function deferred() {
let resolve!: (processes: HookProcessBaseInfo[]) => void;
let reject!: (error: Error) => void;
const promise = new Promise<HookProcessBaseInfo[]>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('process enumeration', () => {
it('shares one native request between concurrent callers', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const first = enumerator.enumerate();
expect(enumerator.enumerate()).toBe(first);
request.resolve([QQ(1), QQ(2)]);
expect(await first).toEqual([QQ(1), QQ(2)]);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
enumerator.dispose();
});
it('keeps timed-out native work in flight until it actually finishes', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise).mockResolvedValueOnce([QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
expect(await enumerator.enumerate()).toEqual([QQ(1)]);
const stalled = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await stalled).toBeNull();
for (let i = 0; i < 10; i++) {
expect(await enumerator.enumerate()).toBeNull();
}
expect(listProcesses).toHaveBeenCalledTimes(2);
request.resolve([QQ(2)]);
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(3)]);
expect(await enumerator.enumerate()).toEqual([QQ(3)]);
expect(await stalled).toBeNull();
enumerator.dispose();
});
it.each(['throw', 'reject'] as const)(
'keeps a %s unknown and allows a fresh request',
async (kind) => {
const listProcesses = vi.fn<() => Promise<HookProcessBaseInfo[]>>(() => {
if (kind === 'throw') {
throw new Error('process table unavailable');
}
return Promise.reject(new Error('process table unavailable'));
});
const enumerator = createProcessEnumerator({ listProcesses });
expect(await enumerator.enumerate()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
listProcesses.mockResolvedValueOnce([]);
expect(await enumerator.enumerate()).toEqual([]);
enumerator.dispose();
},
);
it('handles a rejection after timeout and recovers on the next poll', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
const pending = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await pending).toBeNull();
request.reject(new Error('late failure'));
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(4)]);
expect(await enumerator.enumerate()).toEqual([QQ(4)]);
enumerator.dispose();
});
it('settles disposal immediately without accepting late native results', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
await Promise.resolve();
enumerator.dispose();
enumerator.dispose();
expect(await pending).toBeNull();
expect(vi.getTimerCount()).toBe(0);
request.resolve([QQ(1)]);
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).toHaveBeenCalledTimes(1);
});
it('never calls the lister after disposal', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
enumerator.dispose();
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
it('does not start queued native work when disposed in the same tick', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
enumerator.dispose();
expect(await pending).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import { isQqProcessName, getDatabaseHolders } from '../src/qq-account/database-holders';
const native = vi.hoisted(() => ({ addon: vi.fn<() => unknown>(() => null) }));
vi.mock('../src/injector', () => ({ getNativeHookAddon: native.addon }));
const IS_LINUX = process.platform === 'linux';
const ORIGINAL_PLATFORM = process.platform;
/** Run `getDatabaseHolders` as if the host were Windows, with a stubbed addon. */
async function asWindows<T>(addon: unknown, run: () => Promise<T>): Promise<T> {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
native.addon.mockReturnValue(addon);
try {
return await run();
} finally {
Object.defineProperty(process, 'platform', {
value: ORIGINAL_PLATFORM,
configurable: true,
});
}
}
afterEach(() => {
native.addon.mockReset();
native.addon.mockReturnValue(null);
});
describe('database holder probe', () => {
it.runIf(IS_LINUX)('reports nothing for a file that is not there', async () => {
const missing = path.join(os.tmpdir(), `snowluma-absent-${process.pid}.db`);
expect(await getDatabaseHolders(missing)).toEqual([]);
});
it.runIf(IS_LINUX)('finds the process holding a database', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-lock-'));
const file = path.join(directory, 'nt_msg.db');
const db = new SqliteDatabase(file);
try {
// A rollback-journal write transaction takes a POSIX lock on the
// database file itself, which is exactly what a signed-in client
// holds on the account's nt_msg.db.
await db.exec('PRAGMA journal_mode = DELETE');
await db.exec('CREATE TABLE t(value TEXT)');
await db.exec('BEGIN IMMEDIATE');
const holders = await getDatabaseHolders(file);
const self = holders.find((holder) => holder.pid === process.pid);
expect(self).toBeDefined();
// The holder is named from /proc/<pid>/comm, and that name is what
// keeps a holder that is not QQ from being mistaken for one.
expect(self!.name.length).toBeGreaterThan(0);
expect(isQqProcessName(self!.name)).toBe(false);
await db.exec('ROLLBACK');
expect(
(await getDatabaseHolders(file)).some((holder) => holder.pid === process.pid),
).toBe(false);
} finally {
await db.close();
await rm(directory, { recursive: true, force: true });
}
});
});
describe('Windows holder probe', () => {
it('propagates rejected native queries instead of treating them as no login', async () => {
await expect(
asWindows(
{
getFileHoldersAsync: async () => {
throw new Error('RmGetList failed: 5');
},
},
() => getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('RmGetList failed: 5');
});
it('reports the holders the addon lists', async () => {
const holders = await asWindows(
{
getFileHoldersAsync: async () => [
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
],
},
() => getDatabaseHolders('C:\\Tencent Files\\10001\\nt_qq\\nt_db\\nt_msg.db'),
);
expect(holders).toEqual([
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
]);
});
it.each([[{ pid: 0, name: 'QQ' }], [{ pid: 9 }], [null], 'not-an-array'])(
'reports malformed native results: %j',
async (result) => {
await expect(
asWindows({ getFileHoldersAsync: async () => result }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('Invalid database holders');
},
);
it('accepts an empty holder list', async () => {
expect(
await asWindows({ getFileHoldersAsync: async () => [] }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).toEqual([]);
});
it.each([null, {}])('reports an unavailable native probe: %j', async (addon) => {
await expect(asWindows(addon, () => getDatabaseHolders('C:\\nt_msg.db'))).rejects.toThrow(
'requires a hook addon with getFileHoldersAsync',
);
});
});
describe('QQ process names', () => {
it('accepts the name each platform reports for the client', () => {
expect(isQqProcessName('QQ')).toBe(true);
expect(isQqProcessName('QQ.exe')).toBe(true);
expect(isQqProcessName(' qq ')).toBe(true);
});
it('rejects other holders', () => {
expect(isQqProcessName('node')).toBe(false);
expect(isQqProcessName('qqmusic')).toBe(false);
expect(isQqProcessName('')).toBe(false);
});
});
@@ -0,0 +1,241 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
const mocks = vi.hoisted(() => ({
readLocalAccounts: vi.fn(),
getDatabaseHolders: vi.fn(),
}));
// The account list and the holder probe are covered on their own; this file is
// about how the two are joined.
vi.mock('../src/qq-account/login-db', () => ({ readLocalAccounts: mocks.readLocalAccounts }));
vi.mock('../src/qq-account/database-holders', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/qq-account/database-holders')>()),
getDatabaseHolders: mocks.getDatabaseHolders,
}));
import { detectAccountLogins, detectAccountLoginsForPids } from '../src/qq-account/detect';
import { loginDbPaths, ntMsgDbPath } from '../src/qq-account/paths';
const ALICE = { uin: '10001', uid: 'u_alice', nickname: 'Alice' };
const BOB = { uin: '10002', uid: 'u_bob', nickname: 'Bob' };
const roots: string[] = [];
const children: Array<ReturnType<typeof spawn>> = [];
afterEach(async () => {
mocks.readLocalAccounts.mockReset();
mocks.getDatabaseHolders.mockReset();
for (const child of children.splice(0)) {
child.kill();
}
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-detect-'));
roots.push(root);
return root;
}
/**
* A live process whose `/proc/<pid>/environ` carries `HOME` (and any extra
* variables given).
*
* `XDG_CONFIG_HOME` is dropped unless the caller sets it: it outranks `HOME`
* when deriving the data root, and a CI runner exports its own, which would
* otherwise decide the result instead of the HOME under test.
*
* Resolves only once the child has run past `execve`, because `/proc/<pid>`
* still shows the parent's environment before that.
*/
function spawnWithHome(home: string, extra: Record<string, string> = {}): Promise<number> {
return new Promise((resolve, reject) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.XDG_CONFIG_HOME;
Object.assign(env, { HOME: home }, extra);
const child = spawn(
process.execPath,
['-e', 'console.log("ready"); setTimeout(() => {}, 30000)'],
{ env, stdio: ['ignore', 'pipe', 'ignore'] },
);
children.push(child);
child.stdout!.once('data', () => resolve(child.pid!));
child.once('error', reject);
});
}
describe('local account detection', () => {
it('labels each account with the process holding its database', async () => {
const root = await makeRoot();
mocks.readLocalAccounts.mockResolvedValue({ accounts: [ALICE, BOB], failures: [] });
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) => {
if (dbPath === ntMsgDbPath(root, ALICE)) {
return [{ pid: 4242, name: 'qq' }];
}
// A holder that is not the client must not be mistaken for one.
return [{ pid: 999, name: 'node' }];
});
await expect(detectAccountLogins([root])).resolves.toEqual([
{ ...ALICE, pid: 4242 },
{ ...BOB, pid: null },
]);
});
it('merges the accounts of every root instead of stopping at the first', async () => {
const own = await makeRoot();
const extra = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === own ? { accounts: [ALICE], failures: [] } : { accounts: [BOB], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) =>
dbPath === ntMsgDbPath(extra, BOB) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([own, extra])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...BOB, pid: 5151 },
]);
// An account held under an instance's own HOME is still labelled, which
// is what a single-root lookup cannot do.
expect(mocks.getDatabaseHolders).toHaveBeenCalledWith(ntMsgDbPath(extra, BOB));
});
it('keeps account records with the same uin scoped to their own root', async () => {
const first = await makeRoot();
const second = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === first
? { accounts: [ALICE], failures: [] }
: { accounts: [{ ...ALICE, nickname: 'Stale' }], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (file: string) =>
file === ntMsgDbPath(second, ALICE) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([first, second])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...ALICE, nickname: 'Stale', pid: 5151 },
]);
});
it('reports nothing when there are no roots', async () => {
await expect(detectAccountLogins([])).resolves.toEqual([]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
it('logs a named reason when a login.db is present but unreadable', async () => {
const root = await makeRoot();
const entries: LogEntry[] = [];
const previousLevel = getLogLevel();
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
mocks.readLocalAccounts.mockResolvedValue({
accounts: [],
failures: [
{
path: loginDbPaths(root)[0]!,
reason: 'undecryptable',
},
],
});
mocks.getDatabaseHolders.mockResolvedValue([]);
// An unreadable login.db must not look like "no accounts": an
// operator reading the log has to see why nothing was reported.
await detectAccountLogins([root]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const warned = entries.filter(
(entry) => entry.level === 'warn' && entry.scope === 'QqAccount',
);
expect(warned).toHaveLength(1);
expect(warned[0]!.message).toContain('login.db unreadable');
expect(warned[0]!.message).toContain('undecryptable');
});
});
describe.runIf(process.platform === 'linux')('per-process data roots', () => {
it('reads each pid against the HOME that process runs under', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// `<HOME>/.config/QQ`, from the child's environment rather than ours.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(home, '.config', 'QQ'));
});
it('asks each root once when several processes share a HOME', async () => {
const home = await makeRoot();
const pids = [await spawnWithHome(home), await spawnWithHome(home)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledTimes(1);
});
it('honours XDG_CONFIG_HOME of the process over its HOME', async () => {
const home = await makeRoot();
const configHome = await makeRoot();
const pid = await spawnWithHome(home, { XDG_CONFIG_HOME: configHome });
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// The XDG variable outranks HOME, as it does for the client itself.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(configHome, 'QQ'));
});
it('keeps each process on its own root when the HOMEs differ', async () => {
const first = await makeRoot();
const second = await makeRoot();
const pids = [await spawnWithHome(first), await spawnWithHome(second)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(first, '.config', 'QQ'));
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(second, '.config', 'QQ'));
});
it('does not substitute another HOME when the pid is gone', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
// The child exits, so its environment is no longer readable.
await new Promise<void>((resolve) => {
const child = children[children.length - 1]!;
child.once('exit', () => resolve());
child.kill();
});
await detectAccountLoginsForPids([pid]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,195 @@
/**
* Encryption helpers for the account-detection tests.
*
* The shipped code only decrypts, so producing an encrypted fixture means
* mirroring SQLCipher's page layout here: `[client header][page 1]…` with each
* page ending in `IV || page HMAC`, and a `-wal` whose frame header stays
* plaintext while the page inside it is encrypted.
*/
import { createCipheriv, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
import type { KdfHmacAlgorithm, PageHmacAlgorithm } from '../src/qq-account/sqlcipher';
export interface FixtureAlgorithm {
pageHmac: PageHmacAlgorithm;
kdfHmac: KdfHmacAlgorithm;
}
export const FIXTURE_PASSPHRASE = 'BD156D6710D54D8782F4';
export const FIXTURE_PAGE_SIZE = 4096;
export const FIXTURE_HEADER_SIZE = 1024;
export const FIXTURE_WAL_HEADER_SIZE = 32;
export const FIXTURE_WAL_FRAME_HEADER_SIZE = 24;
const DIGEST_BYTES: Record<PageHmacAlgorithm, number> = {
none: 0,
sha1: 20,
sha256: 32,
sha512: 64,
};
const KDF_ITERATIONS = 4000;
const HMAC_KEY_ITERATIONS = 2;
const HMAC_SALT_MASK = 0x3a;
export function reserveBytes(algorithm: FixtureAlgorithm): number {
return Math.ceil((16 + DIGEST_BYTES[algorithm.pageHmac]) / 16) * 16;
}
/** Encrypt a plaintext SQLite image the way QQ NT stores it. */
export function encryptImage(
plain: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
salt = randomBytes(16),
): Buffer {
const pageCount = plain.length / FIXTURE_PAGE_SIZE;
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const output = Buffer.alloc(FIXTURE_HEADER_SIZE + plain.length);
for (let index = 0; index < pageCount; index++) {
const pageNumber = index + 1;
const skip = pageNumber === 1 ? 16 : 0;
const body = plain.subarray(
index * FIXTURE_PAGE_SIZE + skip,
(index + 1) * FIXTURE_PAGE_SIZE,
);
if (pageNumber === 1) {
// Page 1 stores the KDF salt where SQLite keeps its magic.
salt.copy(output, FIXTURE_HEADER_SIZE);
}
encryptPageInto(
output,
FIXTURE_HEADER_SIZE + index * FIXTURE_PAGE_SIZE,
body,
pageNumber,
key,
salt,
algorithm,
);
}
return output;
}
/** Encrypt the pages of a real (plaintext) WAL, recomputing the frame checksums. */
export function encryptWal(
plainWal: Buffer,
encryptedImage: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
): Buffer {
const salt = encryptedImage.subarray(FIXTURE_HEADER_SIZE, FIXTURE_HEADER_SIZE + 16);
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const output = Buffer.alloc(plainWal.length);
plainWal.copy(output, 0, 0, FIXTURE_WAL_HEADER_SIZE);
let running = walChecksum([0, 0], [output.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)], true);
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= plainWal.length;
offset += frameSize
) {
const frameHeader = plainWal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
const pageNumber = frameHeader.readUInt32BE(0);
const skip = pageNumber === 1 ? 16 : 0;
const page = plainWal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize);
encryptPageInto(
output,
offset + FIXTURE_WAL_FRAME_HEADER_SIZE,
page.subarray(skip),
pageNumber,
key,
salt,
algorithm,
);
const stored = output.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
frameHeader.copy(stored, 0, 0, 8);
running = walChecksum(
running,
[
stored.subarray(0, 8),
output.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
true,
);
stored.writeUInt32BE(running[0], 16);
stored.writeUInt32BE(running[1], 20);
}
return output;
}
/**
* SQLite's rolling WAL checksum, as the fixture needs it to rebuild frame
* checksums — and to prove the shipped implementation agrees with what SQLite
* itself wrote in a real `-wal`.
*/
export function walChecksum(
seed: [number, number],
chunks: readonly Buffer[],
littleEndian: boolean,
): [number, number] {
let first = seed[0];
let second = seed[1];
for (const chunk of chunks) {
for (let offset = 0; offset + 8 <= chunk.length; offset += 8) {
const high = littleEndian ? chunk.readUInt32LE(offset) : chunk.readUInt32BE(offset);
const low = littleEndian
? chunk.readUInt32LE(offset + 4)
: chunk.readUInt32BE(offset + 4);
first = (first + high + second) >>> 0;
second = (second + low + first) >>> 0;
}
}
return [first, second];
}
function encryptPageInto(
output: Buffer,
pageOffset: number,
body: Buffer,
pageNumber: number,
key: Buffer,
salt: Buffer,
algorithm: FixtureAlgorithm,
): void {
// Page 1 stores the KDF salt where SQLite keeps its magic, so its ciphertext
// starts one salt past the page boundary.
const offset = pageOffset + (pageNumber === 1 ? 16 : 0);
const reserve = reserveBytes(algorithm);
const cipherLength = body.length - reserve;
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', key, iv);
cipher.setAutoPadding(false);
const ciphertext = Buffer.concat([
cipher.update(body.subarray(0, cipherLength)),
cipher.final(),
]);
ciphertext.copy(output, offset);
iv.copy(output, offset + cipherLength);
const digestBytes = DIGEST_BYTES[algorithm.pageHmac];
if (digestBytes === 0) {
return;
}
const hmacSalt = Buffer.from(salt.map((byte) => byte ^ HMAC_SALT_MASK));
const hmacKey = pbkdf2Sync(key, hmacSalt, HMAC_KEY_ITERATIONS, 32, algorithm.kdfHmac);
const pageNumberBytes = Buffer.alloc(4);
pageNumberBytes.writeUInt32LE(pageNumber);
const digest = createHmac(algorithm.pageHmac, hmacKey)
.update(ciphertext)
.update(iv)
.update(pageNumberBytes)
.digest();
digest.copy(output, offset + cipherLength + 16);
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
const mocks = vi.hoisted(() => ({
detectAlgorithm: vi.fn(),
decryptDatabase: vi.fn(),
}));
// The page crypto is covered on its own; account reading stands on top of it, so
// the fixtures below hand over plaintext images and the decrypt step is stubbed
// to pass them through.
vi.mock('../src/qq-account/sqlcipher', () => ({
detectAlgorithm: mocks.detectAlgorithm,
decryptDatabase: mocks.decryptDatabase,
}));
vi.mock('../src/qq-account/paths', () => ({
loginDbPaths: (root: string) => [path.join(root, PRIMARY), path.join(root, SECONDARY)],
}));
import { readLocalAccounts } from '../src/qq-account/login-db';
const PRIMARY = path.join('global', 'nt_db', 'login.db');
const SECONDARY = path.join('nt_qq', 'global', 'nt_db', 'login.db');
/** Write a plaintext database with QQ's account table at `file`. */
async function writeLoginDb(
file: string,
rows: ReadonlyArray<readonly [string, string, string]>,
): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await rm(file, { force: true });
await rm(`${file}-wal`, { force: true });
const db = new SqliteDatabase(file);
try {
await db.exec('CREATE TABLE login_table("1000" TEXT, "1001" TEXT, "1007" TEXT)');
for (const [uin, uid, nickname] of rows) {
await db.prepare('INSERT INTO login_table VALUES (?, ?, ?)').run(uin, uid, nickname);
}
} finally {
await db.close();
}
}
async function makeRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
await writeLoginDb(path.join(root, PRIMARY), rows);
if (secondaryRows.length > 0) {
await writeLoginDb(path.join(root, SECONDARY), secondaryRows);
}
return root;
}
const roots: string[] = [];
beforeEach(() => {
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
mocks.decryptDatabase.mockImplementation((image: Buffer) => ({ image, walFrames: 0 }));
});
afterEach(async () => {
mocks.detectAlgorithm.mockReset();
mocks.decryptDatabase.mockReset();
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function trackedRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await makeRoot(rows, secondaryRows);
roots.push(root);
return root;
}
describe('local account list', () => {
it('reads the accounts QQ cached locally', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('merges both login.db locations, letting the primary win on a clash', async () => {
const root = await trackedRoot(
[
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
],
[
['10001', 'u_a_stale', 'AliceStale'],
['10003', 'u_c', 'Carol'],
],
);
expect(await readLocalAccounts(root)).toEqual({
accounts: [
{ uin: '10001', uid: 'u_a', nickname: 'Alice' },
{ uin: '10002', uid: 'u_b', nickname: 'Bob' },
{ uin: '10003', uid: 'u_c', nickname: 'Carol' },
],
failures: [],
});
});
it('re-decrypts only when the database or its sidecar changes', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await readLocalAccounts(root);
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
// A login that just happened rewrites the database; the new rows have to
// show up instead of the cached list.
await writeLoginDb(file, [
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
]);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
expect((await readLocalAccounts(root)).accounts).toHaveLength(2);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(2);
// The sidecar counts as a change too: a batch committed to the WAL only
// leaves login.db untouched.
await writeFile(`${file}-wal`, Buffer.alloc(0));
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(3);
});
it('shares concurrent reads of the same database image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const reports = await Promise.all([readLocalAccounts(root), readLocalAccounts(root)]);
expect(reports[0]).toEqual(reports[1]);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
});
it('reports an unreadable WAL instead of reading a stale base image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await mkdir(`${file}-wal`);
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'read-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports missing schema columns instead of accepting quoted column names as identities', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
const db = new SqliteDatabase(file);
try {
await db.exec('ALTER TABLE login_table DROP COLUMN "1001"');
} finally {
await db.close();
}
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'query-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports no accounts when the file cannot be decrypted, and retries later', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
const file = path.join(root, PRIMARY);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('names an unreadable login.db instead of reporting "no accounts"', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
// A login.db that cannot be decrypted is not the same as a machine with
// no accounts, and the caller has to be able to tell them apart.
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
});
it('keeps an unreadable sidecar from hiding the accounts a good database has', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']], [['10002', 'u_b', 'Bob']]);
const secondary = path.join(root, SECONDARY);
const bogus = Buffer.alloc(8192, 7);
mocks.detectAlgorithm.mockImplementation((image: Buffer) =>
image[0] === 7 ? null : { pageHmac: 'sha1', kdfHmac: 'sha512' },
);
await writeFile(secondary, bogus);
const report = await readLocalAccounts(root);
expect(report.accounts.map((account) => account.uin)).toEqual(['10001']);
expect(report.failures).toEqual([{ path: secondary, reason: 'undecryptable' }]);
});
it('skips rows that carry no usable identity', async () => {
const root = await trackedRoot([
['10001', 'u_a', 'Alice'],
['10002', '', 'Broken'],
['', 'u_d', 'Nameless'],
['../10001', 'u_e', 'Invalid UIN'],
]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('reports no accounts when login.db is missing', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
roots.push(root);
// Neither candidate file exists: nothing cached, and nothing to report.
await expect(readLocalAccounts(root)).resolves.toEqual({ accounts: [], failures: [] });
});
});
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import { qqDataRoots, qqDataRootsForPid } from '../src/qq-account/paths';
const files = vi.hoisted(() => ({ read: vi.fn() }));
vi.mock('node:fs', () => ({ readFileSync: files.read }));
const platform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
files.read.mockReset();
});
describe('account data root isolation', () => {
it('uses the client HOME and honours its XDG configuration', () => {
files.read.mockReturnValue('HOME=/clients/a\0IGNORED=value\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/a', '.config', 'QQ')]);
files.read.mockReturnValue('HOME=/clients/a\0XDG_CONFIG_HOME=/clients/config\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/config', 'QQ')]);
});
it('does not guess a HOME when the client environment has none', () => {
files.read.mockReturnValue('OTHER=value\0');
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('reports no root for an exited process', () => {
files.read.mockImplementation(() => {
throw Object.assign(new Error('gone'), { code: 'ENOENT' });
});
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('surfaces permission failures instead of reading another user directory', () => {
const error = Object.assign(new Error('denied'), { code: 'EACCES' });
files.read.mockImplementation(() => {
throw error;
});
expect(() => qqDataRootsForPid(42)).toThrow(error);
});
it('uses only the configured Windows data root', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
files.read.mockReturnValue('[UserDataSet]\nUserDataSavePath=D:\\QQ\\Tencent Files\n');
expect(qqDataRoots()).toEqual(['D:\\QQ\\Tencent Files']);
expect(files.read).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
accountDirName,
loginDbPaths,
ntMsgDbPath,
tencentFilesRootFromUserDataInfo,
} from '../src/qq-account/paths';
const IS_WINDOWS = process.platform === 'win32';
describe('QQ data paths', () => {
it('derives the Linux account directory from the account uid', () => {
// md5(md5("u_test_account") + "nt_kernel")
expect(accountDirName('u_test_account')).toBe('nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f');
});
it('lists the login.db locations of the current platform', () => {
const root = 'root';
const windows = path.join(root, 'nt_qq', 'global', 'nt_db', 'login.db');
expect(loginDbPaths(root)).toEqual(
IS_WINDOWS ? [windows] : [path.join(root, 'global', 'nt_db', 'login.db'), windows],
);
});
it('locates an account database per platform', () => {
const root = 'root';
const account = { uin: '10001', uid: 'u_test_account' };
expect(ntMsgDbPath(root, account)).toBe(
IS_WINDOWS
? path.join(root, '10001', 'nt_qq', 'nt_db', 'nt_msg.db')
: path.join(root, 'nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f', 'nt_db', 'nt_msg.db'),
);
});
});
describe('Windows data root detection', () => {
/** Recreate `<Public>/Documents/Tencent/QQ/UserDataInfo.ini` for a fake home. */
async function withUserDataInfo(
contents: string,
run: (home: string) => Promise<void> | void,
): Promise<void> {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
const home = path.join(base, 'user');
const ini = path.join(base, 'Public', 'Documents', 'Tencent', 'QQ', 'UserDataInfo.ini');
await mkdir(path.dirname(ini), { recursive: true });
await writeFile(ini, contents, 'utf8');
await run(home);
} finally {
await rm(base, { recursive: true, force: true });
}
}
it('reads UserDataSavePath from the ini QQ itself writes', async () => {
await withUserDataInfo(
'[UserDataSet]\nUserDataSavePath=D:\\Tencent\\Tencent Files\n',
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
},
);
});
it('appends the Tencent Files segment when the ini stops one level short', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=D:\\Tencent\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
});
});
it('ignores other sections and the UserDataSavePathType key', async () => {
await withUserDataInfo(
[
'[UserDataImportSet]',
'OldVerDataPath=D:\\Legacy\\Tencent Files',
'[UserDataSet]',
'UserDataSavePathType=2',
'UserDataSavePath=E:\\Current\\Tencent Files',
'',
].join('\n'),
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('E:\\Current\\Tencent Files');
},
);
});
it('reports nothing when the ini is missing', async () => {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
expect(tencentFilesRootFromUserDataInfo(path.join(base, 'absent'))).toBeNull();
} finally {
await rm(base, { recursive: true, force: true });
}
});
it('reports nothing when the ini has no usable path', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBeNull();
});
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { detectAccountLogins } from '../src/qq-account/detect';
/**
* Account detection against a real QQ data directory.
*
* Point `SNOWLUMA_QQ_DATA_ROOT` at one (for example `~/.config/QQ`) to run
* these; without it they are skipped, which is what CI and any machine without
* the client do. They are the only check that exercises login.db as QQ actually
* writes it, together with the holder probe on the databases it currently holds.
*/
const REAL_DATA_ROOT = process.env.SNOWLUMA_QQ_DATA_ROOT;
describe('account detection against real client data', () => {
it.runIf(Boolean(REAL_DATA_ROOT))('reads the accounts and their processes', async () => {
const accounts = await detectAccountLogins([REAL_DATA_ROOT!]);
expect(accounts.length).toBeGreaterThan(0);
for (const account of accounts) {
expect(account.uin).toMatch(/^\d{5,10}$/);
expect(account.uid).toMatch(/^u_/);
if (account.pid !== null) {
expect(Number.isInteger(account.pid)).toBe(true);
expect(() => process.kill(account.pid!, 0)).not.toThrow();
}
}
});
});
@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import {
DATABASE_HEADER_SIZE,
decryptDatabase,
detectAlgorithm,
type SqlcipherAlgorithm,
} from '../src/qq-account/sqlcipher';
import {
FIXTURE_HEADER_SIZE,
FIXTURE_PAGE_SIZE,
FIXTURE_PASSPHRASE,
FIXTURE_WAL_FRAME_HEADER_SIZE,
FIXTURE_WAL_HEADER_SIZE,
encryptImage,
encryptWal,
reserveBytes,
walChecksum,
type FixtureAlgorithm,
} from './qq-account-fixtures';
const SHA1_SHA512: FixtureAlgorithm = { pageHmac: 'sha1', kdfHmac: 'sha512' };
/**
* Reserved space SQLite is told about for an encrypted database: room for the
* IV plus the largest page digest. SQLCipher's own reserve always fits inside
* it, which is why the tail of every page is disposable.
*/
const HEADER_RESERVED_BYTES = 80;
/** Offset of one frame in a `-wal` buffer. */
function frameOffset(index: number): number {
return FIXTURE_WAL_HEADER_SIZE + index * (FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE);
}
/** Last frame index a merge may have applied, or -1 when nothing was committed. */
function lastCommitFrame(wal: Buffer, maxFrameIndex = frameCount(wal) - 1): number {
let last = -1;
for (let index = 0; index <= maxFrameIndex; index++) {
if (wal.readUInt32BE(frameOffset(index) + 4) !== 0) {
last = index;
}
}
return last;
}
function frameCount(wal: Buffer): number {
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
return Math.floor((wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize);
}
/** Frames up to and including the last commit, i.e. what a merge should apply. */
function committedFrames(wal: Buffer, maxFrameIndex?: number): number {
return lastCommitFrame(wal, maxFrameIndex) + 1;
}
/** Database size the last applicable commit declares. */
function lastCommitSize(wal: Buffer, maxFrameIndex?: number): number {
return wal.readUInt32BE(frameOffset(lastCommitFrame(wal, maxFrameIndex)) + 4);
}
/** Page content as the last applicable commit left it, keyed by page number. */
function committedPages(wal: Buffer, maxFrameIndex?: number): Map<number, Buffer> {
const pages = new Map<number, Buffer>();
const last = lastCommitFrame(wal, maxFrameIndex);
const size = lastCommitSize(wal, maxFrameIndex);
for (let index = 0; index <= last; index++) {
const pageNumber = wal.readUInt32BE(frameOffset(index));
if (pageNumber >= 1 && pageNumber <= size) {
pages.set(
pageNumber,
wal.subarray(
frameOffset(index) + FIXTURE_WAL_FRAME_HEADER_SIZE,
frameOffset(index + 1),
),
);
}
}
return pages;
}
/** Compare one merged page against the page a frame carried. */
function expectPageMatches(
merged: Buffer,
expectedPage: Buffer,
pageNumber: number,
algorithm: FixtureAlgorithm,
): void {
const reserve = reserveBytes(algorithm);
const start = (pageNumber - 1) * FIXTURE_PAGE_SIZE;
// Page 1's SQLite header carries the fields the merge keeps consistent with
// the assembled image (change counter, page count, journal mode), so only the
// page body is compared there.
const bodyStart = pageNumber === 1 ? 100 : 0;
expect(merged.subarray(start + bodyStart, start + FIXTURE_PAGE_SIZE - reserve)).toEqual(
expectedPage.subarray(bodyStart, FIXTURE_PAGE_SIZE - reserve),
);
expect(
merged
.subarray(start + FIXTURE_PAGE_SIZE - reserve, start + FIXTURE_PAGE_SIZE)
.equals(Buffer.alloc(reserve)),
).toBe(true);
}
/** A plaintext image with the header fields the merge keeps consistent. */
function plaintextImage(pageCount: number, marker = 0): Buffer {
const image = Buffer.alloc(pageCount * FIXTURE_PAGE_SIZE);
for (let page = 0; page < pageCount; page++) {
for (let offset = 0; offset < FIXTURE_PAGE_SIZE - HEADER_RESERVED_BYTES; offset++) {
image[page * FIXTURE_PAGE_SIZE + offset] = (page * 31 + offset + marker) % 251;
}
}
image.write('SQLite format 3\0', 0, 'latin1');
image.writeUInt16BE(FIXTURE_PAGE_SIZE, 16);
image[18] = 1;
image[19] = 1;
image[20] = HEADER_RESERVED_BYTES;
image[21] = 64;
image[22] = 32;
image[23] = 32;
image.writeUInt32BE(7, 24);
image.writeUInt32BE(pageCount, 28);
image.writeUInt32BE(7, 92);
return image;
}
/** A real SQLite database in WAL mode, plus the sidecar SQLite wrote for it. */
async function realWalDatabase(
directory: string,
inserts: string[][],
): Promise<{ image: Buffer; wal: Buffer; db: SqliteDatabase }> {
const file = path.join(directory, 'source.db');
const db = new SqliteDatabase(file);
await db.exec('PRAGMA wal_autocheckpoint = 0');
await db.exec('CREATE TABLE t(value TEXT)');
for (const [value] of inserts) {
await db.prepare('INSERT INTO t VALUES (?)').run(value);
}
return { image: await readFile(file), wal: await readFile(`${file}-wal`), db };
}
describe('SQLCipher page decryption', () => {
it('round-trips a page image and normalizes the header', () => {
const plain = plaintextImage(3);
plain[18] = 2; // stored as a WAL-mode database, like QQ writes them
plain[19] = 2;
const encrypted = encryptImage(plain, SHA1_SHA512);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(SHA1_SHA512);
const decrypted = decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, SHA1_SHA512);
expect(decrypted).not.toBeNull();
const expected = Buffer.from(plain);
expected[18] = 1;
expected[19] = 1;
expected.writeUInt32BE(3, 28);
expected.writeUInt32BE(7, 92);
expect(decrypted!.image.equals(expected)).toBe(true);
expect(decrypted!.walFrames).toBe(0);
});
it('probes the algorithm pair instead of assuming one', () => {
const plain = plaintextImage(2);
for (const algorithm of [
{ pageHmac: 'sha256', kdfHmac: 'sha256' },
{ pageHmac: 'sha512', kdfHmac: 'sha1' },
{ pageHmac: 'none', kdfHmac: 'sha512' },
] satisfies SqlcipherAlgorithm[]) {
const encrypted = encryptImage(plain, algorithm);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(algorithm);
expect(
decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, algorithm)!.image.equals(
plain,
),
).toBe(true);
}
});
it('rejects a wrong passphrase and a file that is too short', () => {
const encrypted = encryptImage(plaintextImage(2), SHA1_SHA512);
expect(detectAlgorithm(encrypted, 'wrong-passphrase')).toBeNull();
expect(
detectAlgorithm(Buffer.alloc(DATABASE_HEADER_SIZE + 512), FIXTURE_PASSPHRASE),
).toBeNull();
expect(decryptDatabase(Buffer.alloc(64), null, FIXTURE_PASSPHRASE, SHA1_SHA512)).toBeNull();
});
});
describe('SQLCipher WAL folding', () => {
it('reproduces the frame checksums SQLite itself wrote', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const littleEndian = wal.readUInt32BE(0) === 0x377f0682;
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
expect((wal.length - FIXTURE_WAL_HEADER_SIZE) % frameSize).toBe(0);
const header = walChecksum(
[0, 0],
[wal.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)],
littleEndian,
);
expect(header).toEqual([wal.readUInt32BE(24), wal.readUInt32BE(28)]);
let running = header;
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= wal.length;
offset += frameSize
) {
const frame = wal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
running = walChecksum(
running,
[
frame.subarray(0, 8),
wal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
littleEndian,
);
expect(running).toEqual([frame.readUInt32BE(16), frame.readUInt32BE(20)]);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('folds every committed frame into the decrypted image', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted!.walFrames).toBe(committedFrames(wal));
expect(decrypted!.image.length).toBe(lastCommitSize(wal) * FIXTURE_PAGE_SIZE);
// Every page a committed frame carried has to arrive as it was written,
// apart from the on-disk IV/HMAC region the encryptor owns.
for (const [pageNumber, page] of committedPages(wal)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the intact prefix of a WAL torn by a running client', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const frameCount = (wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize;
// Shred the last frame's page: everything after the previous commit
// must be dropped rather than half-applied.
encryptedWal[encryptedWal.length - 8] ^= 0xff;
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted).not.toBeNull();
expect(decrypted!.walFrames).toBeLessThan(frameCount);
// Frames up to the tear survive; the merge still stops at the last
// commit they contain rather than applying a partial transaction.
expect(decrypted!.walFrames).toBe(committedFrames(wal, frameCount - 2));
// Everything the surviving commits wrote has to be in the image.
for (const [pageNumber, page] of committedPages(wal, frameCount - 2)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('SQLCipher header handling', () => {
it('keeps the client header opaque and stores the salt at the start of page 1', () => {
const plain = plaintextImage(2);
const encrypted = encryptImage(plain, SHA1_SHA512);
const header = encrypted.subarray(0, FIXTURE_HEADER_SIZE);
expect(header.equals(Buffer.alloc(FIXTURE_HEADER_SIZE))).toBe(true);
expect(
encrypted
.subarray(FIXTURE_HEADER_SIZE + 16, FIXTURE_HEADER_SIZE + 48)
.equals(plain.subarray(16, 48)),
).toBe(false);
});
});
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { watchAccountFiles } from '../src/qq-account/watch';
// Keep native filesystem coverage aligned with qqDataRootsForPid's supported platforms.
// macOS is only a development host here: fs.watch() can return before FSEvents
// registration, losing immediate writes. Deterministic logic tests run on every OS.
describe.skipIf(process.platform !== 'win32' && process.platform !== 'linux')(
'account file changes on supported filesystems',
() => {
let root: string;
const subscriptions: Array<() => void> = [];
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-account-watch-'));
});
afterEach(async () => {
for (const stop of subscriptions.splice(0)) {
stop();
}
await rm(root, { recursive: true, force: true });
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
subscriptions.push(watchAccountFiles(dbPaths, changed));
return changed;
}
it('observes database and WAL changes while ignoring message data and SHM', async () => {
const dbPath = path.join(root, 'login.db');
const changed = subscribe([dbPath, dbPath]);
await writeFile(dbPath, 'login');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
const afterWal = changed.mock.calls.length;
await writeFile(path.join(root, 'nt_msg.db'), 'message');
await writeFile(`${dbPath}-shm`, 'shared memory');
await delay(350);
expect(changed).toHaveBeenCalledTimes(afterWal);
});
it('observes database replacement and WAL recreation', async () => {
const dbPath = path.join(root, 'login.db');
await writeFile(dbPath, 'initial');
const changed = subscribe();
await writeFile(path.join(root, 'replacement'), 'replacement');
await rename(path.join(root, 'replacement'), dbPath);
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterReplacement = changed.mock.calls.length;
await writeFile(dbPath, 'updated');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterReplacement),
);
const afterUpdate = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterUpdate));
const afterWal = changed.mock.calls.length;
await rm(`${dbPath}-wal`);
await writeFile(`${dbPath}-wal`, 'new wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterWal));
});
it('starts watching login data created after QQ process discovery', async () => {
const dbPath = path.join(root, 'QQ', 'global', 'nt_db', 'login.db');
const changed = subscribe([dbPath]);
await mkdir(path.dirname(dbPath), { recursive: true });
await writeFile(dbPath, 'initial');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'login');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
});
},
);
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { FSWatcher, WatchListener } from 'node:fs';
import path from 'node:path';
import { createLogger } from '@snowluma/common/logger';
import { watchAccountChangesForPids, watchAccountFiles } from '../src/qq-account/watch';
const files = vi.hoisted(() => ({
watch: vi.fn<
(
directory: string,
options: { persistent: boolean },
listener: WatchListener<string>,
) => FSWatcher
>(),
}));
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs')>()),
watch: files.watch,
}));
class TestWatcher extends EventEmitter implements FSWatcher {
// Keep callbacks reachable after close to exercise queued, stale events.
close = vi.fn();
ref() {
return this;
}
unref() {
return this;
}
}
const root = path.resolve('account-watch-fixture');
const directories = new Set<string>();
const watchers: TestWatcher[] = [];
const subscriptions: Array<() => void> = [];
const platform = process.platform;
beforeEach(() => {
vi.useFakeTimers();
directories.add(root);
files.watch.mockImplementation((directory, _options, listener) => {
if (!directories.has(directory)) {
throw Object.assign(new Error('directory missing'), { code: 'ENOENT' });
}
const watcher = new TestWatcher();
watcher.on('change', listener);
watchers.push(watcher);
return watcher;
});
});
afterEach(() => {
for (const stop of subscriptions.splice(0)) {
stop();
}
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
watchers.length = 0;
directories.clear();
files.watch.mockReset();
vi.useRealTimers();
vi.restoreAllMocks();
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
const log = createLogger('AccountWatchTest');
const warning = vi.spyOn(log, 'warn');
const stop = watchAccountFiles(dbPaths, changed, log);
subscriptions.push(stop);
return { changed, stop, warning };
}
// Native filesystem delivery is covered separately on the production platforms.
// These tests control event ordering and the debounce clock, not OS startup timing.
describe('account file changes', () => {
it('coalesces login database and WAL changes while ignoring message data and SHM', () => {
const dbPath = path.join(root, 'login.db');
const { changed } = subscribe([dbPath, dbPath]);
expect(files.watch).toHaveBeenCalledExactlyOnceWith(
root,
{ persistent: false },
expect.any(Function),
);
const watcher = watchers[0]!;
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(100);
watcher.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(249);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'change', 'nt_msg.db');
watcher.emit('change', 'change', 'login.db-shm');
vi.advanceTimersByTime(1);
expect(changed).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1000);
expect(changed).toHaveBeenCalledOnce();
});
it('keeps the directory watch across database replacement and WAL recreation', () => {
const { changed } = subscribe();
const watcher = watchers[0]!;
watcher.emit('change', 'rename', 'replacement');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'rename', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(4);
expect(files.watch).toHaveBeenCalledOnce();
expect(watcher.close).not.toHaveBeenCalled();
});
it('moves to newly created directories one level at a time', () => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
const attempts = files.watch.mock.calls.length;
ancestor.emit('change', 'rename', 'unrelated');
ancestor.emit('change', 'change', 'login.db-wal');
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(vi.getTimerCount()).toBe(0);
let directory = root;
for (const segment of ['QQ', 'global', 'nt_db']) {
directory = path.join(directory, segment);
directories.add(directory);
const previous = watchers.at(-1)!;
previous.emit('change', 'rename', segment);
expect(previous.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
directory,
{ persistent: false },
expect.any(Function),
);
}
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
});
it.each(['QQ', null])(
'observes an entire new directory tree from a %s ancestor event',
(filename) => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed, warning } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
directories.add(dbDirectory);
ancestor.emit('change', 'rename', filename);
expect(ancestor.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
dbDirectory,
{ persistent: false },
expect.any(Function),
);
const attempts = files.watch.mock.calls.length;
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
ancestor.emit('change', 'rename', 'QQ');
ancestor.emit('error', new Error('late error'));
vi.advanceTimersByTime(250);
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(changed).toHaveBeenCalledOnce();
expect(warning).not.toHaveBeenCalled();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
},
);
it('notifies when the filesystem does not supply a filename', () => {
const { changed } = subscribe();
watchers[0]!.emit('change', 'change', null);
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
});
it('closes every watcher and cancels pending notifications on disposal', () => {
const { changed, stop, warning } = subscribe([
path.join(root, 'login.db'),
path.join(root, 'other', 'login.db'),
]);
expect(watchers).toHaveLength(2);
watchers[0]!.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(30);
expect(vi.getTimerCount()).toBe(1);
stop();
expect(vi.getTimerCount()).toBe(0);
for (const watcher of watchers) {
expect(watcher.close).toHaveBeenCalledOnce();
watcher.emit('change', 'rename', null);
watcher.emit('error', new Error('late error'));
}
vi.advanceTimersByTime(1000);
expect(changed).not.toHaveBeenCalled();
expect(warning).not.toHaveBeenCalled();
});
it('reports watch failures without starting a polling fallback', () => {
files.watch.mockImplementationOnce(() => {
throw Object.assign(new Error('access denied'), { code: 'EACCES' });
});
const { changed, warning } = subscribe();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'access denied',
);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(1000);
expect(files.watch).toHaveBeenCalledOnce();
expect(changed).not.toHaveBeenCalled();
});
it('reports asynchronous watch errors and closes the failed watcher', () => {
const { changed, warning } = subscribe();
const watcher = watchers[0]!;
watcher.emit('error', new Error('watch unavailable'));
expect(watcher.close).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'watch unavailable',
);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
});
it('does not start account file watchers through the production entry point on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
const changed = vi.fn();
subscriptions.push(watchAccountChangesForPids([123], changed));
expect(files.watch).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
expect(changed).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasLowProcessIntegrity, type NativeRuntimeIntegrity } from '../src/runtime-integrity';
const native = vi.hoisted(() => ({ load: vi.fn(), error: vi.fn() }));
vi.mock('../src/injector', () => ({
getNativeHookAddon: native.load,
getNativeHookLoadError: native.error,
}));
const platform = process.platform;
const sample = (): NativeRuntimeIntegrity => ({
pid: process.pid,
executablePath: process.execPath,
process: { status: 'ok', rid: 8192 },
file: { status: 'ok', label: null },
});
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
});
describe('runtime integrity diagnostics', () => {
it('warns only for a successfully read process token below Medium', () => {
const available = { ...sample(), status: 'available' as const };
for (const rid of [0, 4096]) {
expect(hasLowProcessIntegrity({ ...available, process: { status: 'ok', rid } })).toBe(
true,
);
}
for (const rid of [8192, 8448, 12288, 16384]) {
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'ok', rid },
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
}),
).toBe(false);
}
expect(hasLowProcessIntegrity({ status: 'unsupported' })).toBe(false);
expect(hasLowProcessIntegrity({ status: 'unavailable', error: 'unavailable' })).toBe(false);
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'error', operation: 'OpenProcessToken', code: 5 },
}),
).toBe(false);
});
it('keeps one startup snapshot for logging and all WebUI polls', async () => {
const read = vi.fn().mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
const pending = getRuntimeIntegrity();
expect(getRuntimeIntegrity()).toBe(pending);
const first = await pending;
expect(first).toMatchObject({
status: 'available',
process: { rid: 8192 },
file: { label: null },
});
read.mockResolvedValue({
...sample(),
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
});
expect(await getRuntimeIntegrity()).toBe(first);
expect(read).toHaveBeenCalledTimes(1);
});
it.each(['process', 'file'])(
'retains %s query errors and the other successful result',
async (field) => {
native.load.mockReturnValue({
getRuntimeIntegrityAsync: async () => ({
...sample(),
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
}),
});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'available',
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
});
},
);
it('reports old addons as unavailable rather than Medium', async () => {
native.load.mockReturnValue({});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('不支持'),
});
});
it('retains the loader error for the startup log', async () => {
native.load.mockReturnValue(null);
native.error.mockReturnValue('dlopen failed: incompatible binary');
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'dlopen failed: incompatible binary',
});
});
it.each([
null,
{ ...sample(), pid: process.pid + 1 },
{ ...sample(), process: { status: 'ok', rid: -1 } },
{ ...sample(), file: { status: 'ok', label: { rid: 4096, inherited: 'yes', policy: 1 } } },
])('rejects malformed or wrong-process native results', async (result) => {
native.load.mockReturnValue({ getRuntimeIntegrityAsync: async () => result });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('无效数据'),
});
});
it('preserves rejected errors without repeatedly probing on WebUI polls', async () => {
const read = vi
.fn()
.mockRejectedValueOnce(new Error('allocation failed'))
.mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(read).toHaveBeenCalledTimes(1);
});
it('does not load the addon on non-Windows hosts', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toEqual({ status: 'unsupported' });
expect(native.load).not.toHaveBeenCalled();
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+40
View File
@@ -0,0 +1,40 @@
// pnpm --filter @snowluma/common exec vitest bench --run bench/logger.bench.ts
import { bench, describe, vi } from 'vitest';
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
vi.stubEnv('SNOWLUMA_LOG_FILE', '0');
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '5000');
const { createLogger, getLogSnapshot, getRecentLogs, setLogLevel } = await import('../src/logger');
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const log = createLogger('Benchmark');
for (let i = 0; i < 5000; i += 1) {
log.trace('trace %d', i);
if (i % 5 === 0) {
log.info('normal %d', i);
}
}
stdout.mockRestore();
describe('6,000 retained records', () => {
bench('recent 30', () => {
getRecentLogs(30);
});
bench('recent 300', () => {
getRecentLogs(300);
});
bench('complete export', () => {
getLogSnapshot();
});
});
setLogLevel('info');
describe('file-only debug records with disk disabled', () => {
bench('format and route debug', () => {
log.debug('packet command=%s bytes=%d', 'Example.Service', 1024);
});
});
+123
View File
@@ -0,0 +1,123 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/common",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma shared infrastructure — leaf-package utilities with zero business dependencies. Logger / hex codec / log file transport / log line summariser / packet sender wire-protocol types / runtime config loader. Consumed by @snowluma/protocol, @snowluma/core, and @snowluma/onebot. Per-file subpath exports keep import sites unambiguous and let the bundler tree-shake aggressively (importing `@snowluma/common/hex` doesn't pull in `node:fs` from the logger transport).",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./webui-target": {
"types": "./src/webui-target.ts",
"default": "./src/webui-target.ts"
},
"./update-source": {
"types": "./src/update-source.ts",
"default": "./src/update-source.ts"
},
"./semver": {
"types": "./src/semver.ts",
"default": "./src/semver.ts"
},
"./version": {
"types": "./src/version.ts",
"default": "./src/version.ts"
},
"./native-path": {
"types": "./src/native-path.ts",
"default": "./src/native-path.ts"
},
"./errors": {
"types": "./src/errors.ts",
"default": "./src/errors.ts"
},
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./logger": {
"types": "./src/logger.ts",
"default": "./src/logger.ts"
},
"./coerce": {
"types": "./src/coerce.ts",
"default": "./src/coerce.ts"
},
"./env": {
"types": "./src/env.ts",
"default": "./src/env.ts"
},
"./notification-templates": {
"types": "./src/notification-templates.ts",
"default": "./src/notification-templates.ts"
},
"./single-flight-cache": {
"types": "./src/single-flight-cache.ts",
"default": "./src/single-flight-cache.ts"
},
"./concurrency": {
"types": "./src/concurrency.ts",
"default": "./src/concurrency.ts"
},
"./hex": {
"types": "./src/hex.ts",
"default": "./src/hex.ts"
},
"./json": {
"types": "./src/json.ts",
"default": "./src/json.ts"
},
"./log-file-transport": {
"types": "./src/log-file-transport.ts",
"default": "./src/log-file-transport.ts"
},
"./log-sanitize": {
"types": "./src/log-sanitize.ts",
"default": "./src/log-sanitize.ts"
},
"./log-summary": {
"types": "./src/log-summary.ts",
"default": "./src/log-summary.ts"
},
"./packet-sender": {
"types": "./src/packet-sender.ts",
"default": "./src/packet-sender.ts"
},
"./protocol-types": {
"types": "./src/protocol-types.ts",
"default": "./src/protocol-types.ts"
},
"./runtime": {
"types": "./src/runtime.ts",
"default": "./src/runtime.ts"
},
"./uin": {
"types": "./src/uin.ts",
"default": "./src/uin.ts"
},
"./access-token": {
"types": "./src/access-token.ts",
"default": "./src/access-token.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/semver": "7.7.1",
"typescript": "catalog:",
"vitest": "4.1.12"
},
"dependencies": {
"@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.3",
"@zxcvbn-ts/language-en": "^4.1.1",
"semver": "7.7.4"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+68
View File
@@ -0,0 +1,68 @@
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import { adjacencyGraphs, dictionary } from '@zxcvbn-ts/language-common';
import { translations } from '@zxcvbn-ts/language-en';
export const ACCESS_TOKEN_MIN_LENGTH = 16;
export const ACCESS_TOKEN_MIN_SCORE = 3;
export type AccessTokenAssessmentReason = 'empty' | 'too-short' | 'guessable' | 'acceptable';
export interface AccessTokenAssessment {
acceptable: boolean;
reason: AccessTokenAssessmentReason;
score: number;
}
const checker = new ZxcvbnFactory({
translations,
graphs: adjacencyGraphs,
dictionary,
});
function normalizeUserInputs(values: readonly (string | number)[]): Array<string | number> {
const unique = new Set<string | number>(['SnowLuma', 'OneBot']);
for (const value of values) {
if (typeof value === 'number') {
if (Number.isFinite(value)) {
unique.add(value);
}
continue;
}
const trimmed = value.trim();
if (trimmed) {
unique.add(trimmed.slice(0, 256));
}
}
return [...unique].slice(0, 24);
}
/**
* Evaluate a newly chosen OneBot access token against the shared product
* policy. Callers decide whether an empty token is permitted in their own
* trust context; an empty value is never considered strong here.
*/
export function assessAccessToken(
token: string,
userInputs: readonly (string | number)[] = [],
): AccessTokenAssessment {
if (!token) {
return { acceptable: false, reason: 'empty', score: 0 };
}
if (token.length < ACCESS_TOKEN_MIN_LENGTH) {
return { acceptable: false, reason: 'too-short', score: 0 };
}
const score = checker.check(token, normalizeUserInputs(userInputs)).score;
if (score < ACCESS_TOKEN_MIN_SCORE) {
return { acceptable: false, reason: 'guessable', score };
}
return { acceptable: true, reason: 'acceptable', score };
}
+34
View File
@@ -0,0 +1,34 @@
// Scalar coercion primitives for normalizing untrusted JSON config into
// typed values with fallbacks. Zero dependencies — these were duplicated
// verbatim across notifications/config and webui/ui-config; this is their
// single home. Input tolerance is deliberate (numeric strings coerce), but
// validity is strict (a wrong type yields the caller's fallback, never a
// silent 0/'' — except where the primitive's contract says otherwise).
/** True only for a plain, non-null, non-array object. */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** The value when it is a real boolean, else the fallback. Strings/numbers
* are NOT coerced — `'true'` and `1` both yield the fallback. */
export function boolOr(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
/** A finite number (numeric strings coerced) clamped to `[min, max]`, else
* the fallback. Fractional precision is preserved — use `clampInt` to floor. */
export function clampNum(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
if (!Number.isFinite(n)) {
return fallback;
}
return Math.min(max, Math.max(min, n));
}
/** `clampNum` truncated toward zero — clamp first, then drop the fraction. */
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
return Math.trunc(clampNum(value, min, max, fallback));
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Map `items` through `fn` in sequential batches of at most `concurrency`
* items, awaiting each batch (`Promise.all`) before starting the next.
* Results come back in input order.
*
* Batched, not a rolling pool: every item in a batch must settle before the
* next batch starts. This caps peak parallelism — used to keep rate-limited
* fan-out (e.g. the per-uin OIDB profile fetches during forward-node
* enrichment) from bursting past a safe width and tripping Tencent
* risk-control. Pair it with a cap on `items` (slice) and per-item error
* handling inside `fn` for a fully bounded, best-effort enrichment — the
* shape that was previously hand-rolled at each such call site.
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const limit = Math.max(1, Math.trunc(concurrency));
const results = new Array<R>(items.length);
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
for (let j = 0; j < settled.length; j++) {
results[i + j] = settled[j]!;
}
}
return results;
}
+34
View File
@@ -0,0 +1,34 @@
import { createLogger } from './logger';
const log = createLogger('Env');
/** Node's setTimeout delay is a signed 32-bit integer. */
const MAX_POSITIVE_INT = 2_147_483_647;
/** Read a positive integer environment variable.
*
* A blank value is absent and returns `fallback`. A present but unusable
* value is ignored, with a warning, and also returns `fallback`.
*/
export function positiveIntEnv(
name: string,
fallback: number,
options?: { max?: number; env?: NodeJS.ProcessEnv },
): number {
const max = options?.max ?? MAX_POSITIVE_INT;
const raw = (options?.env ?? process.env)[name];
if (typeof raw !== 'string' || !raw.trim()) {
return fallback;
}
const value = raw.trim();
const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed > max) {
log.warn('%s=%j is ignored; expected a positive integer up to %d', name, raw, max);
return fallback;
}
return parsed;
}
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff. Show more