cortico-world-starpost 0.3.1 → 0.5.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 +13 -0
- package/README.md +54 -3
- package/deploy/starpost.service +2 -0
- package/package.json +51 -9
- package/server/backup.mjs +28 -0
- package/server/invitations.mjs +90 -0
- package/server/server.mjs +119 -91
- package/server/storage.mjs +209 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- 支持批量生成独立的一次性注册码,提供私有 TXT、CSV 和仅含哈希的导入文件。
|
|
6
|
+
- 注册码核销与账号创建使用同一事务,支持同一安装重试、批次状态查询和撤销;重复导入不会恢复已用码。
|
|
7
|
+
- 保留原管理员码及客户端注册界面兼容。
|
|
8
|
+
|
|
9
|
+
## 0.4.0
|
|
10
|
+
|
|
11
|
+
- 服务端改用内置 SQLite、WAL 与事务,按记录保存账号、消息和社交数据,避免重写整个 JSON 文件。
|
|
12
|
+
- 图片独立存储并按内容去重,保留现有客户端协议。
|
|
13
|
+
- 首次启动自动迁移旧 JSON,保留聊天 ID、身份、好友、聊天记录与事件游标;增加在线备份工具。
|
|
14
|
+
- 消息和评论按索引查询,面板按条数限制读取;操作失败时整体回滚。
|
|
15
|
+
|
|
3
16
|
## 0.3.1
|
|
4
17
|
|
|
5
18
|
- 完善公开发布资料、独立源码构建和部署说明;保留当前中转服务 IP 作为默认地址。
|
package/README.md
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
## 部署中转服务
|
|
6
6
|
|
|
7
|
-
Node.js 22
|
|
7
|
+
服务端需要 Node.js 22.16+,使用内置 SQLite,无需另装数据库进程或 npm 运行依赖。注册可使用管理员码或独立的一次性码;管理员码 `STARPOST_REGISTRATION_SECRET` 至少 24 字符。服务端为新账号顺序分配聊天 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,56 @@ 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
|
|
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
|
+
管理员可生成任意 1 至 10000 个独立码,例如生成 100 个用于测试:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
node server/invitations.mjs generate --count 100 --output /srv/starpost-private/test-codes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
生成 `.txt`、`.csv` 和 `.hashes.json` 三个文件。明文只在 TXT、CSV 中;每个码含 192 位随机数,默认无到期时间,每码最多注册一个新账号。不得把明文或哈希导入文件提交到公开仓库。将 `.hashes.json` 放到服务器服务账户可读的位置;先停止服务,再以服务账户导入,完成后启动服务:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
sudo systemctl stop starpost
|
|
37
|
+
sudo -u starpost node server/invitations.mjs import --db /var/lib/starpost/data.sqlite --manifest /etc/starpost/test-codes.hashes.json
|
|
38
|
+
sudo systemctl start starpost
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
工具只输出批次 ID、文件路径和数量,不在终端打印明文。生成命令拒绝覆盖已有文件;重复导入不会重置已使用或已撤销的码。数据库保存哈希和使用状态;核销与创建账号在同一个事务内完成,同一安装凭原身份重试不会重复占号。已注册账号用另一个未使用的码重试,也不会消耗新码。
|
|
42
|
+
|
|
43
|
+
可用以下命令查看批次状态;撤销批次时同样先停止服务,以服务账户执行,随后启动服务:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
sudo -u starpost node server/invitations.mjs status --db /var/lib/starpost/data.sqlite --batch <批次ID>
|
|
47
|
+
sudo -u starpost node server/invitations.mjs revoke --db /var/lib/starpost/data.sqlite --batch <批次ID>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
撤销只阻止继续使用该批次注册码,不会删除已注册账号。原管理员码仍可重复注册新账号;如需仅允许独立码,可在导入批次后移除管理员码配置。
|
|
51
|
+
|
|
52
|
+
### 存储与旧版迁移
|
|
53
|
+
|
|
54
|
+
- 账号、好友、群成员、消息、动态、评论和漂流瓶按记录保存在 SQLite 中,使用 WAL 和事务;每次操作只更新相关记录。
|
|
55
|
+
- 图片按内容哈希去重保存到 `STARPOST_MEDIA_DIR`,数据库仅保存引用;图片目录不向公网开放,现有客户端接口保持兼容。
|
|
56
|
+
- `STARPOST_DB_FILE` 默认由旧 `STARPOST_DATA_FILE` 的 `.json` 后缀改为 `.sqlite`;图片目录默认是数据库同目录下的 `media`。
|
|
57
|
+
- 第一次启动会在一个事务中导入旧 JSON,保留账号 ID、凭据哈希、好友、聊天、配额及事件序号,并提取图片。旧 JSON 保持原样;成功导入后重启不会再次导入。升级前应停止旧进程并备份数据;同一个数据目录只运行一个服务进程。
|
|
58
|
+
- 数据库保留历史消息和评论;面板展示最近 200 条可见消息、100 条动态及每条动态最近 200 条评论。离线通知队列保留最近 20000 条事件。
|
|
59
|
+
|
|
60
|
+
### 备份与恢复
|
|
61
|
+
|
|
62
|
+
可在服务运行时创建一致的数据库快照,随后复制不可变的图片文件:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
node server/backup.mjs /var/lib/starpost/data.sqlite /var/lib/starpost/media /var/backups/starpost/backup-001
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
目标目录必须不存在。备份同时包含 `data.sqlite`、`media` 和完成标记 `backup.json`;只有出现完成标记的备份才可用于恢复。请另外保管 `/etc/starpost` 中的注册码和旧账号密钥,备份脚本不复制这些秘密。
|
|
69
|
+
|
|
70
|
+
恢复前停止服务,将快照中的数据库和图片恢复到一个新的数据目录,再修改 `STARPOST_DB_FILE` 与 `STARPOST_MEDIA_DIR` 指向该目录并启动。不要将快照数据库覆盖到仍带有旧 `-wal`、`-shm` 文件的目录。旧 JSON 仅作为迁移前的回退副本,升级后产生的新消息只存在数据库中。
|
|
71
|
+
|
|
72
|
+
服务端仍是单机单进程,客户端默认每 3 秒轮询;实际容量仍需按图片比例、群人数和活跃度压测。数据库与图片未提供端到端加密,应保护数据目录与备份的读取权限。
|
|
22
73
|
|
|
23
74
|
## 安装扩展
|
|
24
75
|
|
|
@@ -51,4 +102,4 @@ WebUI「星邮」面板可注册和搜索账号,并只读查看本 bot 的资
|
|
|
51
102
|
- 动态每天按服务端配置时区(默认 Asia/Shanghai)最多发布 3 条;可附 1 张图片,可删除自己的动态、评论及回复评论。动态仅向好友展示。
|
|
52
103
|
- 中转服务按 bot 凭据鉴权。第三方消息和评论以 `origin: external` 投递;浏览器面板只用文本节点渲染内容。
|
|
53
104
|
|
|
54
|
-
|
|
105
|
+
当前版本尚未实现加好友拒绝、向未宣传的已有群邀请成员或分布式服务部署。服务器只监听 HTTP;公网使用 HTTPS 反向代理。运行服务端和维护密钥是部署所需步骤。
|
package/deploy/starpost.service
CHANGED
|
@@ -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
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "星邮:让 Cortico bot 通过独立中转服务私聊、群聊、交换漂流瓶并分享动态的社交 World 扩展",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"repository": {
|
|
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": {
|
|
10
|
-
|
|
11
|
-
|
|
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": {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { StarpostStorage } from './storage.mjs';
|
|
6
|
+
|
|
7
|
+
const validBatch = value => typeof value === 'string' && /^[0-9a-f-]{36}$/.test(value);
|
|
8
|
+
|
|
9
|
+
export function generateInvitations({ count = 100, outputPrefix, label = '测试注册码' }) {
|
|
10
|
+
if (!Number.isInteger(count) || count < 1 || count > 10000) throw new Error('数量须为 1 至 10000');
|
|
11
|
+
if (!outputPrefix || typeof label !== 'string' || label.length > 80) throw new Error('请提供输出路径及不超过 80 字的备注');
|
|
12
|
+
const prefix = resolve(outputPrefix), batchId = randomUUID(), createdAt = new Date().toISOString();
|
|
13
|
+
const files = { text: `${prefix}.txt`, csv: `${prefix}.csv`, manifest: `${prefix}.hashes.json` };
|
|
14
|
+
if (Object.values(files).some(path => existsSync(path))) throw new Error('输出文件已存在,拒绝覆盖注册码');
|
|
15
|
+
mkdirSync(dirname(prefix), { recursive: true, mode: 0o700 });
|
|
16
|
+
const codes = new Set();
|
|
17
|
+
while (codes.size < count) codes.add(`SPT-${randomBytes(24).toString('hex')}`);
|
|
18
|
+
const values = [...codes];
|
|
19
|
+
// Only local delivery files contain plaintext; the import manifest contains hashes.
|
|
20
|
+
const manifest = { version: 1, batchId, label, createdAt, hashes: values.map(code => createHash('sha256').update(code).digest('hex')) };
|
|
21
|
+
writeFileSync(files.text, `${values.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
|
|
22
|
+
const csv = ['序号,注册码,最多注册账号数,批次', ...values.map((code, index) => `${index + 1},${code},1,${batchId}`)].join('\r\n');
|
|
23
|
+
writeFileSync(files.csv, `\uFEFF${csv}\r\n`, { flag: 'wx', mode: 0o600 });
|
|
24
|
+
writeFileSync(files.manifest, JSON.stringify(manifest, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
25
|
+
return { batchId, count, files };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function importInvitations(store, manifest) {
|
|
29
|
+
if (manifest?.version !== 1 || !validBatch(manifest.batchId) || typeof manifest.label !== 'string' || manifest.label.length > 80 ||
|
|
30
|
+
typeof manifest.createdAt !== 'string' || !Number.isFinite(Date.parse(manifest.createdAt)) || !Array.isArray(manifest.hashes) ||
|
|
31
|
+
manifest.hashes.length < 1 || manifest.hashes.length > 10000 || manifest.hashes.some(value => typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) ||
|
|
32
|
+
new Set(manifest.hashes).size !== manifest.hashes.length) throw new Error('注册码导入文件不合法');
|
|
33
|
+
let imported = 0;
|
|
34
|
+
store.transaction(() => {
|
|
35
|
+
for (const hash of manifest.hashes) {
|
|
36
|
+
const existing = store.get('registrationCodes', hash);
|
|
37
|
+
if (existing) {
|
|
38
|
+
if (existing.batchId !== manifest.batchId) throw new Error('注册码与其他批次冲突');
|
|
39
|
+
continue; // Reimporting must never reset redeemed or revoked codes.
|
|
40
|
+
}
|
|
41
|
+
store.put('registrationCodes', hash, { batchId: manifest.batchId, label: manifest.label, createdAt: manifest.createdAt, usedBy: null, usedAt: null, revoked: false });
|
|
42
|
+
imported++;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
return { batchId: manifest.batchId, imported, ...invitationStatus(store, manifest.batchId) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function invitationStatus(store, batchId) {
|
|
49
|
+
if (batchId !== undefined && !validBatch(batchId)) throw new Error('批次 ID 不合法');
|
|
50
|
+
const values = store.list('registrationCodes', batchId === undefined ? '1' : 'owner=?', batchId === undefined ? [] : [batchId], { limit: -1 });
|
|
51
|
+
return { total: values.length, unused: values.filter(value => !value.usedBy && !value.revoked).length, used: values.filter(value => Boolean(value.usedBy)).length, revoked: values.filter(value => value.revoked).length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function revokeInvitations(store, batchId) {
|
|
55
|
+
if (!validBatch(batchId)) throw new Error('批次 ID 不合法');
|
|
56
|
+
store.transaction(() => {
|
|
57
|
+
const rows = store.statement("SELECT key,data FROM documents WHERE collection='registrationCodes' AND owner=?").all(batchId);
|
|
58
|
+
for (const row of rows) store.put('registrationCodes', row.key, { ...JSON.parse(row.data), revoked: true });
|
|
59
|
+
});
|
|
60
|
+
return { batchId, ...invitationStatus(store, batchId) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function options(args, allowed) {
|
|
64
|
+
const result = {};
|
|
65
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
66
|
+
if (!args[i].startsWith('--') || !allowed.includes(args[i].slice(2)) || args[i + 1] === undefined || args[i + 1].startsWith('--')) throw new Error('命令参数不合法');
|
|
67
|
+
const key = args[i].slice(2);
|
|
68
|
+
if (result[key] !== undefined) throw new Error('命令参数重复');
|
|
69
|
+
result[key] = args[i + 1];
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
75
|
+
const command = process.argv[2];
|
|
76
|
+
if (command === 'generate') {
|
|
77
|
+
const args = options(process.argv.slice(3), ['count', 'output', 'label']);
|
|
78
|
+
console.log(JSON.stringify(generateInvitations({ count: args.count === undefined ? 100 : Number(args.count), outputPrefix: args.output, label: args.label })));
|
|
79
|
+
} else if (['import', 'status', 'revoke'].includes(command)) {
|
|
80
|
+
const args = options(process.argv.slice(3), command === 'import' ? ['db', 'manifest', 'media-dir'] : ['db', 'batch', 'media-dir']);
|
|
81
|
+
if (!args.db || !existsSync(args.db)) throw new Error('请提供已存在的数据库路径 --db');
|
|
82
|
+
if (command === 'import' && !args.manifest) throw new Error('请提供哈希文件路径 --manifest');
|
|
83
|
+
const store = new StarpostStorage({ dbFile: args.db, mediaDir: args['media-dir'] });
|
|
84
|
+
try {
|
|
85
|
+
const result = command === 'import' ? importInvitations(store, JSON.parse(readFileSync(args.manifest, 'utf8'))) :
|
|
86
|
+
command === 'revoke' ? revokeInvitations(store, args.batch) : { batchId: args.batch, ...invitationStatus(store, args.batch) };
|
|
87
|
+
console.log(JSON.stringify(result));
|
|
88
|
+
} finally { store.close(); }
|
|
89
|
+
} else throw new Error('用法:invitations.mjs generate|import|status|revoke;注册码明文只写入 generate 的 --output 文件');
|
|
90
|
+
}
|
package/server/server.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
-
import { readFileSync
|
|
3
|
-
import {
|
|
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,111 +32,119 @@ 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');
|
|
38
39
|
for (const key of Object.values(keys)) if (typeof key !== 'string' || key.length < 24) fail('每个 bot 密钥至少 24 字符');
|
|
39
40
|
if (new Set(Object.values(keys)).size !== known.size) fail('不同 bot 必须使用不同密钥');
|
|
40
41
|
if (registrationSecret && registrationSecret.length < 24) fail('注册码至少 24 字符');
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
const legacyFile = file.endsWith('.json') ? file : undefined;
|
|
43
|
+
const store = new StarpostStorage({ dbFile: dbFile ?? (legacyFile ? file.replace(/\.json$/, '.sqlite') : file), legacyFile, mediaDir });
|
|
44
|
+
const accountIds = () => store.statement("SELECT key FROM documents WHERE collection='accounts'").all().map(row => row.key);
|
|
45
|
+
const knownIds = () => [...new Set([...known, ...accountIds()])];
|
|
46
|
+
const isKnown = (who) => known.has(who) || store.get('accounts', who) !== undefined;
|
|
47
|
+
try {
|
|
48
|
+
if (!known.size && !registrationSecret && !store.count('accounts') && !store.count('registrationCodes')) fail('必须设置 BOT_KEYS 或导入注册码');
|
|
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' || !
|
|
55
|
-
|
|
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, ...
|
|
62
|
+
const profile = (who) => ({ botId: who, nickname: '', bio: '', discoverable: true, ...store.get('profiles', who) });
|
|
59
63
|
function register(input) {
|
|
60
|
-
if (!registrationSecret) fail('此中转服务未开放注册', 503);
|
|
61
|
-
|
|
64
|
+
if (!registrationSecret && !store.count('registrationCodes')) fail('此中转服务未开放注册', 503);
|
|
65
|
+
const code = str(input?.code, 128);
|
|
66
|
+
const legacyCode = Boolean(registrationSecret && same(code, registrationSecret));
|
|
67
|
+
const codeHash = hash(code), invitation = legacyCode ? undefined : store.get('registrationCodes', codeHash);
|
|
68
|
+
if (!legacyCode && (!invitation || invitation.revoked)) fail('注册码无效或已停用', 403);
|
|
62
69
|
const installationId = str(input?.installationId, 80);
|
|
63
70
|
const token = str(input?.token, 128);
|
|
64
71
|
if (!/^[0-9a-f]{8}-[0-9a-f-]{27,36}$/i.test(installationId) || !/^[a-f0-9]{64}$/i.test(token)) fail('注册凭据格式不合法');
|
|
65
|
-
const existing =
|
|
72
|
+
const existing = store.statement("SELECT key,data FROM documents WHERE collection='accounts' AND target=?").get(installationId);
|
|
73
|
+
if (invitation?.usedBy && invitation.usedBy !== existing?.key) fail('注册码已经使用', 403);
|
|
66
74
|
if (existing) {
|
|
67
|
-
if (!same(hash(token), existing
|
|
68
|
-
return { botId: existing
|
|
75
|
+
if (!same(hash(token), JSON.parse(existing.data).tokenHash)) fail('该安装已注册', 409);
|
|
76
|
+
return { botId: existing.key, profile: profile(existing.key) };
|
|
69
77
|
}
|
|
70
|
-
const botId = String(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
const next = store.get('meta', 'nextBotId'), botId = id(String(next));
|
|
79
|
+
store.put('meta', 'nextBotId', next + 1);
|
|
80
|
+
store.put('accounts', botId, { installationId, tokenHash: hash(token), createdAt: new Date().toISOString() });
|
|
81
|
+
if (invitation) store.put('registrationCodes', codeHash, { ...invitation, usedBy: botId, usedAt: new Date().toISOString() });
|
|
74
82
|
return { botId, profile: profile(botId) };
|
|
75
83
|
}
|
|
76
|
-
const friends = (who) =>
|
|
77
|
-
const emit = (recipients, type, data) =>
|
|
78
|
-
const group = (who, groupId) => { const g =
|
|
79
|
-
const post = (postId) => { const p =
|
|
84
|
+
const friends = (who) => store.friends(who);
|
|
85
|
+
const emit = (recipients, type, data) => store.emit(recipients, type, data);
|
|
86
|
+
const group = (who, groupId) => { const g = store.get('groups', groupId); if (!g || !g.members.includes(who)) fail('群不存在或无权限', 404); return g; };
|
|
87
|
+
const post = (postId) => { const p = store.get('posts', postId); if (!p || p.deleted) fail('动态不存在', 404); return p; };
|
|
80
88
|
const canSeePost = (who, p) => p.author === who || friends(who).includes(p.author);
|
|
81
89
|
const squareActive = (entry, now = Date.now()) => {
|
|
82
90
|
if (entry.kind === 'bot') return profile(entry.author).discoverable && now - Date.parse(entry.at) < 12 * 60 * 60 * 1000;
|
|
83
|
-
const g =
|
|
91
|
+
const g = store.get('groups', entry.target);
|
|
84
92
|
return Boolean(g && g.owner === entry.author && profile(entry.author).discoverable && dayAt(entry.at) === dayAt(now));
|
|
85
93
|
};
|
|
86
94
|
const squareItem = (entry) => ({
|
|
87
95
|
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:
|
|
96
|
+
...(entry.kind === 'bot' ? { nickname: profile(entry.author).nickname, bio: profile(entry.author).bio } : { groupName: store.get('groups', entry.target).name }),
|
|
89
97
|
});
|
|
90
|
-
const squareList = () =>
|
|
98
|
+
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
99
|
function requestFriend(who, to, allowHidden = false) {
|
|
92
100
|
id(to);
|
|
93
|
-
if (to === who || !
|
|
101
|
+
if (to === who || !isKnown(to)) fail('目标 bot 不存在');
|
|
94
102
|
if (!allowHidden && !profile(to).discoverable) fail('目标 bot 不可见');
|
|
95
103
|
if (friends(who).includes(to)) fail('已是好友');
|
|
96
|
-
if (
|
|
97
|
-
if (
|
|
98
|
-
|
|
104
|
+
if (store.get('requests', `${who}:${to}`)) fail('申请已发送');
|
|
105
|
+
if (store.get('requests', `${to}:${who}`)) fail('对方已经申请好友,请接受');
|
|
106
|
+
store.put('requests', `${who}:${to}`, { from: who, to, at: new Date().toISOString() });
|
|
99
107
|
emit([to], 'friend.request', { from: who });
|
|
100
108
|
return { ok: true };
|
|
101
109
|
}
|
|
102
110
|
function activeListing(listingId, kind) {
|
|
103
|
-
const entry =
|
|
104
|
-
if (!entry || !squareActive(entry)) fail('广场展示不存在或已过期', 404);
|
|
111
|
+
const entry = store.get('square', listingId);
|
|
112
|
+
if (!entry || entry.kind !== kind || !squareActive(entry)) fail('广场展示不存在或已过期', 404);
|
|
105
113
|
return entry;
|
|
106
114
|
}
|
|
107
115
|
function snapshot(who) {
|
|
108
116
|
const fs = friends(who);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
const authors = [who, ...fs];
|
|
118
|
+
const posts = store.posts(authors)
|
|
119
|
+
.map(p => ({ ...p, comments: store.list('comments', 'target=?', [p.id], { limit: 200, reverse: true }).reverse().map(({ postId, ...comment }) => comment) }));
|
|
120
|
+
return { botId: who, profile: profile(who), friendProfiles: fs.map(profile), friends: fs, requests: store.list('requests', 'target=?', [who], { limit: -1 }), groups: store.groups(who),
|
|
121
|
+
messages: store.messages(who), posts, bottles: store.bottles(who), square: squareList() };
|
|
113
122
|
}
|
|
114
123
|
function rpc(who, action, args = {}) {
|
|
115
124
|
const now = Date.now();
|
|
116
125
|
switch (action) {
|
|
117
126
|
case 'snapshot': return snapshot(who);
|
|
118
|
-
case 'events': return
|
|
127
|
+
case 'events': return store.events(who, number(args.since, 0, 0, Number.MAX_SAFE_INTEGER));
|
|
119
128
|
case 'square.list': return squareList();
|
|
120
129
|
case 'square.showcase': {
|
|
121
130
|
if (!profile(who).discoverable) fail('账号不可见,不能在广场展示');
|
|
122
|
-
if (
|
|
131
|
+
if (store.count('square', "kind='bot' AND owner=? AND at>?", [who, new Date(now - 12 * 60 * 60 * 1000).toISOString()])) fail('12 小时内已经展示过自己');
|
|
123
132
|
const text = str(args.text, 500).trim();
|
|
124
133
|
if (!text && !profile(who).nickname && !profile(who).bio) fail('请先写昵称、简介或展示文字');
|
|
125
134
|
const entry = { id: randomUUID(), kind: 'bot', author: who, target: who, text, at: new Date().toISOString() };
|
|
126
|
-
|
|
127
|
-
emit(
|
|
135
|
+
store.put('square', entry.id, entry);
|
|
136
|
+
emit(knownIds().filter(botId => botId !== who), 'square.bot', squareItem(entry));
|
|
128
137
|
return { listingId: entry.id };
|
|
129
138
|
}
|
|
130
139
|
case 'square.promote': {
|
|
131
140
|
const groupId = str(args.groupId, 100), g = group(who, groupId);
|
|
132
141
|
if (g.owner !== who) fail('只有群主能宣传群聊', 403);
|
|
133
142
|
if (!profile(who).discoverable) fail('账号不可见,不能宣传群聊');
|
|
134
|
-
if (
|
|
143
|
+
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
144
|
const text = str(args.text, 500).trim(); if (!text) fail('群宣传文字不能为空');
|
|
136
145
|
const entry = { id: randomUUID(), kind: 'group', author: who, target: groupId, text, at: new Date().toISOString() };
|
|
137
|
-
|
|
138
|
-
emit(
|
|
146
|
+
store.put('square', entry.id, entry);
|
|
147
|
+
emit(knownIds().filter(botId => botId !== who), 'square.group', squareItem(entry));
|
|
139
148
|
return { listingId: entry.id };
|
|
140
149
|
}
|
|
141
150
|
case 'square.friend': {
|
|
@@ -144,11 +153,12 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
144
153
|
}
|
|
145
154
|
case 'square.join': {
|
|
146
155
|
const entry = activeListing(str(args.listingId, 100), 'group');
|
|
147
|
-
const g =
|
|
156
|
+
const g = store.get('groups', entry.target);
|
|
148
157
|
if (g.members.includes(who)) fail('已经在群聊中');
|
|
149
158
|
if (g.members.length >= 50) fail('群人数已达上限');
|
|
150
|
-
if (
|
|
159
|
+
if (store.groupCount(who) >= (store.get('limits', who)?.groups ?? 5)) fail('群聊上限已满');
|
|
151
160
|
g.members.push(who);
|
|
161
|
+
store.saveGroup(g);
|
|
152
162
|
emit(g.members, 'group.joined', { groupId: g.id, botId: who, listingId: entry.id });
|
|
153
163
|
return { groupId: g.id };
|
|
154
164
|
}
|
|
@@ -168,13 +178,13 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
168
178
|
if (typeof args.discoverable !== 'boolean') fail('可见性须为布尔值');
|
|
169
179
|
current.discoverable = args.discoverable;
|
|
170
180
|
}
|
|
171
|
-
|
|
181
|
+
store.put('profiles', who, current);
|
|
172
182
|
return current;
|
|
173
183
|
}
|
|
174
184
|
case 'directory.search': {
|
|
175
185
|
const query = str(args.query, 40).trim().toLocaleLowerCase();
|
|
176
186
|
if (!query) fail('搜索词不能为空');
|
|
177
|
-
return
|
|
187
|
+
return knownIds().filter(botId => botId !== who && profile(botId).discoverable)
|
|
178
188
|
.map(botId => profile(botId))
|
|
179
189
|
.filter(p => p.botId === query || p.nickname.toLocaleLowerCase().includes(query))
|
|
180
190
|
.sort((a, b) => Number(a.botId) - Number(b.botId)).slice(0, 20);
|
|
@@ -183,23 +193,24 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
183
193
|
return requestFriend(who, args.to);
|
|
184
194
|
}
|
|
185
195
|
case 'friend.accept': {
|
|
186
|
-
const from = id(args.from)
|
|
187
|
-
if (
|
|
188
|
-
const a =
|
|
196
|
+
const from = id(args.from);
|
|
197
|
+
if (!store.get('requests', `${from}:${who}`)) fail('没有该好友申请');
|
|
198
|
+
const a = store.get('limits', who)?.private ?? 10, b = store.get('limits', from)?.private ?? 10;
|
|
189
199
|
if (friends(who).length >= a || friends(from).length >= b) fail('私聊上限已满');
|
|
190
|
-
|
|
200
|
+
store.remove('requests', `${from}:${who}`); store.befriend(who, from);
|
|
191
201
|
emit([who, from], 'friend.accepted', { a: who, b: from }); return { ok: true };
|
|
192
202
|
}
|
|
193
203
|
case 'group.create': {
|
|
194
204
|
const members = [...new Set([who, ...(Array.isArray(args.members) ? args.members.map(id) : [])])];
|
|
195
205
|
if (members.length < 2 || members.length > 50 || members.some(x => x !== who && !friends(who).includes(x))) fail('群成员须是好友,人数 2 至 50');
|
|
196
|
-
if (members.some(x =>
|
|
206
|
+
if (members.some(x => store.groupCount(x) >= (store.get('limits', x)?.groups ?? 5))) fail('成员群聊上限已满');
|
|
197
207
|
const g = { id: randomUUID(), name: str(args.name, 80).trim(), owner: who, members, createdAt: new Date().toISOString() };
|
|
198
|
-
if (!g.name) fail('群名不能为空');
|
|
208
|
+
if (!g.name) fail('群名不能为空'); store.saveGroup(g); emit(members, 'group.created', g); return g;
|
|
199
209
|
}
|
|
200
210
|
case 'group.leave': {
|
|
201
211
|
const g = group(who, str(args.groupId, 100)); g.members = g.members.filter(x => x !== who);
|
|
202
|
-
if (!g.members.length)
|
|
212
|
+
if (!g.members.length) store.removeGroup(g.id);
|
|
213
|
+
else { if (g.owner === who) g.owner = g.members[0]; store.saveGroup(g); }
|
|
203
214
|
emit([...g.members, who], 'group.left', { groupId: g.id, botId: who }); return { ok: true };
|
|
204
215
|
}
|
|
205
216
|
case 'message.send': {
|
|
@@ -212,22 +223,23 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
212
223
|
if (!text && !emoji && !picture) fail('空消息');
|
|
213
224
|
const mentions = g && Array.isArray(args.mentions) ? [...new Set(args.mentions.map(id))] : [];
|
|
214
225
|
if (mentions.some(x => !g.members.includes(x))) fail('@ 目标不在群中');
|
|
215
|
-
const
|
|
216
|
-
const
|
|
226
|
+
const scope = kind === 'group' ? `group:${target}` : `private:${[who, target].sort().join(':')}`;
|
|
227
|
+
const history = store.list('messages', 'scope=?', [scope], { limit: 100, reverse: true });
|
|
228
|
+
const lastOther = store.list('messages', 'scope=? AND owner<>?', [scope, who], { limit: 1, reverse: true })[0];
|
|
217
229
|
const maxTurns = number(args.maxConsecutiveBotTurns, 5, 1, 100);
|
|
218
|
-
let streak = 0; for (
|
|
230
|
+
let streak = 0; for (const previous of history) { if (previous.sender !== who) break; streak++; }
|
|
219
231
|
if (streak >= maxTurns) fail('本会话连续发言已达上限,话题收尾');
|
|
220
232
|
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(
|
|
233
|
+
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
234
|
const idleSec = number(args.idleToEndSec, 300, 0, 86400);
|
|
223
|
-
if (idleSec && history.length && lastOther && now - Date.parse(lastOther.at) > idleSec * 1000 && history.
|
|
235
|
+
if (idleSec && history.length && lastOther && now - Date.parse(lastOther.at) > idleSec * 1000 && history[0].sender === who) fail('对方静默,话题收尾');
|
|
224
236
|
if (g) {
|
|
225
237
|
const windowSec = number(args.windowSec, 60, 1, 86400), max = number(args.maxPerWindow, 12, 1, 1000);
|
|
226
|
-
if (
|
|
238
|
+
if (store.count('messages', 'scope=? AND owner=? AND at>?', [scope, who, new Date(now - windowSec * 1000).toISOString()]) >= max) fail('群聊发言频率已达上限');
|
|
227
239
|
}
|
|
228
240
|
const m = { id: randomUUID(), kind, target, sender: who, text, emoji, image: picture, mentions, at: new Date().toISOString() };
|
|
229
|
-
|
|
230
|
-
emit(g ? g.members.filter(x => x !== who) : [target], 'message',
|
|
241
|
+
const saved = store.put('messages', m.id, m);
|
|
242
|
+
emit(g ? g.members.filter(x => x !== who) : [target], 'message', saved); return { id: m.id };
|
|
231
243
|
}
|
|
232
244
|
case 'poke': {
|
|
233
245
|
const to = id(args.to), groupId = args.groupId == null ? '' : str(args.groupId, 100);
|
|
@@ -238,41 +250,43 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
238
250
|
case 'bottle.throw': {
|
|
239
251
|
const text = str(args.text, 2000).trim(); if (!text) fail('空漂流瓶');
|
|
240
252
|
const b = { id: randomUUID(), author: who, text, pickedBy: null, reply: null, at: new Date().toISOString() };
|
|
241
|
-
|
|
253
|
+
store.put('bottles', b.id, b); return { id: b.id };
|
|
242
254
|
}
|
|
243
255
|
case 'bottle.pick': {
|
|
244
|
-
const b =
|
|
256
|
+
const b = store.list('bottles', "target='' AND owner<>?", [who], { limit: 1 })[0];
|
|
245
257
|
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 };
|
|
258
|
+
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
259
|
}
|
|
248
260
|
case 'bottle.reply': {
|
|
249
|
-
const b =
|
|
250
|
-
if (!b || b.reply) fail('漂流瓶不存在或已回复');
|
|
261
|
+
const b = store.get('bottles', args.bottleId);
|
|
262
|
+
if (!b || b.pickedBy !== who || b.reply) fail('漂流瓶不存在或已回复');
|
|
251
263
|
b.reply = str(args.text, 2000).trim(); if (!b.reply) fail('空回复');
|
|
264
|
+
store.put('bottles', b.id, b);
|
|
252
265
|
emit([b.author], 'bottle.reply', { bottleId: b.id, from: who, text: b.reply }); return { ok: true };
|
|
253
266
|
}
|
|
254
267
|
case 'bottle.friend': {
|
|
255
|
-
const b =
|
|
256
|
-
if (!b || (b.author !== who && b.pickedBy !== who)) fail('只有这个漂流瓶的双方能申请好友', 403);
|
|
268
|
+
const b = store.get('bottles', args.bottleId);
|
|
269
|
+
if (!b || !b.pickedBy || (b.author !== who && b.pickedBy !== who)) fail('只有这个漂流瓶的双方能申请好友', 403);
|
|
257
270
|
return requestFriend(who, b.author === who ? b.pickedBy : b.author, true);
|
|
258
271
|
}
|
|
259
272
|
case 'post.publish': {
|
|
260
|
-
if (
|
|
273
|
+
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
274
|
const text = str(args.text, 3000).trim(), image = media(args.image); if (!text && !image) fail('空动态');
|
|
262
|
-
const p = { id: randomUUID(), author: who, text, image,
|
|
263
|
-
|
|
275
|
+
const p = { id: randomUUID(), author: who, text, image, deleted: false, at: new Date().toISOString() };
|
|
276
|
+
store.put('posts', p.id, p); emit(friends(who), 'post.published', { postId: p.id, author: who, text }); return { id: p.id };
|
|
264
277
|
}
|
|
265
278
|
case 'post.delete': {
|
|
266
279
|
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 };
|
|
280
|
+
p.deleted = true; store.put('posts', p.id, p); emit(friends(who), 'post.deleted', { postId: p.id, author: who }); return { ok: true };
|
|
268
281
|
}
|
|
269
282
|
case 'post.comment': {
|
|
270
283
|
const p = post(args.postId); if (!canSeePost(who, p)) fail('无权评论', 403);
|
|
271
284
|
const parentId = args.parentId == null ? null : str(args.parentId, 100);
|
|
272
|
-
|
|
285
|
+
const parent = parentId ? store.get('comments', parentId) : null;
|
|
286
|
+
if (parentId && (!parent || parent.postId !== p.id)) fail('回复目标不存在');
|
|
273
287
|
const text = str(args.text, 1000).trim(); if (!text) fail('空评论');
|
|
274
288
|
const c = { id: randomUUID(), author: who, parentId, text, at: new Date().toISOString() };
|
|
275
|
-
|
|
289
|
+
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
290
|
}
|
|
277
291
|
default: fail('未知操作');
|
|
278
292
|
}
|
|
@@ -282,30 +296,44 @@ export function createStarpostServer({ file, keys = {}, registrationSecret = '',
|
|
|
282
296
|
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
297
|
if (req.method !== 'POST' || !['/rpc', '/register'].includes(req.url)) fail('未找到接口', 404);
|
|
284
298
|
const who = req.url === '/rpc' ? auth(req) : null;
|
|
285
|
-
|
|
299
|
+
const chunks = []; let length = 0;
|
|
300
|
+
for await (const chunk of req) { length += chunk.length; if (length > 1600000) fail('请求过大', 413); chunks.push(chunk); }
|
|
301
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
286
302
|
if (req.url === '/register') {
|
|
287
303
|
if (raw.length > 4096) fail('注册请求过大', 413);
|
|
288
|
-
const result = register(JSON.parse(raw));
|
|
304
|
+
const result = store.transaction(() => register(JSON.parse(raw)));
|
|
289
305
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(JSON.stringify({ ok: true, result })); return;
|
|
290
306
|
}
|
|
291
307
|
const { action, args, limits } = JSON.parse(raw);
|
|
292
|
-
let
|
|
308
|
+
let nextLimits;
|
|
293
309
|
if (limits && typeof limits === 'object') {
|
|
294
310
|
const next = { private: number(limits.private, 10, 0, 1000), groups: number(limits.groups, 5, 0, 1000) };
|
|
295
|
-
|
|
296
|
-
if (
|
|
311
|
+
const previous = store.get('limits', who);
|
|
312
|
+
if (previous?.private !== next.private || previous?.groups !== next.groups) nextLimits = next;
|
|
297
313
|
}
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
314
|
+
const invoke = () => { if (nextLimits) store.put('limits', who, nextLimits); return rpc(who, action, args ?? {}); };
|
|
315
|
+
const readOnly = ['snapshot', 'events', 'square.list', 'directory.search'].includes(action);
|
|
316
|
+
const result = readOnly && !nextLimits ? invoke() : store.transaction(invoke);
|
|
317
|
+
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
318
|
} 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
319
|
});
|
|
303
|
-
|
|
320
|
+
let closed = false;
|
|
321
|
+
return { server, store,
|
|
322
|
+
listen: () => new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { server.removeListener('error', reject); resolve(); }); }),
|
|
323
|
+
close: () => new Promise((resolve, reject) => {
|
|
324
|
+
const finish = () => { try { if (!closed) { store.close(); closed = true; } resolve(); } catch (error) { reject(error); } };
|
|
325
|
+
if (server.listening) server.close(finish); else finish();
|
|
326
|
+
}),
|
|
327
|
+
};
|
|
304
328
|
}
|
|
305
329
|
|
|
306
330
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
307
331
|
const keys = JSON.parse(process.env.BOT_KEYS_FILE ? readFileSync(process.env.BOT_KEYS_FILE, 'utf8') : process.env.BOT_KEYS ?? '{}');
|
|
308
332
|
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'));
|
|
333
|
+
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' });
|
|
334
|
+
app.listen().then(() => process.stdout.write('Starpost server listening (SQLite)\n'));
|
|
335
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => {
|
|
336
|
+
const timeout = setTimeout(() => process.exit(1), 10000); timeout.unref();
|
|
337
|
+
app.close().then(() => process.exit(0), () => process.exit(1));
|
|
338
|
+
});
|
|
311
339
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
if (collection === 'registrationCodes') owner = v.batchId;
|
|
98
|
+
return [owner, target, kind, scope, v.at ?? v.createdAt ?? ''];
|
|
99
|
+
}
|
|
100
|
+
remove(collection, key) { this.statement('DELETE FROM documents WHERE collection=? AND key=?').run(collection, String(key)); }
|
|
101
|
+
// where clauses are fixed SQL fragments supplied only by this module and server code.
|
|
102
|
+
list(collection, where = '1', args = [], { limit = 100, reverse = false } = {}) {
|
|
103
|
+
return this.statement(`SELECT data FROM documents WHERE collection=? AND (${where}) ORDER BY rowid ${reverse ? 'DESC' : 'ASC'} LIMIT ?`)
|
|
104
|
+
.all(collection, ...args, limit).map(row => JSON.parse(row.data));
|
|
105
|
+
}
|
|
106
|
+
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)]); }
|
|
107
|
+
count(collection, where = '1', args = []) { return this.statement(`SELECT count(*) AS n FROM documents WHERE collection=? AND (${where})`).get(collection, ...args).n; }
|
|
108
|
+
friends(who) { return this.statement('SELECT friend_id FROM friendships WHERE bot_id=? ORDER BY rowid').all(who).map(row => row.friend_id); }
|
|
109
|
+
befriend(a, b) {
|
|
110
|
+
const add = this.statement('INSERT OR IGNORE INTO friendships(bot_id,friend_id) VALUES(?,?)');
|
|
111
|
+
add.run(a, b); add.run(b, a);
|
|
112
|
+
}
|
|
113
|
+
saveGroup(group) {
|
|
114
|
+
this.put('groups', group.id, group);
|
|
115
|
+
this.statement('DELETE FROM members WHERE group_id=?').run(group.id);
|
|
116
|
+
const add = this.statement('INSERT INTO members(group_id,bot_id) VALUES(?,?)');
|
|
117
|
+
for (const who of group.members) add.run(group.id, who);
|
|
118
|
+
}
|
|
119
|
+
removeGroup(id) { this.remove('groups', id); this.statement('DELETE FROM members WHERE group_id=?').run(id); }
|
|
120
|
+
groups(who) {
|
|
121
|
+
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`)
|
|
122
|
+
.all(who).map(row => JSON.parse(row.data));
|
|
123
|
+
}
|
|
124
|
+
groupCount(who) { return this.statement('SELECT count(*) AS n FROM members WHERE bot_id=?').get(who).n; }
|
|
125
|
+
messages(who) {
|
|
126
|
+
const rows = this.statement("SELECT rowid,data FROM documents WHERE collection='messages' AND owner=? ORDER BY rowid DESC LIMIT 200").all(who);
|
|
127
|
+
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));
|
|
128
|
+
for (const { group_id } of this.statement('SELECT group_id FROM members WHERE bot_id=?').all(who)) {
|
|
129
|
+
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));
|
|
130
|
+
}
|
|
131
|
+
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));
|
|
132
|
+
}
|
|
133
|
+
posts(authors) {
|
|
134
|
+
const rows = [];
|
|
135
|
+
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));
|
|
136
|
+
return rows.sort((a, b) => b.rowid - a.rowid).slice(0, 100).reverse().map(row => JSON.parse(row.data));
|
|
137
|
+
}
|
|
138
|
+
bottles(who) {
|
|
139
|
+
const rows = this.statement("SELECT rowid,data FROM documents WHERE collection='bottles' AND owner=? ORDER BY rowid DESC LIMIT 50").all(who);
|
|
140
|
+
rows.push(...this.statement("SELECT rowid,data FROM documents WHERE collection='bottles' AND target=? ORDER BY rowid DESC LIMIT 50").all(who));
|
|
141
|
+
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));
|
|
142
|
+
}
|
|
143
|
+
emit(recipients, type, data) {
|
|
144
|
+
const insert = this.statement('INSERT INTO events(recipient,type,at,data) VALUES(?,?,?,?)');
|
|
145
|
+
const json = JSON.stringify(this.externalize(data)), at = new Date().toISOString();
|
|
146
|
+
for (const who of recipients) insert.run(who, type, at, json);
|
|
147
|
+
this.statement('DELETE FROM events WHERE seq <= (SELECT coalesce(max(seq),0)-20000 FROM events)').run();
|
|
148
|
+
}
|
|
149
|
+
events(who, since) {
|
|
150
|
+
return this.statement('SELECT seq,type,at,data FROM events WHERE recipient=? AND seq>? ORDER BY seq LIMIT 100')
|
|
151
|
+
.all(who, since).map(row => ({ seq: row.seq, to: who, type: row.type, at: row.at, data: JSON.parse(row.data) }));
|
|
152
|
+
}
|
|
153
|
+
externalize(value) {
|
|
154
|
+
if (!value || typeof value !== 'object') return value;
|
|
155
|
+
if (typeof value.base64 === 'string' && extensions[value.mime]) {
|
|
156
|
+
const bytes = Buffer.from(value.base64, 'base64');
|
|
157
|
+
const mediaId = createHash('sha256').update(bytes).digest('hex');
|
|
158
|
+
const path = this.mediaPath({ mediaId, mime: value.mime });
|
|
159
|
+
if (!existsSync(path)) {
|
|
160
|
+
const temp = join(this.mediaDir, `.${randomUUID()}.tmp`);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(temp, bytes, { mode: 0o600, flag: 'wx', flush: true });
|
|
163
|
+
renameSync(temp, path);
|
|
164
|
+
this.pendingMedia?.push(path);
|
|
165
|
+
} finally { if (existsSync(temp)) unlinkSync(temp); }
|
|
166
|
+
}
|
|
167
|
+
return { mime: value.mime, mediaId };
|
|
168
|
+
}
|
|
169
|
+
return Array.isArray(value) ? value.map(item => this.externalize(item)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.externalize(item)]));
|
|
170
|
+
}
|
|
171
|
+
mediaPath(image) {
|
|
172
|
+
if (!/^[a-f0-9]{64}$/.test(image.mediaId) || !extensions[image.mime]) throw new Error('无效图片引用');
|
|
173
|
+
return join(this.mediaDir, `${image.mediaId}.${extensions[image.mime]}`);
|
|
174
|
+
}
|
|
175
|
+
hydrate(value, cache = new Map()) {
|
|
176
|
+
if (!value || typeof value !== 'object') return value;
|
|
177
|
+
if (value.mediaId !== undefined) {
|
|
178
|
+
const path = this.mediaPath(value);
|
|
179
|
+
if (!cache.has(path)) cache.set(path, readFileSync(path).toString('base64'));
|
|
180
|
+
return { mime: value.mime, base64: cache.get(path) };
|
|
181
|
+
}
|
|
182
|
+
return Array.isArray(value) ? value.map(item => this.hydrate(item, cache)) : Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.hydrate(item, cache)]));
|
|
183
|
+
}
|
|
184
|
+
importLegacy(state) {
|
|
185
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new Error('旧数据格式不合法');
|
|
186
|
+
for (const collection of ['accounts', 'profiles', 'limits']) {
|
|
187
|
+
for (const [key, value] of Object.entries(state[collection] ?? {})) this.put(collection, key, value);
|
|
188
|
+
}
|
|
189
|
+
for (const [who, peers] of Object.entries(state.friends ?? {})) for (const peer of peers) this.befriend(who, peer);
|
|
190
|
+
for (const request of state.requests ?? []) this.put('requests', `${request.from}:${request.to}`, request);
|
|
191
|
+
for (const group of Object.values(state.groups ?? {})) this.saveGroup(group);
|
|
192
|
+
for (const collection of ['messages', 'bottles', 'square']) for (const item of state[collection] ?? []) this.put(collection, item.id, item);
|
|
193
|
+
for (const post of state.posts ?? []) {
|
|
194
|
+
const { comments = [], ...data } = post;
|
|
195
|
+
this.put('posts', post.id, data);
|
|
196
|
+
for (const comment of comments) this.put('comments', comment.id, { ...comment, postId: post.id });
|
|
197
|
+
}
|
|
198
|
+
const insert = this.statement('INSERT INTO events(seq,recipient,type,at,data) VALUES(?,?,?,?,?)');
|
|
199
|
+
for (const event of state.events ?? []) insert.run(event.seq, event.to, event.type, event.at, JSON.stringify(this.externalize(event.data)));
|
|
200
|
+
// Preserve cursors even if the old event queue is empty or has been pruned.
|
|
201
|
+
const high = Math.max(state.seq ?? 0, ...[0, ...(state.events ?? []).map(event => event.seq)]);
|
|
202
|
+
if (high > 0) {
|
|
203
|
+
if (!this.statement("SELECT 1 FROM sqlite_sequence WHERE name='events'").get()) this.statement("INSERT INTO sqlite_sequence(name,seq) VALUES('events',?)").run(high);
|
|
204
|
+
else this.statement("UPDATE sqlite_sequence SET seq=max(seq,?) WHERE name='events'").run(high);
|
|
205
|
+
}
|
|
206
|
+
this.put('meta', 'nextBotId', state.nextBotId ?? 10001);
|
|
207
|
+
}
|
|
208
|
+
close() { this.db.close(); }
|
|
209
|
+
}
|