cortico-world-starpost 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - 服务端改用内置 SQLite、WAL 与事务,按记录保存账号、消息和社交数据,避免重写整个 JSON 文件。
6
+ - 图片独立存储并按内容去重,保留现有客户端协议。
7
+ - 首次启动自动迁移旧 JSON,保留聊天 ID、身份、好友、聊天记录与事件游标;增加在线备份工具。
8
+ - 消息和评论按索引查询,面板按条数限制读取;操作失败时整体回滚。
9
+
3
10
  ## 0.3.1
4
11
 
5
12
  - 完善公开发布资料、独立源码构建和部署说明;保留当前中转服务 IP 作为默认地址。
package/README.md CHANGED
@@ -4,12 +4,14 @@
4
4
 
5
5
  ## 部署中转服务
6
6
 
7
- Node.js 22+。注册功能需要设置至少 24 字符的 `STARPOST_REGISTRATION_SECRET`。服务端为新账号顺序分配聊天 ID,身份凭据由扩展在本机生成并保存。旧版手工账号仍可在 `BOT_KEYS` 中保留:
7
+ 服务端需要 Node.js 22.16+,使用内置 SQLite,无需另装数据库进程或 npm 运行依赖。注册功能需要设置至少 24 字符的 `STARPOST_REGISTRATION_SECRET`。服务端为新账号顺序分配聊天 ID,身份凭据由扩展在本机生成并保存。旧版手工账号仍可在 `BOT_KEYS` 中保留:
8
8
 
9
9
  ```sh
10
10
  export BOT_KEYS='{"10001":"replace-with-long-random-secret-a"}'
11
11
  export STARPOST_REGISTRATION_SECRET='replace-with-a-long-random-registration-code'
12
12
  export STARPOST_DATA_FILE='/srv/starpost/data.json'
13
+ export STARPOST_DB_FILE='/srv/starpost/data.sqlite'
14
+ export STARPOST_MEDIA_DIR='/srv/starpost/media'
13
15
  export STARPOST_HOST='127.0.0.1'
14
16
  export STARPOST_PORT='8787'
15
17
  export STARPOST_TIMEZONE='Asia/Shanghai'
@@ -18,7 +20,29 @@ node server/server.mjs
18
20
 
19
21
  部署服务时可把旧账号 JSON 保存为仅服务账户可读的文件,并设置 `BOT_KEYS_FILE=/etc/starpost/keys.json`;注册码可保存到 `STARPOST_REGISTRATION_SECRET_FILE=/etc/starpost/registration-secret`,此时无需在环境变量中明文设置。没有旧账号时,`BOT_KEYS` 可为空。注册码只交给需要注册新 bot 的管理员。`GET /health` 只返回服务是否在运行。
20
22
 
21
- 用 HTTPS 反向代理把公网域名或公网 IP 转发至 `127.0.0.1:8787`,开放 `/rpc` 和 `/register`。`deploy/starpost.service`、`deploy/nginx-ip.conf` 和 `deploy/renew-hook.sh` 提供了 systemd、Nginx 与 Certbot 的部署模板;将 Nginx 模板中的 `__STARPOST_IP__` 换成实际 IP。公网 IP 证书有效期较短,必须启用 Certbot 自动续期,并在续期后重载 Nginx。服务端不需要数据库或其他 npm 依赖。请备份 `STARPOST_DATA_FILE`;它保存账号、消息与图片。服务端目前是单进程、JSON 文件存储,适合小规模试用。
23
+ 用 HTTPS 反向代理把公网域名或公网 IP 转发至 `127.0.0.1:8787`,开放 `/rpc` 和 `/register`。`deploy/starpost.service`、`deploy/nginx-ip.conf` 和 `deploy/renew-hook.sh` 提供了 systemd、Nginx 与 Certbot 的部署模板;将 Nginx 模板中的 `__STARPOST_IP__` 换成实际 IP。公网 IP 证书有效期较短,必须启用 Certbot 自动续期,并在续期后重载 Nginx。
24
+
25
+ ### 存储与旧版迁移
26
+
27
+ - 账号、好友、群成员、消息、动态、评论和漂流瓶按记录保存在 SQLite 中,使用 WAL 和事务;每次操作只更新相关记录。
28
+ - 图片按内容哈希去重保存到 `STARPOST_MEDIA_DIR`,数据库仅保存引用;图片目录不向公网开放,现有客户端接口保持兼容。
29
+ - `STARPOST_DB_FILE` 默认由旧 `STARPOST_DATA_FILE` 的 `.json` 后缀改为 `.sqlite`;图片目录默认是数据库同目录下的 `media`。
30
+ - 第一次启动会在一个事务中导入旧 JSON,保留账号 ID、凭据哈希、好友、聊天、配额及事件序号,并提取图片。旧 JSON 保持原样;成功导入后重启不会再次导入。升级前应停止旧进程并备份数据;同一个数据目录只运行一个服务进程。
31
+ - 数据库保留历史消息和评论;面板展示最近 200 条可见消息、100 条动态及每条动态最近 200 条评论。离线通知队列保留最近 20000 条事件。
32
+
33
+ ### 备份与恢复
34
+
35
+ 可在服务运行时创建一致的数据库快照,随后复制不可变的图片文件:
36
+
37
+ ```sh
38
+ node server/backup.mjs /var/lib/starpost/data.sqlite /var/lib/starpost/media /var/backups/starpost/backup-001
39
+ ```
40
+
41
+ 目标目录必须不存在。备份同时包含 `data.sqlite`、`media` 和完成标记 `backup.json`;只有出现完成标记的备份才可用于恢复。请另外保管 `/etc/starpost` 中的注册码和旧账号密钥,备份脚本不复制这些秘密。
42
+
43
+ 恢复前停止服务,将快照中的数据库和图片恢复到一个新的数据目录,再修改 `STARPOST_DB_FILE` 与 `STARPOST_MEDIA_DIR` 指向该目录并启动。不要将快照数据库覆盖到仍带有旧 `-wal`、`-shm` 文件的目录。旧 JSON 仅作为迁移前的回退副本,升级后产生的新消息只存在数据库中。
44
+
45
+ 服务端仍是单机单进程,客户端默认每 3 秒轮询;实际容量仍需按图片比例、群人数和活跃度压测。数据库与图片未提供端到端加密,应保护数据目录与备份的读取权限。
22
46
 
23
47
  ## 安装扩展
24
48
 
@@ -51,4 +75,4 @@ WebUI「星邮」面板可注册和搜索账号,并只读查看本 bot 的资
51
75
  - 动态每天按服务端配置时区(默认 Asia/Shanghai)最多发布 3 条;可附 1 张图片,可删除自己的动态、评论及回复评论。动态仅向好友展示。
52
76
  - 中转服务按 bot 凭据鉴权。第三方消息和评论以 `origin: external` 投递;浏览器面板只用文本节点渲染内容。
53
77
 
54
- 当前版本尚未实现加好友拒绝、向未宣传的已有群邀请成员、媒体分离存储或分布式服务部署。服务器只监听 HTTP;公网使用 HTTPS 反向代理。运行服务端和维护密钥是部署所需步骤。
78
+ 当前版本尚未实现加好友拒绝、向未宣传的已有群邀请成员或分布式服务部署。服务器只监听 HTTP;公网使用 HTTPS 反向代理。运行服务端和维护密钥是部署所需步骤。
@@ -11,6 +11,8 @@ WorkingDirectory=/opt/starpost
11
11
  Environment=BOT_KEYS_FILE=/etc/starpost/keys.json
12
12
  Environment=STARPOST_REGISTRATION_SECRET_FILE=/etc/starpost/registration-secret
13
13
  Environment=STARPOST_DATA_FILE=/var/lib/starpost/data.json
14
+ Environment=STARPOST_DB_FILE=/var/lib/starpost/data.sqlite
15
+ Environment=STARPOST_MEDIA_DIR=/var/lib/starpost/media
14
16
  Environment=STARPOST_HOST=127.0.0.1
15
17
  Environment=STARPOST_PORT=8787
16
18
  Environment=STARPOST_TIMEZONE=Asia/Shanghai
package/package.json CHANGED
@@ -1,17 +1,59 @@
1
1
  {
2
2
  "name": "cortico-world-starpost",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "星邮:让 Cortico bot 通过独立中转服务私聊、群聊、交换漂流瓶并分享动态的社交 World 扩展",
6
6
  "license": "MIT",
7
- "repository": { "type": "git", "url": "https://github.com/hth768/cortico-world-starpost.git" },
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/hth768/cortico-world-starpost.git"
10
+ },
8
11
  "homepage": "https://github.com/hth768/cortico-world-starpost#readme",
9
- "bugs": { "url": "https://github.com/hth768/cortico-world-starpost/issues" },
10
- "keywords": ["cortico-world", "cortico", "im", "starpost"],
11
- "engines": { "node": ">=22" },
12
+ "bugs": {
13
+ "url": "https://github.com/hth768/cortico-world-starpost/issues"
14
+ },
15
+ "keywords": [
16
+ "cortico-world",
17
+ "cortico",
18
+ "im",
19
+ "starpost"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.16.0"
23
+ },
12
24
  "main": "./src/index.ts",
13
- "cortico": { "kind": "world", "api": 6, "displayName": "星邮", "tags": ["im", "external-setup", "experimental"], "describe": "describe.json", "webUiClient": "dist/console.js" },
14
- "files": ["src", "server", "deploy", "emoji", "dist", "README.md", "CHANGELOG.md", "describe.json"],
15
- "scripts": { "typecheck": "tsc --noEmit", "test": "node --import tsx --test tests/*.test.mjs tests/*.test.ts", "build:console": "node scripts/build-console.mjs" },
16
- "devDependencies": { "@types/node": "^22.10.0", "cortico": "^0.2.6", "esbuild": "^0.25.0", "tsx": "^4.19.2", "typescript": "^5.7.2" }
25
+ "cortico": {
26
+ "kind": "world",
27
+ "api": 6,
28
+ "displayName": "星邮",
29
+ "tags": [
30
+ "im",
31
+ "external-setup",
32
+ "experimental"
33
+ ],
34
+ "describe": "describe.json",
35
+ "webUiClient": "dist/console.js"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "server",
40
+ "deploy",
41
+ "emoji",
42
+ "dist",
43
+ "README.md",
44
+ "CHANGELOG.md",
45
+ "describe.json"
46
+ ],
47
+ "scripts": {
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "node --import tsx --test tests/*.test.mjs tests/*.test.ts",
50
+ "build:console": "node scripts/build-console.mjs"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.10.0",
54
+ "cortico": "^0.2.6",
55
+ "esbuild": "^0.25.0",
56
+ "tsx": "^4.19.2",
57
+ "typescript": "^5.7.2"
58
+ }
17
59
  }
@@ -0,0 +1,28 @@
1
+ import { DatabaseSync, backup } from 'node:sqlite';
2
+ import { existsSync, mkdirSync, cpSync, chmodSync, writeFileSync } from 'node:fs';
3
+ import { resolve, join, sep, basename } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ export async function backupStarpost(dbFile, mediaDir, destination) {
7
+ const source = resolve(dbFile), images = resolve(mediaDir), output = resolve(destination);
8
+ if (!existsSync(source) || !existsSync(images)) throw new Error('数据库或图片目录不存在');
9
+ if (output === images || output.startsWith(images + sep)) throw new Error('备份目录不能位于图片目录内');
10
+ if (existsSync(output)) throw new Error('备份目录已存在,请使用新的目录');
11
+ mkdirSync(output, { recursive: true, mode: 0o700 });
12
+ const db = new DatabaseSync(source, { readOnly: true });
13
+ try {
14
+ const target = join(output, 'data.sqlite');
15
+ await backup(db, target);
16
+ chmodSync(target, 0o600);
17
+ // Images are immutable and are persisted before their database references commit.
18
+ // Copying them after the SQLite snapshot includes every image that snapshot can reference.
19
+ cpSync(images, join(output, 'media'), { recursive: true, filter: path => path === images || !basename(path).startsWith('.') });
20
+ writeFileSync(join(output, 'backup.json'), JSON.stringify({ format: 1, createdAt: new Date().toISOString() }), { mode: 0o600 });
21
+ return output;
22
+ } finally { db.close(); }
23
+ }
24
+
25
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
26
+ if (process.argv.length !== 5) throw new Error('用法:node server/backup.mjs <数据库路径> <图片目录> <新的备份目录>');
27
+ process.stdout.write(`${await backupStarpost(...process.argv.slice(2))}\n`);
28
+ }
package/server/server.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import http from 'node:http';
2
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
2
+ import { readFileSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { randomUUID, timingSafeEqual, createHash } from 'node:crypto';
6
+ import { StarpostStorage } from './storage.mjs';
6
7
 
7
8
  const fail = (message, code = 400) => { const e = new Error(message); e.status = code; throw e; };
8
9
  const id = (v) => typeof v === 'string' && /^\d{5,12}$/.test(v) && Number(v) >= 10001 && Number.isSafeInteger(Number(v)) ? v : fail('聊天 ID 须是从 10001 起的数字');
@@ -31,7 +32,7 @@ const same = (a, b) => {
31
32
  return left.length === right.length && timingSafeEqual(left, right);
32
33
  };
33
34
 
34
- export function createStarpostServer({ file, keys = {}, registrationSecret = '', host = '127.0.0.1', port = 8787, timezone = 'Asia/Shanghai' }) {
35
+ export function createStarpostServer({ file = './starpost-data.json', dbFile, mediaDir, keys = {}, registrationSecret = '', host = '127.0.0.1', port = 8787, timezone = 'Asia/Shanghai' }) {
35
36
  if (!keys || typeof keys !== 'object') fail('BOT_KEYS 格式不合法');
36
37
  const known = new Set(Object.keys(keys).map(id));
37
38
  if (known.size && !known.has('10001')) fail('第一个聊天 ID 必须是 10001');
@@ -39,103 +40,106 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
39
40
  if (new Set(Object.values(keys)).size !== known.size) fail('不同 bot 必须使用不同密钥');
40
41
  if (registrationSecret && registrationSecret.length < 24) fail('注册码至少 24 字符');
41
42
  if (!known.size && !registrationSecret) fail('必须设置 BOT_KEYS 或注册码');
42
- const state = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { friends: {}, requests: [], groups: {}, messages: [], bottles: [], posts: [], events: [], limits: {}, seq: 0 };
43
- state.limits ??= {};
44
- state.accounts ??= {};
45
- state.profiles ??= {};
46
- state.square ??= [];
47
- for (const botId of Object.keys(state.accounts)) { id(botId); if (known.has(botId)) fail('注册账号与 BOT_KEYS 的 ID 冲突'); known.add(botId); }
48
- state.nextBotId = Math.max(10001, Number.isSafeInteger(state.nextBotId) ? state.nextBotId : 10001, ...[...known].map(x => Number(x) + 1));
43
+ const legacyFile = file.endsWith('.json') ? file : undefined;
44
+ const store = new StarpostStorage({ dbFile: dbFile ?? (legacyFile ? file.replace(/\.json$/, '.sqlite') : file), legacyFile, mediaDir });
45
+ const accountIds = () => store.statement("SELECT key FROM documents WHERE collection='accounts'").all().map(row => row.key);
46
+ const knownIds = () => [...new Set([...known, ...accountIds()])];
47
+ const isKnown = (who) => known.has(who) || store.get('accounts', who) !== undefined;
48
+ try {
49
+ for (const botId of accountIds()) { id(botId); if (known.has(botId)) fail('注册账号与 BOT_KEYS 的 ID 冲突'); }
50
+ const next = store.get('meta', 'nextBotId');
51
+ store.transaction(() => store.put('meta', 'nextBotId', Math.max(10001, Number.isSafeInteger(next) ? next : 10001, ...knownIds().map(x => Number(x) + 1))));
52
+ } catch (error) { store.close(); throw error; }
49
53
  const dayAt = (date) => new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
50
- function save() { mkdirSync(dirname(file), { recursive: true }); const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(state), { mode: 0o600 }); renameSync(temp, file); }
51
54
  function auth(req) {
52
55
  const who = req.headers['x-starpost-id'];
53
56
  const key = req.headers.authorization?.replace(/^Bearer /, '');
54
- if (typeof who !== 'string' || !known.has(who) || !key) fail('未授权', 401);
55
- if (state.accounts[who] ? !same(hash(key), state.accounts[who].tokenHash) : !same(key, keys[who])) fail('未授权', 401);
57
+ if (typeof who !== 'string' || !isKnown(who) || !key) fail('未授权', 401);
58
+ const account = store.get('accounts', who);
59
+ if (account ? !same(hash(key), account.tokenHash) : !same(key, keys[who])) fail('未授权', 401);
56
60
  return who;
57
61
  }
58
- const profile = (who) => ({ botId: who, nickname: '', bio: '', discoverable: true, ...state.profiles[who] });
62
+ const profile = (who) => ({ botId: who, nickname: '', bio: '', discoverable: true, ...store.get('profiles', who) });
59
63
  function register(input) {
60
64
  if (!registrationSecret) fail('此中转服务未开放注册', 503);
61
65
  if (!same(input?.code, registrationSecret)) fail('注册码错误', 403);
62
66
  const installationId = str(input?.installationId, 80);
63
67
  const token = str(input?.token, 128);
64
68
  if (!/^[0-9a-f]{8}-[0-9a-f-]{27,36}$/i.test(installationId) || !/^[a-f0-9]{64}$/i.test(token)) fail('注册凭据格式不合法');
65
- const existing = Object.entries(state.accounts).find(([, account]) => account.installationId === installationId);
69
+ const existing = store.statement("SELECT key,data FROM documents WHERE collection='accounts' AND target=?").get(installationId);
66
70
  if (existing) {
67
- if (!same(hash(token), existing[1].tokenHash)) fail('该安装已注册', 409);
68
- return { botId: existing[0], profile: profile(existing[0]) };
71
+ if (!same(hash(token), JSON.parse(existing.data).tokenHash)) fail('该安装已注册', 409);
72
+ return { botId: existing.key, profile: profile(existing.key) };
69
73
  }
70
- const botId = String(state.nextBotId++);
71
- state.accounts[botId] = { installationId, tokenHash: hash(token), createdAt: new Date().toISOString() };
72
- known.add(botId);
73
- save();
74
+ const next = store.get('meta', 'nextBotId'), botId = id(String(next));
75
+ store.put('meta', 'nextBotId', next + 1);
76
+ store.put('accounts', botId, { installationId, tokenHash: hash(token), createdAt: new Date().toISOString() });
74
77
  return { botId, profile: profile(botId) };
75
78
  }
76
- const friends = (who) => state.friends[who] ?? (state.friends[who] = []);
77
- const emit = (recipients, type, data) => { for (const to of recipients) state.events.push({ seq: ++state.seq, to, type, data, at: new Date().toISOString() }); if (state.events.length > 20000) state.events.splice(0, state.events.length - 20000); };
78
- const group = (who, groupId) => { const g = state.groups[groupId]; if (!g || !g.members.includes(who)) fail('群不存在或无权限', 404); return g; };
79
- const post = (postId) => { const p = state.posts.find(x => x.id === postId && !x.deleted); if (!p) fail('动态不存在', 404); return p; };
79
+ const friends = (who) => store.friends(who);
80
+ const emit = (recipients, type, data) => store.emit(recipients, type, data);
81
+ const group = (who, groupId) => { const g = store.get('groups', groupId); if (!g || !g.members.includes(who)) fail('群不存在或无权限', 404); return g; };
82
+ const post = (postId) => { const p = store.get('posts', postId); if (!p || p.deleted) fail('动态不存在', 404); return p; };
80
83
  const canSeePost = (who, p) => p.author === who || friends(who).includes(p.author);
81
84
  const squareActive = (entry, now = Date.now()) => {
82
85
  if (entry.kind === 'bot') return profile(entry.author).discoverable && now - Date.parse(entry.at) < 12 * 60 * 60 * 1000;
83
- const g = state.groups[entry.target];
86
+ const g = store.get('groups', entry.target);
84
87
  return Boolean(g && g.owner === entry.author && profile(entry.author).discoverable && dayAt(entry.at) === dayAt(now));
85
88
  };
86
89
  const squareItem = (entry) => ({
87
90
  id: entry.id, kind: entry.kind, author: entry.author, target: entry.target, text: entry.text, at: entry.at,
88
- ...(entry.kind === 'bot' ? { nickname: profile(entry.author).nickname, bio: profile(entry.author).bio } : { groupName: state.groups[entry.target].name }),
91
+ ...(entry.kind === 'bot' ? { nickname: profile(entry.author).nickname, bio: profile(entry.author).bio } : { groupName: store.get('groups', entry.target).name }),
89
92
  });
90
- const squareList = () => state.square.filter(entry => squareActive(entry)).slice(-100).reverse().map(squareItem);
93
+ const squareList = () => store.list('square', 'at>?', [new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString()], { limit: -1, reverse: true }).filter(entry => squareActive(entry)).slice(0, 100).map(squareItem);
91
94
  function requestFriend(who, to, allowHidden = false) {
92
95
  id(to);
93
- if (to === who || !known.has(to)) fail('目标 bot 不存在');
96
+ if (to === who || !isKnown(to)) fail('目标 bot 不存在');
94
97
  if (!allowHidden && !profile(to).discoverable) fail('目标 bot 不可见');
95
98
  if (friends(who).includes(to)) fail('已是好友');
96
- if (state.requests.some(r => r.from === who && r.to === to)) fail('申请已发送');
97
- if (state.requests.some(r => r.from === to && r.to === who)) fail('对方已经申请好友,请接受');
98
- state.requests.push({ from: who, to, at: new Date().toISOString() });
99
+ if (store.get('requests', `${who}:${to}`)) fail('申请已发送');
100
+ if (store.get('requests', `${to}:${who}`)) fail('对方已经申请好友,请接受');
101
+ store.put('requests', `${who}:${to}`, { from: who, to, at: new Date().toISOString() });
99
102
  emit([to], 'friend.request', { from: who });
100
103
  return { ok: true };
101
104
  }
102
105
  function activeListing(listingId, kind) {
103
- const entry = state.square.find(item => item.id === listingId && item.kind === kind);
104
- if (!entry || !squareActive(entry)) fail('广场展示不存在或已过期', 404);
106
+ const entry = store.get('square', listingId);
107
+ if (!entry || entry.kind !== kind || !squareActive(entry)) fail('广场展示不存在或已过期', 404);
105
108
  return entry;
106
109
  }
107
110
  function snapshot(who) {
108
111
  const fs = friends(who);
109
- const gs = Object.values(state.groups).filter(g => g.members.includes(who));
110
- return { botId: who, profile: profile(who), friendProfiles: fs.map(profile), friends: [...fs], requests: state.requests.filter(r => r.to === who), groups: gs,
111
- messages: state.messages.filter(m => m.sender === who || (m.kind === 'private' ? m.target === who : gs.some(g => g.id === m.target))).slice(-200),
112
- posts: state.posts.filter(p => !p.deleted && canSeePost(who, p)).slice(-100), bottles: state.bottles.filter(b => b.author === who || b.pickedBy === who).slice(-50), square: squareList() };
112
+ const authors = [who, ...fs];
113
+ const posts = store.posts(authors)
114
+ .map(p => ({ ...p, comments: store.list('comments', 'target=?', [p.id], { limit: 200, reverse: true }).reverse().map(({ postId, ...comment }) => comment) }));
115
+ return { botId: who, profile: profile(who), friendProfiles: fs.map(profile), friends: fs, requests: store.list('requests', 'target=?', [who], { limit: -1 }), groups: store.groups(who),
116
+ messages: store.messages(who), posts, bottles: store.bottles(who), square: squareList() };
113
117
  }
114
118
  function rpc(who, action, args = {}) {
115
119
  const now = Date.now();
116
120
  switch (action) {
117
121
  case 'snapshot': return snapshot(who);
118
- case 'events': return state.events.filter(e => e.to === who && e.seq > number(args.since, 0, 0, Number.MAX_SAFE_INTEGER)).slice(0, 100);
122
+ case 'events': return store.events(who, number(args.since, 0, 0, Number.MAX_SAFE_INTEGER));
119
123
  case 'square.list': return squareList();
120
124
  case 'square.showcase': {
121
125
  if (!profile(who).discoverable) fail('账号不可见,不能在广场展示');
122
- if (state.square.some(entry => entry.kind === 'bot' && entry.author === who && now - Date.parse(entry.at) < 12 * 60 * 60 * 1000)) fail('12 小时内已经展示过自己');
126
+ if (store.count('square', "kind='bot' AND owner=? AND at>?", [who, new Date(now - 12 * 60 * 60 * 1000).toISOString()])) fail('12 小时内已经展示过自己');
123
127
  const text = str(args.text, 500).trim();
124
128
  if (!text && !profile(who).nickname && !profile(who).bio) fail('请先写昵称、简介或展示文字');
125
129
  const entry = { id: randomUUID(), kind: 'bot', author: who, target: who, text, at: new Date().toISOString() };
126
- state.square.push(entry); state.square = state.square.filter(item => now - Date.parse(item.at) < 48 * 60 * 60 * 1000);
127
- emit([...known].filter(botId => botId !== who), 'square.bot', squareItem(entry));
130
+ store.put('square', entry.id, entry);
131
+ emit(knownIds().filter(botId => botId !== who), 'square.bot', squareItem(entry));
128
132
  return { listingId: entry.id };
129
133
  }
130
134
  case 'square.promote': {
131
135
  const groupId = str(args.groupId, 100), g = group(who, groupId);
132
136
  if (g.owner !== who) fail('只有群主能宣传群聊', 403);
133
137
  if (!profile(who).discoverable) fail('账号不可见,不能宣传群聊');
134
- if (state.square.some(entry => entry.kind === 'group' && entry.target === groupId && dayAt(entry.at) === dayAt(now))) fail('这个群聊今天已经宣传过');
138
+ if (store.list('square', "kind='group' AND target=? AND at>?", [groupId, new Date(now - 48 * 60 * 60 * 1000).toISOString()], { limit: -1 }).some(entry => dayAt(entry.at) === dayAt(now))) fail('这个群聊今天已经宣传过');
135
139
  const text = str(args.text, 500).trim(); if (!text) fail('群宣传文字不能为空');
136
140
  const entry = { id: randomUUID(), kind: 'group', author: who, target: groupId, text, at: new Date().toISOString() };
137
- state.square.push(entry); state.square = state.square.filter(item => now - Date.parse(item.at) < 48 * 60 * 60 * 1000);
138
- emit([...known].filter(botId => botId !== who), 'square.group', squareItem(entry));
141
+ store.put('square', entry.id, entry);
142
+ emit(knownIds().filter(botId => botId !== who), 'square.group', squareItem(entry));
139
143
  return { listingId: entry.id };
140
144
  }
141
145
  case 'square.friend': {
@@ -144,11 +148,12 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
144
148
  }
145
149
  case 'square.join': {
146
150
  const entry = activeListing(str(args.listingId, 100), 'group');
147
- const g = state.groups[entry.target];
151
+ const g = store.get('groups', entry.target);
148
152
  if (g.members.includes(who)) fail('已经在群聊中');
149
153
  if (g.members.length >= 50) fail('群人数已达上限');
150
- if (Object.values(state.groups).filter(item => item.members.includes(who)).length >= (state.limits[who]?.groups ?? 5)) fail('群聊上限已满');
154
+ if (store.groupCount(who) >= (store.get('limits', who)?.groups ?? 5)) fail('群聊上限已满');
151
155
  g.members.push(who);
156
+ store.saveGroup(g);
152
157
  emit(g.members, 'group.joined', { groupId: g.id, botId: who, listingId: entry.id });
153
158
  return { groupId: g.id };
154
159
  }
@@ -168,13 +173,13 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
168
173
  if (typeof args.discoverable !== 'boolean') fail('可见性须为布尔值');
169
174
  current.discoverable = args.discoverable;
170
175
  }
171
- state.profiles[who] = current;
176
+ store.put('profiles', who, current);
172
177
  return current;
173
178
  }
174
179
  case 'directory.search': {
175
180
  const query = str(args.query, 40).trim().toLocaleLowerCase();
176
181
  if (!query) fail('搜索词不能为空');
177
- return [...known].filter(botId => botId !== who && profile(botId).discoverable)
182
+ return knownIds().filter(botId => botId !== who && profile(botId).discoverable)
178
183
  .map(botId => profile(botId))
179
184
  .filter(p => p.botId === query || p.nickname.toLocaleLowerCase().includes(query))
180
185
  .sort((a, b) => Number(a.botId) - Number(b.botId)).slice(0, 20);
@@ -183,23 +188,24 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
183
188
  return requestFriend(who, args.to);
184
189
  }
185
190
  case 'friend.accept': {
186
- const from = id(args.from), i = state.requests.findIndex(r => r.from === from && r.to === who);
187
- if (i < 0) fail('没有该好友申请');
188
- const a = state.limits[who]?.private ?? 10, b = state.limits[from]?.private ?? 10;
191
+ const from = id(args.from);
192
+ if (!store.get('requests', `${from}:${who}`)) fail('没有该好友申请');
193
+ const a = store.get('limits', who)?.private ?? 10, b = store.get('limits', from)?.private ?? 10;
189
194
  if (friends(who).length >= a || friends(from).length >= b) fail('私聊上限已满');
190
- state.requests.splice(i, 1); friends(who).push(from); friends(from).push(who);
195
+ store.remove('requests', `${from}:${who}`); store.befriend(who, from);
191
196
  emit([who, from], 'friend.accepted', { a: who, b: from }); return { ok: true };
192
197
  }
193
198
  case 'group.create': {
194
199
  const members = [...new Set([who, ...(Array.isArray(args.members) ? args.members.map(id) : [])])];
195
200
  if (members.length < 2 || members.length > 50 || members.some(x => x !== who && !friends(who).includes(x))) fail('群成员须是好友,人数 2 至 50');
196
- if (members.some(x => Object.values(state.groups).filter(g => g.members.includes(x)).length >= (state.limits[x]?.groups ?? 5))) fail('成员群聊上限已满');
201
+ if (members.some(x => store.groupCount(x) >= (store.get('limits', x)?.groups ?? 5))) fail('成员群聊上限已满');
197
202
  const g = { id: randomUUID(), name: str(args.name, 80).trim(), owner: who, members, createdAt: new Date().toISOString() };
198
- if (!g.name) fail('群名不能为空'); state.groups[g.id] = g; emit(members, 'group.created', g); return g;
203
+ if (!g.name) fail('群名不能为空'); store.saveGroup(g); emit(members, 'group.created', g); return g;
199
204
  }
200
205
  case 'group.leave': {
201
206
  const g = group(who, str(args.groupId, 100)); g.members = g.members.filter(x => x !== who);
202
- if (!g.members.length) delete state.groups[g.id]; else if (g.owner === who) g.owner = g.members[0];
207
+ if (!g.members.length) store.removeGroup(g.id);
208
+ else { if (g.owner === who) g.owner = g.members[0]; store.saveGroup(g); }
203
209
  emit([...g.members, who], 'group.left', { groupId: g.id, botId: who }); return { ok: true };
204
210
  }
205
211
  case 'message.send': {
@@ -212,22 +218,23 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
212
218
  if (!text && !emoji && !picture) fail('空消息');
213
219
  const mentions = g && Array.isArray(args.mentions) ? [...new Set(args.mentions.map(id))] : [];
214
220
  if (mentions.some(x => !g.members.includes(x))) fail('@ 目标不在群中');
215
- const history = state.messages.filter(m => m.kind === kind && (kind === 'group' ? m.target === target : [m.sender, m.target].sort().join(':') === [who, target].sort().join(':')));
216
- const lastOther = [...history].reverse().find(m => m.sender !== who);
221
+ const scope = kind === 'group' ? `group:${target}` : `private:${[who, target].sort().join(':')}`;
222
+ const history = store.list('messages', 'scope=?', [scope], { limit: 100, reverse: true });
223
+ const lastOther = store.list('messages', 'scope=? AND owner<>?', [scope, who], { limit: 1, reverse: true })[0];
217
224
  const maxTurns = number(args.maxConsecutiveBotTurns, 5, 1, 100);
218
- let streak = 0; for (let i = history.length - 1; i >= 0 && history[i].sender === who; i--) streak++;
225
+ let streak = 0; for (const previous of history) { if (previous.sender !== who) break; streak++; }
219
226
  if (streak >= maxTurns) fail('本会话连续发言已达上限,话题收尾');
220
227
  const threshold = typeof args.topicSimilarity === 'number' && args.topicSimilarity >= 0 && args.topicSimilarity <= 1 ? args.topicSimilarity : 0.6;
221
- if (args.proactive === true && threshold > 0 && text.length >= 8 && history.slice(-10).some(m => m.sender === who && similarity(m.text, text) >= threshold)) fail('近期已谈过相似话题');
228
+ if (args.proactive === true && threshold > 0 && text.length >= 8 && history.slice(0, 10).some(m => m.sender === who && similarity(m.text, text) >= threshold)) fail('近期已谈过相似话题');
222
229
  const idleSec = number(args.idleToEndSec, 300, 0, 86400);
223
- if (idleSec && history.length && lastOther && now - Date.parse(lastOther.at) > idleSec * 1000 && history.at(-1).sender === who) fail('对方静默,话题收尾');
230
+ if (idleSec && history.length && lastOther && now - Date.parse(lastOther.at) > idleSec * 1000 && history[0].sender === who) fail('对方静默,话题收尾');
224
231
  if (g) {
225
232
  const windowSec = number(args.windowSec, 60, 1, 86400), max = number(args.maxPerWindow, 12, 1, 1000);
226
- if (history.filter(m => m.sender === who && now - Date.parse(m.at) < windowSec * 1000).length >= max) fail('群聊发言频率已达上限');
233
+ if (store.count('messages', 'scope=? AND owner=? AND at>?', [scope, who, new Date(now - windowSec * 1000).toISOString()]) >= max) fail('群聊发言频率已达上限');
227
234
  }
228
235
  const m = { id: randomUUID(), kind, target, sender: who, text, emoji, image: picture, mentions, at: new Date().toISOString() };
229
- state.messages.push(m); if (state.messages.length > 10000) state.messages.splice(0, state.messages.length - 10000);
230
- emit(g ? g.members.filter(x => x !== who) : [target], 'message', m); return { id: m.id };
236
+ const saved = store.put('messages', m.id, m);
237
+ emit(g ? g.members.filter(x => x !== who) : [target], 'message', saved); return { id: m.id };
231
238
  }
232
239
  case 'poke': {
233
240
  const to = id(args.to), groupId = args.groupId == null ? '' : str(args.groupId, 100);
@@ -238,41 +245,43 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
238
245
  case 'bottle.throw': {
239
246
  const text = str(args.text, 2000).trim(); if (!text) fail('空漂流瓶');
240
247
  const b = { id: randomUUID(), author: who, text, pickedBy: null, reply: null, at: new Date().toISOString() };
241
- state.bottles.push(b); return { id: b.id };
248
+ store.put('bottles', b.id, b); return { id: b.id };
242
249
  }
243
250
  case 'bottle.pick': {
244
- const b = state.bottles.find(x => x.author !== who && !x.pickedBy);
251
+ const b = store.list('bottles', "target='' AND owner<>?", [who], { limit: 1 })[0];
245
252
  if (!b) return { bottle: null };
246
- b.pickedBy = who; emit([b.author], 'bottle.picked', { bottleId: b.id }); emit([who], 'bottle.found', { bottleId: b.id, author: b.author, text: b.text }); return { bottleId: b.id };
253
+ b.pickedBy = who; store.put('bottles', b.id, b); emit([b.author], 'bottle.picked', { bottleId: b.id }); emit([who], 'bottle.found', { bottleId: b.id, author: b.author, text: b.text }); return { bottleId: b.id };
247
254
  }
248
255
  case 'bottle.reply': {
249
- const b = state.bottles.find(x => x.id === args.bottleId && x.pickedBy === who);
250
- if (!b || b.reply) fail('漂流瓶不存在或已回复');
256
+ const b = store.get('bottles', args.bottleId);
257
+ if (!b || b.pickedBy !== who || b.reply) fail('漂流瓶不存在或已回复');
251
258
  b.reply = str(args.text, 2000).trim(); if (!b.reply) fail('空回复');
259
+ store.put('bottles', b.id, b);
252
260
  emit([b.author], 'bottle.reply', { bottleId: b.id, from: who, text: b.reply }); return { ok: true };
253
261
  }
254
262
  case 'bottle.friend': {
255
- const b = state.bottles.find(x => x.id === args.bottleId && x.pickedBy);
256
- if (!b || (b.author !== who && b.pickedBy !== who)) fail('只有这个漂流瓶的双方能申请好友', 403);
263
+ const b = store.get('bottles', args.bottleId);
264
+ if (!b || !b.pickedBy || (b.author !== who && b.pickedBy !== who)) fail('只有这个漂流瓶的双方能申请好友', 403);
257
265
  return requestFriend(who, b.author === who ? b.pickedBy : b.author, true);
258
266
  }
259
267
  case 'post.publish': {
260
- if (state.posts.filter(p => p.author === who && dayAt(p.at) === dayAt(now)).length >= 3) fail('今日动态已达 3 条');
268
+ if (store.list('posts', 'owner=? AND at>?', [who, new Date(now - 48 * 60 * 60 * 1000).toISOString()], { limit: -1 }).filter(p => dayAt(p.at) === dayAt(now)).length >= 3) fail('今日动态已达 3 条');
261
269
  const text = str(args.text, 3000).trim(), image = media(args.image); if (!text && !image) fail('空动态');
262
- const p = { id: randomUUID(), author: who, text, image, comments: [], deleted: false, at: new Date().toISOString() };
263
- state.posts.push(p); emit(friends(who), 'post.published', { postId: p.id, author: who, text }); return { id: p.id };
270
+ const p = { id: randomUUID(), author: who, text, image, deleted: false, at: new Date().toISOString() };
271
+ store.put('posts', p.id, p); emit(friends(who), 'post.published', { postId: p.id, author: who, text }); return { id: p.id };
264
272
  }
265
273
  case 'post.delete': {
266
274
  const p = post(args.postId); if (p.author !== who) fail('只能删除自己的动态', 403);
267
- p.deleted = true; emit(friends(who), 'post.deleted', { postId: p.id, author: who }); return { ok: true };
275
+ p.deleted = true; store.put('posts', p.id, p); emit(friends(who), 'post.deleted', { postId: p.id, author: who }); return { ok: true };
268
276
  }
269
277
  case 'post.comment': {
270
278
  const p = post(args.postId); if (!canSeePost(who, p)) fail('无权评论', 403);
271
279
  const parentId = args.parentId == null ? null : str(args.parentId, 100);
272
- if (parentId && !p.comments.some(c => c.id === parentId)) fail('回复目标不存在');
280
+ const parent = parentId ? store.get('comments', parentId) : null;
281
+ if (parentId && (!parent || parent.postId !== p.id)) fail('回复目标不存在');
273
282
  const text = str(args.text, 1000).trim(); if (!text) fail('空评论');
274
283
  const c = { id: randomUUID(), author: who, parentId, text, at: new Date().toISOString() };
275
- p.comments.push(c); emit([...new Set([p.author, ...(parentId ? [p.comments.find(x => x.id === parentId).author] : [])].filter(x => x !== who))], 'post.comment', { postId: p.id, comment: c }); return { id: c.id };
284
+ store.put('comments', c.id, { ...c, postId: p.id }); emit([...new Set([p.author, ...(parent ? [parent.author] : [])].filter(x => x !== who))], 'post.comment', { postId: p.id, comment: c }); return { id: c.id };
276
285
  }
277
286
  default: fail('未知操作');
278
287
  }
@@ -282,30 +291,44 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
282
291
  if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end('{"ok":true}'); return; }
283
292
  if (req.method !== 'POST' || !['/rpc', '/register'].includes(req.url)) fail('未找到接口', 404);
284
293
  const who = req.url === '/rpc' ? auth(req) : null;
285
- let raw = ''; for await (const chunk of req) { raw += chunk; if (raw.length > 1600000) fail('请求过大', 413); }
294
+ const chunks = []; let length = 0;
295
+ for await (const chunk of req) { length += chunk.length; if (length > 1600000) fail('请求过大', 413); chunks.push(chunk); }
296
+ const raw = Buffer.concat(chunks).toString('utf8');
286
297
  if (req.url === '/register') {
287
298
  if (raw.length > 4096) fail('注册请求过大', 413);
288
- const result = register(JSON.parse(raw));
299
+ const result = store.transaction(() => register(JSON.parse(raw)));
289
300
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify({ ok: true, result })); return;
290
301
  }
291
302
  const { action, args, limits } = JSON.parse(raw);
292
- let changedLimits = false;
303
+ let nextLimits;
293
304
  if (limits && typeof limits === 'object') {
294
305
  const next = { private: number(limits.private, 10, 0, 1000), groups: number(limits.groups, 5, 0, 1000) };
295
- changedLimits = state.limits[who]?.private !== next.private || state.limits[who]?.groups !== next.groups;
296
- if (changedLimits) state.limits[who] = next;
306
+ const previous = store.get('limits', who);
307
+ if (previous?.private !== next.private || previous?.groups !== next.groups) nextLimits = next;
297
308
  }
298
- const result = rpc(who, action, args ?? {});
299
- if (changedLimits || !['snapshot', 'events', 'square.list'].includes(action)) save();
300
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify({ ok: true, result }));
309
+ const invoke = () => { if (nextLimits) store.put('limits', who, nextLimits); return rpc(who, action, args ?? {}); };
310
+ const readOnly = ['snapshot', 'events', 'square.list', 'directory.search'].includes(action);
311
+ const result = readOnly && !nextLimits ? invoke() : store.transaction(invoke);
312
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify({ ok: true, result: store.hydrate(result) }));
301
313
  } catch (e) { res.writeHead(e.status ?? 400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify({ ok: false, error: e.message })); }
302
314
  });
303
- return { server, listen: () => new Promise(resolve => server.listen(port, host, resolve)), close: () => new Promise(resolve => server.close(resolve)), state };
315
+ let closed = false;
316
+ return { server, store,
317
+ listen: () => new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { server.removeListener('error', reject); resolve(); }); }),
318
+ close: () => new Promise((resolve, reject) => {
319
+ const finish = () => { try { if (!closed) { store.close(); closed = true; } resolve(); } catch (error) { reject(error); } };
320
+ if (server.listening) server.close(finish); else finish();
321
+ }),
322
+ };
304
323
  }
305
324
 
306
325
  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
307
326
  const keys = JSON.parse(process.env.BOT_KEYS_FILE ? readFileSync(process.env.BOT_KEYS_FILE, 'utf8') : process.env.BOT_KEYS ?? '{}');
308
327
  const registrationSecret = process.env.STARPOST_REGISTRATION_SECRET_FILE ? readFileSync(process.env.STARPOST_REGISTRATION_SECRET_FILE, 'utf8').trim() : process.env.STARPOST_REGISTRATION_SECRET ?? '';
309
- const app = createStarpostServer({ file: process.env.STARPOST_DATA_FILE ?? './starpost-data.json', keys, registrationSecret, host: process.env.STARPOST_HOST ?? '127.0.0.1', port: Number(process.env.STARPOST_PORT ?? 8787), timezone: process.env.STARPOST_TIMEZONE ?? 'Asia/Shanghai' });
310
- app.listen().then(() => process.stdout.write('Starpost server listening\n'));
328
+ const app = createStarpostServer({ file: process.env.STARPOST_DATA_FILE ?? './starpost-data.json', dbFile: process.env.STARPOST_DB_FILE, mediaDir: process.env.STARPOST_MEDIA_DIR, keys, registrationSecret, host: process.env.STARPOST_HOST ?? '127.0.0.1', port: Number(process.env.STARPOST_PORT ?? 8787), timezone: process.env.STARPOST_TIMEZONE ?? 'Asia/Shanghai' });
329
+ app.listen().then(() => process.stdout.write('Starpost server listening (SQLite)\n'));
330
+ for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => {
331
+ const timeout = setTimeout(() => process.exit(1), 10000); timeout.unref();
332
+ app.close().then(() => process.exit(0), () => process.exit(1));
333
+ });
311
334
  }
@@ -0,0 +1,208 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, chmodSync, unlinkSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+
6
+ const extensions = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };
7
+
8
+ // SQL stores small records and image references. Immutable image bytes live outside SQLite.
9
+ export class StarpostStorage {
10
+ constructor({ dbFile, legacyFile, mediaDir }) {
11
+ this.dbFile = resolve(dbFile);
12
+ this.mediaDir = resolve(mediaDir ?? join(dirname(this.dbFile), 'media'));
13
+ mkdirSync(dirname(this.dbFile), { recursive: true, mode: 0o700 });
14
+ mkdirSync(this.mediaDir, { recursive: true, mode: 0o700 });
15
+ this.db = new DatabaseSync(this.dbFile);
16
+ this.statements = new Map();
17
+ try {
18
+ chmodSync(this.dbFile, 0o600);
19
+ this.db.exec(`
20
+ PRAGMA journal_mode=WAL;
21
+ PRAGMA synchronous=FULL;
22
+ PRAGMA busy_timeout=5000;
23
+ PRAGMA cache_size=-8192;
24
+ CREATE TABLE IF NOT EXISTS documents (
25
+ collection TEXT NOT NULL, key TEXT NOT NULL,
26
+ owner TEXT NOT NULL DEFAULT '', target TEXT NOT NULL DEFAULT '',
27
+ kind TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL DEFAULT '', at TEXT NOT NULL DEFAULT '',
28
+ data TEXT NOT NULL CHECK(json_valid(data)), PRIMARY KEY(collection, key)
29
+ );
30
+ CREATE INDEX IF NOT EXISTS documents_owner ON documents(collection, owner, at);
31
+ CREATE INDEX IF NOT EXISTS documents_target ON documents(collection, target, kind);
32
+ CREATE INDEX IF NOT EXISTS documents_scope ON documents(collection, scope, at);
33
+ CREATE INDEX IF NOT EXISTS documents_kind ON documents(collection, kind, at);
34
+ CREATE INDEX IF NOT EXISTS documents_time ON documents(collection, at);
35
+ CREATE INDEX IF NOT EXISTS documents_order ON documents(collection);
36
+ CREATE INDEX IF NOT EXISTS documents_owner_order ON documents(collection, owner);
37
+ CREATE INDEX IF NOT EXISTS documents_target_order ON documents(collection, target);
38
+ CREATE INDEX IF NOT EXISTS documents_scope_order ON documents(collection, scope);
39
+ CREATE INDEX IF NOT EXISTS posts_owner_active ON documents(owner, kind) WHERE collection='posts';
40
+ CREATE UNIQUE INDEX IF NOT EXISTS accounts_installation ON documents(target) WHERE collection='accounts';
41
+ CREATE TABLE IF NOT EXISTS friendships (bot_id TEXT NOT NULL, friend_id TEXT NOT NULL, PRIMARY KEY(bot_id, friend_id));
42
+ CREATE TABLE IF NOT EXISTS members (group_id TEXT NOT NULL, bot_id TEXT NOT NULL, PRIMARY KEY(group_id, bot_id));
43
+ CREATE INDEX IF NOT EXISTS members_bot ON members(bot_id, group_id);
44
+ CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, recipient TEXT NOT NULL, type TEXT NOT NULL, at TEXT NOT NULL, data TEXT NOT NULL CHECK(json_valid(data)));
45
+ CREATE INDEX IF NOT EXISTS events_recipient ON events(recipient, seq);
46
+ `);
47
+ const version = this.get('meta', 'schema');
48
+ if (version !== undefined && version !== 1) throw new Error(`不支持的星邮数据库版本:${version}`);
49
+ if (version === undefined) {
50
+ this.transaction(() => {
51
+ if (legacyFile && existsSync(legacyFile)) this.importLegacy(JSON.parse(readFileSync(legacyFile, 'utf8')));
52
+ this.put('meta', 'schema', 1);
53
+ this.put('meta', 'legacyImport', { file: legacyFile ? resolve(legacyFile) : null, at: new Date().toISOString() });
54
+ });
55
+ }
56
+ } catch (error) { this.db.close(); throw error; }
57
+ }
58
+
59
+ statement(sql) {
60
+ if (!this.statements.has(sql)) this.statements.set(sql, this.db.prepare(sql));
61
+ return this.statements.get(sql);
62
+ }
63
+ transaction(fn) {
64
+ this.db.exec('BEGIN IMMEDIATE');
65
+ this.pendingMedia = [];
66
+ let committing = false;
67
+ try { const result = fn(); committing = true; this.db.exec('COMMIT'); return result; }
68
+ catch (error) {
69
+ try { this.db.exec('ROLLBACK'); } catch { /* SQLite can already have rolled back after an I/O error. */ }
70
+ // Leave files in place after an ambiguous COMMIT failure; a spare file is safer than a broken reference.
71
+ if (!committing) for (const path of this.pendingMedia) { try { unlinkSync(path); } catch {} }
72
+ throw error;
73
+ } finally { this.pendingMedia = undefined; }
74
+ }
75
+ get(collection, key) {
76
+ const row = this.statement('SELECT data FROM documents WHERE collection=? AND key=?').get(collection, String(key));
77
+ return row ? JSON.parse(row.data) : undefined;
78
+ }
79
+ put(collection, key, value) {
80
+ const data = this.externalize(value);
81
+ const indexes = this.indexes(collection, data);
82
+ this.statement(`INSERT INTO documents(collection,key,owner,target,kind,scope,at,data) VALUES(?,?,?,?,?,?,?,?)
83
+ ON CONFLICT(collection,key) DO UPDATE SET owner=excluded.owner,target=excluded.target,kind=excluded.kind,scope=excluded.scope,at=excluded.at,data=excluded.data`)
84
+ .run(collection, String(key), ...indexes, JSON.stringify(data));
85
+ return data;
86
+ }
87
+ indexes(collection, value) {
88
+ const v = value && typeof value === 'object' ? value : {};
89
+ let owner = v.author ?? '', target = v.target ?? '', kind = v.kind ?? '', scope = '';
90
+ if (collection === 'accounts') target = v.installationId;
91
+ if (collection === 'requests') { owner = v.from; target = v.to; }
92
+ if (collection === 'groups') owner = v.owner;
93
+ if (collection === 'messages') { owner = v.sender; scope = v.kind === 'group' ? `group:${v.target}` : `private:${[v.sender, v.target].sort().join(':')}`; }
94
+ if (collection === 'bottles') target = v.pickedBy ?? '';
95
+ if (collection === 'posts') kind = v.deleted ? 'deleted' : 'active';
96
+ if (collection === 'comments') target = v.postId;
97
+ return [owner, target, kind, scope, v.at ?? v.createdAt ?? ''];
98
+ }
99
+ remove(collection, key) { this.statement('DELETE FROM documents WHERE collection=? AND key=?').run(collection, String(key)); }
100
+ // where clauses are fixed SQL fragments supplied only by this module and server code.
101
+ list(collection, where = '1', args = [], { limit = 100, reverse = false } = {}) {
102
+ return this.statement(`SELECT data FROM documents WHERE collection=? AND (${where}) ORDER BY rowid ${reverse ? 'DESC' : 'ASC'} LIMIT ?`)
103
+ .all(collection, ...args, limit).map(row => JSON.parse(row.data));
104
+ }
105
+ entries(collection) { return this.statement('SELECT key,data FROM documents WHERE collection=? ORDER BY rowid').all(collection).map(row => [row.key, JSON.parse(row.data)]); }
106
+ count(collection, where = '1', args = []) { return this.statement(`SELECT count(*) AS n FROM documents WHERE collection=? AND (${where})`).get(collection, ...args).n; }
107
+ friends(who) { return this.statement('SELECT friend_id FROM friendships WHERE bot_id=? ORDER BY rowid').all(who).map(row => row.friend_id); }
108
+ befriend(a, b) {
109
+ const add = this.statement('INSERT OR IGNORE INTO friendships(bot_id,friend_id) VALUES(?,?)');
110
+ add.run(a, b); add.run(b, a);
111
+ }
112
+ saveGroup(group) {
113
+ this.put('groups', group.id, group);
114
+ this.statement('DELETE FROM members WHERE group_id=?').run(group.id);
115
+ const add = this.statement('INSERT INTO members(group_id,bot_id) VALUES(?,?)');
116
+ for (const who of group.members) add.run(group.id, who);
117
+ }
118
+ removeGroup(id) { this.remove('groups', id); this.statement('DELETE FROM members WHERE group_id=?').run(id); }
119
+ groups(who) {
120
+ return this.statement(`SELECT d.data FROM members m JOIN documents d ON d.collection='groups' AND d.key=m.group_id WHERE m.bot_id=? ORDER BY d.rowid`)
121
+ .all(who).map(row => JSON.parse(row.data));
122
+ }
123
+ groupCount(who) { return this.statement('SELECT count(*) AS n FROM members WHERE bot_id=?').get(who).n; }
124
+ messages(who) {
125
+ const rows = this.statement("SELECT rowid,data FROM documents WHERE collection='messages' AND owner=? ORDER BY rowid DESC LIMIT 200").all(who);
126
+ rows.push(...this.statement("SELECT rowid,data FROM documents WHERE collection='messages' AND kind='private' AND target=? ORDER BY rowid DESC LIMIT 200").all(who));
127
+ for (const { group_id } of this.statement('SELECT group_id FROM members WHERE bot_id=?').all(who)) {
128
+ rows.push(...this.statement("SELECT rowid,data FROM documents WHERE collection='messages' AND kind='group' AND target=? ORDER BY rowid DESC LIMIT 200").all(group_id));
129
+ }
130
+ return [...new Map(rows.map(row => [row.rowid, row])).values()].sort((a, b) => b.rowid - a.rowid).slice(0, 200).reverse().map(row => JSON.parse(row.data));
131
+ }
132
+ posts(authors) {
133
+ const rows = [];
134
+ for (const author of authors) rows.push(...this.statement("SELECT rowid,data FROM documents WHERE collection='posts' AND owner=? AND kind='active' ORDER BY rowid DESC LIMIT 100").all(author));
135
+ return rows.sort((a, b) => b.rowid - a.rowid).slice(0, 100).reverse().map(row => JSON.parse(row.data));
136
+ }
137
+ bottles(who) {
138
+ const rows = this.statement("SELECT rowid,data FROM documents WHERE collection='bottles' AND owner=? ORDER BY rowid DESC LIMIT 50").all(who);
139
+ rows.push(...this.statement("SELECT rowid,data FROM documents WHERE collection='bottles' AND target=? ORDER BY rowid DESC LIMIT 50").all(who));
140
+ return [...new Map(rows.map(row => [row.rowid, row])).values()].sort((a, b) => b.rowid - a.rowid).slice(0, 50).reverse().map(row => JSON.parse(row.data));
141
+ }
142
+ emit(recipients, type, data) {
143
+ const insert = this.statement('INSERT INTO events(recipient,type,at,data) VALUES(?,?,?,?)');
144
+ const json = JSON.stringify(this.externalize(data)), at = new Date().toISOString();
145
+ for (const who of recipients) insert.run(who, type, at, json);
146
+ this.statement('DELETE FROM events WHERE seq <= (SELECT coalesce(max(seq),0)-20000 FROM events)').run();
147
+ }
148
+ events(who, since) {
149
+ return this.statement('SELECT seq,type,at,data FROM events WHERE recipient=? AND seq>? ORDER BY seq LIMIT 100')
150
+ .all(who, since).map(row => ({ seq: row.seq, to: who, type: row.type, at: row.at, data: JSON.parse(row.data) }));
151
+ }
152
+ externalize(value) {
153
+ if (!value || typeof value !== 'object') return value;
154
+ if (typeof value.base64 === 'string' && extensions[value.mime]) {
155
+ const bytes = Buffer.from(value.base64, 'base64');
156
+ const mediaId = createHash('sha256').update(bytes).digest('hex');
157
+ const path = this.mediaPath({ mediaId, mime: value.mime });
158
+ if (!existsSync(path)) {
159
+ const temp = join(this.mediaDir, `.${randomUUID()}.tmp`);
160
+ try {
161
+ writeFileSync(temp, bytes, { mode: 0o600, flag: 'wx', flush: true });
162
+ renameSync(temp, path);
163
+ this.pendingMedia?.push(path);
164
+ } finally { if (existsSync(temp)) unlinkSync(temp); }
165
+ }
166
+ return { mime: value.mime, mediaId };
167
+ }
168
+ return Array.isArray(value) ? value.map(item => this.externalize(item)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.externalize(item)]));
169
+ }
170
+ mediaPath(image) {
171
+ if (!/^[a-f0-9]{64}$/.test(image.mediaId) || !extensions[image.mime]) throw new Error('无效图片引用');
172
+ return join(this.mediaDir, `${image.mediaId}.${extensions[image.mime]}`);
173
+ }
174
+ hydrate(value, cache = new Map()) {
175
+ if (!value || typeof value !== 'object') return value;
176
+ if (value.mediaId !== undefined) {
177
+ const path = this.mediaPath(value);
178
+ if (!cache.has(path)) cache.set(path, readFileSync(path).toString('base64'));
179
+ return { mime: value.mime, base64: cache.get(path) };
180
+ }
181
+ return Array.isArray(value) ? value.map(item => this.hydrate(item, cache)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.hydrate(item, cache)]));
182
+ }
183
+ importLegacy(state) {
184
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('旧数据格式不合法');
185
+ for (const collection of ['accounts', 'profiles', 'limits']) {
186
+ for (const [key, value] of Object.entries(state[collection] ?? {})) this.put(collection, key, value);
187
+ }
188
+ for (const [who, peers] of Object.entries(state.friends ?? {})) for (const peer of peers) this.befriend(who, peer);
189
+ for (const request of state.requests ?? []) this.put('requests', `${request.from}:${request.to}`, request);
190
+ for (const group of Object.values(state.groups ?? {})) this.saveGroup(group);
191
+ for (const collection of ['messages', 'bottles', 'square']) for (const item of state[collection] ?? []) this.put(collection, item.id, item);
192
+ for (const post of state.posts ?? []) {
193
+ const { comments = [], ...data } = post;
194
+ this.put('posts', post.id, data);
195
+ for (const comment of comments) this.put('comments', comment.id, { ...comment, postId: post.id });
196
+ }
197
+ const insert = this.statement('INSERT INTO events(seq,recipient,type,at,data) VALUES(?,?,?,?,?)');
198
+ for (const event of state.events ?? []) insert.run(event.seq, event.to, event.type, event.at, JSON.stringify(this.externalize(event.data)));
199
+ // Preserve cursors even if the old event queue is empty or has been pruned.
200
+ const high = Math.max(state.seq ?? 0, ...[0, ...(state.events ?? []).map(event => event.seq)]);
201
+ if (high > 0) {
202
+ if (!this.statement("SELECT 1 FROM sqlite_sequence WHERE name='events'").get()) this.statement("INSERT INTO sqlite_sequence(name,seq) VALUES('events',?)").run(high);
203
+ else this.statement("UPDATE sqlite_sequence SET seq=max(seq,?) WHERE name='events'").run(high);
204
+ }
205
+ this.put('meta', 'nextBotId', state.nextBotId ?? 10001);
206
+ }
207
+ close() { this.db.close(); }
208
+ }