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.
- package/lib/api/accountsApi.js +43 -0
- package/lib/api/forumApi.js +39 -0
- package/lib/api/vtcApi.js +26 -0
- package/lib/command/announce.js +151 -0
- package/lib/command/bind.js +100 -0
- package/lib/command/changelog.js +177 -0
- package/lib/command/company.js +290 -0
- package/lib/command/locate.js +345 -0
- package/lib/command/login.js +387 -0
- package/lib/command/menu.js +84 -0
- package/lib/command/query.js +520 -0
- package/lib/command/ranking.js +138 -0
- package/lib/command/register.js +123 -0
- package/lib/command/status.js +94 -0
- package/lib/command/traffic.js +138 -0
- package/lib/command/version.js +204 -0
- package/lib/database/cityCache.js +100 -0
- package/lib/database/companySnap.js +65 -0
- package/lib/database/model.js +46 -0
- package/lib/database/rankCache.js +138 -0
- package/lib/index.js +111 -0
- package/lib/resource/map/index.html +132 -0
- package/lib/resource/map/maplibre-gl-shared.mjs +6 -0
- package/lib/resource/map/maplibre-gl-worker.mjs +10 -0
- package/lib/resource/map/maplibre-gl.css +1 -0
- package/lib/resource/map/maplibre-gl.mjs +819 -0
- package/lib/resource/map/style.js +44 -0
- package/lib/util/common.js +290 -0
- package/lib/util/constant.js +12 -0
- package/lib/util/geo.js +83 -0
- package/lib/util/translate.js +47 -0
- package/package.json +37 -0
- package/readme.md +83 -0
|
@@ -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 }
|