Custom emoji in markdown rich messages (send + round-trip)

Parse custom emoji into RichText.textCustomEmoji when sending markdown
rich messages, and round-trip them through edit, copy, and paste using a
shared tg://emoji?id=<fileId> markdown-link marker.

- Send: rewrite each customEmoji input attribute into a
  [<alt>](tg://emoji?id=<fileId>) marker before the CommonMark parse, then
  intercept the marker URL afterward to emit .textCustomEmoji. Only rich
  messages are affected; a custom emoji alone stays on the entity path.
- Reverse: InstantPageToMarkdown (whole-message copy + edit reconstruction)
  and InstantPageMultiTextAdapter (selection copy) emit the marker;
  edit-load and chat paste reattach it as a live customEmoji attribute.
- Marker helpers shared in TextFormat/CustomEmojiMarkdownMarker.swift.
- Rich sends now pass inlineStickers so recipients can fetch the files.

Follow-up to verify at runtime: recipient rendering goes out with
Api.InputRichMessage.documents: nil; if recipients see only the fallback
glyph, populate documents: in apiInputRichMessage().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-31 17:51:37 +02:00
parent 6408175725
commit 3ed8c926a8
8 changed files with 164 additions and 13 deletions

View file

@ -0,0 +1,84 @@
import Foundation
/// The private markdown-link URL that carries a custom emoji's file id between
/// the send path and the rich-message renderer. Shared by the forward
/// (compose/send) path and the reverse (copy/edit) converters so the encode and
/// decode cannot drift. Format: a markdown link `[<alt>](tg://emoji?id=<fileId>)`.
public func customEmojiMarkdownURL(fileId: Int64) -> String {
return "tg://emoji?id=\(fileId)"
}
/// Backslash-escapes only the characters that would break a marker link's
/// display text (the `alt`): backslash, the link-text brackets, and newline.
/// Minimal by design the forward CommonMark parser unescapes these, so the
/// alt round-trips. Shared by every site that emits a `[<alt>](tg://emoji?id=)`
/// marker so the escaping cannot drift between encoders.
public func escapeCustomEmojiMarkdownAlt(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for character in string {
switch character {
case "\\", "[", "]", "\n":
result.append("\\")
result.append(character)
default:
result.append(character)
}
}
return result
}
/// Parses the file id out of a `tg://emoji?id=<fileId>` marker URL.
/// Returns nil for any other URL (ordinary links flow through unchanged).
public func parseCustomEmojiFileId(fromMarkdownURL url: String) -> Int64? {
let prefix = "tg://emoji?id="
guard url.hasPrefix(prefix) else {
return nil
}
return Int64(url.dropFirst(prefix.count))
}
/// Regex matching an emitted marker link: `[<alt>](tg://emoji?id=<digits>)`.
/// `alt` is captured as group 1 (any run of non-`]` chars), the file id as group 2.
private let customEmojiMarkerRegex = try? NSRegularExpression(
pattern: "\\[([^\\]]*)\\]\\(tg://emoji\\?id=(-?\\d+)\\)",
options: []
)
/// Reverse of the forward normalization: takes reconstructed markdown source
/// (e.g. from `markdownStringFromInstantPage`) and returns an attributed string
/// where each `tg://emoji?id=` marker link has been turned back into a live
/// `ChatTextInputAttributes.customEmoji` run (the alt text carrying a
/// `ChatTextInputTextCustomEmojiAttribute`). Everything else stays verbatim
/// markdown text. Used to populate the edit compose field so it shows the
/// animated emoji; on re-save the forward path reads the attribute's fileId back.
///
/// `file` is left nil the renderer resolves the emoji lazily from `fileId`,
/// and the send path only needs the fileId. Known limitation: an alt containing
/// a literal `]` is not matched (emoji alts do not contain brackets).
public func chatInputTextWithReattachedCustomEmoji(_ markdown: String) -> NSAttributedString {
let result = NSMutableAttributedString(string: markdown)
guard let regex = customEmojiMarkerRegex else {
return result
}
let matches = regex.matches(in: markdown, options: [], range: NSRange(markdown.startIndex..., in: markdown))
// Replace from last match to first so the earlier NSRanges stay valid.
for match in matches.reversed() {
guard match.numberOfRanges == 3 else {
continue
}
guard let altRange = Range(match.range(at: 1), in: markdown),
let idRange = Range(match.range(at: 2), in: markdown),
let fileId = Int64(markdown[idRange]) else {
continue
}
let alt = String(markdown[altRange])
// The attribute must ride on at least one character; use a space if the
// alt was emitted empty (selection-copy with no alt available).
let displayText = alt.isEmpty ? " " : alt
let attribute = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil)
let replacement = NSAttributedString(string: displayText, attributes: [ChatTextInputAttributes.customEmoji: attribute])
result.replaceCharacters(in: match.range, with: replacement)
}
return result
}