Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 38 additions & 6 deletions packages/platform-crossgram/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2592,7 +2592,7 @@ function formatError(error: unknown): string {

function mapConversation(input: WireConversation): IMConversation<QQMediaLocator> {
const selfPermissions = input.selfRole ? permissions(input.selfRole) : undefined
return {
return sanitizeDbStrings({
id: input.id,
kind: input.kind,
title: input.title,
Expand All @@ -2607,7 +2607,7 @@ function mapConversation(input: WireConversation): IMConversation<QQMediaLocator
...(input.participantCount === undefined ? {} : { participantsCount: input.participantCount }),
...(input.selfRole ? { qqSelfRole: input.selfRole } : {}),
},
}
})
}

function mapRequest(input: WireRequest): IMRequest<QQMediaLocator> {
Expand Down Expand Up @@ -2700,7 +2700,7 @@ function mapMessage(
stickerProviderId = 'qqnt:stickers',
projectMedia?: (media: IMMedia<QQMediaLocator>) => IMMedia<QQMediaLocator>,
): IMMessage<QQMediaLocator> {
return {
return sanitizeDbStrings({
id: input.id,
sourceIds: input.sourceIds,
conversationId: input.conversationId,
Expand Down Expand Up @@ -2736,7 +2736,7 @@ function mapMessage(
parts: mapParts(input, stickerProviderId, reactionCatalog, projectMedia),
inlineKeyboard: mapInlineKeyboard(input),
},
}
})
}


Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -2838,6 +2838,38 @@ function mapParts(
return parts
}

function stripNulTextPart(
part: Extract<IMMessage<QQMediaLocator>['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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '')
}

function sanitizeDbStrings<T>(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<string, unknown> = {}
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, '')
}
Expand Down
7 changes: 5 additions & 2 deletions packages/platform-crossgram/src/message-order.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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' }],
},
},
}))
Expand Down Expand Up @@ -773,6 +774,8 @@ describe('QQNT durable event checkpoint E2E', () => {
const reactionDelivered = Promise.withResolvers<void>()
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)
Expand Down
23 changes: 23 additions & 0 deletions packages/platform-crossgram/src/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
Loading