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,290 @@
|
|
|
1
|
+
const { esc, fmtTime, renderCard, bindingQuery, isText, cardTheme, CARD_DECO } = require('../util/common')
|
|
2
|
+
const { fetchCompany } = require('../api/vtcApi')
|
|
3
|
+
const { loadSnap, saveSnap, makeSnap, rotateSnapshots, todayStr, fmtKm } = require('../database/companySnap')
|
|
4
|
+
|
|
5
|
+
const cardCssFor = (accent) => cardTheme(accent || '#3aa86f', '#7fd4a0', 780) + `
|
|
6
|
+
.head{display:flex;align-items:baseline;justify-content:space-between}
|
|
7
|
+
.title{font-size:23px;font-weight:700;color:#fff;letter-spacing:1px}
|
|
8
|
+
.brand{color:#7f93a8;font-size:13px;font-weight:700;letter-spacing:2px}
|
|
9
|
+
.sub{font-size:12.5px;color:#8ba0b5;margin-top:6px}
|
|
10
|
+
.podium{display:flex;align-items:flex-end;justify-content:center;gap:14px;margin-top:24px;padding:0 8px}
|
|
11
|
+
.pod{flex:1;max-width:220px;text-align:center;min-width:0}
|
|
12
|
+
.podium .pname{color:#fff;font-weight:700;font-size:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
13
|
+
.podium .phandle{color:#9cc6f0;font-size:11.5px;margin-top:2px}
|
|
14
|
+
.podium .pkm{color:#7fe0ac;font-weight:700;font-size:14px;margin:9px 0 9px;text-shadow:0 0 14px rgba(58,168,111,.45)}
|
|
15
|
+
.pbase{border-radius:12px 12px 0 0;display:flex;align-items:flex-start;justify-content:center;padding-top:9px;font-size:21px;font-weight:800}
|
|
16
|
+
.pod1 .pbase{height:76px;background:linear-gradient(180deg,rgba(232,185,106,.6),rgba(232,185,106,.10));color:#ffd98a;box-shadow:0 -8px 36px rgba(232,185,106,.4)}
|
|
17
|
+
.pod2 .pbase{height:54px;background:linear-gradient(180deg,rgba(205,214,224,.42),rgba(205,214,224,.07));color:#e6ebf0}
|
|
18
|
+
.pod3 .pbase{height:42px;background:linear-gradient(180deg,rgba(201,141,94,.5),rgba(201,141,94,.09));color:#eab98f}
|
|
19
|
+
.board{margin-top:18px;background:var(--glass);border:1px solid var(--stroke);border-radius:12px;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)}
|
|
20
|
+
.row{display:flex;align-items:center;gap:12px;padding:10px 16px}
|
|
21
|
+
.row + .row{border-top:1px solid rgba(255,255,255,.06)}
|
|
22
|
+
.row.me{background:linear-gradient(90deg,rgba(58,168,111,.14),rgba(74,144,217,.12))}
|
|
23
|
+
.pos{width:34px;text-align:center;font-weight:700;font-size:14.5px;color:#7f93a8;flex:none}
|
|
24
|
+
.who{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
25
|
+
.who b{color:#fff;font-size:14.5px;font-weight:600}
|
|
26
|
+
.who span{color:#9cc6f0;font-size:12.5px;margin-left:7px}
|
|
27
|
+
.km{font-size:14.5px;font-weight:700;color:#5fd39a;flex:none}
|
|
28
|
+
.km small{color:#62748a;font-size:11.5px;font-weight:400;margin-left:8px}
|
|
29
|
+
.you{display:inline-block;font-size:11px;color:#5fd39a;border:1px solid rgba(58,168,111,.5);background:rgba(58,168,111,.16);border-radius:999px;padding:1px 8px;margin-left:8px}
|
|
30
|
+
.empty{padding:16px;color:#62748a;font-size:13px;text-align:center}
|
|
31
|
+
.mine{margin-top:18px;background:linear-gradient(180deg,rgba(58,168,111,.10),rgba(7,14,22,.55));border:1px solid rgba(58,168,111,.4);border-radius:12px;padding:14px 16px;display:flex;align-items:center;justify-content:space-between;gap:12px;box-shadow:0 0 24px rgba(58,168,111,.12),inset 0 1px 0 rgba(255,255,255,.06)}
|
|
32
|
+
.mine .lab{font-size:13px;color:#5fd39a;flex:none;letter-spacing:1px}
|
|
33
|
+
.mine b{color:#fff;font-size:17px}
|
|
34
|
+
.mine .note{font-size:12.5px;color:#8ba0b5}
|
|
35
|
+
.foot{margin-top:18px;padding-top:12px;border-top:1px solid rgba(255,255,255,.10);display:flex;justify-content:space-between;font-size:12px;color:#62748a}
|
|
36
|
+
`
|
|
37
|
+
|
|
38
|
+
function keyOf(session) {
|
|
39
|
+
return `${session.platform}:${session.channelId || session.userId}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCompanyId(input) {
|
|
43
|
+
let s = String(input || '').trim()
|
|
44
|
+
if (!s) return ''
|
|
45
|
+
const m = s.match(/haulmp\.com\/([^/?#\s]+)\/?$/i)
|
|
46
|
+
if (m) {
|
|
47
|
+
s = m[1]
|
|
48
|
+
} else if (/^https?:\/\//i.test(s)) {
|
|
49
|
+
try {
|
|
50
|
+
const u = new URL(s)
|
|
51
|
+
const parts = u.pathname.split('/').filter(Boolean)
|
|
52
|
+
s = parts[parts.length - 1] || ''
|
|
53
|
+
} catch (e) { /* keep */ }
|
|
54
|
+
}
|
|
55
|
+
return decodeURIComponent(s.split(/[?#]/)[0])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function board(ctx, config, key, bind) {
|
|
59
|
+
const company = await fetchCompany(config, bind.slug)
|
|
60
|
+
if (!company) return null
|
|
61
|
+
|
|
62
|
+
const snaps = await loadSnap(ctx)
|
|
63
|
+
const today = todayStr()
|
|
64
|
+
const s = snaps[key]
|
|
65
|
+
const sameDay = s && s.slug === bind.slug && s.day === today
|
|
66
|
+
|
|
67
|
+
if (!sameDay) {
|
|
68
|
+
// 换档:新的一天/首次 → 立即建立"今天"的基准(旧档直接丢弃,不跨天出数)
|
|
69
|
+
snaps[key] = makeSnap(company)
|
|
70
|
+
await saveSnap(ctx, snaps)
|
|
71
|
+
return { fresh: true, company, rows: [], snapAt: snaps[key].at }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const baseline = new Map(s.members.map((m) => [String(m[0]).toLowerCase(), Number(m[2]) || 0]))
|
|
75
|
+
const current = (company.roster || []).map((r) => ({
|
|
76
|
+
handle: r.driver.handle,
|
|
77
|
+
name: r.driver.displayName,
|
|
78
|
+
km: r.kmTotal || 0,
|
|
79
|
+
}))
|
|
80
|
+
const rows = current
|
|
81
|
+
.map((c) => {
|
|
82
|
+
const base = baseline.get(c.handle.toLowerCase())
|
|
83
|
+
return { ...c, delta: base == null ? 0 : Math.max(0, c.km - base) }
|
|
84
|
+
})
|
|
85
|
+
.sort((a, b) => b.delta - a.delta || b.km - a.km)
|
|
86
|
+
|
|
87
|
+
return { fresh: false, company, rows, snapAt: s.at }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 🎨 设计提示词(今日里程):车队荣誉墙。以车队品牌色渲染光带与光斑,
|
|
91
|
+
// 前三名镀金/银/铜辉光,"我的今日"面板如徽章托,团队感与竞技感并存。底板为共享 ink 玻璃主题。
|
|
92
|
+
function boardCard(data, me) {
|
|
93
|
+
const c = data.company.company
|
|
94
|
+
const rows = data.rows
|
|
95
|
+
const isMine = (r) => me && String(r.handle).toLowerCase() === String(me.handle).toLowerCase()
|
|
96
|
+
const podOrder = [
|
|
97
|
+
{ r: rows[1], cls: 'pod2' },
|
|
98
|
+
{ r: rows[0], cls: 'pod1' },
|
|
99
|
+
{ r: rows[2], cls: 'pod3' },
|
|
100
|
+
]
|
|
101
|
+
const podium = podOrder.map(({ r, cls }) => {
|
|
102
|
+
if (!r) return '<div class="pod" style="visibility:hidden"></div>'
|
|
103
|
+
const rank = cls === 'pod1' ? 1 : cls === 'pod2' ? 2 : 3
|
|
104
|
+
return `<div class="pod ${cls}"><div class="pname">${esc(r.name || r.handle)}${isMine(r) ? '<span class="you">你</span>' : ''}</div><div class="phandle">@${esc(r.handle)}</div><div class="pkm">+${fmtKm(r.delta)} km</div><div class="pbase">${rank}</div></div>`
|
|
105
|
+
}).join('')
|
|
106
|
+
const list = rows.slice(3, 10).map((r, i) => {
|
|
107
|
+
return `<div class="row${isMine(r) ? ' me' : ''}"><span class="pos">${i + 4}</span><div class="who"><b>${esc(r.name || r.handle)}</b><span>@${esc(r.handle)}</span>${isMine(r) ? '<span class="you">你</span>' : ''}</div><div class="km">+${fmtKm(r.delta)} km<small>总 ${fmtKm(r.km)}</small></div></div>`
|
|
108
|
+
}).join('')
|
|
109
|
+
|
|
110
|
+
let mineHtml
|
|
111
|
+
if (me) {
|
|
112
|
+
const idx = rows.findIndex((r) => String(r.handle).toLowerCase() === String(me.handle).toLowerCase())
|
|
113
|
+
if (idx >= 0) {
|
|
114
|
+
mineHtml = `<div class="mine"><span class="lab">我的今日</span><span><b>+${fmtKm(rows[idx].delta)} km</b> <span class="note">· 第 ${idx + 1} 名 / 共 ${rows.length} 人 · 累计 ${fmtKm(rows[idx].km)} km</span></span></div>`
|
|
115
|
+
} else {
|
|
116
|
+
mineHtml = `<div class="mine"><span class="lab">我的今日</span><span class="note">你不在该车队名册中</span></div>`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>${cardCssFor(c.accent)}</style></head><body><div id="card">${CARD_DECO}
|
|
121
|
+
<div class="head"><span class="title">今日里程排行</span><span class="brand">DAILY</span></div>
|
|
122
|
+
<div class="sub">${esc(c.name)} [${esc(c.tag)}] · 基准 ${fmtTime(data.snapAt)} · 共 ${rows.length} 名司机</div>
|
|
123
|
+
${rows.length ? `<div class="podium">${podium}</div>` : ''}
|
|
124
|
+
<div class="board">${list || (rows.length ? '' : '<div class="empty">暂无数据</div>')}</div>
|
|
125
|
+
${mineHtml}
|
|
126
|
+
<div class="foot"><span>HaulMP · vtc.haulmp.com</span><span>${fmtTime(Date.now())}</span></div>
|
|
127
|
+
</div></body></html>`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function boardText(data, me) {
|
|
131
|
+
const c = data.company.company
|
|
132
|
+
const lines = [`🚛 今日里程排行 · ${c.name} [${c.tag}]`, `基准:${fmtTime(data.snapAt)}`, '━━━━━━━━━━━━']
|
|
133
|
+
data.rows.slice(0, 10).forEach((r, i) => {
|
|
134
|
+
const isMe = me && String(r.handle).toLowerCase() === String(me.handle).toLowerCase()
|
|
135
|
+
lines.push(`${i + 1}. ${r.name || r.handle}${isMe ? '(你)' : ''} — +${fmtKm(r.delta)} km(总 ${fmtKm(r.km)})`)
|
|
136
|
+
})
|
|
137
|
+
lines.push('━━━━━━━━━━━━')
|
|
138
|
+
if (me) {
|
|
139
|
+
const idx = data.rows.findIndex((r) => String(r.handle).toLowerCase() === String(me.handle).toLowerCase())
|
|
140
|
+
if (idx >= 0) lines.push(`👤 我的今日:+${fmtKm(data.rows[idx].delta)} km · 第 ${idx + 1} 名 / 共 ${data.rows.length} 人`)
|
|
141
|
+
else lines.push('👤 你不在该车队名册中')
|
|
142
|
+
}
|
|
143
|
+
return lines.join('\n')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fmtDate(iso) {
|
|
147
|
+
if (!iso) return ''
|
|
148
|
+
const d = new Date(iso)
|
|
149
|
+
if (isNaN(d.getTime())) return ''
|
|
150
|
+
const t = new Date(d.getTime() + 8 * 3600 * 1000)
|
|
151
|
+
const p = (n) => String(n).padStart(2, '0')
|
|
152
|
+
return `${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 🎨 设计提示词(车队绑定):入队仪式。取车队品牌色渲染光带/光斑,
|
|
156
|
+
// 徽记发光,"已绑定本群"如绶带,数据块顶光刃,归属感拉满。底板为共享 ink 玻璃主题。
|
|
157
|
+
function bindCard(company, rosterCount) {
|
|
158
|
+
const c = company.company
|
|
159
|
+
const tag = c.tag || 'VTC'
|
|
160
|
+
const initial = String(tag.replace(/[^A-Za-z0-9\u4e00-\u9fa5]/g, '')[0] || 'V').toUpperCase()
|
|
161
|
+
const ownerHandle = (c.owner && c.owner.handle) || ''
|
|
162
|
+
const founded = fmtDate(c.createdAt)
|
|
163
|
+
const acc = c.accent || '#4a90d9'
|
|
164
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>${cardTheme(acc, '#7fb4e6', 720)}
|
|
165
|
+
.head{display:flex;align-items:center;gap:16px;padding-bottom:16px;border-bottom:1px solid var(--stroke)}
|
|
166
|
+
.ava{width:64px;height:64px;border-radius:16px;background:linear-gradient(150deg,rgba(255,255,255,.2),rgba(255,255,255,.05));border:2px solid rgba(255,255,255,.28);color:#fff;font-weight:800;font-size:26px;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 0 26px var(--ac)}
|
|
167
|
+
.who{flex:1;min-width:0}
|
|
168
|
+
.kicker{font-size:13px;color:#cfe3f7;letter-spacing:1px}
|
|
169
|
+
.cname{font-size:24px;font-weight:800;color:#fff;margin-top:3px;letter-spacing:.5px;text-shadow:0 2px 18px rgba(0,0,0,.4)}
|
|
170
|
+
.cname .tag{color:#9cc6f0;font-size:13px;font-weight:600;margin-left:8px}
|
|
171
|
+
.ok{flex:none;background:linear-gradient(180deg,rgba(58,168,111,.22),rgba(58,168,111,.08));border:1px solid rgba(94,211,154,.55);color:#7fe0ac;font-weight:700;font-size:13.5px;border-radius:999px;padding:7px 14px;box-shadow:0 0 18px rgba(58,168,111,.22)}
|
|
172
|
+
.facts{display:flex;margin-top:18px;background:linear-gradient(180deg,rgba(74,144,217,.10),rgba(7,14,22,.55) 50%);border:1px solid rgba(74,144,217,.22);border-radius:14px;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,.05)}
|
|
173
|
+
.fact{flex:1;text-align:center;padding:15px 8px;min-width:0}
|
|
174
|
+
.fact + .fact{border-left:1px solid rgba(255,255,255,.07)}
|
|
175
|
+
.fact b{display:block;font-size:22px;color:#fff;font-weight:800;text-shadow:0 2px 14px rgba(74,144,217,.35);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
176
|
+
.fact span{display:block;font-size:12px;color:#93a7ba;margin-top:4px;letter-spacing:.5px}
|
|
177
|
+
.foot{margin-top:18px;padding-top:12px;border-top:1px solid var(--stroke);display:flex;justify-content:space-between;font-size:11.5px;color:#62748a}
|
|
178
|
+
</style></head><body><div id="card">${CARD_DECO}
|
|
179
|
+
<div class="head"><div class="ava">${esc(initial)}</div><div class="who"><div class="kicker">车队绑定成功</div><div class="cname">${esc(c.name)}<span class="tag">${esc(tag)}</span></div></div><div class="ok">已绑定本群</div></div>
|
|
180
|
+
<div class="facts">
|
|
181
|
+
<div class="fact"><b>${rosterCount}</b><span>司机</span></div>
|
|
182
|
+
<div class="fact"><b>${ownerHandle ? '@' + esc(ownerHandle) : '—'}</b><span>队长</span></div>
|
|
183
|
+
<div class="fact"><b>${founded || '—'}</b><span>成立</span></div>
|
|
184
|
+
</div>
|
|
185
|
+
<div class="foot"><span>今日基准已建立 · 发送「今日里程H」查看排行</span><span>HaulMP</span></div>
|
|
186
|
+
</div></body></html>`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function apply(ctx, config) {
|
|
190
|
+
ctx.setTimeout(() => rotateSnapshots(ctx, config).catch(() => {}), 20 * 1000)
|
|
191
|
+
ctx.setInterval(() => rotateSnapshots(ctx, config).catch(() => {}), 5 * 60 * 1000)
|
|
192
|
+
|
|
193
|
+
ctx.command('绑定车队H <id>', '绑定本群车队(仅队长可绑定,车队ID或链接)')
|
|
194
|
+
.alias('bindch', '绑定车队h')
|
|
195
|
+
.usage('示例:绑定车队H abc123(或直接粘贴车队链接)')
|
|
196
|
+
.action(async ({ session }, keyword) => {
|
|
197
|
+
if (!session) return
|
|
198
|
+
const kw = parseCompanyId(keyword)
|
|
199
|
+
if (!kw) return '请输入车队 ID 或链接,例如:绑定车队H abc123'
|
|
200
|
+
|
|
201
|
+
const [b] = await ctx.database.get('haulmp_binding', bindingQuery(session))
|
|
202
|
+
if (!b) return '请先发送「绑定H 你的ID」绑定论坛账号,绑定车队需要验证队长身份'
|
|
203
|
+
|
|
204
|
+
const company = await fetchCompany(config, kw).catch(() => null)
|
|
205
|
+
if (!company) return `没找到车队「${kw}」,请确认为车队 ID(不是名字)`
|
|
206
|
+
|
|
207
|
+
const owner = (company.company && company.company.owner) || {}
|
|
208
|
+
if (!owner.handle || String(owner.handle).toLowerCase() !== String(b.handle).toLowerCase()) {
|
|
209
|
+
return `只有队长才能绑定车队(「${company.company.name}」的队长是 @${owner.handle || '未知'})`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const key = keyOf(session)
|
|
213
|
+
await ctx.database.remove('haulmp_company', { platform: session.platform, channelId: key })
|
|
214
|
+
await ctx.database.create('haulmp_company', {
|
|
215
|
+
platform: session.platform,
|
|
216
|
+
channelId: key,
|
|
217
|
+
slug: company.company.slug,
|
|
218
|
+
name: company.company.name,
|
|
219
|
+
tag: company.company.tag || '',
|
|
220
|
+
ownerHandle: owner.handle,
|
|
221
|
+
boundBy: session.userId,
|
|
222
|
+
boundAt: new Date(),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const snaps = await loadSnap(ctx)
|
|
226
|
+
snaps[key] = makeSnap(company)
|
|
227
|
+
await saveSnap(ctx, snaps)
|
|
228
|
+
|
|
229
|
+
if (!isText(config, 'companyBind')) {
|
|
230
|
+
const card = await renderCard(ctx, bindCard(company, (company.roster || []).length))
|
|
231
|
+
if (card) return card
|
|
232
|
+
}
|
|
233
|
+
return `绑定成功!本群车队:${company.company.name} [${company.company.tag}](${(company.roster || []).length} 名司机)\n今日基准已建立,发送「今日里程H」查看排行`
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
ctx.command('解绑车队H', '解除本群车队绑定(绑定人或队长可操作)')
|
|
237
|
+
.alias('unbindch', '解绑车队h')
|
|
238
|
+
.action(async ({ session }) => {
|
|
239
|
+
if (!session) return
|
|
240
|
+
const key = keyOf(session)
|
|
241
|
+
const [bind] = await ctx.database.get('haulmp_company', { platform: session.platform, channelId: key })
|
|
242
|
+
if (!bind) return '本群还没有绑定车队'
|
|
243
|
+
|
|
244
|
+
let allowed = String(bind.boundBy) === String(session.userId)
|
|
245
|
+
if (!allowed && bind.ownerHandle) {
|
|
246
|
+
const [b] = await ctx.database.get('haulmp_binding', bindingQuery(session))
|
|
247
|
+
if (b && String(b.handle).toLowerCase() === String(bind.ownerHandle).toLowerCase()) allowed = true
|
|
248
|
+
}
|
|
249
|
+
if (!allowed) return '只有绑定人或车队队长可以解绑'
|
|
250
|
+
|
|
251
|
+
await ctx.database.remove('haulmp_company', { platform: session.platform, channelId: key })
|
|
252
|
+
const snaps = await loadSnap(ctx)
|
|
253
|
+
if (snaps[key]) {
|
|
254
|
+
delete snaps[key]
|
|
255
|
+
await saveSnap(ctx, snaps)
|
|
256
|
+
}
|
|
257
|
+
return `已解绑本群车队「${bind.name} [${bind.tag}]」`
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
ctx.command('今日里程H', '本群车队今日里程排行')
|
|
261
|
+
.alias('todayh', '今日里程h')
|
|
262
|
+
.action(async ({ session }) => {
|
|
263
|
+
if (!session) return
|
|
264
|
+
const key = keyOf(session)
|
|
265
|
+
const [bind] = await ctx.database.get('haulmp_company', { platform: session.platform, channelId: key })
|
|
266
|
+
if (!bind) return '本群还没绑定车队,队长请先发「绑定车队H 车队名」'
|
|
267
|
+
|
|
268
|
+
let data
|
|
269
|
+
try {
|
|
270
|
+
data = await board(ctx, config, key, bind)
|
|
271
|
+
} catch (e) {
|
|
272
|
+
ctx.logger('haulmp').warn('今日里程查询失败: %s', e.message)
|
|
273
|
+
return '查询失败,请稍后再试'
|
|
274
|
+
}
|
|
275
|
+
if (!data) return '查询失败,请稍后再试'
|
|
276
|
+
|
|
277
|
+
if (data.fresh) {
|
|
278
|
+
return `基准已建立(${fmtTime(data.snapAt)})\n司机们跑一段后再发「今日里程H」即可看到排行`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const [ub] = await ctx.database.get('haulmp_binding', bindingQuery(session))
|
|
282
|
+
if (!isText(config, 'companyBoard')) {
|
|
283
|
+
const card = await renderCard(ctx, boardCard(data, ub))
|
|
284
|
+
if (card) return card
|
|
285
|
+
}
|
|
286
|
+
return boardText(data, ub)
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = { apply, board, keyOf, boardCard, boardText, bindCard }
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
const http = require('http')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const { h } = require('koishi')
|
|
5
|
+
const { fetchJson, fetchJsonMemo, bindingQuery, matchByName, loadIdMap, memberByNick, isText } = require('../util/common')
|
|
6
|
+
const { COMPASS } = require('../util/constant')
|
|
7
|
+
const { gameToLngLat, nearestCity, loadPlaces, headingBearing, haversine } = require('../util/geo')
|
|
8
|
+
const { cityZhBatch } = require('../database/cityCache')
|
|
9
|
+
|
|
10
|
+
function nearbyPlayers(all, target) {
|
|
11
|
+
const [tlon, tlat] = gameToLngLat(target.x, target.z)
|
|
12
|
+
const list = []
|
|
13
|
+
for (const p of all) {
|
|
14
|
+
if (p === target) continue
|
|
15
|
+
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.z)) continue
|
|
16
|
+
const [lon, lat] = gameToLngLat(p.x, p.z)
|
|
17
|
+
const d = haversine(tlon, tlat, lon, lat)
|
|
18
|
+
if (d <= 30) list.push({ name: p.name, km: d, speed: p.speed, lon, lat })
|
|
19
|
+
}
|
|
20
|
+
list.sort((a, b) => a.km - b.km)
|
|
21
|
+
return list.slice(0, 5)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function matchPlayer(players, key) {
|
|
25
|
+
const k = String(key || '').trim()
|
|
26
|
+
if (!k) return null
|
|
27
|
+
if (/^\d+$/.test(k)) {
|
|
28
|
+
const byId = players.find((p) => p && String(p.id) === k)
|
|
29
|
+
if (byId) return byId
|
|
30
|
+
}
|
|
31
|
+
return matchByName(players, k)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function syncTileVersion(ctx, force) {
|
|
35
|
+
try {
|
|
36
|
+
const page = await getMapPage(ctx)
|
|
37
|
+
if (!page) return
|
|
38
|
+
const html = await fetch('https://map.haulmp.com/', { signal: AbortSignal.timeout(20000) }).then((r) => r.text())
|
|
39
|
+
const mHtml = html.match(/src="(\/live-map\.[^"]+\.js)"/)
|
|
40
|
+
if (!mHtml) return
|
|
41
|
+
const js = await fetch('https://map.haulmp.com' + mHtml[1], { signal: AbortSignal.timeout(30000) }).then((r) => r.text())
|
|
42
|
+
const mVer = js.match(/\/tiles\/\{z\}\/\{x\}\/\{y\}\.pbf\?v=([^"'`]+)/)
|
|
43
|
+
if (!mVer) return
|
|
44
|
+
const version = mVer[1]
|
|
45
|
+
const current = await page.evaluate(() => window.getTileUrl())
|
|
46
|
+
if (current && current.includes(version)) return
|
|
47
|
+
const newUrl = `/proxy/tiles/{z}/{x}/{y}.pbf?v=${version}`
|
|
48
|
+
const ok = await page.evaluate((u) => window.setTileUrl(u), newUrl)
|
|
49
|
+
ctx.logger('haulmp').info('地图瓦片版本同步: %s -> %s (%s)', current ? current.split('v=')[1] : '无', version, ok ? 'ok' : 'fail')
|
|
50
|
+
} catch (e) {
|
|
51
|
+
ctx.logger('haulmp').warn('地图版本同步失败: %s', e.message)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let httpServer = null
|
|
56
|
+
let mapPage = null
|
|
57
|
+
|
|
58
|
+
function withTimeout(promise, ms, onTimeout) {
|
|
59
|
+
let timer
|
|
60
|
+
return Promise.race([
|
|
61
|
+
promise.finally(() => clearTimeout(timer)),
|
|
62
|
+
new Promise((resolve, reject) => {
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
try { if (onTimeout) onTimeout() } catch (e) { /* ignore */ }
|
|
65
|
+
reject(new Error(`地图渲染超时(${ms}ms)`))
|
|
66
|
+
}, ms)
|
|
67
|
+
}),
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function startStaticServer() {
|
|
72
|
+
if (httpServer) return
|
|
73
|
+
// lib/command/locate.js → lib/resource/map;src/command/locate.js → src/resource/map(相对层级一致)
|
|
74
|
+
const root = path.join(__dirname, '..', 'resource', 'map')
|
|
75
|
+
httpServer = http.createServer((req, res) => {
|
|
76
|
+
const u = decodeURIComponent((req.url || '/').split('?')[0])
|
|
77
|
+
const qs = (req.url || '').includes('?') ? (req.url || '').slice((req.url || '').indexOf('?')) : ''
|
|
78
|
+
if (u.startsWith('/proxy/')) {
|
|
79
|
+
const upstream = 'https://map.haulmp.com' + u.slice('/proxy'.length) + qs
|
|
80
|
+
fetch(upstream, { signal: AbortSignal.timeout(30000) })
|
|
81
|
+
.then(async (up) => {
|
|
82
|
+
const buf = Buffer.from(await up.arrayBuffer())
|
|
83
|
+
res.writeHead(up.status, {
|
|
84
|
+
'Content-Type': up.headers.get('content-type') || 'application/octet-stream',
|
|
85
|
+
'Cache-Control': 'max-age=86400',
|
|
86
|
+
})
|
|
87
|
+
res.end(buf)
|
|
88
|
+
})
|
|
89
|
+
.catch(() => {
|
|
90
|
+
res.writeHead(502)
|
|
91
|
+
res.end()
|
|
92
|
+
})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const p = u
|
|
97
|
+
const file = path.normalize(path.join(root, p === '/' ? 'index.html' : p))
|
|
98
|
+
if (!file.startsWith(root)) {
|
|
99
|
+
res.writeHead(403)
|
|
100
|
+
res.end()
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
const data = fs.readFileSync(file)
|
|
104
|
+
const ext = path.extname(file).toLowerCase()
|
|
105
|
+
const mime = {
|
|
106
|
+
'.html': 'text/html; charset=utf-8',
|
|
107
|
+
'.js': 'text/javascript',
|
|
108
|
+
'.mjs': 'text/javascript',
|
|
109
|
+
'.css': 'text/css',
|
|
110
|
+
'.json': 'application/json',
|
|
111
|
+
}[ext] || 'application/octet-stream'
|
|
112
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'max-age=86400' })
|
|
113
|
+
res.end(data)
|
|
114
|
+
} catch (e) {
|
|
115
|
+
res.writeHead(404)
|
|
116
|
+
res.end('not found')
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
httpServer.on('error', () => {})
|
|
120
|
+
httpServer.listen(12665, '127.0.0.1')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function getMapPage(ctx) {
|
|
124
|
+
let puppeteer = null
|
|
125
|
+
try {
|
|
126
|
+
puppeteer = ctx.get('puppeteer')
|
|
127
|
+
} catch (e) { /* ignore */ }
|
|
128
|
+
if (!puppeteer) return null
|
|
129
|
+
startStaticServer()
|
|
130
|
+
if (mapPage) {
|
|
131
|
+
try {
|
|
132
|
+
if (!mapPage.isClosed()) {
|
|
133
|
+
ctx.logger('haulmp').info('使用已就绪地图页面')
|
|
134
|
+
return mapPage
|
|
135
|
+
}
|
|
136
|
+
} catch (e) { /* ignore */ }
|
|
137
|
+
}
|
|
138
|
+
ctx.logger('haulmp').info('重建地图页面…')
|
|
139
|
+
mapPage = await puppeteer.page()
|
|
140
|
+
const t0 = Date.now()
|
|
141
|
+
await mapPage.setViewport({ width: 720, height: 500, deviceScaleFactor: 2 })
|
|
142
|
+
await mapPage.goto('http://127.0.0.1:12665/', { waitUntil: 'domcontentloaded', timeout: 30000 })
|
|
143
|
+
await mapPage.waitForFunction(() => window.__ready === true, { timeout: 30000 })
|
|
144
|
+
ctx.logger('haulmp').info('地图页面构建完成 %dms', Date.now() - t0)
|
|
145
|
+
preloadEurope(mapPage).catch(() => {})
|
|
146
|
+
return mapPage
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function preloadEurope(page) {
|
|
150
|
+
try {
|
|
151
|
+
await page.evaluate(() => {
|
|
152
|
+
const m = window.__map
|
|
153
|
+
if (!m) return
|
|
154
|
+
m.jumpTo({ center: [12, 50], zoom: 4 })
|
|
155
|
+
})
|
|
156
|
+
} catch (e) { /* ignore */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let mapQueue = Promise.resolve()
|
|
160
|
+
|
|
161
|
+
async function mapShot(ctx, info, p, nearby) {
|
|
162
|
+
ctx.logger('haulmp').info('地图任务入队')
|
|
163
|
+
const run = mapQueue.then(
|
|
164
|
+
() => doMapShot(ctx, info, p, nearby),
|
|
165
|
+
() => doMapShot(ctx, info, p, nearby),
|
|
166
|
+
)
|
|
167
|
+
mapQueue = run.catch(() => {})
|
|
168
|
+
return run
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function doMapShot(ctx, info, p, nearby) {
|
|
172
|
+
try {
|
|
173
|
+
const page = await getMapPage(ctx)
|
|
174
|
+
if (!page) return null
|
|
175
|
+
const [lng, lat] = gameToLngLat(p.x, p.z)
|
|
176
|
+
const resetPage = () => {
|
|
177
|
+
try { page.close().catch(() => {}) } catch (e) { /* ignore */ }
|
|
178
|
+
mapPage = null
|
|
179
|
+
}
|
|
180
|
+
const t1 = Date.now()
|
|
181
|
+
await withTimeout(
|
|
182
|
+
page.evaluate((data) => window.updateView(data), {
|
|
183
|
+
lng,
|
|
184
|
+
lat,
|
|
185
|
+
zoom: 11,
|
|
186
|
+
info,
|
|
187
|
+
nearby: nearby.map((n) => ({ lng: n.lon, lat: n.lat, name: n.name, km: n.km })),
|
|
188
|
+
}),
|
|
189
|
+
15000,
|
|
190
|
+
resetPage,
|
|
191
|
+
)
|
|
192
|
+
const t2 = Date.now()
|
|
193
|
+
await new Promise((r) => setTimeout(r, 120))
|
|
194
|
+
const buf = await withTimeout(page.screenshot({ encoding: 'binary', type: 'png' }), 10000, resetPage)
|
|
195
|
+
ctx.logger('haulmp').info('updateView %dms | 截图 %dms', t2 - t1, Date.now() - t2)
|
|
196
|
+
return buf
|
|
197
|
+
} catch (e) {
|
|
198
|
+
ctx.logger('haulmp').warn('地图渲染失败: %s', e.message)
|
|
199
|
+
if (mapPage) {
|
|
200
|
+
try { mapPage.close().catch(() => {}) } catch (err) { /* ignore */ }
|
|
201
|
+
mapPage = null
|
|
202
|
+
}
|
|
203
|
+
return null
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function apply(ctx, config) {
|
|
208
|
+
ctx.setTimeout(() => {
|
|
209
|
+
getMapPage(ctx).catch(() => {})
|
|
210
|
+
}, 12000)
|
|
211
|
+
ctx.setTimeout(() => {
|
|
212
|
+
syncTileVersion(ctx).catch(() => {})
|
|
213
|
+
}, 30000)
|
|
214
|
+
ctx.setInterval(() => {
|
|
215
|
+
syncTileVersion(ctx).catch(() => {})
|
|
216
|
+
}, 6 * 60 * 60 * 1000)
|
|
217
|
+
ctx.command('定位H [name]', '定位玩家位置(地图 + 朝向 + 车型)')
|
|
218
|
+
.alias('locateh', '定位h')
|
|
219
|
+
.usage('不填时定位自己;例如:定位H / 定位H 玩家名 / 定位H 316810984 / 定位H @群友')
|
|
220
|
+
.action(async ({ session }, name) => {
|
|
221
|
+
if (!session) return
|
|
222
|
+
const raw = String(name || '').trim()
|
|
223
|
+
const key = raw.replace(/^@+/, '')
|
|
224
|
+
|
|
225
|
+
let atId = null
|
|
226
|
+
const mAt = raw.match(/<at id="([^"]+)"/)
|
|
227
|
+
if (mAt) atId = mAt[1]
|
|
228
|
+
if (!atId && session.event && Array.isArray(session.event.message)) {
|
|
229
|
+
for (const el of session.event.message) {
|
|
230
|
+
if (el.type === 'at' && el.attrs && el.attrs.id != null) atId = String(el.attrs.id)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (atId && atId === String(session.selfId)) atId = String(session.userId)
|
|
234
|
+
|
|
235
|
+
let target = key.replace(/<at[^>]*\/?>/g, '').trim()
|
|
236
|
+
if (atId) {
|
|
237
|
+
const [b] = await ctx.database.get('haulmp_binding', { platform: session.platform, userId: atId })
|
|
238
|
+
if (!b) return 'TA 还没有绑定,让 TA 发送「绑定H 论坛ID」后即可定位'
|
|
239
|
+
target = b.displayName || b.handle
|
|
240
|
+
}
|
|
241
|
+
if (!target) {
|
|
242
|
+
const [b] = await ctx.database.get('haulmp_binding', bindingQuery(session))
|
|
243
|
+
if (!b) return '你还没有绑定,发送「绑定H 你的ID」,或直接用「定位H 玩家名」/「定位H @群友」'
|
|
244
|
+
target = b.displayName || b.handle
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
let players = null
|
|
248
|
+
let cities = null
|
|
249
|
+
let status = null
|
|
250
|
+
try {
|
|
251
|
+
const [live, cs, st] = await Promise.all([
|
|
252
|
+
fetchJsonMemo(config.mapLiveUrl, 30000),
|
|
253
|
+
loadPlaces(),
|
|
254
|
+
fetchJsonMemo(config.statusUrl, 60000).catch(() => null),
|
|
255
|
+
])
|
|
256
|
+
players = live && Array.isArray(live.players) ? live.players : null
|
|
257
|
+
cities = cs
|
|
258
|
+
status = st
|
|
259
|
+
} catch (e) {
|
|
260
|
+
ctx.logger('haulmp').warn('定位查询失败: %s', e.message)
|
|
261
|
+
return '查询失败,请稍后再试'
|
|
262
|
+
}
|
|
263
|
+
if (!players || !cities || !cities.length) return '查询失败,请稍后再试'
|
|
264
|
+
|
|
265
|
+
ctx.logger('haulmp').info('定位查询: %s', target)
|
|
266
|
+
let p = matchPlayer(players, target)
|
|
267
|
+
if (!p && /^\d+$/.test(target)) {
|
|
268
|
+
const [b] = await ctx.database.get('haulmp_binding', { platform: session.platform, userId: target })
|
|
269
|
+
if (b) {
|
|
270
|
+
const t2 = b.displayName || b.handle
|
|
271
|
+
const p2 = matchPlayer(players, t2)
|
|
272
|
+
target = t2
|
|
273
|
+
if (p2) p = p2
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (!p && /^\d+$/.test(target)) {
|
|
277
|
+
const name = loadIdMap(ctx)[target]
|
|
278
|
+
if (name) {
|
|
279
|
+
const p2 = matchPlayer(players, name)
|
|
280
|
+
if (p2) {
|
|
281
|
+
target = name
|
|
282
|
+
p = p2
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!p && session.guildId) {
|
|
287
|
+
const m = await memberByNick(session.bot, session.guildId, target).catch(() => null)
|
|
288
|
+
if (m) {
|
|
289
|
+
const [b] = await ctx.database.get('haulmp_binding', { platform: session.platform, userId: m.id })
|
|
290
|
+
if (b) {
|
|
291
|
+
const t2 = b.displayName || b.handle
|
|
292
|
+
ctx.logger('haulmp').info('群名片 %s → 绑定 %s(游戏名 %s)', target, b.handle, t2)
|
|
293
|
+
const p2 = matchPlayer(players, t2)
|
|
294
|
+
target = t2
|
|
295
|
+
if (p2) p = p2
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!p) return `未检测到 ${target} 在线(可能不在游戏中)`
|
|
300
|
+
|
|
301
|
+
const [lon, lat] = gameToLngLat(p.x, p.z)
|
|
302
|
+
const near = nearestCity(cities, lon, lat)
|
|
303
|
+
const zhMap = await cityZhBatch(ctx, [near.name]).catch(() => new Map())
|
|
304
|
+
const zhCity = zhMap.get(near.name) || ''
|
|
305
|
+
ctx.logger('haulmp').info('cityZh %s → %s', near.name, zhCity || '-')
|
|
306
|
+
const cityDisplay = zhCity || near.name
|
|
307
|
+
const bearingDeg = headingBearing(p)
|
|
308
|
+
const bearingText = bearingDeg == null ? '—' : `${COMPASS[Math.round(bearingDeg / 45) % 8]} ${Math.round(bearingDeg)}°`
|
|
309
|
+
let model = ''
|
|
310
|
+
if (status && Array.isArray(status.drivers)) {
|
|
311
|
+
const d = matchByName(status.drivers, p.name)
|
|
312
|
+
if (d && d.model) model = d.model
|
|
313
|
+
}
|
|
314
|
+
const nearby = nearbyPlayers(players, p)
|
|
315
|
+
|
|
316
|
+
const info = {
|
|
317
|
+
initial: (String(p.name || '?').replace(/^(\[[^\]]*\]\s*)+/, '').trim()[0] || '?').toUpperCase(),
|
|
318
|
+
name: p.name,
|
|
319
|
+
sub: `${p.speed} km/h · 朝向 ${bearingText}${model ? ` · ${model}` : ' · 车型未知'}`,
|
|
320
|
+
city: cityDisplay,
|
|
321
|
+
dist: near.km.toFixed(0),
|
|
322
|
+
id: p.id,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let card = null
|
|
326
|
+
if (!isText(config, 'locate')) {
|
|
327
|
+
const mapBuffer = await mapShot(ctx, info, p, nearby)
|
|
328
|
+
card = mapBuffer ? h.image(mapBuffer, 'image/png') : null
|
|
329
|
+
}
|
|
330
|
+
ctx.logger('haulmp').info('定位完成: %s | 卡片:%s', p.name, !!card)
|
|
331
|
+
|
|
332
|
+
let text = `📍 ${p.name}(ID ${p.id})· 「${cityDisplay}」附近(${near.km.toFixed(0)} km)\n🏎 速度 ${p.speed} km/h · 朝向 ${bearingText}${model ? ` · 车型 ${model}` : ' · 车型未知'}`
|
|
333
|
+
if (nearby.length) {
|
|
334
|
+
text += '\n👥 附近玩家(30 km 内):'
|
|
335
|
+
for (const n of nearby) text += `\n · ${n.name}(${n.km.toFixed(1)} km · ${n.speed} km/h)`
|
|
336
|
+
} else {
|
|
337
|
+
text += '\n👥 附近 30 km 内没有其他玩家'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (card) return card
|
|
341
|
+
return text
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
module.exports = { apply, gameToLngLat, nearestCity, nearbyPlayers, loadPlaces, matchPlayer, headingBearing, mapShot, syncTileVersion }
|