@fengye_nobug/dsh-qq-bot 0.0.0-stage → 0.2.4
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 +297 -0
- package/DESIGN.md +184 -0
- package/LICENSE +26 -0
- package/README.md +624 -2
- package/assets/market-cover.jpg +0 -0
- package/client/client.js +1203 -0
- package/cordis.patch.yml +68 -0
- package/faces.js +285 -0
- package/index.js +4256 -0
- package/napcat.js +485 -0
- package/onebot.js +388 -0
- package/package.json +73 -4
- package/screenshots.json +3 -0
- package/stickers.js +237 -0
- package/updates.js +272 -0
package/stickers.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// stickers.js — 表情包登记表:纯函数 + 文件读写,导出以便测试。
|
|
2
|
+
//
|
|
3
|
+
// 登记表把“群里出现过的一张图”变成可复用的资产:
|
|
4
|
+
// * 图片本体复制到 `qq-bridge-stickers/<hash>.<ext>`;
|
|
5
|
+
// * 元数据(mood / description / 来源 / 发送次数)写在 `qq-bridge-stickers.json`。
|
|
6
|
+
// * 商城表情另外带一份 mface(emoji_id / emoji_package_id / key / summary)。
|
|
7
|
+
// 四字段齐全时出站发 OneBot mface;缺了就仍发图片。
|
|
8
|
+
//
|
|
9
|
+
// hash 取图片字节 sha256 的**前 16 位十六进制**(小写)—— 与 `qq-bridge-images/` 的文件名同构,
|
|
10
|
+
// 因此登记的图能直接命中宿主的图片描述缓存(省一次多模态调用)。
|
|
11
|
+
//
|
|
12
|
+
// 本文件不依赖 index.js,也不碰任何宿主服务:读不到、坏了都返回空表,绝不抛。
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { basename, dirname, extname, join } from 'node:path'
|
|
17
|
+
|
|
18
|
+
/** 登记表的文件名(放在 statePath 同一个目录)。 */
|
|
19
|
+
export const STICKERS_FILE = 'qq-bridge-stickers.json'
|
|
20
|
+
/** 图片本体目录名(同样放在 statePath 同一个目录)。 */
|
|
21
|
+
export const STICKERS_DIR = 'qq-bridge-stickers'
|
|
22
|
+
/** hash 位数(sha256 十六进制的前 16 位)。 */
|
|
23
|
+
export const HASH_LEN = 16
|
|
24
|
+
/** 短哈希位数(正文占位符与工具里显示的是它)。 */
|
|
25
|
+
export const SHORT_HASH_LEN = 6
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把入站段或登记项上的商城表情字段收成一份干净对象。
|
|
29
|
+
* 只有 summary(普通图片也会带简介)不算商城表情,返回 null。
|
|
30
|
+
* `emoji_package_id` 收成非负整数;带不来的字段直接不写。
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeMface(raw) {
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
34
|
+
const emojiId = String(raw.emoji_id ?? raw.emojiId ?? '').trim()
|
|
35
|
+
const key = String(raw.key ?? '').trim()
|
|
36
|
+
const summary = String(raw.summary ?? '').trim()
|
|
37
|
+
const pkg = packageIdOf(raw.emoji_package_id ?? raw.emojiPackageId)
|
|
38
|
+
if (emojiId === '' && key === '' && pkg === undefined) return null
|
|
39
|
+
const out = {}
|
|
40
|
+
if (emojiId !== '') out.emoji_id = emojiId
|
|
41
|
+
if (pkg !== undefined) out.emoji_package_id = pkg
|
|
42
|
+
if (key !== '') out.key = key
|
|
43
|
+
if (summary !== '') out.summary = summary
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 非负安全整数。数字或纯数字字符串都可以,别的(含小数)丢掉。 */
|
|
48
|
+
function packageIdOf(value) {
|
|
49
|
+
if (typeof value === 'number') {
|
|
50
|
+
if (Number.isInteger(value) && value >= 0 && Number.isSafeInteger(value)) return value
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
const text = String(value ?? '').trim()
|
|
54
|
+
if (!/^[0-9]+$/.test(text)) return undefined
|
|
55
|
+
const n = Number(text)
|
|
56
|
+
if (!Number.isSafeInteger(n)) return undefined
|
|
57
|
+
return n
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 四字段都在,才能发 OneBot mface。缺任何一个就当普通图片。 */
|
|
61
|
+
export function isCompleteMface(raw) {
|
|
62
|
+
const m = normalizeMface(raw)
|
|
63
|
+
return Boolean(m && m.emoji_id && m.key && m.summary && Number.isInteger(m.emoji_package_id))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 登记项 → 出站 mface 的 data。不齐就返回 null,调用方改发图片。
|
|
68
|
+
* `emoji_package_id` 是数字,跟 NapCat 当前的消息段定义一致。
|
|
69
|
+
*/
|
|
70
|
+
export function mfaceSendData(entry) {
|
|
71
|
+
const m = normalizeMface(entry?.mface)
|
|
72
|
+
if (!isCompleteMface(m)) return null
|
|
73
|
+
return {
|
|
74
|
+
emoji_package_id: m.emoji_package_id,
|
|
75
|
+
emoji_id: m.emoji_id,
|
|
76
|
+
key: m.key,
|
|
77
|
+
summary: m.summary,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 发给模型看的种类:四字段齐全是 mface,否则是 image。 */
|
|
82
|
+
export function stickerKind(entry) {
|
|
83
|
+
return isCompleteMface(entry?.mface) ? 'mface' : 'image'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 图片字节 → 16 位小写十六进制 hash。 */
|
|
87
|
+
export function hashOfBuffer(buffer) {
|
|
88
|
+
return createHash('sha256').update(buffer).digest('hex').slice(0, HASH_LEN)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 长哈希 → 短哈希。传进来空值时返回空串(调用方据此退回不带 hash 的占位符)。 */
|
|
92
|
+
export function shortHash(hash) {
|
|
93
|
+
return String(hash ?? '')
|
|
94
|
+
.trim()
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.slice(0, SHORT_HASH_LEN)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 空登记表。 */
|
|
100
|
+
export function emptyStore() {
|
|
101
|
+
return { version: 1, stickers: {} }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 把任意输入收敛成 `{ version: 1, stickers }`,坏结构当空表。 */
|
|
105
|
+
function normalizeStore(data) {
|
|
106
|
+
if (!data || typeof data !== 'object') return emptyStore()
|
|
107
|
+
const stickers =
|
|
108
|
+
data.stickers && typeof data.stickers === 'object' && !Array.isArray(data.stickers) ? data.stickers : {}
|
|
109
|
+
return { version: 1, stickers }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 读登记表。文件不存在、不是 JSON、结构不对 → 返回空表,绝不抛。 */
|
|
113
|
+
export function loadStickers(path) {
|
|
114
|
+
try {
|
|
115
|
+
return normalizeStore(JSON.parse(readFileSync(path, 'utf8')))
|
|
116
|
+
} catch {
|
|
117
|
+
return emptyStore()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 原子写:先写 `*.tmp` 再 rename。失败返回 false,绝不抛。 */
|
|
122
|
+
export function saveStickers(path, data) {
|
|
123
|
+
try {
|
|
124
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
125
|
+
const tmp = `${path}.tmp`
|
|
126
|
+
writeFileSync(tmp, `${JSON.stringify(normalizeStore(data), null, 2)}\n`, 'utf8')
|
|
127
|
+
renameSync(tmp, path)
|
|
128
|
+
return true
|
|
129
|
+
} catch {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 登记或更新一条(按 hash 合并)。
|
|
136
|
+
*
|
|
137
|
+
* 没有显式给值时保留旧的 `addedAt` / `sentCount` / `lastSentAt`:
|
|
138
|
+
* 重存同一张图只该换 mood 与描述,不该把累计发送次数清零。
|
|
139
|
+
*/
|
|
140
|
+
export function upsertSticker(store, entry) {
|
|
141
|
+
// 就地改传进来的那个对象:调用方(宿主半)把它当“内存里的登记表”持有,
|
|
142
|
+
// 拿一份新对象回写会让它悄悄丢失这次登记。
|
|
143
|
+
const s = store && typeof store === 'object' ? store : emptyStore()
|
|
144
|
+
if (!s.stickers || typeof s.stickers !== 'object' || Array.isArray(s.stickers)) s.stickers = {}
|
|
145
|
+
s.version = 1
|
|
146
|
+
const hash = String(entry?.hash ?? '')
|
|
147
|
+
.trim()
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
if (hash === '') return null
|
|
150
|
+
const prev = s.stickers[hash] && typeof s.stickers[hash] === 'object' ? s.stickers[hash] : {}
|
|
151
|
+
const next = { ...prev, ...entry, hash }
|
|
152
|
+
if (entry?.addedAt === undefined && prev.addedAt !== undefined) next.addedAt = prev.addedAt
|
|
153
|
+
if (entry?.sentCount === undefined && prev.sentCount !== undefined) next.sentCount = prev.sentCount
|
|
154
|
+
if (entry?.lastSentAt === undefined && prev.lastSentAt !== undefined) next.lastSentAt = prev.lastSentAt
|
|
155
|
+
s.stickers[hash] = next
|
|
156
|
+
return next
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 列出登记项(可只列某个 mood)。按登记时间**从新到旧**排序,`limit` 为 0 时返回空数组。 */
|
|
160
|
+
export function listStickers(store, { mood, limit } = {}) {
|
|
161
|
+
const s = normalizeStore(store)
|
|
162
|
+
const m = String(mood ?? '').trim()
|
|
163
|
+
let items = Object.values(s.stickers).filter((e) => e && typeof e === 'object')
|
|
164
|
+
if (m !== '') items = items.filter((e) => String(e.mood ?? '') === m)
|
|
165
|
+
items.sort((a, b) => Number(b.addedAt ?? 0) - Number(a.addedAt ?? 0))
|
|
166
|
+
const n = Number(limit)
|
|
167
|
+
return Number.isFinite(n) && n >= 0 ? items.slice(0, n) : items
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 查一条。`hash` 可以是短哈希或全哈希(按前缀匹配);只给 `mood` 时返回该情绪里最近登记的一条。
|
|
172
|
+
* 查不到返回 null。
|
|
173
|
+
*/
|
|
174
|
+
export function findSticker(store, { hash, mood } = {}) {
|
|
175
|
+
const s = normalizeStore(store)
|
|
176
|
+
const want = String(hash ?? '')
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase()
|
|
179
|
+
if (want !== '') {
|
|
180
|
+
if (s.stickers[want]) return s.stickers[want]
|
|
181
|
+
for (const [h, e] of Object.entries(s.stickers)) {
|
|
182
|
+
if (h.toLowerCase().startsWith(want)) return e
|
|
183
|
+
}
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
const m = String(mood ?? '').trim()
|
|
187
|
+
if (m === '') return null
|
|
188
|
+
return listStickers(s, { mood: m, limit: 1 })[0] ?? null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 把一张本地图片收进登记表:算 hash → 按需复制进 destDir → 登记并返回条目。
|
|
193
|
+
*
|
|
194
|
+
* 源文件读不到时**抛错**(由调用方翻成一句给模型看的提示)。落盘由调用方负责
|
|
195
|
+
* (宿主半统一 `saveStickers`),这样本函数保持“输入进、条目出”的纯形状。
|
|
196
|
+
* `mface` 可选:有商城表情字段就合并进条目;这次没给就留着上次的。
|
|
197
|
+
*/
|
|
198
|
+
export function importSticker({ store, srcPath, destDir, mood, description, source, mface }) {
|
|
199
|
+
const data = readFileSync(srcPath)
|
|
200
|
+
const hash = hashOfBuffer(data)
|
|
201
|
+
const rawExt = extname(String(srcPath ?? ''))
|
|
202
|
+
.replace(/^\./, '')
|
|
203
|
+
.toLowerCase()
|
|
204
|
+
const ext = rawExt === '' ? 'png' : rawExt
|
|
205
|
+
const name = `${hash}.${ext}`
|
|
206
|
+
const destPath = join(destDir, name)
|
|
207
|
+
mkdirSync(destDir, { recursive: true })
|
|
208
|
+
if (!existsSync(destPath)) copyFileSync(srcPath, destPath)
|
|
209
|
+
|
|
210
|
+
const prev = findSticker(store, { hash })
|
|
211
|
+
// 新来的字段盖住旧的;没带来的字段留着。这次没传 mface 就不写这个键,
|
|
212
|
+
// upsert 会保住登记表里已有的那份(重存普通图不该把商城表情信息抹掉)。
|
|
213
|
+
// 只带 summary 时:没有旧记录就不把普通图片简介当成商城表情;有旧记录就只改表情名。
|
|
214
|
+
let merged = null
|
|
215
|
+
if (mface !== undefined) {
|
|
216
|
+
const prevM = normalizeMface(prev?.mface) ?? {}
|
|
217
|
+
const incoming = normalizeMface(mface)
|
|
218
|
+
if (incoming) merged = { ...prevM, ...incoming }
|
|
219
|
+
else {
|
|
220
|
+
const summary = String(mface?.summary ?? '').trim()
|
|
221
|
+
if (summary !== '' && Object.keys(prevM).length > 0) merged = { ...prevM, summary }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const entry = {
|
|
225
|
+
hash,
|
|
226
|
+
mood: String(mood ?? '').trim(),
|
|
227
|
+
description: String(description ?? '').trim(),
|
|
228
|
+
// file 相对 statePath 所在目录 —— 宿主的出站按它读回图片本体。
|
|
229
|
+
file: `${basename(destDir)}/${name}`,
|
|
230
|
+
ext,
|
|
231
|
+
source: String(source ?? '').trim(),
|
|
232
|
+
// 新条目给全字段;已登记的图只换 mood / 描述,保留累计次数与首次登记时间。
|
|
233
|
+
...(prev ? {} : { addedAt: Date.now(), sentCount: 0, lastSentAt: null }),
|
|
234
|
+
...(merged ? { mface: merged } : {}),
|
|
235
|
+
}
|
|
236
|
+
return upsertSticker(store, entry)
|
|
237
|
+
}
|
package/updates.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// 检查 GitHub Release 上有没有比当前包更新的正式版。
|
|
2
|
+
//
|
|
3
|
+
// 只比较版本、拼白话说明。不下载、不安装、不改配置。
|
|
4
|
+
// 网络细节留在 checkPluginUpdate;界面和测试都吃这里的返回值。
|
|
5
|
+
|
|
6
|
+
export const UPDATE_REPO = 'yunfengnobug/dsh-qq-bot'
|
|
7
|
+
|
|
8
|
+
const RELEASES_PER_PAGE = 30
|
|
9
|
+
|
|
10
|
+
/** @param {string} [repo] */
|
|
11
|
+
export function releasesApiUrl(repo = UPDATE_REPO) {
|
|
12
|
+
return `https://api.github.com/repos/${repo}/releases?per_page=${RELEASES_PER_PAGE}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 解析 `v1.2.3` / `1.2.3-rc.1`。对不上返回 null。
|
|
17
|
+
* @param {unknown} input
|
|
18
|
+
*/
|
|
19
|
+
export function parseSemver(input) {
|
|
20
|
+
const text = String(input ?? '').trim()
|
|
21
|
+
const match = text.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/)
|
|
22
|
+
if (!match) return null
|
|
23
|
+
return {
|
|
24
|
+
major: Number(match[1]),
|
|
25
|
+
minor: Number(match[2]),
|
|
26
|
+
patch: Number(match[3]),
|
|
27
|
+
pre: match[4] ? match[4].split('.').filter((part) => part !== '') : [],
|
|
28
|
+
raw: match[4] ? `${match[1]}.${match[2]}.${match[3]}-${match[4]}` : `${match[1]}.${match[2]}.${match[3]}`,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function compareIdentifiers(left, right) {
|
|
33
|
+
const leftNum = /^[0-9]+$/.test(left)
|
|
34
|
+
const rightNum = /^[0-9]+$/.test(right)
|
|
35
|
+
if (leftNum && rightNum) {
|
|
36
|
+
const diff = Number(left) - Number(right)
|
|
37
|
+
if (diff !== 0) return diff > 0 ? 1 : -1
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
if (leftNum) return -1
|
|
41
|
+
if (rightNum) return 1
|
|
42
|
+
if (left === right) return 0
|
|
43
|
+
return left > right ? 1 : -1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function comparePre(left, right) {
|
|
47
|
+
if (left.length === 0 && right.length === 0) return 0
|
|
48
|
+
if (left.length === 0) return 1
|
|
49
|
+
if (right.length === 0) return -1
|
|
50
|
+
const count = Math.max(left.length, right.length)
|
|
51
|
+
for (let i = 0; i < count; i++) {
|
|
52
|
+
if (left[i] === undefined) return -1
|
|
53
|
+
if (right[i] === undefined) return 1
|
|
54
|
+
const diff = compareIdentifiers(left[i], right[i])
|
|
55
|
+
if (diff !== 0) return diff
|
|
56
|
+
}
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `a > b` 返回 1,相等返回 0,`a < b` 返回 -1。有一边解析不了返回 null。
|
|
62
|
+
* 正式版高于同号的预发布版。
|
|
63
|
+
* @param {unknown} a
|
|
64
|
+
* @param {unknown} b
|
|
65
|
+
*/
|
|
66
|
+
export function compareSemver(a, b) {
|
|
67
|
+
const left = typeof a === 'object' && a ? a : parseSemver(a)
|
|
68
|
+
const right = typeof b === 'object' && b ? b : parseSemver(b)
|
|
69
|
+
if (!left?.raw || !right?.raw) return null
|
|
70
|
+
if (left.major !== right.major) return left.major > right.major ? 1 : -1
|
|
71
|
+
if (left.minor !== right.minor) return left.minor > right.minor ? 1 : -1
|
|
72
|
+
if (left.patch !== right.patch) return left.patch > right.patch ? 1 : -1
|
|
73
|
+
return comparePre(left.pre ?? [], right.pre ?? [])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 安装命令里的 spec。版本号不带 v 时补上。 */
|
|
77
|
+
export function installSpec(version, repo = UPDATE_REPO) {
|
|
78
|
+
const parsed = parseSemver(version)
|
|
79
|
+
const raw = parsed?.raw ?? String(version ?? '').trim().replace(/^v/i, '')
|
|
80
|
+
return `github:${repo}#v${raw}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function installCommand(version, repo = UPDATE_REPO) {
|
|
84
|
+
return `dsh plugin --profile web add ${installSpec(version, repo)}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function releasePage(tag, repo = UPDATE_REPO) {
|
|
88
|
+
const parsed = parseSemver(tag)
|
|
89
|
+
const name = parsed ? `v${parsed.raw}` : String(tag ?? '').trim()
|
|
90
|
+
return `https://github.com/${repo}/releases/tag/${name}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Release 正文收成一两句白话。去掉围栏代码和链接,避免把整篇更新日志贴进卡片。
|
|
95
|
+
* @param {unknown} body
|
|
96
|
+
* @param {number} [maxChars]
|
|
97
|
+
*/
|
|
98
|
+
export function summarizeReleaseNotes(body, maxChars = 240) {
|
|
99
|
+
const lines = String(body ?? '')
|
|
100
|
+
.replace(/\r\n/g, '\n')
|
|
101
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
102
|
+
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
|
103
|
+
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
|
104
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
105
|
+
.split('\n')
|
|
106
|
+
.map((line) => line.replace(/^[-*]\s+/, '').replace(/[`*_>]/g, '').trim())
|
|
107
|
+
.filter((line) => line !== '' && !/^[-—]+$/.test(line))
|
|
108
|
+
const joined = lines.slice(0, 4).join(' ')
|
|
109
|
+
if (joined.length <= maxChars) return joined
|
|
110
|
+
return `${joined.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function safeReleaseUrl(rel, tag, repo) {
|
|
114
|
+
const raw = typeof rel?.html_url === 'string' ? rel.html_url : ''
|
|
115
|
+
const prefix = `https://github.com/${repo}/`
|
|
116
|
+
if (raw.startsWith(prefix)) return raw
|
|
117
|
+
return releasePage(tag, repo)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {{ current: unknown, releases: unknown, repo?: string }} input
|
|
122
|
+
*/
|
|
123
|
+
export function assessUpdate({ current, releases, repo = UPDATE_REPO }) {
|
|
124
|
+
const parsedCurrent = parseSemver(current)
|
|
125
|
+
if (!parsedCurrent) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
kind: 'bad-current',
|
|
129
|
+
current: String(current ?? ''),
|
|
130
|
+
updateAvailable: false,
|
|
131
|
+
message: '当前版本号读不出来,没法对比。',
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const stable = []
|
|
135
|
+
for (const rel of Array.isArray(releases) ? releases : []) {
|
|
136
|
+
if (!rel || typeof rel !== 'object') continue
|
|
137
|
+
if (rel.draft === true || rel.prerelease === true) continue
|
|
138
|
+
const tag = String(rel.tag_name ?? '')
|
|
139
|
+
const parsed = parseSemver(tag)
|
|
140
|
+
if (!parsed) continue
|
|
141
|
+
stable.push({ rel, parsed, tag })
|
|
142
|
+
}
|
|
143
|
+
if (stable.length === 0) {
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
kind: 'no-release',
|
|
147
|
+
current: parsedCurrent.raw,
|
|
148
|
+
updateAvailable: false,
|
|
149
|
+
message: 'GitHub 上还没有可用的正式版,没法对比新版本。',
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
stable.sort((left, right) => compareSemver(right.parsed, left.parsed) ?? 0)
|
|
153
|
+
const latest = stable[0]
|
|
154
|
+
const summary = summarizeReleaseNotes(latest.rel.body)
|
|
155
|
+
const spec = installSpec(latest.parsed.raw, repo)
|
|
156
|
+
const command = installCommand(latest.parsed.raw, repo)
|
|
157
|
+
const url = safeReleaseUrl(latest.rel, latest.tag, repo)
|
|
158
|
+
const compared = compareSemver(latest.parsed, parsedCurrent)
|
|
159
|
+
const base = {
|
|
160
|
+
ok: true,
|
|
161
|
+
current: parsedCurrent.raw,
|
|
162
|
+
latest: latest.parsed.raw,
|
|
163
|
+
tag: `v${latest.parsed.raw}`,
|
|
164
|
+
summary,
|
|
165
|
+
install: spec,
|
|
166
|
+
command,
|
|
167
|
+
url,
|
|
168
|
+
}
|
|
169
|
+
if (compared > 0) {
|
|
170
|
+
const lines = [`有新版本 ${latest.parsed.raw}(你现在是 ${parsedCurrent.raw})。`]
|
|
171
|
+
if (summary) lines.push(`说明:${summary}`)
|
|
172
|
+
lines.push(`升级命令:${command}`)
|
|
173
|
+
lines.push('装完请重启 dsh-web。')
|
|
174
|
+
lines.push(`发布页:${url}`)
|
|
175
|
+
return { ...base, kind: 'update', updateAvailable: true, message: lines.join('\n') }
|
|
176
|
+
}
|
|
177
|
+
if (compared < 0) {
|
|
178
|
+
return {
|
|
179
|
+
...base,
|
|
180
|
+
kind: 'ahead',
|
|
181
|
+
updateAvailable: false,
|
|
182
|
+
message: `你现在的 ${parsedCurrent.raw} 比 GitHub 上最新正式版 ${latest.parsed.raw} 还新,不用按 Release 往回装。`,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
kind: 'current',
|
|
188
|
+
updateAvailable: false,
|
|
189
|
+
message: `已经是最新正式版 ${parsedCurrent.raw},不用更新。`,
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 把 GitHub / 网络失败收成一句白话。不要把英文堆栈或限流正文里的地址回给界面。
|
|
195
|
+
* @param {unknown} status
|
|
196
|
+
* @param {unknown} snippet
|
|
197
|
+
*/
|
|
198
|
+
export function failureMessage(status, snippet) {
|
|
199
|
+
const code = Number(status)
|
|
200
|
+
const text = String(snippet ?? '')
|
|
201
|
+
if (code === 403 || code === 429 || /rate limit/i.test(text)) {
|
|
202
|
+
return 'GitHub 限流了,等一会儿再点「检查更新」。'
|
|
203
|
+
}
|
|
204
|
+
if (code === 404) return '没有查到这个仓库的 Release。'
|
|
205
|
+
return '连不上 GitHub,这次没有检查到更新。稍后再试。'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function readSnippet(response) {
|
|
209
|
+
try {
|
|
210
|
+
if (typeof response?.text === 'function') return String(await response.text()).slice(0, 500)
|
|
211
|
+
} catch {
|
|
212
|
+
// 读失败也按连不上处理,不把读取异常的英文抛出去。
|
|
213
|
+
}
|
|
214
|
+
return ''
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {typeof fetch | undefined} fetchImpl
|
|
219
|
+
* @param {{ repo?: string, timeoutMs?: number }} [options]
|
|
220
|
+
*/
|
|
221
|
+
export async function queryGithubReleases(fetchImpl, options = {}) {
|
|
222
|
+
// 显式传入 null 表示「不要上网」。只有没传时才用全局 fetch。
|
|
223
|
+
const fetchFn = fetchImpl === undefined ? globalThis.fetch : fetchImpl
|
|
224
|
+
if (typeof fetchFn !== 'function') {
|
|
225
|
+
return { ok: false, error: failureMessage(0, '') }
|
|
226
|
+
}
|
|
227
|
+
const repo = options.repo || UPDATE_REPO
|
|
228
|
+
const timeoutMs = options.timeoutMs ?? 15000
|
|
229
|
+
let response
|
|
230
|
+
try {
|
|
231
|
+
response = await fetchFn(releasesApiUrl(repo), {
|
|
232
|
+
headers: {
|
|
233
|
+
accept: 'application/vnd.github+json',
|
|
234
|
+
'user-agent': 'dsh-qq-bot',
|
|
235
|
+
},
|
|
236
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
237
|
+
})
|
|
238
|
+
} catch {
|
|
239
|
+
return { ok: false, error: failureMessage(0, '') }
|
|
240
|
+
}
|
|
241
|
+
if (!response || response.ok !== true) {
|
|
242
|
+
const snippet = await readSnippet(response)
|
|
243
|
+
return { ok: false, error: failureMessage(response?.status, snippet), status: response?.status }
|
|
244
|
+
}
|
|
245
|
+
let data
|
|
246
|
+
try {
|
|
247
|
+
data = await response.json()
|
|
248
|
+
} catch {
|
|
249
|
+
return { ok: false, error: 'GitHub 返回的内容读不懂,这次没有检查到更新。' }
|
|
250
|
+
}
|
|
251
|
+
if (!Array.isArray(data)) {
|
|
252
|
+
const message = data && typeof data === 'object' ? data.message : ''
|
|
253
|
+
return { ok: false, error: failureMessage(response.status, message) }
|
|
254
|
+
}
|
|
255
|
+
return { ok: true, releases: data }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {{ current: unknown, fetchImpl?: typeof fetch, repo?: string }} [input]
|
|
260
|
+
*/
|
|
261
|
+
export async function checkPluginUpdate(input = {}) {
|
|
262
|
+
const current = input.current
|
|
263
|
+
const queried = await queryGithubReleases(input.fetchImpl, { repo: input.repo })
|
|
264
|
+
if (!queried.ok) {
|
|
265
|
+
return { ok: false, current: String(current ?? ''), error: queried.error, message: queried.error }
|
|
266
|
+
}
|
|
267
|
+
const assessed = assessUpdate({ current, releases: queried.releases, repo: input.repo })
|
|
268
|
+
if (assessed.ok === false) {
|
|
269
|
+
return { ok: false, current: assessed.current, error: assessed.message, message: assessed.message }
|
|
270
|
+
}
|
|
271
|
+
return assessed
|
|
272
|
+
}
|