@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 +1 -1
- package/src/naplink/client.ts +78 -1
- package/src/naplink/media.ts +9 -1
- package/src/naplink/render.ts +7 -2
package/package.json
CHANGED
package/src/naplink/client.ts
CHANGED
|
@@ -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.
|
|
185
|
+
await hydrateMedia(this.api, message, this.logger)
|
|
109
186
|
} catch (error) {
|
|
110
187
|
this.logger.warn("补全媒体直链失败", error)
|
|
111
188
|
}
|
package/src/naplink/media.ts
CHANGED
|
@@ -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://"))
|
|
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 {
|
package/src/naplink/render.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|