koishi-plugin-haulmp-tools 1.0.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.
@@ -0,0 +1,43 @@
1
+ // accounts.haulmp.com 账号服务(注册 / 设备码登录 / 找回密码共用的请求层)
2
+ const { ACCOUNTS_BASE } = require('../util/constant')
3
+
4
+ function cookiesOf(res) {
5
+ try {
6
+ if (typeof res.headers.getSetCookie === 'function') {
7
+ return res.headers.getSetCookie().map((c) => String(c).split(';')[0]).join('; ')
8
+ }
9
+ const raw = res.headers.get('set-cookie') || ''
10
+ return raw.split(',').map((c) => c.split(';')[0].trim()).filter(Boolean).join('; ')
11
+ } catch (e) {
12
+ return ''
13
+ }
14
+ }
15
+
16
+ async function request(url, { cookie, body } = {}) {
17
+ const headers = { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' }
18
+ if (cookie) headers.Cookie = cookie
19
+ if (body) {
20
+ headers['Content-Type'] = 'application/json'
21
+ headers.Origin = ACCOUNTS_BASE
22
+ headers.Referer = ACCOUNTS_BASE + '/'
23
+ }
24
+ const res = await fetch(url, {
25
+ method: body ? 'POST' : 'GET',
26
+ headers,
27
+ body: body ? JSON.stringify(body) : undefined,
28
+ signal: AbortSignal.timeout(15000),
29
+ })
30
+ const data = await res.json().catch(() => ({}))
31
+ return { status: res.status, data, cookies: cookiesOf(res) }
32
+ }
33
+
34
+ async function accountsApi(path, opts) {
35
+ return request(ACCOUNTS_BASE + path, opts)
36
+ }
37
+
38
+ async function registerAccount(handle, email, password) {
39
+ const s1 = await accountsApi('/api/session')
40
+ return accountsApi('/api/auth/register', { body: { handle, email, password }, cookie: s1.cookies })
41
+ }
42
+
43
+ module.exports = { request, accountsApi, registerAccount, cookiesOf }
@@ -0,0 +1,39 @@
1
+ // HaulMP 论坛 API(资料 / 成员翻页 / 通知)
2
+ const { fetchJson, fetchJsonMemo } = require('../util/common')
3
+ const { FORUM_BASE } = require('../util/constant')
4
+ const { request } = require('./accountsApi')
5
+
6
+ async function fetchProfile(apiBase, key) {
7
+ const url = `${apiBase}/u/${encodeURIComponent(key)}`
8
+ let data = null
9
+ try {
10
+ data = await fetchJsonMemo(url, 300000)
11
+ } catch (e) {
12
+ await new Promise((r) => setTimeout(r, 1200))
13
+ data = await fetchJsonMemo(url, 300000).catch(() => null)
14
+ }
15
+ if (!data || data.error || !data.member || !data.member.handle) return null
16
+ return data
17
+ }
18
+
19
+ // 排行缓存构建用的成员翻页(带多重退避重试)
20
+ async function fetchMembersPage(apiBase, page) {
21
+ const waits = [0, 2000, 5000, 10000]
22
+ for (let attempt = 0; attempt < waits.length; attempt++) {
23
+ if (waits[attempt]) await new Promise((r) => setTimeout(r, waits[attempt]))
24
+ let d = null
25
+ try {
26
+ d = await fetchJson(`${apiBase}/members?page=${page}`)
27
+ } catch (e) {
28
+ d = null
29
+ }
30
+ if (d && Array.isArray(d.members)) return d
31
+ }
32
+ return null
33
+ }
34
+
35
+ async function fetchNotifications(cookies) {
36
+ return request(`${FORUM_BASE}/api/forum/notifications?page=1`, { cookie: cookies })
37
+ }
38
+
39
+ module.exports = { fetchProfile, fetchMembersPage, fetchNotifications }
@@ -0,0 +1,26 @@
1
+ // HaulMP VTC API(车队信息 / 公告 / 登录态的驾驶数据)
2
+ const { fetchJson } = require('../util/common')
3
+ const { VTC_ROOT } = require('../util/constant')
4
+ const { request } = require('./accountsApi')
5
+
6
+ async function fetchCompany(config, slug) {
7
+ const d = await fetchJson(`${config.vtcBase}/public/company/${encodeURIComponent(slug)}`)
8
+ if (!d || !d.company || !Array.isArray(d.roster)) return null
9
+ return d
10
+ }
11
+
12
+ async function fetchAnnouncements(config, slug) {
13
+ const d = await fetchJson(`${config.vtcBase}/public/vtcs/${encodeURIComponent(slug)}`)
14
+ return (d && d.announcements) || []
15
+ }
16
+
17
+ // 登录态接口(我的行驶记录)
18
+ async function driverMe(cookies) {
19
+ return request(`${VTC_ROOT}/api/driver/me`, { cookie: cookies })
20
+ }
21
+
22
+ async function gameStatus(cookies) {
23
+ return request(`${VTC_ROOT}/api/game/status`, { cookie: cookies }).catch(() => null)
24
+ }
25
+
26
+ module.exports = { fetchCompany, fetchAnnouncements, driverMe, gameStatus }
@@ -0,0 +1,151 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { renderCard, esc, isText, cardTheme, CARD_DECO } = require('../util/common')
4
+ const { fetchAnnouncements } = require('../api/vtcApi')
5
+ const { translateBatch } = require('../util/translate')
6
+
7
+ function stateFile(ctx) {
8
+ return path.join(ctx.baseDir || process.cwd(), 'data', 'haulmp_company_ann.json')
9
+ }
10
+
11
+ function loadState(ctx) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(stateFile(ctx), 'utf8'))
14
+ } catch (e) {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ function saveState(ctx, state) {
20
+ try {
21
+ fs.writeFileSync(stateFile(ctx), JSON.stringify(state, null, 1))
22
+ } catch (e) { /* ignore */ }
23
+ }
24
+
25
+ function fmtWhen(iso) {
26
+ if (!iso) return ''
27
+ const d = new Date(iso)
28
+ if (isNaN(d.getTime())) return ''
29
+ const t = new Date(d.getTime() + 8 * 3600 * 1000)
30
+ const p = (n) => String(n).padStart(2, '0')
31
+ return `${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`
32
+ }
33
+
34
+ // 🎨 设计提示词(车队公告):车队长喇叭。琥珀暖光如封蜡火漆,徽记泛光,
35
+ // 正文像正式通告,温暖、有人情味。底板为共享 ink 玻璃主题。
36
+ function annCard(row, ann, titleCn, bodyCn) {
37
+ const title = titleCn || ann.title || '(无标题)'
38
+ const body = bodyCn || ann.body || ''
39
+ const tag = row.tag || 'VTC'
40
+ const initial = String(tag.replace(/[^A-Za-z0-9\u4e00-\u9fa5]/g, '')[0] || 'V').toUpperCase()
41
+ return `<!doctype html><html><head><meta charset="utf-8"><style>${cardTheme('#f59e0b', '#fbbf24', 720)}
42
+ .head{display:flex;align-items:center;gap:14px;padding-bottom:16px;border-bottom:1px solid rgba(245,158,11,.18)}
43
+ .ava{width:54px;height:54px;border-radius:13px;background:linear-gradient(150deg,rgba(245,158,11,.30),rgba(251,191,36,.12));border:1px solid rgba(245,158,11,.6);color:#ffd08a;font-weight:700;font-size:22px;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 0 22px rgba(245,158,11,.3)}
44
+ .who{flex:1;min-width:0}
45
+ .kicker{font-size:13px;color:#c9a86a;letter-spacing:1px}
46
+ .cname{font-size:20px;font-weight:700;color:#fff;margin-top:3px}
47
+ .cname .tag{color:#ffd08a;font-size:13px;font-weight:600;margin-left:8px}
48
+ .stamp{width:60px;height:60px;border:2px solid rgba(245,158,11,.7);border-radius:50%;color:#ffd08a;font-size:14px;font-weight:700;display:flex;align-items:center;justify-content:center;letter-spacing:3px;transform:rotate(-12deg);box-shadow:0 0 20px rgba(245,158,11,.28),inset 0 0 16px rgba(245,158,11,.15);flex:none}
49
+ .title{margin-top:20px;font-size:20px;font-weight:700;color:#fff;line-height:1.5;padding-left:14px;border-left:3px solid rgba(245,158,11,.7);text-shadow:0 2px 14px rgba(245,158,11,.25)}
50
+ .body{margin-top:12px;font-size:14.5px;line-height:1.95;color:#cdd9e6;white-space:pre-wrap;padding:13px 17px;background:var(--glass-lo);border:1px solid rgba(245,158,11,.14);border-radius:12px}
51
+ .foot{margin-top:18px;padding-top:12px;border-top:1px solid rgba(245,158,11,.16);display:flex;justify-content:space-between;font-size:11.5px;color:#62748a}
52
+ </style></head><body><div id="card">${CARD_DECO}
53
+ <div class="head"><div class="ava">${esc(initial)}</div><div class="who"><div class="kicker">车队公告</div><div class="cname">${esc(row.name || row.slug)}<span class="tag">${esc(tag)}</span></div></div><div class="stamp">公告</div></div>
54
+ <div class="title">${esc(title)}</div>
55
+ <div class="body">${esc(body)}</div>
56
+ <div class="foot"><span>发布者:${esc(ann.display_name || ann.handle || '—')} · ${fmtWhen(ann.created_at)}</span><span>自动推送 · 机器翻译仅供参考</span></div>
57
+ </div></body></html>`
58
+ }
59
+
60
+ function annText(row, ann, titleCn, bodyCn) {
61
+ return `📢 ${row.name || row.slug} 车队公告\n${titleCn || ann.title || ''}\n\n${bodyCn || ann.body || ''}\n\n发布者:${ann.display_name || ann.handle || '—'} · ${fmtWhen(ann.created_at)}`
62
+ }
63
+
64
+ async function pushTo(ctx, rows, content) {
65
+ let sent = 0
66
+ for (const row of rows) {
67
+ try {
68
+ const idx = String(row.channelId).indexOf(':')
69
+ const platform = idx > 0 ? row.channelId.slice(0, idx) : row.platform
70
+ const id = idx > 0 ? row.channelId.slice(idx + 1) : row.channelId
71
+ const bot = ctx.bots.find((b) => b.platform === platform)
72
+ if (!bot) continue
73
+ await bot.sendMessage(id, content)
74
+ sent++
75
+ } catch (e) {
76
+ ctx.logger('haulmp').warn('公告推送失败(%s): %s', row.channelId, e.message)
77
+ }
78
+ }
79
+ return sent
80
+ }
81
+
82
+ async function check(ctx, config) {
83
+ const logger = ctx.logger('haulmp')
84
+ let rows = []
85
+ try {
86
+ rows = await ctx.database.get('haulmp_company', {})
87
+ } catch (e) {
88
+ return
89
+ }
90
+ if (!rows || !rows.length) return
91
+
92
+ const bySlug = new Map()
93
+ for (const r of rows) {
94
+ if (!bySlug.has(r.slug)) bySlug.set(r.slug, [])
95
+ bySlug.get(r.slug).push(r)
96
+ }
97
+
98
+ const state = loadState(ctx)
99
+ let changed = false
100
+
101
+ for (const [slug, group] of bySlug) {
102
+ let ann = []
103
+ try {
104
+ ann = await fetchAnnouncements(config, slug)
105
+ } catch (e) {
106
+ continue
107
+ }
108
+ if (!ann.length) continue
109
+ const latest = ann.reduce((a, b) => (new Date(b.created_at || 0) > new Date(a.created_at || 0) ? b : a))
110
+ const prev = state[slug]
111
+ if (!prev) {
112
+ state[slug] = { id: latest.id, at: Date.now() }
113
+ changed = true
114
+ logger.info('首次记录车队公告 %s(不推送)', slug)
115
+ continue
116
+ }
117
+ if (prev.id === latest.id) continue
118
+
119
+ state[slug] = { id: latest.id, at: Date.now() }
120
+ changed = true
121
+
122
+ let titleCn = ''
123
+ let bodyCn = ''
124
+ try {
125
+ const parts = await translateBatch([latest.title || '', latest.body || ''])
126
+ if (parts && parts.length === 2) {
127
+ if (parts[0] && parts[0] !== latest.title) titleCn = parts[0]
128
+ if (parts[1] && parts[1] !== latest.body) bodyCn = parts[1]
129
+ }
130
+ } catch (e) { /* 用原文 */ }
131
+
132
+ const row = group[0]
133
+ let content = annText(row, latest, titleCn, bodyCn)
134
+ if (!isText(config, 'announce')) {
135
+ const card = await renderCard(ctx, annCard(row, latest, titleCn, bodyCn))
136
+ if (card) content = card
137
+ }
138
+ const sent = await pushTo(ctx, group, content)
139
+ logger.info('车队公告推送 %s「%s」→ %d 个群', slug, String(latest.title || '').slice(0, 30), sent)
140
+ }
141
+
142
+ if (changed) saveState(ctx, state)
143
+ }
144
+
145
+ function apply(ctx, config) {
146
+ const run = () => check(ctx, config).catch((e) => ctx.logger('haulmp').warn('车队公告检测异常: %s', e.message))
147
+ setTimeout(run, 60000)
148
+ ctx.setInterval(run, 10 * 60 * 1000)
149
+ }
150
+
151
+ module.exports = { apply, check, annCard, annText }
@@ -0,0 +1,100 @@
1
+ const { bindingQuery, esc, avatarUrl, resolveAvatar, renderCard, isText, cardTheme, CARD_DECO } = require('../util/common')
2
+ const { fetchProfile } = require('../api/forumApi')
3
+
4
+ // 🎨 设计提示词(绑定成功):成就解锁。翡翠绿庆典氛围,光环扩散 + 勾选徽记居中,
5
+ // 光线收束到姓名,像游戏通关结算界面。底板用共享 ink 玻璃主题(深墨蓝 + 扫描线)。
6
+ const CARD_CSS = cardTheme('#3aa86f', '#7fd4a0', 640) + `
7
+ .hero{position:relative;height:150px;margin:-26px -28px 0;background:linear-gradient(120deg,rgba(58,168,111,.42),rgba(45,212,191,.24) 60%,rgba(7,16,26,.05));overflow:hidden}
8
+ .hero::after{content:'';position:absolute;inset:0;background:radial-gradient(circle at 50% 135%,rgba(255,255,255,.20),transparent 60%)}
9
+ .avatar{width:104px;height:104px;border-radius:50%;object-fit:cover;border:3px solid #14283b;background:#0f2233;box-shadow:0 0 0 3px rgba(94,211,154,.45),0 12px 40px rgba(0,0,0,.5),0 0 44px rgba(58,168,111,.4)}
10
+ .avatar.ph{display:flex;align-items:center;justify-content:center;margin:0 auto;font-size:40px;font-weight:700;color:#dff8ea;background:linear-gradient(150deg,rgba(58,168,111,.45),rgba(45,212,191,.35))}
11
+ h1{color:#fff;font-size:26px;margin-top:16px;letter-spacing:6px;text-align:center;text-shadow:0 2px 20px rgba(58,168,111,.4)}
12
+ .uline{width:60px;height:3px;margin:11px auto 0;border-radius:2px;background:linear-gradient(90deg,transparent,var(--ac),transparent);box-shadow:0 0 12px var(--ac)}
13
+ .name{margin-top:16px;font-size:25px;color:#fff;font-weight:700;text-align:center}
14
+ .handle{font-size:14px;color:#9cc6f0;text-align:center;margin-top:4px}
15
+ .ribbon{display:flex;gap:8px;justify-content:center;flex-wrap:wrap;margin-top:18px}
16
+ .rchip{font-size:12.5px;color:#cfe3f7;background:var(--glass);border:1px solid var(--stroke-hi);border-radius:999px;padding:5px 14px;box-shadow:inset 0 1px 0 rgba(255,255,255,.07)}
17
+ .rchip b{color:#fff}
18
+ .hint{margin-top:18px;font-size:13px;color:#8ba0b5;line-height:1.85;text-align:center}
19
+ .hint b{color:#cdd9e6}
20
+ .foot{margin-top:22px;padding-top:13px;border-top:1px solid rgba(94,211,154,.16);display:flex;justify-content:space-between;font-size:12px;color:#62748a}
21
+ `
22
+
23
+ function successCard(member, session, avatarSrc) {
24
+ const m = member
25
+ const src = avatarSrc !== undefined ? avatarSrc : avatarUrl(m.avatar)
26
+ const avatarEl = src
27
+ ? `<img class="avatar" src="${esc(src)}">`
28
+ : `<div class="avatar ph">${esc((m.displayName || m.handle || '?').slice(0, 1))}</div>`
29
+ return `<!doctype html>
30
+ <html><head><meta charset="utf-8"><style>${CARD_CSS}</style></head><body><div id="card">${CARD_DECO}
31
+ <div class="hero"></div>
32
+ <div style="text-align:center;margin-top:-72px;position:relative;z-index:3">${avatarEl}</div>
33
+ <h1>绑定成功</h1>
34
+ <div class="uline"></div>
35
+ <div class="name">${esc(m.displayName || m.handle)}</div>
36
+ <div class="handle">@${esc(m.handle)}</div>
37
+ <div class="ribbon"><span class="rchip">QQ <b>${esc(session.userId)}</b></span>${m.company ? `<span class="rchip">车队 <b>${esc(m.company.name)} [${esc(m.company.tag)}]</b></span>` : ''}</div>
38
+ <div class="hint">发送「查询H」查看完整资料 · 发送「解绑H」解除绑定</div>
39
+ <div class="foot"><span>HaulMP Forums</span><span>forum.haulmp.com</span></div>
40
+ </div></body></html>`
41
+ }
42
+
43
+ function apply(ctx, config) {
44
+ ctx.command('绑定H <handle>', '绑定 HaulMP 论坛账号')
45
+ .alias('bindh', '绑定h')
46
+ .usage('示例:绑定H 123456')
47
+ .action(async ({ session }, handle) => {
48
+ if (!session) return
49
+ const key = String(handle || '').trim().replace(/^@+/, '')
50
+ if (!key) return '请输入要绑定的 ID,例如:绑定H 123456'
51
+
52
+ const [exist] = await ctx.database.get('haulmp_binding', bindingQuery(session))
53
+ if (exist) {
54
+ return `你已经绑定过 ${exist.displayName}(@${exist.handle})了,如需更换请先发送「解绑H」`
55
+ }
56
+
57
+ let data
58
+ try {
59
+ data = await fetchProfile(config.apiBase, key)
60
+ } catch (e) {
61
+ ctx.logger('haulmp').warn('绑定查询失败: %s', e.message)
62
+ return '查询失败,请稍后再试'
63
+ }
64
+ if (!data) return `没找到用户「${key}」,请检查 ID 是否正确`
65
+
66
+ const m = data.member
67
+ await ctx.database.create('haulmp_binding', {
68
+ ...bindingQuery(session),
69
+ handle: m.handle,
70
+ displayName: m.displayName || m.handle,
71
+ forumId: m.id || '',
72
+ boundAt: new Date(),
73
+ })
74
+
75
+ if (!isText(config, 'bind')) {
76
+ const card = await renderCard(ctx, successCard(m, session, await resolveAvatar(m)))
77
+ if (card) return card
78
+ }
79
+ return `${m.displayName || m.handle} 绑定成功!`
80
+ })
81
+
82
+ ctx.command('我的绑定H', '查看已绑定的 HaulMP 账号')
83
+ .alias('我的绑定h')
84
+ .action(async ({ session }) => {
85
+ if (!session) return
86
+ const [b] = await ctx.database.get('haulmp_binding', bindingQuery(session))
87
+ if (!b) return '你还没有绑定,发送「绑定H 你的ID」即可绑定'
88
+ return `你绑定的是 ${b.displayName}(@${b.handle})`
89
+ })
90
+
91
+ ctx.command('解绑H', '解除 HaulMP 账号绑定')
92
+ .alias('解绑h')
93
+ .action(async ({ session }) => {
94
+ if (!session) return
95
+ const res = await ctx.database.remove('haulmp_binding', bindingQuery(session))
96
+ return res && res.removed ? '已解绑' : '你还没有绑定'
97
+ })
98
+ }
99
+
100
+ module.exports = { apply, successCard }
@@ -0,0 +1,177 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { renderCard, esc, fetchJson, isText, cardTheme, CARD_DECO } = require('../util/common')
4
+ const { translateBatch } = require('../util/translate')
5
+
6
+ const STATUS_API = 'https://haulmp.com/api/status'
7
+
8
+ function statePath(ctx) {
9
+ return path.join(ctx.baseDir, 'data', 'haulmp_client.json')
10
+ }
11
+
12
+ function loadState(ctx) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(statePath(ctx), 'utf8'))
15
+ } catch (e) {
16
+ return null
17
+ }
18
+ }
19
+
20
+ function saveState(ctx, state) {
21
+ try {
22
+ const p = statePath(ctx)
23
+ fs.mkdirSync(path.dirname(p), { recursive: true })
24
+ fs.writeFileSync(p, JSON.stringify(state, null, 1))
25
+ } catch (e) { /* ignore */ }
26
+ }
27
+
28
+ function fmtBytes(v) {
29
+ if (v == null || isNaN(v)) return '—'
30
+ const n = Number(v)
31
+ if (n < 1024) return `${n} B`
32
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
33
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
34
+ }
35
+
36
+ function fmtPublished(iso) {
37
+ if (!iso) return ''
38
+ const d = new Date(iso)
39
+ if (isNaN(d.getTime())) return ''
40
+ const t = new Date(d.getTime() + 8 * 3600 * 1000)
41
+ const p = (n) => String(n).padStart(2, '0')
42
+ return `${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())} ${p(t.getUTCHours())}:${p(t.getUTCMinutes())}`
43
+ }
44
+
45
+ // 🎨 设计提示词(客户端更新):信号塔通报。冰蓝数据流,版本徽章自带辉光,
46
+ // 像新固件在空中广播,干净、科技、可信。底板为共享 ink 玻璃主题。
47
+ function updateCard(status, notesCn, oldVersion) {
48
+ const notes = notesCn || status.notes || '(本次更新无说明)'
49
+ const nm = String(status.notes || '').match(/HaulMP\s+([\d.]+)/i)
50
+ const verName = nm ? nm[1] : ''
51
+ return `<!doctype html><html><head><meta charset="utf-8"><style>${cardTheme('#38bdf8', '#22d3ee', 720)}
52
+ .head{display:flex;align-items:baseline;justify-content:space-between;padding-bottom:15px;border-bottom:1px solid rgba(56,189,248,.16)}
53
+ .title{font-size:24px;font-weight:700;color:#fff;letter-spacing:1px}
54
+ .brand{color:#6f9bb3;font-size:13px;font-weight:700;letter-spacing:2px}
55
+ .verline{display:flex;align-items:center;gap:14px;margin-top:18px}
56
+ .vold{color:#8ba0b5;font-size:15px;padding:6px 13px;border:1px solid var(--stroke-hi);border-radius:10px}
57
+ .arrow{color:#38bdf8;font-size:19px;font-weight:700;text-shadow:0 0 12px rgba(56,189,248,.7)}
58
+ .vname{font-size:15px;font-weight:700;color:#9bdcff;background:rgba(56,189,248,.14);border:1px solid rgba(56,189,248,.45);border-radius:999px;padding:5px 13px;box-shadow:0 0 16px rgba(56,189,248,.2)}
59
+ .vnew{font-size:22px;font-weight:800;color:#9bdcff;padding:6px 17px;background:linear-gradient(180deg,rgba(56,189,248,.25),rgba(56,189,248,.10));border:1px solid rgba(56,189,248,.55);border-radius:12px;box-shadow:0 0 26px rgba(56,189,248,.32)}
60
+ .vinfo{margin-left:auto;color:#8ba0b5;font-size:12.5px;text-align:right;line-height:1.7}
61
+ .notes{margin-top:16px;padding:14px 18px;background:var(--glass-lo);border:1px solid rgba(56,189,248,.16);border-left:3px solid rgba(56,189,248,.65);border-radius:10px;font-size:14.5px;line-height:1.9;color:#cdd9e6;white-space:pre-wrap}
62
+ .foot{margin-top:18px;padding-top:12px;border-top:1px solid rgba(56,189,248,.16);display:flex;justify-content:space-between;font-size:11.5px;color:#62748a}
63
+ </style></head><body><div id="card">${CARD_DECO}
64
+ <div class="head"><span class="title">HaulMP 客户端更新</span><span class="brand">RELEASE</span></div>
65
+ <div class="verline">${oldVersion ? `<span class="vold">v${oldVersion}</span><span class="arrow">→</span>` : ''}<span class="vnew">v${status.version}</span>${verName ? `<span class="vname">HaulMP ${verName}</span>` : ''}<span class="vinfo">发布于 ${fmtPublished(status.publishedAt) || '—'}<br>${fmtBytes(status.bytes)}</span></div>
66
+ <div class="notes">${esc(notes)}</div>
67
+ <div class="foot"><span>HaulMP · haulmp.com</span><span>自动推送 · 机器翻译仅供参考</span></div>
68
+ </div></body></html>`
69
+ }
70
+
71
+ function textFallback(status, notesCn, oldVersion) {
72
+ const notes = notesCn || status.notes || '(本次更新无说明)'
73
+ const nm4 = String(status.notes || '').match(/HaulMP\s+([\d.]+)/i)
74
+ return `📦 HaulMP 客户端更新${oldVersion ? ` v${oldVersion} → v${status.version}` : ` v${status.version}`}${nm4 ? `(HaulMP ${nm4[1]})` : ''}\n发布于:${fmtPublished(status.publishedAt)} · ${fmtBytes(status.bytes)}\n\n${notes}`
75
+ }
76
+
77
+ async function broadcast(ctx, config, content) {
78
+ let sent = 0
79
+ const only = String((config && config.pushChannels) || '').split(',').map((s) => s.trim()).filter(Boolean)
80
+ if (only.length) {
81
+ for (const ch of only) {
82
+ const idx = ch.indexOf(':')
83
+ const platform = idx > 0 ? ch.slice(0, idx) : 'onebot'
84
+ const id = idx > 0 ? ch.slice(idx + 1) : ch
85
+ const bot = ctx.bots.find((b) => b.platform === platform)
86
+ if (!bot) continue
87
+ try {
88
+ await bot.sendMessage(id, content)
89
+ sent++
90
+ } catch (e) {
91
+ ctx.logger('haulmp').warn('更新推送失败(%s): %s', ch, e.message)
92
+ }
93
+ }
94
+ return sent
95
+ }
96
+ const bots = ctx.bots.filter((b) => b.platform === 'onebot')
97
+ for (const bot of bots) {
98
+ try {
99
+ const res = await bot.getGuildList()
100
+ const groups = (res && res.data) || res || []
101
+ for (const g of groups) {
102
+ const channelId = g.id != null ? g.id : g.guildId
103
+ if (channelId == null) continue
104
+ try {
105
+ await bot.sendMessage(String(channelId), content)
106
+ sent++
107
+ } catch (e) {
108
+ ctx.logger('haulmp').warn('更新推送失败(群 %s): %s', channelId, e.message)
109
+ }
110
+ }
111
+ } catch (e) {
112
+ ctx.logger('haulmp').warn('获取群列表失败: %s', e.message)
113
+ }
114
+ }
115
+ return sent
116
+ }
117
+
118
+ async function check(ctx, config) {
119
+ const logger = ctx.logger('haulmp')
120
+ let client
121
+ try {
122
+ const d = await fetchJson(STATUS_API)
123
+ client = d && d.client
124
+ } catch (e) {
125
+ return
126
+ }
127
+ if (!client || client.version == null) return
128
+
129
+ const state = loadState(ctx)
130
+ if (!state || state.version == null) {
131
+ saveState(ctx, {
132
+ version: client.version,
133
+ notes: client.notes || '',
134
+ bytes: client.bytes,
135
+ publishedAt: client.publishedAt,
136
+ seenAt: Date.now(),
137
+ })
138
+ logger.info('首次记录客户端版本 v%s(不推送)', client.version)
139
+ return
140
+ }
141
+ if (state.version === client.version) return
142
+
143
+ let notesCn = ''
144
+ if (client.notes) {
145
+ try {
146
+ const parts = await translateBatch([client.notes])
147
+ if (parts && parts[0]) notesCn = parts[0]
148
+ } catch (e) {
149
+ notesCn = ''
150
+ }
151
+ }
152
+
153
+ saveState(ctx, {
154
+ version: client.version,
155
+ notes: client.notes || '',
156
+ notesCn,
157
+ bytes: client.bytes,
158
+ publishedAt: client.publishedAt,
159
+ pushedAt: Date.now(),
160
+ })
161
+
162
+ let content = textFallback(client, notesCn, state.version)
163
+ if (!isText(config, 'changelog')) {
164
+ const card = await renderCard(ctx, updateCard(client, notesCn, state.version))
165
+ if (card) content = card
166
+ }
167
+ const sent = await broadcast(ctx, config, content)
168
+ logger.info('客户端更新 v%s → v%s,已推送到 %d 个群', state.version, client.version, sent)
169
+ }
170
+
171
+ function apply(ctx, config) {
172
+ const run = () => check(ctx, config).catch((e) => ctx.logger('haulmp').warn('更新检测异常: %s', e.message))
173
+ setTimeout(run, 45000)
174
+ ctx.setInterval(run, 10 * 60 * 1000)
175
+ }
176
+
177
+ module.exports = { apply, check, updateCard, textFallback, broadcast }