From 49e190905636071dd7a209abb07c604acc72c342 Mon Sep 17 00:00:00 2001 From: leemour Date: Fri, 30 Oct 2026 11:53:29 +0100 Subject: [PATCH 1/2] feat(domain): map MAX contact cards to Attachment.contact Measured on the personal-account protocol (pnpm probe:attachments, 2026-10-30): CONTACT carries contactId, firstName, lastName, name and photoUrl. There is no phone and no vCard, so contact has name, first_name, last_name and user_id only. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ src/domain/map.test.ts | 21 +++++++++++++++++++++ src/domain/map.ts | 19 ++++++++++++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5711568d..34bea59e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ## Не выпущено +### Добавлено + +- **Карточка контакта в сообщении (`CONTACT`) читается как контакт: у вложения есть `contact` с именем, `first_name`, `last_name` и `user_id`.** + Раньше такое вложение приходило только с видом `contact`, без имени. Что учесть: MAX не присылает телефон в карточке, + поэтому поля `phone` нет. + ### Исправлено - **Поиск по названию чата и по имени контакта не зависит от регистра в любом алфавите (cli-core 0.484.0).** Раньше diff --git a/src/domain/map.test.ts b/src/domain/map.test.ts index e7bbfffd..c2a57240 100644 --- a/src/domain/map.test.ts +++ b/src/domain/map.test.ts @@ -143,6 +143,27 @@ describe("toMessage", () => { ]) }) + it("maps a shared contact card to Attachment.contact, without its avatar link or a phone MAX does not send", () => { + const card = { + _type: "CONTACT", + contactId: 40000001, + firstName: "Ivan", + lastName: "Testov", + name: "Ivan Testov", + photoUrl: "https://example.test/a.webp", + } + expect(toMessage({ ...messageWire, attaches: [card] }, "7268926").attachments).toEqual([ + { + kind: "contact", + name: "Ivan Testov", + contact: { name: "Ivan Testov", first_name: "Ivan", last_name: "Testov", user_id: "40000001" }, + }, + ]) + expect( + toMessage({ ...messageWire, attaches: [{ _type: "CONTACT", firstName: "Ivan" }] }, "7268926").attachments, + ).toEqual([{ kind: "contact", contact: { name: "Ivan", first_name: "Ivan" } }]) + }) + it("gives a quoted poll the chat and message that carry it", () => { const poll = { _type: "POLL", pollId: 900, title: "Lunch?", answers: [{ answerId: 1, text: "Yes" }] } const quoted = { id: 9, sender: 3260455, time: 1789776000000, attaches: [poll] } diff --git a/src/domain/map.ts b/src/domain/map.ts index 13a5c29e..105daef5 100644 --- a/src/domain/map.ts +++ b/src/domain/map.ts @@ -395,8 +395,23 @@ export const toReactions = (raw: Payload): Reactions => { * `pnpm probe:attachments` measured on 2026-09-22: `baseUrl` on a photo, `url` on a share. A file * and a video have no link, only `fileId` and `videoId` (measured 2026-09-23). A service message is * `CONTROL` with `event`, and `userIds` for `new`/`add` but a single `userId` for `remove` - * (`pnpm probe:control`, 2026-09-27). MAX's `audio` is a recorded voice message; music goes as a file. + * (`pnpm probe:control`, 2026-09-27). A shared contact is `CONTACT` with `contactId`, `firstName`, `lastName`, + * `name` and `photoUrl`, and no phone number (`pnpm probe:attachments`, 2026-10-30). MAX's `audio` is a recorded voice message; music goes as a file. */ +const toSharedContact = (entry: Payload): Attachment["contact"] => { + const firstName = text(entry.firstName) + const lastName = text(entry.lastName) + const name = text(entry.name) ?? ([firstName, lastName].filter(Boolean).join(" ") || null) + const userId = asId(entry.contactId) + if (!name) return undefined + return { + name, + ...(firstName ? { first_name: firstName } : {}), + ...(lastName ? { last_name: lastName } : {}), + ...(userId ? { user_id: userId } : {}), + } +} + const attachments = (value: unknown, chatId: Id, messageId: Id): Attachment[] => { if (!Array.isArray(value)) return [] return value @@ -420,6 +435,7 @@ const attachments = (value: unknown, chatId: Id, messageId: Id): Attachment[] => const userIds = people?.map(asId).filter((id): id is Id => id !== undefined) const poll = entry._type === "POLL" ? toPoll(entry, chatId, messageId) : undefined const buttons = entry._type === "INLINE_KEYBOARD" ? toButtons(entry) : undefined + const contact = entry._type === "CONTACT" ? toSharedContact(entry) : undefined const kind = typeof entry._type === "string" ? entry._type.toLowerCase() : "unknown" const ref: ProviderMetadata = { ...(fileId ? { fileId } : {}), @@ -438,6 +454,7 @@ const attachments = (value: unknown, chatId: Id, messageId: Id): Attachment[] => ...(Object.keys(ref).length > 0 ? { providerRef: ref } : {}), ...(poll ? { poll } : {}), ...(buttons ? { buttons } : {}), + ...(contact ? { contact } : {}), } }) } From e2b4dc8592fe6a40859867aba64ae2bd943790aa Mon Sep 17 00:00:00 2001 From: leemour Date: Fri, 30 Oct 2026 11:57:14 +0100 Subject: [PATCH 2/2] =?UTF-8?q?docs(changelog):=20use=20the=20=D0=A7=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20heading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bea59e..7099efb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ## Не выпущено -### Добавлено +### Что нового - **Карточка контакта в сообщении (`CONTACT`) читается как контакт: у вложения есть `contact` с именем, `first_name`, `last_name` и `user_id`.** Раньше такое вложение приходило только с видом `contact`, без имени. Что учесть: MAX не присылает телефон в карточке,