koishi-plugin-bns-rate 1.2.0 → 1.2.1

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 +22 -0
  2. package/index.js +124 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,6 +32,28 @@
32
32
 
33
33
  其它平台(OneBot / Telegram 等)、QQ 频道(`qqguild`)、以及卡片发送失败时,都会**自动回退为纯文本**,不会发不出消息。
34
34
 
35
+ ### 发送方式(重要)
36
+
37
+ 卡片通过 `bot.internal.sendMessage` 发送**一条** payload,`markdown` 与 `keyboard` 装在同一个请求里,因此**卡片正文和按钮在同一条消息内**(不会拆成两条)。命令回复使用**被动消息**(带触发消息的 `msg_id` + `msg_seq`),不占用主动消息配额。
38
+
39
+ 发送失败时会依次降级:**带按钮 → 不带按钮 → 纯文本**。
40
+
41
+ ## 横幅图片要求
42
+
43
+ `bannerUrl` 必须是**公网可直接访问的图片直链**(QQ 会下载转存,勿用官方 `server-temp`)。
44
+
45
+ 关键不是绝对像素,而是**图片自身的宽高比必须等于 `bannerWidth : bannerHeight`**,否则会被拉伸变形。
46
+
47
+ | 想做的比例 | 建议原图尺寸 | 对应配置 |
48
+ | --- | --- | --- |
49
+ | 4.67:1(默认) | **1120×240**(2 倍图) | `bannerWidth: 560` `bannerHeight: 120` |
50
+ | 4:1 | **1200×300** | `bannerWidth: 600` `bannerHeight: 150` |
51
+ | 5:1 | **1000×200** | `bannerWidth: 500` `bannerHeight: 100` |
52
+
53
+ - 宽度做 **1000~1200px** 足够清晰:QQ 会把图片缩放到卡片内容宽度(手机上约 300~400 逻辑像素),再大也会被压下来。
54
+ - 文件大小建议 **200KB 以内**,格式 PNG(文字/透明底)或 JPG(渐变/照片)。
55
+ - `bannerWidth`/`bannerHeight` 填 `0` 会输出 `#0px #0px`(让 QQ 自行缩放),但各客户端表现不一致,**不推荐**。
56
+
35
57
  ## 配置项
36
58
 
37
59
  | 配置 | 默认值 | 说明 |
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'
@@ -204,19 +204,100 @@ function buildCardMarkdown(rates, timestamp, config) {
204
204
  return lines.join('\n')
205
205
  }
206
206
 
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 || '点此查看详情')))
207
+ // —— QQ 官方 internal API 发送(单条 payload 内同时带 markdown keyboard,避免被拆成两条消息)——
208
+ // koishi-plugin-ll-group-welcome / ll-schedule 用的是同一套已验证写法
209
+ const KNOWN_IGNORE_CODES = new Set([11293, 40034101, 40034105, 304101, 304102])
210
+ // 被动回复窗口:超过 5 分钟的 msg_id 已失效,此时改发主动消息
211
+ const PASSIVE_TIMEOUT = 5 * 60 * 1000 - 2000
212
+
213
+ function errCode(e) {
214
+ const d = e && e.response && e.response.data
215
+ if (!d) return undefined
216
+ return d.code !== undefined ? d.code : d.err_code
217
+ }
218
+
219
+ function isKnownIgnoreError(e) {
220
+ const code = errCode(e)
221
+ return typeof code === 'number' && KNOWN_IGNORE_CODES.has(code)
222
+ }
223
+
224
+ // markdown → 纯文本(用作 msg_type=2 的 content 兜底字段)
225
+ function toPlainText(markdown, buttonUrl) {
226
+ let out = String(markdown || '')
227
+ .replace(/<qqbot-at-user[^>]*\/>/g, '')
228
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '') // 去掉图片(含横幅)
229
+ .replace(/<[^>]+>/g, '') // 去掉 <font color="..."> 等标签,保留其中文字
230
+ .replace(/^#{1,6}\s*/gm, '')
231
+ .replace(/^\s*\*\*\*\s*$/gm, '') // 去掉水平分割线
232
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
233
+ .replace(/\*([^*]+)\*/g, '$1')
234
+ .replace(/`([^`]+)`/g, '$1')
235
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1: $2')
236
+ .replace(/^>\s?/gm, '')
237
+ .replace(/[ \t]+\n/g, '\n')
238
+ .replace(/\n{3,}/g, '\n\n')
239
+ .trim()
240
+ if (buttonUrl) out += `\n详情: ${buttonUrl}`
241
+ return out || ' '
242
+ }
243
+
244
+ // QQ 官方「消息按钮」:跳转按钮(action.type=0),所有人可点(permission.type=2)
245
+ function buildKeyboard(config) {
246
+ const url = String(config.buttonUrl || '').trim()
247
+ if (!config.button || !url) return undefined
248
+ const label = config.buttonText || '点此查看详情'
249
+ return {
250
+ content: {
251
+ rows: [{
252
+ buttons: [{
253
+ render_data: { label, visited_label: label, style: 1 },
254
+ action: {
255
+ type: 0,
256
+ permission: { type: 2 },
257
+ data: url,
258
+ unsupport_tips: '请升级客户端后点击按钮',
259
+ },
260
+ }],
261
+ }],
262
+ },
263
+ }
264
+ }
265
+
266
+ // 被动回复:带上触发消息的 msg_id + 自增 msg_seq(不占主动消息配额)
267
+ function passiveMeta(session) {
268
+ const messageId = session && session.messageId
269
+ const timestamp = (session && session.timestamp) || 0
270
+ if (!messageId || !timestamp || Date.now() - timestamp > PASSIVE_TIMEOUT) return null
271
+ const seq = (Number(session.seq) || 0) + 1
272
+ session.seq = seq
273
+ return { msg_id: messageId, msg_seq: seq }
274
+ }
275
+
276
+ // 一条 payload 同时装 markdown + keyboard(分成两个 payload 就会变成两条消息)
277
+ function buildCardPayload(markdown, plain, config, passive, withButtons) {
278
+ const payload = {
279
+ msg_type: 2,
280
+ content: plain || ' ',
281
+ markdown: { content: markdown },
282
+ }
283
+ if (passive) {
284
+ payload.msg_id = passive.msg_id
285
+ payload.msg_seq = passive.msg_seq
286
+ }
287
+ if (withButtons) {
288
+ const keyboard = buildKeyboard(config)
289
+ if (keyboard) payload.keyboard = keyboard
290
+ }
291
+ return payload
292
+ }
293
+
294
+ async function sendQqCard(session, payload) {
295
+ const internal = session.bot.internal
296
+ const target = session.channelId || session.guildId || session.userId
297
+ if ((session.isDirect || session.subtype === 'private') && typeof internal.sendPrivateMessage === 'function') {
298
+ return internal.sendPrivateMessage(target, payload)
218
299
  }
219
- return elements
300
+ return internal.sendMessage(target, payload)
220
301
  }
221
302
 
222
303
  // 只有官方QQ机器人的「群聊 / 私聊」支持自定义 markdown + 自定义按钮
@@ -267,15 +348,26 @@ function apply(ctx, config) {
267
348
  return '获取汇率失败: ' + message
268
349
  }
269
350
 
270
- // 官方QQ机器人的群聊/私聊:发 Markdown 卡片
271
- if (config.markdown && session && supportsCard(session) && typeof session.send === 'function') {
351
+ // 官方QQ机器人的群聊/私聊:发 Markdown 卡片(markdown + 按钮在同一条消息里)
352
+ if (config.markdown && session && supportsCard(session)) {
272
353
  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)
354
+ const plain = toPlainText(markdown, config.button ? config.buttonUrl : '')
355
+ const passive = passiveMeta(session)
356
+ const hasButtons = !!(config.button && String(config.buttonUrl || '').trim())
357
+ // 依次尝试:带按钮 不带按钮(按钮能力/权限不可用时仍出卡片),都失败才回退纯文本
358
+ for (const withButtons of (hasButtons ? [true, false] : [false])) {
359
+ try {
360
+ await sendQqCard(session, buildCardPayload(markdown, plain, config, passive, withButtons))
361
+ logger.debug(`已发送 Markdown 卡片到 ${session.channelId}${withButtons ? '(含按钮)' : '(无按钮)'}`)
362
+ return
363
+ } catch (e) {
364
+ const code = errCode(e)
365
+ const detail = code !== undefined ? `code=${code}` : ((e && e.message) || e)
366
+ const level = isKnownIgnoreError(e) ? 'debug' : 'warn'
367
+ logger[level](`Markdown 卡片发送失败(${withButtons ? '含按钮' : '无按钮'}): ${detail}`)
368
+ }
278
369
  }
370
+ logger.warn('Markdown 卡片全部尝试失败,回退纯文本')
279
371
  }
280
372
 
281
373
  return formatText(data.rates, data.timestamp, { stale: data.stale, error: data.error })
@@ -297,5 +389,16 @@ function apply(ctx, config) {
297
389
  module.exports = {
298
390
  Config,
299
391
  apply,
300
- _test: { fetchRates, formatText, buildCardMarkdown, buildCardElements, supportsCard, fillTemplate, truncate },
392
+ _test: {
393
+ fetchRates,
394
+ formatText,
395
+ buildCardMarkdown,
396
+ buildCardPayload,
397
+ buildKeyboard,
398
+ toPlainText,
399
+ passiveMeta,
400
+ supportsCard,
401
+ fillTemplate,
402
+ truncate,
403
+ },
301
404
  }
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.1",
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": [