@@ -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)),
|
||||
}));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user