diff --git a/packages/core/tests/highway/image-upload.test.ts b/packages/core/tests/highway/image-upload.test.ts index 3be8105d..6f54b6c4 100644 --- a/packages/core/tests/highway/image-upload.test.ts +++ b/packages/core/tests/highway/image-upload.test.ts @@ -6,7 +6,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@snowluma/protocol/highway/pipeline', () => ({ - runNtv2Upload: vi.fn(async () => ({ msgInfo: { msgInfoBody: [], extBizInfo: {} } })), + runNtv2Upload: vi.fn(async () => ({ + msgInfo: { msgInfoBody: [{ index: { fileUuid: 'uploaded-image' } }], extBizInfo: {} }, + })), finalizeMediaMsgInfo: vi.fn(() => new Uint8Array([0xca, 0xfe])), hexToBytes: vi.fn((hex: string) => new Uint8Array(hex.length / 2)), })); diff --git a/packages/onebot/REPLIES.md b/packages/onebot/REPLIES.md index 31819cc0..800b978c 100644 --- a/packages/onebot/REPLIES.md +++ b/packages/onebot/REPLIES.md @@ -26,3 +26,17 @@ identifier without logging quoted content. Fetch failures remain visible. Group quotes retain their group-sequence lookup. Forwarded nodes retain their inline quote content, which can be resent even when the original message is not available in the local account's history. + +Image uploads retain their download reference and actual filename in durable +message metadata. `get_msg` refreshes each image from that message's own reference, +so a reused filename or a restart does not select another upload. + +For older records missing an image reference, `get_msg` requests bounded history +for the stored conversation and sequence (or the stored timestamp and client +sequence for legacy private records). Only a unique message with matching sender, +time and ordered image fingerprints can fill the missing references. The repair +is saved under the existing signed `message_id`; it preserves message content +and identity metadata and never restores a record removed while the request was pending. +Complete images need no history request. If history is unavailable or cannot +verify the match, the unverified image data is left intact and a warning is logged; +download URLs are not guessed. diff --git a/packages/onebot/src/instance-context.ts b/packages/onebot/src/instance-context.ts index 98b0b21f..708f3b88 100644 --- a/packages/onebot/src/instance-context.ts +++ b/packages/onebot/src/instance-context.ts @@ -5,6 +5,7 @@ import type { ApiActionContext } from './api-handler'; import type { ConverterContext } from './event-converter'; import type { MediaStore } from './media-store'; import type { MessageStore } from './message-store'; +import { readMessageWithImageRepair } from './message-image-repair'; import type { TempSessionStore } from './temp-session-store'; import { getDownloadRKeys, @@ -76,7 +77,7 @@ export function buildApiContext(ref: OneBotInstanceContext): ApiActionContext { getLoginInfo: () => getLoginInfo(ref), isOnline: () => true, getUptimeMs: () => ref.getUptimeMs(), - getMessage: async (messageId) => await messageStore.findEvent(messageId), + getMessage: (messageId) => readMessageWithImageRepair(ref, messageId), refreshMessageMedia: (messageId, event) => refreshMessageMedia(ref, messageId, event), getMessageMeta: async (messageId) => await messageStore.findMeta(messageId), cacheMessageMetas: async (entries) => await messageStore.storeMetas(entries), diff --git a/packages/onebot/src/media-indexer.ts b/packages/onebot/src/media-indexer.ts index c8ea267f..d80664e9 100644 --- a/packages/onebot/src/media-indexer.ts +++ b/packages/onebot/src/media-indexer.ts @@ -22,7 +22,7 @@ export class MediaIndexer { file: file || element.fileId || '', url, fileSize: element.fileSize ?? 0, - fileName: element.fileId ?? '', + fileName: element.fileName || element.fileId || '', subType: element.subType ?? 0, summary: element.summary ?? '', imageUrl: element.imageUrl ?? '', diff --git a/packages/onebot/src/message-image-repair.ts b/packages/onebot/src/message-image-repair.ts new file mode 100644 index 00000000..4a824008 --- /dev/null +++ b/packages/onebot/src/message-image-repair.ts @@ -0,0 +1,288 @@ +import { createLogger } from '@snowluma/common/logger'; +import type { FriendMessage, GroupMessage, MessageElementOf } from '@snowluma/protocol/events'; +import type { OneBotInstanceContext } from './instance-context'; +import { segmentsToRawMessage } from './helper/cq'; +import { + attachMessageMedia, + imageMd5FromFile, + isStoredMediaSegment, + type MessageMedia, + type StoredMediaSegment, +} from './message-media'; +import { PRIVATE_QUOTE_MAX_DRIFT_SECONDS } from './private-quote'; +import { hasAuthoritativeSequence, type JsonObject, type MessageMeta } from './types'; + +const log = createLogger('OneBot'); +type RepairContext = Pick; +type HistoryMessage = FriendMessage | GroupMessage; + +function remoteUrl(value: unknown): value is string { + return typeof value === 'string' && /^https?:\/\//i.test(value); +} + +function imageReference(element: MessageMedia | undefined): string { + return remoteUrl(element?.imageUrl) + ? element.imageUrl + : remoteUrl(element?.url) + ? element.url + : ''; +} + +/** Repair only missing image references. Full server events must not replace the + * original message body, sender classification, signed id or sequence metadata. + */ +export async function readMessageWithImageRepair( + ref: RepairContext, + messageId: number, +): Promise { + const event = await ref.messageStore.findEvent(messageId); + + if (!event || !Array.isArray(event.message)) { + return event; + } + + const segments = event.message.filter(isStoredMediaSegment); + + if (!segments.some((segment) => segment.type === 'image' && !remoteUrl(segment.data.url))) { + return event; + } + + try { + const media = await ref.messageStore.findMedia(messageId); + + if ( + media && + (media.length !== segments.length || + media.some((element, index) => element.type !== segments[index]!.type)) + ) { + throw new Error('stored media association is inconsistent'); + } + + const missing = segments.map( + (segment, index) => + segment.type === 'image' && + !remoteUrl(segment.data.url) && + !imageReference(media?.[index]), + ); + + if (!missing.some(Boolean)) { + return event; + } + + const storedMeta = await ref.messageStore.findMeta(messageId); + // As in private history listing, old self-keyed outgoing rows require + // their explicit recipient. This changes lookup scope, never stored identity. + const meta = + storedMeta && + !storedMeta.isGroup && + storedMeta.targetId === ref.selfId && + event.user_id === ref.selfId && + typeof event.target_id === 'number' && + Number.isSafeInteger(event.target_id) && + event.target_id > 0 + ? { ...storedMeta, targetId: event.target_id } + : storedMeta; + + if ( + !meta || + meta.targetId <= 0 || + meta.timestamp <= 0 || + !Number.isSafeInteger(event.user_id) || + Number(event.user_id) <= 0 || + (event.self_id !== undefined && event.self_id !== ref.selfId) || + event.message_type !== (meta.isGroup ? 'group' : 'private') || + (meta.isGroup ? event.group_id !== meta.targetId : event.sub_type !== 'friend') || + (!meta.isGroup && event.target_id !== undefined && event.target_id !== meta.targetId) + ) { + throw new Error('stored message identity is incomplete'); + } + + const candidates = (await fetchHistory(ref, meta)).filter((source) => + matchesIdentity(source, event, meta, ref.selfId), + ); + + if (candidates.length !== 1) { + throw new Error(`expected one matching history message, got ${candidates.length}`); + } + + const images = candidates[0]!.elements.filter( + (element): element is MessageElementOf<'image'> => element.type === 'image', + ); + const storedImages = segments.filter((segment) => segment.type === 'image'); + + if (images.length !== storedImages.length) { + throw new Error('history image count differs from stored message'); + } + + const updated = structuredClone(event); + const updatedSegments = (updated.message as JsonObject[]).filter(isStoredMediaSegment); + // Never create empty placeholders for other media: they would prevent + // a later complete history response from filling their real metadata. + const attachSnapshots = + media !== null || segments.every((segment) => segment.type === 'image'); + let imageIndex = 0; + + for (const [index, segment] of updatedSegments.entries()) { + const old = media?.[index]; + let element: MessageMedia = old ?? { type: segment.type }; + + if (segment.type === 'image') { + const image = images[imageIndex++]!; + verifyImage(old, segment, image); + + if (missing[index]) { + const url = imageReference(image); + + if (!url) { + throw new Error('history image has no download reference'); + } + + element = { ...image, imageUrl: url }; + segment.data.url = url; + } else if (!old) { + element = { ...image, imageUrl: String(segment.data.url) }; + } + } + + if (attachSnapshots) { + attachMessageMedia(segment, element); + } + } + + updated.raw_message = segmentsToRawMessage(updated.message as JsonObject[]); + + if (!(await ref.messageStore.updateMedia(messageId, event, updated))) { + log.debug( + '[get_msg] image history repair skipped after concurrent update message_id=%d', + messageId, + ); + return ref.messageStore.findEvent(messageId); + } + + log.debug( + '[get_msg] image history repair saved message_id=%d images=%d', + messageId, + missing.filter(Boolean).length, + ); + return updated; + } catch (error) { + // get_msg retains its documented partial-media behavior; do not invent + // a URL or overwrite a record when history cannot prove the association. + log.warn( + '[get_msg] image history repair failed message_id=%d: %s', + messageId, + error instanceof Error ? error.message : String(error), + ); + return ref.messageStore.findEvent(messageId); + } +} + +async function fetchHistory(ref: RepairContext, meta: MessageMeta): Promise { + if (meta.isGroup) { + if (!hasAuthoritativeSequence(meta)) { + throw new Error('group message has no verified sequence'); + } + + const message = await ref.bridge.apis.message.getGroupMessageBySeq( + meta.targetId, + meta.sequence, + ref.selfId, + ); + return message ? [message] : []; + } + + const uid = await ref.bridge.resolveUserUid(meta.targetId); + + if (!uid) { + throw new Error('private history peer could not be resolved'); + } + + if (meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0) { + const message = await ref.bridge.apis.message.getC2cMessageBySeq( + uid, + meta.sequence, + ref.selfId, + ); + return message ? [message] : []; + } + + if (meta.clientSequence <= 0) { + throw new Error('private message has no verified client sequence'); + } + + return ref.bridge.apis.message.getC2cLatestHistory( + uid, + 20, + ref.selfId, + Math.min(0xffff_ffff, meta.timestamp + 1), + ); +} + +function matchesIdentity( + source: HistoryMessage, + event: JsonObject, + meta: MessageMeta, + selfId: number, +): boolean { + if ( + source.selfUin !== selfId || + source.senderUin !== event.user_id || + !Number.isSafeInteger(source.time) || + source.time <= 0 || + Math.abs(source.time - meta.timestamp) > PRIVATE_QUOTE_MAX_DRIFT_SECONDS + ) { + return false; + } + + if (source.kind === 'group_message') { + return meta.isGroup && source.groupId === meta.targetId && source.msgSeq === meta.sequence; + } + + // These history requests explicitly bind the peer UID. Older outgoing + // responses can omit toUin; preserve that binding, never substitute selfId. + const peer = source.peerUin ?? (source.senderUin !== selfId ? source.senderUin : meta.targetId); + + if ( + meta.isGroup || + peer !== meta.targetId || + (meta.privateDirection && + (source.senderUin === selfId) !== (meta.privateDirection === 'outgoing')) + ) { + return false; + } + + return meta.sequenceAuthoritative && Number.isSafeInteger(meta.sequence) && meta.sequence > 0 + ? source.sequenceAuthoritative !== false && source.ntMsgSeq === meta.sequence + : (source.clientSeq ?? source.msgSeq) === meta.clientSequence; +} + +function verifyImage( + old: MessageMedia | undefined, + segment: StoredMediaSegment, + image: MessageElementOf<'image'>, +): void { + // Older rows lack per-message snapshots but expose the content hash as the + // image filename. Arbitrary aliases cannot establish this association. + const legacyMd5 = imageMd5FromFile(segment.data.file ?? segment.data.file_id); + const fingerprints = { md5Hex: old?.md5Hex || legacyMd5, sha1Hex: old?.sha1Hex }; + let matched = false; + + for (const field of ['md5Hex', 'sha1Hex'] as const) { + const before = fingerprints[field]; + const after = image[field]; + + if (!before || !after) { + continue; + } + + if (before.toLowerCase() !== after.toLowerCase()) { + throw new Error('history image fingerprint conflicts with stored message'); + } + + matched = true; + } + + if (!matched) { + throw new Error('history image fingerprint cannot be verified'); + } +} diff --git a/packages/onebot/src/message-media.ts b/packages/onebot/src/message-media.ts index 59f7fbe7..cc45959c 100644 --- a/packages/onebot/src/message-media.ts +++ b/packages/onebot/src/message-media.ts @@ -1,8 +1,29 @@ import type { MessageElement } from '@snowluma/protocol/events'; import { serializeMediaInfo } from './media-store'; -import type { JsonObject } from './types'; +import type { JsonObject, JsonValue } from './types'; export type MessageMedia = Extract; +export type StoredMediaSegment = JsonObject & { type: MessageMedia['type']; data: JsonObject }; + +export function isStoredMediaSegment(segment: JsonValue): segment is StoredMediaSegment { + return ( + segment !== null && + typeof segment === 'object' && + !Array.isArray(segment) && + (segment.type === 'image' || segment.type === 'record' || segment.type === 'video') && + segment.data !== null && + typeof segment.data === 'object' && + !Array.isArray(segment.data) + ); +} + +/** Legacy image segments use a content hash filename; arbitrary aliases prove nothing. */ +export function imageMd5FromFile(file: unknown): string | undefined { + return typeof file === 'string' + ? /^([a-f0-9]{32})\.(?:png|jpe?g|webp|gif|bmp)$/i.exec(file)?.[1]?.toLowerCase() + : undefined; +} + const mediaSnapshot = Symbol('message media snapshot'); type MediaSegment = JsonObject & { [mediaSnapshot]?: MessageMedia }; diff --git a/packages/onebot/src/message-store.ts b/packages/onebot/src/message-store.ts index 8d112153..0ff3221b 100644 --- a/packages/onebot/src/message-store.ts +++ b/packages/onebot/src/message-store.ts @@ -45,6 +45,7 @@ export class MessageStore { private stmtStoreMeta!: SqliteStatement; private stmtFindEvent!: SqliteStatement; private stmtFindMedia!: SqliteStatement; + private stmtUpdateMedia!: SqliteStatement; private stmtFindMeta!: SqliteStatement; private stmtResolveReplyGroup!: SqliteStatement; private stmtResolveReplyPrivate!: SqliteStatement; @@ -122,6 +123,10 @@ export class MessageStore { this.stmtFindMedia = this.db.prepare( 'SELECT media_data FROM messages WHERE message_hash = ?', ); + this.stmtUpdateMedia = this.db.prepare( + `UPDATE messages SET data = ?, media_data = ? + WHERE message_hash = ? AND data = ? AND media_data IS ?`, + ); this.stmtFindMeta = this.db.prepare( 'SELECT is_group, session_id, sequence, sequence_authoritative, event_name, client_sequence, private_direction, random, timestamp FROM messages WHERE message_hash = ?', @@ -326,6 +331,33 @@ export class MessageStore { }); } + /** Enrich a read record without changing its identity or restoring a removed row. */ + async updateMedia( + messageId: number, + expected: JsonObject, + updated: JsonObject, + ): Promise { + const before = JSON.stringify(expected); + updated = snapshotMessageEvent(updated); + return this.db.serial(async () => { + await this.initialization; + const previous = (await this.stmtFindMedia.get(messageId)) as + { media_data: string | null } | undefined; + const media = mergeMessageMedia( + previous?.media_data ?? null, + serializeMessageMedia(updated), + ); + const result = await this.stmtUpdateMedia.run( + JSON.stringify(updated), + media, + messageId, + before, + previous?.media_data ?? null, + ); + return result.changes === 1; + }); + } + async findMedia(messageId: number): Promise { return this.db.serial(async () => { await this.initialization; diff --git a/packages/onebot/src/modules/media-actions.ts b/packages/onebot/src/modules/media-actions.ts index a565f598..7b0718da 100644 --- a/packages/onebot/src/modules/media-actions.ts +++ b/packages/onebot/src/modules/media-actions.ts @@ -1,30 +1,18 @@ import type { BridgeInterface } from '@snowluma/core/bridge-interface'; +import type { MessageElementOf } from '@snowluma/protocol/events'; import { createLogger } from '@snowluma/common/logger'; import type { ImageUrlResolver } from '../event-converter'; import type { CachedImage, MediaStore } from '../media-store'; import type { MessageStore } from '../message-store'; import type { OneBotInstanceContext } from '../instance-context'; import { resolveRemoteMediaUrl } from '../media-url-resolver'; +import { imageMd5FromFile, isStoredMediaSegment } from '../message-media'; import { segmentsToRawMessage } from '../helper/cq'; import { pttTransKey, requestPttTransText } from './ptt-trans-waiter'; -import type { JsonObject, JsonValue } from '../types'; +import type { JsonObject } from '../types'; const log = createLogger('OneBot'); -type StoredMediaSegment = JsonObject & { type: 'image' | 'record' | 'video'; data: JsonObject }; - -function isStoredMediaSegment(segment: JsonValue): segment is StoredMediaSegment { - return ( - segment !== null && - typeof segment === 'object' && - !Array.isArray(segment) && - (segment.type === 'image' || segment.type === 'record' || segment.type === 'video') && - segment.data !== null && - typeof segment.data === 'object' && - !Array.isArray(segment.data) - ); -} - export async function refreshMessageMedia( ref: Pick, messageId: number, @@ -36,13 +24,17 @@ export async function refreshMessageMedia( const segments = event.message.filter(isStoredMediaSegment); const voiceVideoSegments = segments.filter((segment) => segment.type !== 'image'); - const media = - voiceVideoSegments.length > 0 - ? (await ref.messageStore.findMedia(messageId))?.filter( - (element) => element.type !== 'image', - ) - : null; - const meta = voiceVideoSegments.length > 0 ? await ref.messageStore.findMeta(messageId) : null; + const snapshots = segments.length > 0 ? await ref.messageStore.findMedia(messageId) : null; + const meta = segments.length > 0 ? await ref.messageStore.findMeta(messageId) : null; + const media = snapshots?.filter((element) => element.type !== 'image'); + const aligned = + snapshots?.length === segments.length && + snapshots.every((element, index) => element.type === segments[index]!.type); + + if (snapshots && !aligned && segments.some((segment) => segment.type === 'image')) { + log.warn('[get_msg] image metadata association inconsistent message_id=%d', messageId); + } + // Validate the ordered association once; a filename lookup can select another upload. const voiceVideo = meta && @@ -64,19 +56,58 @@ export async function refreshMessageMedia( let url: string | null; if (segment.type === 'image') { - const file = - typeof segment.data.file === 'string' - ? segment.data.file - : segment.data.file_id; + const snapshot = aligned ? snapshots?.[index] : undefined; + const ownUrl = + typeof segment.data.url === 'string' && /^https?:\/\//i.test(segment.data.url) + ? segment.data.url + : ''; - if (typeof file !== 'string' || !file) { - continue; + if (meta && (snapshot?.type === 'image' || ownUrl)) { + const element: MessageElementOf<'image'> = + snapshot?.type === 'image' + ? snapshot + : { + type: 'image' as const, + subType: + typeof segment.data.sub_type === 'number' + ? segment.data.sub_type + : 0, + }; + const source = element.imageUrl || element.url || ownUrl; + + if (typeof source === 'string' && /^https?:\/\//i.test(source)) { + url = ref.converterCtx.imageUrlResolver + ? await ref.converterCtx.imageUrlResolver( + { ...element, imageUrl: source }, + meta.isGroup, + ) + : source; + } else { + url = null; + } + } else if (!snapshots) { + const file = + typeof segment.data.file === 'string' + ? segment.data.file + : segment.data.file_id; + + if (typeof file !== 'string' || !file) { + continue; + } + + const cached = await ref.mediaStore.findImage(file); + const md5 = imageMd5FromFile(file); + url = + cached && + meta && + cached.isGroup === meta.isGroup && + cached.sessionId === meta.targetId && + (!md5 || cached.md5Hex?.toLowerCase() === md5) + ? await resolveImageUrl(cached, ref.converterCtx.imageUrlResolver) + : null; + } else { + url = null; } - - url = await resolveImageUrl( - await ref.mediaStore.findImage(file), - ref.converterCtx.imageUrlResolver, - ); } else { if (!voiceVideo) { continue; diff --git a/packages/onebot/tests/image-history.test.ts b/packages/onebot/tests/image-history.test.ts new file mode 100644 index 00000000..0de02015 --- /dev/null +++ b/packages/onebot/tests/image-history.test.ts @@ -0,0 +1,735 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { EncodableMediaMsgInfo, NTV2UploadRichMediaResp } from '@snowluma/proto-defs/highway'; +import type { BridgeContext } from '@snowluma/protocol/bridge-context'; +import type { FriendMessage, GroupMessage, MessageElement } from '@snowluma/protocol/events'; +import { uploadImageMsgInfo } from '@snowluma/protocol/highway/image-upload'; +import { subscribeLogs, type LogEntry } from '@snowluma/common/logger'; +import { ApiHandler } from '../src/api-handler'; +import { elementsToOneBotSegments } from '../src/event-converter'; +import { buildApiContext, type OneBotInstanceContext } from '../src/instance-context'; +import { MediaIndexer } from '../src/media-indexer'; +import { MediaStore } from '../src/media-store'; +import { MessageStore } from '../src/message-store'; +import { + backfillReplyTarget, + sendGroupMessage, + sendPrivateMessage, +} from '../src/modules/message-actions'; +import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../src/message-id'; +import { createMessageIdResolver } from '../src/message-id-resolver'; +import { segmentsToRawMessage } from '../src/helper/cq'; +import type { JsonObject } from '../src/types'; + +const png = + 'base64://iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jFZkAAAAASUVORK5CYII='; +const self = 10001; +const peer = 20002; +const time = 1700000000; +const legacyId = -659; +const md5 = 'aa'.repeat(16); +const sha1 = 'bb'.repeat(20); +const cleanups: Array<() => void | Promise> = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } +}); + +function fixture(isGroup: boolean) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'image-history-')); + cleanups.push(() => fs.rmSync(directory, { recursive: true, force: true })); + const dbPath = path.join(directory, 'messages.db'); + const url = `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=original-resource`; + const remoteImage = { + type: 'image', + fileId: `${md5}.webp`, + imageUrl: url, + md5Hex: md5, + sha1Hex: sha1, + } satisfies MessageElement; + const historyMessage: FriendMessage | GroupMessage = { + selfUin: self, + senderUin: self, + senderNick: 'self', + time, + msgId: 456, + elements: [remoteImage, { type: 'text', text: 'server representation' }], + ...(isGroup + ? { + kind: 'group_message', + groupId: peer, + groupName: '', + senderCard: '', + senderRole: 'member', + msgSeq: 42, + } + : { + kind: 'friend_message', + peerUin: peer, + msgSeq: 123, + clientSeq: 123, + ntMsgSeq: 42, + sequenceAuthoritative: true, + }), + }; + const getGroupMessageBySeq = vi.fn(async (): Promise => + isGroup ? (historyMessage as GroupMessage) : null, + ); + const getC2cMessageBySeq = vi.fn(async (): Promise => + isGroup ? null : (historyMessage as FriendMessage), + ); + const getC2cLatestHistory = vi.fn(async (): Promise => + isGroup ? [] : [historyMessage as FriendMessage], + ); + const resolver = vi.fn(async (element: MessageElement) => `${element.imageUrl}&rkey=fresh`); + const uploads: EncodableMediaMsgInfo[] = []; + const protocolBridge = { + identity: { uin: String(self) }, + sendRawPacket: vi.fn(async () => ({ + success: true, + gotResponse: true, + errorCode: 0, + responseData: protobuf_encode>({ + body: { + upload: { + msgInfo: { + msgInfoBody: [ + { + index: { fileUuid: 'original-resource' }, + picture: { + domain: 'multimedia.nt.qq.com.cn', + urlPath: url.slice( + 'https://multimedia.nt.qq.com.cn'.length, + ), + }, + }, + ], + }, + }, + }, + }), + })), + } as unknown as BridgeContext; + + const send = async (_target: number, elements: MessageElement[]) => { + for (const element of elements) { + if (element.type === 'image') { + uploads.push( + protobuf_decode( + await uploadImageMsgInfo( + protocolBridge, + isGroup, + isGroup ? peer : 'u_peer', + element, + ), + ), + ); + } + } + + return { sequence: 42, clientSequence: 123, random: 456, timestamp: time }; + }; + + async function open() { + const messageStore = new MessageStore(dbPath); + await messageStore.initialization; + const mediaStore = new MediaStore(dbPath); + await mediaStore.initialization; + const indexer = new MediaIndexer(mediaStore); + + const close = async () => { + await mediaStore.close(); + await messageStore.close(); + }; + + cleanups.push(close); + const ref = { + selfId: self, + uin: String(self), + messageStore, + mediaStore, + bridge: { + identity: { nickname: 'self' }, + resolveUserUid: vi.fn(async () => 'u_peer'), + apis: { + message: { + sendPrivate: send, + sendGroup: send, + getGroupMessageBySeq, + getC2cMessageBySeq, + getC2cLatestHistory, + }, + }, + }, + converterCtx: { + selfId: self, + imageUrlResolver: resolver, + mediaUrlResolver: null, + messageIdResolver: createMessageIdResolver(messageStore), + mediaSegmentSink: (...args: Parameters) => + indexer.remember(...args), + }, + cacheMessageMeta: messageStore.storeMeta.bind(messageStore), + } as unknown as OneBotInstanceContext; + return { + ref, + handler: new ApiHandler(buildApiContext(ref)), + close: async () => { + cleanups.splice(cleanups.indexOf(close), 1); + await close(); + }, + }; + } + + return { + open, + resolver, + url, + uploads, + historyMessage, + getGroupMessageBySeq, + getC2cMessageBySeq, + getC2cLatestHistory, + }; +} + +async function saveIncomplete( + ref: OneBotInstanceContext, + isGroup: boolean, + authoritative = true, + snapshot = true, + elements?: MessageElement[], +) { + const eventName = isGroup ? GROUP_MESSAGE_EVENT : privateMessageEventName(true, authoritative); + await ref.messageStore.storeMeta(legacyId, { + isGroup, + targetId: peer, + sequence: authoritative ? 42 : 123, + sequenceAuthoritative: authoritative, + eventName, + clientSequence: 123, + ...(isGroup ? {} : { privateDirection: 'outgoing' as const }), + random: 456, + timestamp: time, + }); + const message = await elementsToOneBotSegments( + { ...ref.converterCtx, imageUrlResolver: null }, + elements ?? [ + { + type: 'image', + url: png, + fileName: `${md5}.webp`, + md5Hex: md5, + sha1Hex: sha1, + picFormat: 1002, + }, + { type: 'text', text: 'original caption' }, + ], + isGroup, + peer, + ); + const event: JsonObject = { + message_id: legacyId, + self_id: self, + post_type: 'message_sent', + message_type: isGroup ? 'group' : 'private', + sub_type: isGroup ? 'normal' : 'friend', + user_id: self, + target_id: peer, + ...(isGroup ? { group_id: peer } : {}), + time, + message_seq: isGroup ? 42 : 123, + message: snapshot ? message : structuredClone(message), + raw_message: segmentsToRawMessage(message), + }; + await ref.messageStore.storeEvent( + legacyId, + isGroup, + peer, + authoritative ? 42 : 123, + eventName, + event, + { sequenceAuthoritative: authoritative }, + ); + return ref.messageStore.findEvent(legacyId); +} + +describe('image history (#659)', () => { + it('accepts an omitted outgoing peer only within the explicitly requested history conversation', async () => { + const f = fixture(false); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, false); + delete (f.historyMessage as FriendMessage).peerUin; + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { url: `${f.url}&rkey=fresh` } }, {}] }, + }); + expect(f.getC2cMessageBySeq).toHaveBeenCalledExactlyOnceWith('u_peer', 42, self); + }); + + it('uses the explicit recipient on older self-keyed private records without rewriting their identity', async () => { + const f = fixture(false); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, false); + const meta = { ...(await ref.messageStore.findMeta(legacyId))!, targetId: self }; + await ref.messageStore.storeMeta(legacyId, meta); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message_id: legacyId, message: [{ data: { url: `${f.url}&rkey=fresh` } }, {}] }, + }); + expect(ref.bridge.resolveUserUid).toHaveBeenCalledExactlyOnceWith(peer); + expect(await ref.messageStore.findMeta(legacyId)).toEqual(meta); + }); + + it.each([false, true])( + 'never restores a private message recalled while its history request is in flight (failed=%s)', + async (failed) => { + const f = fixture(false); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, false); + f.getC2cMessageBySeq.mockImplementationOnce(async () => { + expect(await ref.messageStore.recordPrivateRecall(peer, 123, true, time + 1)).toBe( + legacyId, + ); + + if (failed) { + throw new Error('history request failed after recall'); + } + + return f.historyMessage as FriendMessage; + }); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'failed', + }); + expect(await ref.messageStore.findEvent(legacyId)).toBeNull(); + expect(await ref.messageStore.findMeta(legacyId)).toBeNull(); + }, + ); + + it('keeps a newer stored body when history completes after an update', async () => { + const f = fixture(false); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, false); + f.getC2cMessageBySeq.mockImplementationOnce(async () => { + await saveIncomplete(ref, false, true, true, [ + { + type: 'image', + imageUrl: 'https://new.example/image', + md5Hex: md5, + sha1Hex: sha1, + }, + { type: 'text', text: 'newer caption' }, + ]); + return f.historyMessage as FriendMessage; + }); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { + message_id: legacyId, + message: [ + { data: { url: 'https://new.example/image&rkey=fresh' } }, + { data: { text: 'newer caption' } }, + ], + }, + }); + expect((await ref.messageStore.findMedia(legacyId))![0]!.imageUrl).toBe( + 'https://new.example/image', + ); + }); + + it('refuses an ambiguous sender-local sequence instead of choosing the first history item', async () => { + const f = fixture(false); + const { ref, handler } = await f.open(); + const before = await saveIncomplete(ref, false, false); + f.getC2cLatestHistory.mockResolvedValue([ + f.historyMessage as FriendMessage, + { ...(f.historyMessage as FriendMessage), ntMsgSeq: 43 }, + ]); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { url: '' } }, {}] }, + }); + expect(await ref.messageStore.findEvent(legacyId)).toEqual(before); + }); + + it.each([false, true])( + 'repairs only the missing image in a mixed message and preserves ordered metadata (group=%s)', + async (isGroup) => { + const f = fixture(isGroup); + const { ref, handler } = await f.open(); + const first: MessageElement = { type: 'image', md5Hex: md5, sha1Hex: sha1, url: png }; + const second: MessageElement = { + type: 'image', + md5Hex: 'cc'.repeat(16), + sha1Hex: 'dd'.repeat(20), + imageUrl: 'https://existing.example/second', + fileName: 'second.webp', + }; + await saveIncomplete(ref, isGroup, true, true, [ + first, + { type: 'text', text: 'between images' }, + second, + ]); + const before = await ref.messageStore.findMedia(legacyId); + f.historyMessage.elements = [ + { ...first, imageUrl: f.url }, + { ...second, imageUrl: 'https://history.example/second' }, + ]; + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { + message_id: legacyId, + message: [ + { data: { url: `${f.url}&rkey=fresh` } }, + { data: { text: 'between images' } }, + { data: { url: 'https://existing.example/second&rkey=fresh' } }, + ], + }, + }); + expect((await ref.messageStore.findMedia(legacyId))![1]).toEqual(before![1]); + }, + ); + + it('rejects reordered images atomically without saving a partial repair', async () => { + const f = fixture(true); + const { ref, handler } = await f.open(); + const first: MessageElement = { type: 'image', md5Hex: md5, sha1Hex: sha1, url: png }; + const second: MessageElement = { + type: 'image', + md5Hex: 'cc'.repeat(16), + sha1Hex: 'dd'.repeat(20), + url: png, + }; + const before = await saveIncomplete(ref, true, true, true, [first, second]); + f.historyMessage.elements = [ + { ...second, imageUrl: f.url }, + { ...first, imageUrl: f.url }, + ]; + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { url: '' } }, { data: { url: '' } }] }, + }); + expect(await ref.messageStore.findEvent(legacyId)).toEqual(before); + }); + + it('repairs a legacy image without inventing snapshots for other media', async () => { + const f = fixture(true); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, true, true, false, [ + { type: 'image', url: png, md5Hex: md5, sha1Hex: sha1 }, + { type: 'record', fileName: 'voice.amr', url: 'https://original.example/voice' }, + ]); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { + message: [ + { type: 'image', data: { url: `${f.url}&rkey=fresh` } }, + { type: 'record', data: { url: 'https://original.example/voice' } }, + ], + }, + }); + expect(await ref.messageStore.findMedia(legacyId)).toBeNull(); + expect(await ref.messageStore.findEvent(legacyId)).toMatchObject({ + message: [{ data: { url: f.url } }, {}], + }); + }); + + it('does not repair a message with an unverified group identity', async () => { + const f = fixture(true); + const { ref, handler } = await f.open(); + const before = await saveIncomplete(ref, true); + (f.historyMessage as GroupMessage).groupId = 30003; + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { url: '' } }, {}] }, + }); + expect(await ref.messageStore.findEvent(legacyId)).toEqual(before); + }); + + it.each(['conversation', 'fingerprint'] as const)( + 'does not use a conflicting legacy filename alias (%s)', + async (conflict) => { + const f = fixture(false); + const { ref, handler } = await f.open(); + const before = await saveIncomplete(ref, false, true, false); + f.getC2cMessageBySeq.mockResolvedValue(null); + await ref.mediaStore.rememberImage({ + file: `${md5}.png`, + fileName: `${md5}.png`, + fileSize: 1, + imageUrl: 'https://wrong.example/image', + url: '', + isGroup: conflict === 'conversation', + sessionId: conflict === 'conversation' ? 30003 : peer, + md5Hex: 'cc'.repeat(16), + subType: 0, + summary: '', + }); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { url: '' } }, {}] }, + }); + expect(f.resolver).not.toHaveBeenCalled(); + expect(await ref.messageStore.findEvent(legacyId)).toEqual(before); + }, + ); + + it('repairs incoming private media under its existing message identity', async () => { + const f = fixture(false); + const { ref, handler } = await f.open(); + await saveIncomplete(ref, false); + const source = f.historyMessage as FriendMessage; + source.senderUin = peer; + delete source.peerUin; + const meta = (await ref.messageStore.findMeta(legacyId))!; + await ref.messageStore.storeMeta(legacyId, { ...meta, privateDirection: 'incoming' }); + const message = await elementsToOneBotSegments( + { ...ref.converterCtx, imageUrlResolver: null }, + [{ type: 'image', md5Hex: md5, sha1Hex: sha1 }], + false, + peer, + ); + const event = { + ...(await ref.messageStore.findEvent(legacyId))!, + user_id: peer, + post_type: 'message', + message, + raw_message: segmentsToRawMessage(message), + }; + await ref.messageStore.storeEvent(legacyId, false, peer, 42, meta.eventName, event); + source.elements = [source.elements[0]!]; + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { + message_id: legacyId, + user_id: peer, + message: [{ data: { url: `${f.url}&rkey=fresh` } }], + }, + }); + expect((await ref.messageStore.findMeta(legacyId))!.privateDirection).toBe('incoming'); + }); + + it('reports an unverifiable image instead of guessing a resource from an arbitrary filename', async () => { + const f = fixture(true); + const { ref, handler } = await f.open(); + const before = await saveIncomplete(ref, true, true, false, [ + { type: 'image', fileId: 'photo.webp' }, + ]); + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { message: [{ data: { file: 'photo.webp', url: '' } }] }, + }); + expect(await ref.messageStore.findEvent(legacyId)).toEqual(before); + }); + + it.each([false, true])( + 'retains a newly uploaded resource through send, cache churn and restart (group=%s)', + async (isGroup) => { + const f = fixture(isGroup); + let account = await f.open(); + const sent = await (isGroup ? sendGroupMessage : sendPrivateMessage)( + account.ref, + peer, + [{ type: 'image', data: { file: png } }], + false, + ); + expect(f.uploads[0]?.msgInfoBody?.[0]?.index?.fileUuid).toBe('original-resource'); + const media = (await account.ref.messageStore.findMedia(sent.messageId))![0]!; + expect(media.imageUrl).toBe(f.url); + const saved = await account.ref.messageStore.findEvent(sent.messageId); + expect(saved).toMatchObject({ + message: [{ type: 'image', data: { file: media.fileName, url: f.url } }], + }); + expect(JSON.stringify(media)).not.toContain('base64://'); + expect(JSON.stringify(saved)).not.toContain('base64://'); + expect(f.resolver).not.toHaveBeenCalled(); + await account.close(); + account = await f.open(); + // Reused filenames and newer resources cannot replace a message's own reference. + await account.ref.mediaStore.rememberImage({ + file: media.fileName!, + fileName: media.fileName!, + fileSize: 1, + imageUrl: 'https://wrong.example/image', + url: '', + isGroup: !isGroup, + sessionId: 30003, + subType: 0, + summary: '', + }); + expect( + await account.handler.handle('get_msg', { message_id: sent.messageId }), + ).toMatchObject({ + status: 'ok', + data: { + message_id: sent.messageId, + real_id: sent.messageId, + message: [{ type: 'image', data: { url: `${f.url}&rkey=fresh` } }], + }, + }); + expect(f.resolver).toHaveBeenCalledWith( + expect.objectContaining({ imageUrl: f.url }), + isGroup, + ); + expect(f.getC2cMessageBySeq).not.toHaveBeenCalled(); + expect(f.getC2cLatestHistory).not.toHaveBeenCalled(); + expect(f.getGroupMessageBySeq).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { isGroup: false, authoritative: true, snapshot: true }, + { isGroup: true, authoritative: true, snapshot: true }, + { isGroup: false, authoritative: false, snapshot: true }, + { isGroup: false, authoritative: true, snapshot: false }, + { isGroup: true, authoritative: true, snapshot: false }, + ])( + 'repairs an incomplete stored image without replacing its identity ($isGroup/$authoritative/$snapshot)', + async ({ isGroup, authoritative, snapshot }) => { + const f = fixture(isGroup); + let account = await f.open(); + await saveIncomplete(account.ref, isGroup, authoritative, snapshot); + const meta = await account.ref.messageStore.findMeta(legacyId); + await account.close(); + account = await f.open(); + + if (!isGroup) { + const quoted = await backfillReplyTarget(account.ref, { + kind: 'friend_message', + selfUin: self, + senderUin: peer, + peerUin: peer, + senderNick: 'peer', + time: time + 2, + msgSeq: 900, + clientSeq: 900, + ntMsgSeq: 9900, + msgId: 55, + elements: [ + { type: 'reply', replySeq: 777, replySenderUin: self, replyTime: time }, + ], + }); + expect(quoted.elements[0]).toMatchObject({ replyMessageId: legacyId }); + } + + const expected = { + status: 'ok', + data: { + message_id: legacyId, + real_id: legacyId, + message: [ + { type: 'image', data: { file: `${md5}.png`, url: `${f.url}&rkey=fresh` } }, + { type: 'text', data: { text: 'original caption' } }, + ], + }, + }; + expect(await account.handler.handle('get_msg', { message_id: legacyId })).toMatchObject( + expected, + ); + expect(await account.ref.messageStore.findMeta(legacyId)).toEqual(meta); + expect(await account.ref.messageStore.findEvent(legacyId)).toMatchObject({ + message_id: legacyId, + message: [{ data: { url: f.url } }, { data: { text: 'original caption' } }], + }); + expect((await account.ref.messageStore.findMedia(legacyId))![0]).toMatchObject({ + imageUrl: f.url, + md5Hex: md5, + }); + + if (isGroup) { + expect(f.getGroupMessageBySeq).toHaveBeenCalledExactlyOnceWith(peer, 42, self); + } else if (authoritative) { + expect(f.getC2cMessageBySeq).toHaveBeenCalledExactlyOnceWith('u_peer', 42, self); + } else { + expect(f.getC2cLatestHistory).toHaveBeenCalledExactlyOnceWith( + 'u_peer', + 20, + self, + time + 1, + ); + } + + await account.close(); + account = await f.open(); + f.getGroupMessageBySeq.mockClear(); + f.getC2cMessageBySeq.mockClear(); + f.getC2cLatestHistory.mockClear(); + expect(await account.handler.handle('get_msg', { message_id: legacyId })).toMatchObject( + expected, + ); + expect(f.getGroupMessageBySeq).not.toHaveBeenCalled(); + expect(f.getC2cMessageBySeq).not.toHaveBeenCalled(); + expect(f.getC2cLatestHistory).not.toHaveBeenCalled(); + }, + ); + + it.each(['sender', 'peer', 'sequence', 'time', 'fingerprint', 'missing', 'request'] as const)( + 'keeps the original record and reports an unverified repair: %s', + async (mismatch) => { + const f = fixture(false); + const { ref, handler } = await f.open(); + const original = await saveIncomplete(ref, false); + const media = await ref.messageStore.findMedia(legacyId); + const source = f.historyMessage as FriendMessage; + + if (mismatch === 'sender') { + source.senderUin = peer; + } + + if (mismatch === 'peer') { + source.peerUin = 30003; + } + + if (mismatch === 'sequence') { + source.ntMsgSeq = 43; + } + + if (mismatch === 'time') { + source.time += 60; + } + + if (mismatch === 'fingerprint') { + source.elements[0]!.md5Hex = 'cc'.repeat(16); + } + + if (mismatch === 'missing') { + f.getC2cMessageBySeq.mockResolvedValue(null); + } + + if (mismatch === 'request') { + f.getC2cMessageBySeq.mockRejectedValue(new Error('history unavailable')); + } + + const logs: LogEntry[] = []; + const unsubscribe = subscribeLogs((entry) => logs.push(entry)); + + try { + expect(await handler.handle('get_msg', { message_id: legacyId })).toMatchObject({ + status: 'ok', + data: { + message_id: legacyId, + message: [{ data: { url: '' } }, { data: { text: 'original caption' } }], + }, + }); + expect( + logs.some( + (entry) => + entry.level === 'warn' && + entry.message.includes('image history repair'), + ), + ).toBe(true); + } finally { + unsubscribe(); + } + + expect(await ref.messageStore.findEvent(legacyId)).toEqual(original); + expect(await ref.messageStore.findMedia(legacyId)).toEqual(media); + }, + ); +}); diff --git a/packages/onebot/tests/self-sent-media.test.ts b/packages/onebot/tests/self-sent-media.test.ts index 6f1eca51..979c0240 100644 --- a/packages/onebot/tests/self-sent-media.test.ts +++ b/packages/onebot/tests/self-sent-media.test.ts @@ -167,7 +167,12 @@ describe('self-sent remote media', () => { raw_message: `[CQ:image,file=failed.jpg,url=${originalUrl}][CQ:image,file=,url=https://fresh.example/fresh.jpg][CQ:image,file=missing.jpg,url=https://original.example/missing.jpg]`, }, }); - expect(resolver).toHaveBeenCalledTimes(2); + // URLs already on the message remain refreshable without a global alias. + expect(resolver).toHaveBeenCalledTimes(3); + expect(resolver).toHaveBeenLastCalledWith( + expect.objectContaining({ imageUrl: 'https://original.example/missing.jpg' }), + true, + ); expect(await ref.messageStore.findEvent(42)).toEqual(original); }, ); diff --git a/packages/protocol/src/highway/image-upload.ts b/packages/protocol/src/highway/image-upload.ts index 0241787e..a6ce7028 100644 --- a/packages/protocol/src/highway/image-upload.ts +++ b/packages/protocol/src/highway/image-upload.ts @@ -11,6 +11,7 @@ import { type MediaSubFileUpload, } from './pipeline'; import { computeHashes, detectImageFormat, loadBinarySource } from './utils'; +import { ntImageUrlFromMsgInfo } from '../msg-push/helpers'; const moduleLog = createLogger('Highway.Image'); @@ -208,8 +209,16 @@ export async function uploadImageMsgInfo( }); const encoded = finalizeMediaMsgInfo(upload, pic); + const imageUrl = ntImageUrlFromMsgInfo(upload.msgInfo?.msgInfoBody?.[0], isGroup); + + if (!imageUrl) { + throw new Error('image upload response is missing a download reference'); + } + // Retain successful upload metadata for the caller's self-sent history. Object.assign(element, { + imageUrl, + fileId: image.fileName, md5Hex: image.md5Hex, sha1Hex: image.sha1Hex, fileSize: image.fileSize, diff --git a/packages/protocol/src/msg-push/helpers.ts b/packages/protocol/src/msg-push/helpers.ts index 0dd9a562..01b9096b 100644 --- a/packages/protocol/src/msg-push/helpers.ts +++ b/packages/protocol/src/msg-push/helpers.ts @@ -3,6 +3,7 @@ import { hexPreview } from '@snowluma/common/hex'; import { inflateSync } from 'zlib'; import type { IdentityService } from '../identity-service'; import type { OperatorInfo } from '@snowluma/proto-defs/notify'; +import type { MsgInfoBody } from '@snowluma/proto-defs/element'; export const MAX_RICH_CARD_OUTPUT_BYTES = 4 * 1024 * 1024; export const MAX_RICH_CARD_MESSAGE_OUTPUT_BYTES = 8 * 1024 * 1024; @@ -51,6 +52,16 @@ export function ntImageUrlFromFileId(fileId: string, isGroup: boolean): string { return `https://multimedia.nt.qq.com.cn/download?appid=${appid}&fileid=${encodeURIComponent(fileId)}`; } +/** Upload receipts and received messages carry the same NT image reference. */ +export function ntImageUrlFromMsgInfo(body: MsgInfoBody | undefined, isGroup: boolean): string { + if (body?.picture?.urlPath) { + const { domain, urlPath, ext } = body.picture; + return `https://${domain ?? 'multimedia.nt.qq.com.cn'}${urlPath}${ext?.originalParameter ?? ''}`; + } + + return ntImageUrlFromFileId(body?.index?.fileUuid ?? '', isGroup); +} + export function decompressData( data: Uint8Array, maxOutputBytes = MAX_RICH_CARD_OUTPUT_BYTES, diff --git a/packages/protocol/src/msg-push/rich-body-decoder.ts b/packages/protocol/src/msg-push/rich-body-decoder.ts index aebbf3fe..605e8e99 100644 --- a/packages/protocol/src/msg-push/rich-body-decoder.ts +++ b/packages/protocol/src/msg-push/rich-body-decoder.ts @@ -33,7 +33,7 @@ import { makeImageUrl, MAX_RICH_CARD_MESSAGE_OUTPUT_BYTES, MAX_RICH_CARD_OUTPUT_BYTES, - ntImageUrlFromFileId, + ntImageUrlFromMsgInfo, } from './helpers'; import { readXmlMsgAttributes } from './xml-card'; @@ -1096,27 +1096,9 @@ function convertElements(elems: ElemDecoded[], isGroup: boolean): MessageElement if (bizType === 10 || bizType === 20) { // Image - let url = ''; - - if (body.picture) { - const domain = body.picture.domain ?? 'multimedia.nt.qq.com.cn'; - const path = body.picture.urlPath ?? ''; - - if (path) { - url = 'https://' + domain + path; - - if (body.picture.ext?.originalParameter) { - url += body.picture.ext.originalParameter; - } - } - } - + let url = ntImageUrlFromMsgInfo(body, isGroup); let fileId = fi.fileName || idx.fileUuid || ''; - if (!url && idx.fileUuid) { - url = ntImageUrlFromFileId(idx.fileUuid, isGroup); - } - const me: MessageElement = { type: 'image', fileId, diff --git a/packages/protocol/tests/highway/image-sticker.test.ts b/packages/protocol/tests/highway/image-sticker.test.ts index a504f923..7fbcc53a 100644 --- a/packages/protocol/tests/highway/image-sticker.test.ts +++ b/packages/protocol/tests/highway/image-sticker.test.ts @@ -64,7 +64,9 @@ describe('image sticker presentation (#468)', () => { width: 1, height: 1, picFormat: 1001, + imageUrl: `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=cached-image`, }); + expect(element.fileId).toBe(element.fileName); const sent = protobuf_decode(bytes); expect(sendRawPacket).toHaveBeenCalledTimes(1); expect(sent.msgInfoBody?.[0]?.index?.fileUuid).toBe('cached-image'); diff --git a/packages/protocol/tests/highway/image-upload-reference.test.ts b/packages/protocol/tests/highway/image-upload-reference.test.ts new file mode 100644 index 00000000..18167735 --- /dev/null +++ b/packages/protocol/tests/highway/image-upload-reference.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest'; +import { protobuf_decode, protobuf_encode } from '@snowluma/proton'; +import type { OidbBase } from '@snowluma/proto-defs/oidb'; +import type { + EncodableMediaMsgInfo, + HighwayMsgInfoBody, + NTV2UploadRichMediaResp, +} from '@snowluma/proto-defs/highway'; +import type { BridgeContext } from '../../src/bridge-context'; +import type { MessageElement } from '../../src/events'; +import { uploadImageMsgInfo } from '../../src/highway/image-upload'; + +const webp = 'base64://UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA'; + +function bridge(body: HighwayMsgInfoBody): BridgeContext { + return { + identity: { uin: '10001' }, + sendRawPacket: vi.fn(async () => ({ + success: true, + gotResponse: true, + errorCode: 0, + responseData: protobuf_encode>({ + body: { upload: { msgInfo: { msgInfoBody: [body] } } }, + }), + })), + } as unknown as BridgeContext; +} + +describe('uploaded image download references (#659)', () => { + it.each([false, true])( + 'retains the returned host, original parameters and WebP filename (group=%s)', + async (isGroup) => { + const picture = { + domain: 'image.example', + urlPath: '/download?fileid=server-resource', + ext: { originalParameter: '&spec=0', thumbParameter: '&spec=small' }, + }; + const element: MessageElement = { type: 'image', url: webp }; + const encoded = await uploadImageMsgInfo( + bridge({ index: { fileUuid: 'resource' }, picture }), + isGroup, + isGroup ? 20002 : 'u_peer', + element, + ); + expect(element).toMatchObject({ + imageUrl: 'https://image.example/download?fileid=server-resource&spec=0', + picFormat: 1002, + width: 1, + height: 1, + }); + expect(element.fileName).toBe(`${element.md5Hex}.webp`); + expect(element.fileId).toBe(element.fileName); + expect( + protobuf_decode(encoded).msgInfoBody?.[0]?.picture, + ).toMatchObject(picture); + }, + ); + + it.each([false, true])( + 'uses an encoded upload resource id when the server omits the URL (group=%s)', + async (isGroup) => { + const element: MessageElement = { type: 'image', url: webp }; + await uploadImageMsgInfo( + bridge({ index: { fileUuid: 'resource/+=' } }), + isGroup, + isGroup ? 20002 : 'u_peer', + element, + ); + expect(element.imageUrl).toBe( + `https://multimedia.nt.qq.com.cn/download?appid=${isGroup ? 1407 : 1406}&fileid=resource%2F%2B%3D`, + ); + }, + ); + + it('uses the standard image host when a wire response omits the domain', async () => { + const element: MessageElement = { type: 'image', url: webp }; + await uploadImageMsgInfo( + bridge({ picture: { urlPath: '/download?fileid=resource' } }), + false, + 'u_peer', + element, + ); + expect(element.imageUrl).toBe('https://multimedia.nt.qq.com.cn/download?fileid=resource'); + }); + + it('rejects an unusable upload receipt before publishing incomplete send metadata', async () => { + const element: MessageElement = { type: 'image', url: webp }; + await expect(uploadImageMsgInfo(bridge({}), false, 'u_peer', element)).rejects.toThrow( + 'missing a download reference', + ); + expect(element).toEqual({ type: 'image', url: webp }); + }); +});