@cmach_socket/opencode-qq-bot 0.3.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmach_socket/opencode-qq-bot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode V2 plugin bridging a QQ group chat (NapCat via NapLink) to an OpenCode session.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -8,6 +8,83 @@ import {
8
8
  import type { Logger } from "../logger"
9
9
  import type { NapcatConfig } from "../config"
10
10
 
11
+ const MEDIA_SEGMENT_TYPES = ["image", "video", "record", "audio", "file"] as const
12
+
13
+ type MediaSegmentType = (typeof MEDIA_SEGMENT_TYPES)[number]
14
+
15
+ interface MediaSegmentData {
16
+ file?: string
17
+ file_id?: string
18
+ url?: string
19
+ }
20
+
21
+ interface MediaFileLike {
22
+ file?: unknown
23
+ url?: unknown
24
+ }
25
+
26
+ function isMediaSegmentType(type: string): type is MediaSegmentType {
27
+ return (MEDIA_SEGMENT_TYPES as readonly string[]).includes(type)
28
+ }
29
+
30
+ /** 取第一个可下载的 http(s) 直链,忽略本地路径与 base64。 */
31
+ export function pickRemoteUrl(...values: unknown[]): string | undefined {
32
+ for (const value of values) {
33
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * 补全单个媒体段的远程直链。
40
+ *
41
+ * NapCat 同时返回本地路径(`file`)和 CDN 直链(`url`),SDK 自带的补全逻辑
42
+ * 会优先选用本地路径,导致插件无法下载。这里只写入 `url`,且优先使用 http(s) 地址。
43
+ */
44
+ export async function hydrateSegment(
45
+ api: OneBotApi,
46
+ segment: MessageSegment,
47
+ logger: Logger,
48
+ ): Promise<void> {
49
+ if (!isMediaSegmentType(segment.type)) return
50
+ const data = segment.data as MediaSegmentData
51
+ // 已有可用的远程直链时不要覆盖。
52
+ if (pickRemoteUrl(data.url)) return
53
+ const fileId = data.file ?? data.file_id
54
+ if (typeof fileId !== "string" || fileId.length === 0) return
55
+ if (/^(file|base64|data):/i.test(fileId)) return
56
+
57
+ try {
58
+ const file = (await api.getFile(fileId)) as MediaFileLike | undefined
59
+ const direct = pickRemoteUrl(file?.url, file?.file)
60
+ if (direct) {
61
+ data.url = direct
62
+ return
63
+ }
64
+ if (segment.type === "image") {
65
+ const image = (await api.getImage(fileId)) as MediaFileLike | undefined
66
+ const imageUrl = pickRemoteUrl(image?.url, image?.file)
67
+ if (imageUrl) data.url = imageUrl
68
+ } else if (segment.type === "record" || segment.type === "audio") {
69
+ const record = (await api.getRecord(fileId, "mp3")) as MediaFileLike | undefined
70
+ const recordUrl = pickRemoteUrl(record?.url, record?.file)
71
+ if (recordUrl) data.url = recordUrl
72
+ }
73
+ } catch (error) {
74
+ logger.debug(`补全媒体直链失败 type=${segment.type} file=${fileId}`, error)
75
+ }
76
+ }
77
+
78
+ /** 补全一条消息里所有媒体段的远程直链。 */
79
+ export async function hydrateMedia(
80
+ api: OneBotApi,
81
+ message: MessageSegment[],
82
+ logger: Logger,
83
+ ): Promise<void> {
84
+ if (!Array.isArray(message)) return
85
+ await Promise.all(message.map((segment) => hydrateSegment(api, segment, logger)))
86
+ }
87
+
11
88
  /** NapCat 的连接与 API 门面,屏蔽 SDK 细节。 */
12
89
  export class QQClient {
13
90
  private readonly link: NapLink
@@ -105,7 +182,7 @@ export class QQClient {
105
182
  /** 补全消息里媒体段的直链,便于下载图片。 */
106
183
  async hydrate(message: MessageSegment[]): Promise<void> {
107
184
  try {
108
- await this.link.hydrateMessage(message)
185
+ await hydrateMedia(this.api, message, this.logger)
109
186
  } catch (error) {
110
187
  this.logger.warn("补全媒体直链失败", error)
111
188
  }
@@ -83,6 +83,11 @@ function truncateCodePoints(text: string, max: number): string {
83
83
  return Array.from(text).slice(0, max).join("")
84
84
  }
85
85
 
86
+ /** 截断过长的地址,避免日志被超长 URL 淹没。 */
87
+ function truncateForLog(value: string, max = 120): string {
88
+ return value.length > max ? `${value.slice(0, max)}…` : value
89
+ }
90
+
86
91
  /** 判断主机名是否指向本机或内网,避免下载请求打内网服务。 */
87
92
  export function isPrivateHost(hostname: string): boolean {
88
93
  let host = hostname.trim().toLowerCase()
@@ -199,7 +204,10 @@ export class MediaStore {
199
204
  return { bytes, mime }
200
205
  }
201
206
  if (url.startsWith("data:")) return parseDataUrl(url)
202
- if (!url.startsWith("http://") && !url.startsWith("https://")) return undefined
207
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
208
+ this.logger.warn(`不支持的图片地址,跳过下载 url=${truncateForLog(url)}`)
209
+ return undefined
210
+ }
203
211
 
204
212
  let parsed: URL
205
213
  try {
@@ -2,6 +2,7 @@ import type { MessageEvent, MessageSegment, OneBotApi } from "@naplink/naplink"
2
2
  import type { ChatTarget } from "../chat"
3
3
  import { targetKey } from "../chat"
4
4
  import type { Logger } from "../logger"
5
+ import { hydrateSegment } from "./client"
5
6
  import {
6
7
  MAX_IMAGE_BYTES,
7
8
  inlineImageBytes,
@@ -174,8 +175,10 @@ async function renderForwardSegments(
174
175
  break
175
176
  case "image": {
176
177
  parts.push("[图片]")
178
+ if (budget.images <= 0) break
179
+ await hydrateSegment(ctx.api, segment, ctx.logger)
177
180
  const url = segment.data.url || segment.data.file
178
- if (!url || budget.images <= 0) break
181
+ if (!url) break
179
182
  const stored = await ctx.media.storeImage(url)
180
183
  if (!stored || !takeImage(budget, stored)) break
181
184
  images.push(stored)
@@ -363,8 +366,10 @@ export async function renderMessage(
363
366
  break
364
367
  case "image": {
365
368
  textBuffer.push("[图片] ")
369
+ if (budget.images <= 0) break
370
+ await hydrateSegment(ctx.api, segment, ctx.logger)
366
371
  const url = segment.data.url || segment.data.file
367
- if (!url || budget.images <= 0) break
372
+ if (!url) break
368
373
  const stored = await ctx.media.storeImage(url)
369
374
  if (!stored || !takeImage(budget, stored)) break
370
375
  imageIndex += 1