fix(onebot): preserve images in message history

Refs #659
This commit is contained in:
DrSmoothl
2026-11-07 13:31:14 +08:00
parent 87f7bd8c54
commit f2ade5603f
15 changed files with 1284 additions and 58 deletions
@@ -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)),
}));
+14
View File
@@ -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.
+2 -1
View File
@@ -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),
+1 -1
View File
@@ -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 ?? '',
+288
View File
@@ -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<OneBotInstanceContext, 'selfId' | 'bridge' | 'messageStore'>;
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<JsonObject | null> {
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<HistoryMessage[]> {
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');
}
}
+22 -1
View File
@@ -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<MessageElement, { type: 'image' | 'record' | 'video' }>;
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 };
+32
View File
@@ -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<boolean> {
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<MessageMedia[] | null> {
return this.db.serial(async () => {
await this.initialization;
+64 -33
View File
@@ -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<OneBotInstanceContext, 'bridge' | 'messageStore' | 'mediaStore' | 'converterCtx'>,
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;
+735
View File
@@ -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<void>> = [];
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<GroupMessage | null> =>
isGroup ? (historyMessage as GroupMessage) : null,
);
const getC2cMessageBySeq = vi.fn(async (): Promise<FriendMessage | null> =>
isGroup ? null : (historyMessage as FriendMessage),
);
const getC2cLatestHistory = vi.fn(async (): Promise<FriendMessage[]> =>
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<OidbBase<NTV2UploadRichMediaResp>>({
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<EncodableMediaMsgInfo>(
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<MediaIndexer['remember']>) =>
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);
},
);
});
@@ -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);
},
);
@@ -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,
+11
View File
@@ -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,
@@ -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,
@@ -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<EncodableMediaMsgInfo>(bytes);
expect(sendRawPacket).toHaveBeenCalledTimes(1);
expect(sent.msgInfoBody?.[0]?.index?.fileUuid).toBe('cached-image');
@@ -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<OidbBase<NTV2UploadRichMediaResp>>({
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<EncodableMediaMsgInfo>(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 });
});
});