diff --git a/packages/platform-crossgram/src/index.ts b/packages/platform-crossgram/src/index.ts index 065c658b..1b8b5cd7 100644 --- a/packages/platform-crossgram/src/index.ts +++ b/packages/platform-crossgram/src/index.ts @@ -2592,7 +2592,7 @@ function formatError(error: unknown): string { function mapConversation(input: WireConversation): IMConversation { const selfPermissions = input.selfRole ? permissions(input.selfRole) : undefined - return { + return sanitizeDbStrings({ id: input.id, kind: input.kind, title: input.title, @@ -2607,7 +2607,7 @@ function mapConversation(input: WireConversation): IMConversation { @@ -2700,7 +2700,7 @@ function mapMessage( stickerProviderId = 'qqnt:stickers', projectMedia?: (media: IMMedia) => IMMedia, ): IMMessage { - return { + return sanitizeDbStrings({ id: input.id, sourceIds: input.sourceIds, conversationId: input.conversationId, @@ -2736,7 +2736,7 @@ function mapMessage( parts: mapParts(input, stickerProviderId, reactionCatalog, projectMedia), inlineKeyboard: mapInlineKeyboard(input), }, - } + }) } @@ -2782,7 +2782,7 @@ function mapParts( for (const part of input.parts) { if (part.type === 'text') { if (markdownText && !part.entities?.length && markdownText.includes(compactText(part.text))) continue - const normalized = normalizeTextPart(part, reactionCatalog) + const normalized = stripNulTextPart(normalizeTextPart(part, reactionCatalog)) const previous = parts.at(-1) if (previous?.type === 'text') { const offset = previous.text.length @@ -2795,7 +2795,7 @@ function mapParts( parts.push(normalized) } } else if (part.type === 'markdown') { - const normalized = parseQQMarkdown(part.content) + const normalized = stripNulTextPart(parseQQMarkdown(part.content)) if (normalized.text) parts.push(normalized) } else if (part.type === 'inline-keyboard') { continue @@ -2838,6 +2838,38 @@ function mapParts( return parts } +function stripNulTextPart( + part: Extract['content']['parts'][number], { type: 'text' }>, +): typeof part { + const text = sanitizeDbString(part.text) + if (text === part.text) return part + const mappedOffset = (offset: number) => sanitizeDbString(part.text.slice(0, offset)).length + const entities = part.entities?.map((entity) => ({ + ...entity, + offset: mappedOffset(entity.offset), + length: mappedOffset(entity.offset + entity.length) - mappedOffset(entity.offset), + })).filter((entity) => entity.length > 0) + return { ...part, text, entities } +} + +function sanitizeDbString(value: string): string { + return value.replace(/\0|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(value: T): T { + if (typeof value === 'string') return sanitizeDbString(value) as T + if (Array.isArray(value)) return value.map(sanitizeDbStrings) as T + if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return value + let changed = false + const output: Record = {} + for (const [key, item] of Object.entries(value)) { + const clean = sanitizeDbStrings(item) + output[key] = clean + if (clean !== item) changed = true + } + return changed ? output as T : value +} + function compactText(value: string): string { return value.replace(/\s+/g, '') } diff --git a/packages/platform-crossgram/src/message-order.e2e.test.ts b/packages/platform-crossgram/src/message-order.e2e.test.ts index 7a8ab1a9..a77ee5a2 100644 --- a/packages/platform-crossgram/src/message-order.e2e.test.ts +++ b/packages/platform-crossgram/src/message-order.e2e.test.ts @@ -677,7 +677,7 @@ describe('QQNT zero-peer quarantine E2E', () => { }) describe('QQNT durable event checkpoint E2E', () => { - it('consumes a replayed message edit and immediately delivers the following reaction update', async () => { + it('sanitizes a replayed message edit and delivers the following reaction update', async () => { const ctx = new Context() const fibers = [ ctx.plugin(Database), @@ -726,7 +726,8 @@ describe('QQNT durable event checkpoint E2E', () => { id: targetId, conversationId: conversation.id, senderId: checkpointSession.userId, timestamp: 1_787_215_084, outgoing: true, msgSeq: '463806', telegramMessageId: 463806, - parts: [{ type: 'text', text: 'after edit' }], + sender: { id: checkpointSession.userId, name: 'Name\uD800', alias: 'bad\0alias' }, + parts: [{ type: 'text', text: 'after\0 edit' }], }, }, })) @@ -773,6 +774,8 @@ describe('QQNT durable event checkpoint E2E', () => { const reactionDelivered = Promise.withResolvers() const unsubscribe = await platform.subscribe(checkpointSession, async (event) => { if (event.type === 'message-edit') { + expect(event.message.senderTitle).toBe('badalias') + expect(event.message.sender?.firstName).toBe('Name') await store.ingest(checkpointSession, event.conversation, event.message) } else if (event.type === 'message-reactions') { await store.setReactions(checkpointSession, event.conversation, event.target, event.context) diff --git a/packages/platform-crossgram/src/platform.test.ts b/packages/platform-crossgram/src/platform.test.ts index cd266b5b..8a0c60d7 100644 --- a/packages/platform-crossgram/src/platform.test.ts +++ b/packages/platform-crossgram/src/platform.test.ts @@ -1724,6 +1724,29 @@ describe('QQNTPlatform mapping', () => { }) }) + it('removes QQ text NUL bytes while preserving mention offsets', async () => { + const platform = new QQNTPlatform() + platform.client.getHistory = vi.fn(async () => ({ + messages: [{ + id: 'nul-message', conversationId: '2:group', senderId: 'alice', timestamp: 1, outgoing: false, + sender: { id: 'alice', name: 'A\uD800', alias: 'bad\0alias' }, + parts: [{ + type: 'text' as const, text: '@abc\0def', + entities: [{ type: 'mention' as const, offset: 0, length: 8, userId: 'alice' }], + }], + }], + })) + + const history = await platform.getHistory(session, { id: '2:group' }) + expect(history.messages[0].content.parts).toMatchObject([{ + type: 'text', text: '@abcdef', + entities: [{ type: 'mention', offset: 0, length: 7, userId: 'alice' }], + }]) + expect(history.messages[0]).toMatchObject({ + senderTitle: 'badalias', sender: { firstName: 'A', metadata: { qqName: 'A' } }, + }) + }) + it('publishes the exact size and version of a relayed QQ face sticker', async () => { const platform = new QQNTPlatform() platform.client.getReactionCatalog = vi.fn(async () => ({ available: [], reactions: [], maxSelected: 20 }))