koishi-plugin-bns-rate 1.2.0 → 1.2.2

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.
Files changed (3) hide show
  1. package/README.md +41 -9
  2. package/index.js +151 -29
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,22 +16,53 @@
16
16
 
17
17
  官方 QQ 机器人的**群聊 / 私聊**下,命令回复会变成 Markdown 卡片,形如:
18
18
 
19
- ```
19
+ ````
20
20
  # 神石比例实时播报
21
21
 
22
22
  ![DD373 DATA #560px #120px](<横幅图片直链>)
23
23
 
24
- 大家关注的 DD373 最低神石比例前5条来啦!当前时段(2026/9/10 10:46)最优价格如下:
24
+ 大家关注的 DD373 最低神石比例前5条来啦!当前时段(2026/9/10 11:03)最优价格如下:
25
+
26
+ ```
27
+ 1元 = 151.51 神石 | 1神石 = 0.0066元
28
+ 1元 = 149.54 神石 | 1神石 = 0.0067元
29
+ 1元 = 149.40 神石 | 1神石 = 0.0067元
30
+ ```
25
31
 
26
- 1元 = <font color="#E60012">151.51</font>神石 | 1神石 = <font color="#00A650">0.0066</font>元
27
- ...
28
- ***
29
32
  如需查看完整详情或进行交易,请点击下方按钮直接跳转至 DD373 官网。
30
33
  [ 点此直达DD373网站 ]
31
- ```
34
+ ````
35
+
36
+ 报价列表默认用**代码块**包起来,在客户端渲染成**灰色框**(`ratesBox: code`)。代码块是等宽字体,插件会用空格把两列**对齐**(数字位数不同也对齐)。
37
+
38
+ > ⚠️ 手机端(安卓 / iOS)**不渲染 HTML 标签**:`<font color="#E60012">` 会**原样显示成一串字符**。因此默认的 `code` 模式**不输出任何颜色标签**;只有把 `ratesBox` 改成 `quote` / `none` 时才会输出颜色(桌面端有颜色,手机端会露出标签,自行取舍)。
39
+
40
+ 若手机上把 ` ``` ` 显示成了原文(不支持代码块),把 `ratesBox` 改成 `quote`,即可换成引用样式的外框。
32
41
 
33
42
  其它平台(OneBot / Telegram 等)、QQ 频道(`qqguild`)、以及卡片发送失败时,都会**自动回退为纯文本**,不会发不出消息。
34
43
 
44
+ ### 发送方式(重要)
45
+
46
+ 卡片通过 `bot.internal.sendMessage` 发送**一条** payload,`markdown` 与 `keyboard` 装在同一个请求里,因此**卡片正文和按钮在同一条消息内**(不会拆成两条)。命令回复使用**被动消息**(带触发消息的 `msg_id` + `msg_seq`),不占用主动消息配额。
47
+
48
+ 发送失败时会依次降级:**带按钮 → 不带按钮 → 纯文本**。
49
+
50
+ ## 横幅图片要求
51
+
52
+ `bannerUrl` 必须是**公网可直接访问的图片直链**(QQ 会下载转存,勿用官方 `server-temp`)。
53
+
54
+ 关键不是绝对像素,而是**图片自身的宽高比必须等于 `bannerWidth : bannerHeight`**,否则会被拉伸变形。
55
+
56
+ | 想做的比例 | 建议原图尺寸 | 对应配置 |
57
+ | --- | --- | --- |
58
+ | 4.67:1(默认) | **1120×240**(2 倍图) | `bannerWidth: 560` `bannerHeight: 120` |
59
+ | 4:1 | **1200×300** | `bannerWidth: 600` `bannerHeight: 150` |
60
+ | 5:1 | **1000×200** | `bannerWidth: 500` `bannerHeight: 100` |
61
+
62
+ - 宽度做 **1000~1200px** 足够清晰:QQ 会把图片缩放到卡片内容宽度(手机上约 300~400 逻辑像素),再大也会被压下来。
63
+ - 文件大小建议 **200KB 以内**,格式 PNG(文字/透明底)或 JPG(渐变/照片)。
64
+ - `bannerWidth`/`bannerHeight` 填 `0` 会输出 `#0px #0px`(让 QQ 自行缩放),但各客户端表现不一致,**不推荐**。
65
+
35
66
  ## 配置项
36
67
 
37
68
  | 配置 | 默认值 | 说明 |
@@ -49,14 +80,15 @@
49
80
  | `button` | `true` | 是否显示底部跳转按钮 |
50
81
  | `buttonText` | `点此直达DD373网站` | 按钮文字 |
51
82
  | `buttonUrl` | `https://www.dd373.com/s-d5gqt8.html` | 按钮跳转链接 |
52
- | `colorForward` | `#E60012` | 「1元=xx神石」数字颜色,留空不染色 |
53
- | `colorReverse` | `#00A650` | 「1神石=xx元」数字颜色,留空不染色 |
83
+ | `ratesBox` | `code` | 报价列表外框:`code` 代码块(灰色框、等宽、两列对齐、**无颜色**)/`quote` 引用(灰底+左侧竖条,桌面端可上色)/`none` 不加框 |
84
+ | `colorForward` | `#E60012` | 「1元=xx神石」数字颜色,留空不染色。**仅在 `ratesBox != code` 时生效** |
85
+ | `colorReverse` | `#00A650` | 「1神石=xx元」数字颜色,留空不染色。同上 |
54
86
  | `decimalsForward` | `2` | 「1元=xx神石」保留小数位(**截断**,不四舍五入),`0` = 原始值 |
55
87
  | `decimalsReverse` | `4` | 「1神石=xx元」保留小数位(截断),`0` = 原始值 |
56
88
 
57
89
  ## ⚠️ 注意事项
58
90
 
59
- 1. **颜色只在桌面端生效**。QQ 官方 Markdown 的[支持格式](https://bot.qq.com/wiki/develop/api-v2/server-inter/message/type/markdown.html)中没有字体颜色,颜色依赖 HTML 标签,**只有桌面端 QQNT 会渲染**;安卓 / iOS 会显示为普通文字(卡片本身不会出错)。
91
+ 1. **手机端不渲染 HTML 标签**。QQ 官方 Markdown 的[支持格式](https://bot.qq.com/wiki/develop/api-v2/server-inter/message/type/markdown.html)里没有字体颜色,颜色靠 HTML 标签实现,而**安卓 / iOS 会把这串标签当纯文本原样显示出来**(实测:手机上会看到 `<font color="#E60012">151.51</font>` 这样的字符)。所以默认 `ratesBox: code` 完全不输出颜色标签,用**代码块灰框 + 等宽对齐**来排版;想要桌面端红绿数字就把 `ratesBox` 改成 `quote` 或 `none`,代价是手机端会露出标签。
60
92
  2. **横幅图片必须是公网直链**,QQ 会下载并转存该资源。请勿使用官方的 `server-temp`(首次访问后即删除文件,群里多人同时查看会出现白图)。
61
93
  3. 群聊/单聊自定义 Markdown 自 2026/04/23 起已开放给所有机器人,**无需申请 Markdown 模板**;QQ 频道(`qqguild`)场景仍需内邀开通,本插件在频道下自动回退纯文本。
62
94
  4. 命令回复属被动消息,不占用主动消息配额。
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const { Schema, h } = require('koishi')
1
+ const { Schema } = require('koishi')
2
2
 
3
3
  const DEFAULT_URL = 'https://www.dd373.com/s-d5gqt8-0-0-0-0-0-0-0-0-0-0-0-1-0-5-0.html'
4
4
  const DEFAULT_BUTTON_URL = 'https://www.dd373.com/s-d5gqt8.html'
@@ -68,10 +68,13 @@ const Config = Schema.object({
68
68
 
69
69
  colorForward: Schema.string()
70
70
  .default('#E60012')
71
- .description('「1元=xx神石」数字的颜色,如 #E60012(红)。留空 = 不染色。⚠️ 颜色仅在桌面端 QQ 显示,安卓/iOS 会显示为普通文字'),
71
+ .description('「1元=xx神石」数字的颜色,如 #E60012(红)。留空 = 不染色。⚠️ 颜色只在桌面端 QQ 渲染;② 安卓/iOS 会把 <font> 标签当纯文本显示出来,所以仅在 ratesBox != code 时才有意义;③ 代码块内不支持颜色'),
72
72
  colorReverse: Schema.string()
73
73
  .default('#00A650')
74
74
  .description('「1神石=xx元」数字的颜色,如 #00A650(绿)。留空 = 不染色'),
75
+ ratesBox: Schema.union(['code', 'quote', 'none'])
76
+ .default('code')
77
+ .description('📦 报价列表的外框样式:code = 代码块(灰色框,等宽字体,行列对齐,不支持颜色);quote = 引用(灰底 + 左侧竖条,桌面端可显示颜色);none = 不加框。</br>若手机上把 ``` 显示成了原文,改成 quote 即可'),
75
78
  decimalsForward: Schema.number()
76
79
  .default(2)
77
80
  .min(0)
@@ -191,32 +194,129 @@ function buildCardMarkdown(rates, timestamp, config) {
191
194
  }).trim()
192
195
  if (intro) lines.push('', intro)
193
196
 
194
- lines.push('', ...rates.map(r => (
195
- `1元 = ${colorize(truncate(r.forward, config.decimalsForward), config.colorForward)}神石 | `
196
- + `1神石 = ${colorize(truncate(r.reverse, config.decimalsReverse), config.colorReverse)}元`
197
- )))
197
+ // 报价列表
198
+ const box = String(config.ratesBox || 'code')
199
+ const rows = rates.map(r => ({
200
+ fwd: truncate(r.forward, config.decimalsForward),
201
+ rev: truncate(r.reverse, config.decimalsReverse),
202
+ }))
203
+
204
+ if (box === 'code') {
205
+ // 代码块 = 灰色框(等宽字体,正好可以用空格把两列对齐)
206
+ // ⚠️ 代码块内部不支持颜色,塞 <font> 标签只会被原样显示,所以这里不加颜色
207
+ const wf = Math.max(...rows.map(r => r.fwd.length))
208
+ const wr = Math.max(...rows.map(r => r.rev.length))
209
+ const body = rows.map(r => `1元 = ${r.fwd.padEnd(wf)} 神石 | 1神石 = ${r.rev.padEnd(wr)}元`)
210
+ lines.push('', '```', ...body, '```')
211
+ } else {
212
+ const body = rows.map(r => (
213
+ `1元 = ${colorize(r.fwd, config.colorForward)}神石 | `
214
+ + `1神石 = ${colorize(r.rev, config.colorReverse)}元`
215
+ ))
216
+ lines.push('', ...(box === 'quote' ? body.map(l => '> ' + l) : body))
217
+ }
198
218
 
199
219
  const footer = String(config.footer || '').trim()
200
- if (config.button && footer) {
201
- lines.push('', '***', '', footer)
202
- }
220
+ if (config.button && footer) lines.push('', footer)
203
221
 
204
222
  return lines.join('\n')
205
223
  }
206
224
 
207
- // 组装成 koishi 元素:qq:markdown = 原生 markdown 正文,button-group/button = 卡片底部按钮
208
- // 注意:必须用 qq:markdown 元素包裹,否则适配器会把 # * - > 等符号全部转义成纯文本
209
- function buildCardElements(markdown, config) {
210
- const elements = [h('qq:markdown', {}, markdown)]
211
- if (config.button && String(config.buttonUrl || '').trim()) {
212
- elements.push(h('button-group', {},
213
- h('button', {
214
- type: 'link',
215
- href: String(config.buttonUrl).trim(),
216
- class: 'primary',
217
- }, config.buttonText || '点此查看详情')))
225
+ // —— QQ 官方 internal API 发送(单条 payload 内同时带 markdown keyboard,避免被拆成两条消息)——
226
+ // koishi-plugin-ll-group-welcome / ll-schedule 用的是同一套已验证写法
227
+ const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
228
+ // 被动回复窗口:超过 5 分钟的 msg_id 已失效,此时改发主动消息
229
+ const PASSIVE_TIMEOUT = 5 * 60 * 1000 - 2000
230
+
231
+ function errCode(e) {
232
+ const d = e && e.response && e.response.data
233
+ if (!d) return undefined
234
+ return d.code !== undefined ? d.code : d.err_code
235
+ }
236
+
237
+ function isKnownIgnoreError(e) {
238
+ const code = errCode(e)
239
+ return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
240
+ }
241
+
242
+ // markdown → 纯文本(用作 msg_type=2 的 content 兜底字段)
243
+ function toPlainText(markdown, buttonUrl) {
244
+ let out = String(markdown || '')
245
+ .replace(/<qqbot-at-user[^>]*\/>/g, '')
246
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // 去掉图片(含横幅)
247
+ .replace(/<[^>]+>/g, '') // 去掉 <font color="..."> 等标签,保留其中文字
248
+ .replace(/^#{1,6}\s*/gm, '')
249
+ .replace(/^\s*```.*$/gm, '') // 去掉代码块围栏
250
+ .replace(/^\s*\*\*\*\s*$/gm, '') // 去掉水平分割线
251
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
252
+ .replace(/\*([^*]+)\*/g, '$1')
253
+ .replace(/`([^`]+)`/g, '$1')
254
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1: $2')
255
+ .replace(/^\s*>\s?/gm, '')
256
+ .replace(/[ \t]+\n/g, '\n')
257
+ .replace(/\n{3,}/g, '\n\n')
258
+ .trim()
259
+ if (buttonUrl) out += `\n详情: ${buttonUrl}`
260
+ return out || ' '
261
+ }
262
+
263
+ // QQ 官方「消息按钮」:跳转按钮(action.type=0),所有人可点(permission.type=2)
264
+ function buildKeyboard(config) {
265
+ const url = String(config.buttonUrl || '').trim()
266
+ if (!config.button || !url) return undefined
267
+ const label = config.buttonText || '点此查看详情'
268
+ return {
269
+ content: {
270
+ rows: [{
271
+ buttons: [{
272
+ render_data: { label, visited_label: label, style: 1 },
273
+ action: {
274
+ type: 0,
275
+ permission: { type: 2 },
276
+ data: url,
277
+ unsupport_tips: '请升级客户端后点击按钮',
278
+ },
279
+ }],
280
+ }],
281
+ },
282
+ }
283
+ }
284
+
285
+ // 被动回复:带上触发消息的 msg_id + 自增 msg_seq(不占主动消息配额)
286
+ function passiveMeta(session) {
287
+ const messageId = session && session.messageId
288
+ const timestamp = (session && session.timestamp) || 0
289
+ if (!messageId || !timestamp || Date.now() - timestamp > PASSIVE_TIMEOUT) return null
290
+ const seq = (Number(session.seq) || 0) + 1
291
+ session.seq = seq
292
+ return { msg_id: messageId, msg_seq: seq }
293
+ }
294
+
295
+ // 一条 payload 同时装 markdown + keyboard(分成两个 payload 就会变成两条消息)
296
+ function buildCardPayload(markdown, plain, config, passive, withButtons) {
297
+ const payload = {
298
+ msg_type: 2,
299
+ content: plain || ' ',
300
+ markdown: { content: markdown },
301
+ }
302
+ if (passive) {
303
+ payload.msg_id = passive.msg_id
304
+ payload.msg_seq = passive.msg_seq
305
+ }
306
+ if (withButtons) {
307
+ const keyboard = buildKeyboard(config)
308
+ if (keyboard) payload.keyboard = keyboard
309
+ }
310
+ return payload
311
+ }
312
+
313
+ async function sendQqCard(session, payload) {
314
+ const internal = session.bot.internal
315
+ const target = session.channelId || session.guildId || session.userId
316
+ if ((session.isDirect || session.subtype === 'private') && typeof internal.sendPrivateMessage === 'function') {
317
+ return internal.sendPrivateMessage(target, payload)
218
318
  }
219
- return elements
319
+ return internal.sendMessage(target, payload)
220
320
  }
221
321
 
222
322
  // 只有官方QQ机器人的「群聊 / 私聊」支持自定义 markdown + 自定义按钮
@@ -267,15 +367,26 @@ function apply(ctx, config) {
267
367
  return '获取汇率失败: ' + message
268
368
  }
269
369
 
270
- // 官方QQ机器人的群聊/私聊:发 Markdown 卡片
271
- if (config.markdown && session && supportsCard(session) && typeof session.send === 'function') {
370
+ // 官方QQ机器人的群聊/私聊:发 Markdown 卡片(markdown + 按钮在同一条消息里)
371
+ if (config.markdown && session && supportsCard(session)) {
272
372
  const markdown = buildCardMarkdown(data.rates, data.timestamp, config)
273
- try {
274
- await session.send(buildCardElements(markdown, config))
275
- return
276
- } catch (e) {
277
- logger.warn('Markdown 卡片发送失败,回退纯文本:', (e && e.message) || e)
373
+ const plain = toPlainText(markdown, config.button ? config.buttonUrl : '')
374
+ const passive = passiveMeta(session)
375
+ const hasButtons = !!(config.button && String(config.buttonUrl || '').trim())
376
+ // 依次尝试:带按钮 不带按钮(按钮能力/权限不可用时仍出卡片),都失败才回退纯文本
377
+ for (const withButtons of (hasButtons ? [true, false] : [false])) {
378
+ try {
379
+ await sendQqCard(session, buildCardPayload(markdown, plain, config, passive, withButtons))
380
+ logger.debug(`已发送 Markdown 卡片到 ${session.channelId}${withButtons ? '(含按钮)' : '(无按钮)'}`)
381
+ return
382
+ } catch (e) {
383
+ const code = errCode(e)
384
+ const detail = code !== undefined ? `code=${code}` : ((e && e.message) || e)
385
+ const level = isKnownIgnoreError(e) ? 'debug' : 'warn'
386
+ logger[level](`Markdown 卡片发送失败(${withButtons ? '含按钮' : '无按钮'}): ${detail}`)
387
+ }
278
388
  }
389
+ logger.warn('Markdown 卡片全部尝试失败,回退纯文本')
279
390
  }
280
391
 
281
392
  return formatText(data.rates, data.timestamp, { stale: data.stale, error: data.error })
@@ -297,5 +408,16 @@ function apply(ctx, config) {
297
408
  module.exports = {
298
409
  Config,
299
410
  apply,
300
- _test: { fetchRates, formatText, buildCardMarkdown, buildCardElements, supportsCard, fillTemplate, truncate },
411
+ _test: {
412
+ fetchRates,
413
+ formatText,
414
+ buildCardMarkdown,
415
+ buildCardPayload,
416
+ buildKeyboard,
417
+ toPlainText,
418
+ passiveMeta,
419
+ supportsCard,
420
+ fillTemplate,
421
+ truncate,
422
+ },
301
423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-bns-rate",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Fetch BNS Classic Divine Stone exchange rate from dd373.com, with QQ official Markdown card support",
5
5
  "main": "index.js",
6
6
  "files": [