refactor: improved typings for MessageEntity

This commit is contained in:
alina 🌸 2023-10-06 04:53:19 +03:00
parent 5600f292f7
commit 75021648eb
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
14 changed files with 184 additions and 180 deletions

View file

@ -1,5 +1,3 @@
import { isPresent } from '@mtcute/core/utils'
import { TelegramClient } from '../../client'
import { InputPeerLike, MessageEntity } from '../../types'
@ -28,5 +26,5 @@ export async function translateMessage(
toLang: toLanguage,
})
return [res.result[0].text, res.result[0].entities.map((it) => MessageEntity._parse(it)).filter(isPresent)]
return [res.result[0].text, res.result[0].entities.map((it) => new MessageEntity(it, res.result[0].text))]
}

View file

@ -1,5 +1,4 @@
import { tl } from '@mtcute/core'
import { isPresent } from '@mtcute/core/utils'
import { makeInspectable } from '../../utils'
import { MessageEntity } from '../messages'
@ -37,7 +36,7 @@ export class TermsOfService {
* Terms of Service entities text
*/
get entities(): ReadonlyArray<MessageEntity> {
return (this._entities ??= this.tos.entities.map((it) => MessageEntity._parse(it)).filter(isPresent))
return (this._entities ??= this.tos.entities.map((it) => new MessageEntity(it, this.tos.text)))
}
}

View file

@ -152,8 +152,7 @@ export class Poll {
if (this.results.solutionEntities?.length) {
for (const ent of this.results.solutionEntities) {
const parsed = MessageEntity._parse(ent)
if (parsed) this._entities.push(parsed)
this._entities.push(new MessageEntity(ent, this.results.solution))
}
}
}
@ -168,9 +167,9 @@ export class Poll {
* @param parseMode Parse mode to use (`null` for default)
*/
unparseSolution(parseMode?: string | null): string | null {
if (!this.solution) return null
if (!this.results?.solutionEntities) return null
return this.client.getParseMode(parseMode).unparse(this.solution, this.solutionEntities!)
return this.client.getParseMode(parseMode).unparse(this.results.solution!, this.results.solutionEntities)
}
/**

View file

@ -48,8 +48,7 @@ export class DraftMessage {
if (this.raw.entities?.length) {
for (const ent of this.raw.entities) {
const parsed = MessageEntity._parse(ent)
if (parsed) this._entities.push(parsed)
this._entities.push(new MessageEntity(ent, this.raw.message))
}
}
}

View file

@ -2,34 +2,13 @@ import { tl } from '@mtcute/core'
import { makeInspectable } from '../../utils'
const entityToType: Partial<Record<tl.TypeMessageEntity['_'], MessageEntityType>> = {
messageEntityBlockquote: 'blockquote',
messageEntityBold: 'bold',
messageEntityBotCommand: 'bot_command',
messageEntityCashtag: 'cashtag',
messageEntityCode: 'code',
messageEntityEmail: 'email',
messageEntityHashtag: 'hashtag',
messageEntityItalic: 'italic',
messageEntityMention: 'mention',
messageEntityMentionName: 'text_mention',
messageEntityPhone: 'phone_number',
messageEntityPre: 'pre',
messageEntityStrike: 'strikethrough',
messageEntitySpoiler: 'spoiler',
messageEntityTextUrl: 'text_link',
messageEntityUnderline: 'underline',
messageEntityUrl: 'url',
messageEntityCustomEmoji: 'emoji',
}
/**
* Type of the entity. Can be:
* Params of the entity. `.kind` can be:
* - 'mention': `@username`.
* - 'hashtag': `#hashtag`.
* - 'cashtag': `$USD`.
* - 'bot_command': `/start`.
* - 'url': `https://example.com` (see {@link MessageEntity.url}).
* - 'url': `https://example.com`
* - 'email': `example@example.com`.
* - 'phone_number': `+42000`.
* - 'bold': **bold text**.
@ -37,96 +16,145 @@ const entityToType: Partial<Record<tl.TypeMessageEntity['_'], MessageEntityType>
* - 'underline': <u>underlined</u> text.
* - 'strikethrough': <s>strikethrough</s> text.
* - 'code': `monospaced` string.
* - 'pre': `monospaced` block (see {@link MessageEntity.language}).
* - 'pre': `monospaced` block. `.language` contains the language of the block (if available).
* - 'text_link': for clickable text URLs.
* - 'text_mention': for users without usernames (see {@link MessageEntity.user} below).
* - 'text_mention': for user mention by name. `.userId` contains the ID of the mentioned user.
* - 'blockquote': A blockquote
* - 'emoji': A custom emoji
* - 'emoji': A custom emoji. `.emojiId` contains the emoji ID.
*/
export type MessageEntityType =
| 'mention'
| 'hashtag'
| 'cashtag'
| 'bot_command'
| 'url'
| 'email'
| 'phone_number'
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'spoiler'
| 'code'
| 'pre'
| 'text_link'
| 'text_mention'
| 'blockquote'
| 'emoji'
export type MessageEntityParams =
| {
kind:
| 'mention'
| 'hashtag'
| 'cashtag'
| 'bot_command'
| 'url'
| 'email'
| 'phone_number'
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'spoiler'
| 'code'
| 'blockquote'
| 'bank_card'
| 'unknown'
}
| { kind: 'pre'; language?: string }
| { kind: 'text_link'; url: string }
| { kind: 'text_mention'; userId: number }
| { kind: 'emoji'; emojiId: tl.Long }
/**
* Kind of the entity. For more information, see {@link MessageEntityParams}
*/
export type MessageEntityKind = MessageEntityParams['kind']
/**
* One special entity in a text message (like mention, hashtag, URL, etc.)
*/
export class MessageEntity<Type extends MessageEntityType = MessageEntityType> {
/**
* Underlying raw TL object
*/
readonly raw!: tl.TypeMessageEntity
/**
* Type of the entity. See {@link MessageEntity.Type} for a list of possible values
*/
readonly type!: MessageEntityType
export class MessageEntity {
constructor(readonly raw: tl.TypeMessageEntity, readonly _text?: string) {}
/**
* Offset in UTF-16 code units to the start of the entity.
*
* Since JS strings are UTF-16, you can use this as-is
*/
readonly offset!: number
get offset() {
return this.raw.offset
}
/**
* Length of the entity in UTF-16 code units.
*
* Since JS strings are UTF-16, you can use this as-is
*/
readonly length!: number
get length() {
return this.raw.length
}
/**
* When `type=text_link`, contains the URL that would be opened if user taps on the text
* Kind of the entity (see {@link MessageEntityParams})
*/
readonly url?: Type extends 'text_link' ? string : never
get kind(): MessageEntityKind {
return this.params.kind
}
private _params?: MessageEntityParams
/**
* When `type=text_mention`, contains the ID of the user mentioned.
* Params of the entity
*/
readonly userId?: number
get params(): MessageEntityParams {
if (this._params) return this._params
/**
* When `type=pre`, contains the programming language of the entity text
*/
readonly language?: string
/**
* When `type=emoji`, ID of the custom emoji.
* The emoji itself must be loaded separately (and presumably cached)
* using {@link TelegramClient#getCustomEmojis}
*/
readonly emojiId?: tl.Long
static _parse(obj: tl.TypeMessageEntity): MessageEntity | null {
const type = entityToType[obj._]
if (!type) return null
return {
raw: obj,
type,
offset: obj.offset,
length: obj.length,
url: obj._ === 'messageEntityTextUrl' ? obj.url : undefined,
userId: obj._ === 'messageEntityMentionName' ? obj.userId : undefined,
language: obj._ === 'messageEntityPre' ? obj.language : undefined,
emojiId: obj._ === 'messageEntityCustomEmoji' ? obj.documentId : undefined,
switch (this.raw._) {
case 'messageEntityMention':
return (this._params = { kind: 'mention' })
case 'messageEntityHashtag':
return (this._params = { kind: 'hashtag' })
case 'messageEntityCashtag':
return (this._params = { kind: 'cashtag' })
case 'messageEntityBotCommand':
return (this._params = { kind: 'bot_command' })
case 'messageEntityUrl':
return (this._params = { kind: 'url' })
case 'messageEntityEmail':
return (this._params = { kind: 'email' })
case 'messageEntityPhone':
return (this._params = { kind: 'phone_number' })
case 'messageEntityBold':
return (this._params = { kind: 'bold' })
case 'messageEntityItalic':
return (this._params = { kind: 'italic' })
case 'messageEntityUnderline':
return (this._params = { kind: 'underline' })
case 'messageEntityStrike':
return (this._params = { kind: 'strikethrough' })
case 'messageEntitySpoiler':
return (this._params = { kind: 'spoiler' })
case 'messageEntityCode':
return (this._params = { kind: 'code' })
case 'messageEntityPre':
return (this._params = { kind: 'pre', language: this.raw.language })
case 'messageEntityTextUrl':
return (this._params = { kind: 'text_link', url: this.raw.url })
case 'messageEntityMentionName':
return (this._params = { kind: 'text_mention', userId: this.raw.userId })
case 'messageEntityBlockquote':
return (this._params = { kind: 'blockquote' })
case 'messageEntityCustomEmoji':
return (this._params = { kind: 'emoji', emojiId: this.raw.documentId })
case 'messageEntityBankCard':
return (this._params = { kind: 'bank_card' })
}
return (this._params = { kind: 'unknown' })
}
/**
* Text contained in this entity.
*
* > **Note**: This does not take into account that entities may overlap,
* > and is only useful for simple cases.
*/
get text(): string {
if (!this._text) return ''
return this._text.slice(this.raw.offset, this.raw.offset + this.raw.length)
}
/**
* Checks if this entity is of the given type, and adjusts the type accordingly.
* @param kind
* @returns
*/
is<const T extends MessageEntityKind>(
kind: T,
): this is MessageEntity & { content: Extract<MessageEntityParams, { kind: T }>; kind: T } {
return this.params.kind === kind
}
}

View file

@ -404,8 +404,7 @@ export class Message {
if (this.raw._ === 'message' && this.raw.entities?.length) {
for (const ent of this.raw.entities) {
const parsed = MessageEntity._parse(ent)
if (parsed) this._entities.push(parsed)
this._entities.push(new MessageEntity(ent, this.raw.message))
}
}
}
@ -572,7 +571,9 @@ export class Message {
* @param parseMode Parse mode to use (`null` for default)
*/
unparse(parseMode?: string | null): string {
return this.client.getParseMode(parseMode).unparse(this.text, this.entities)
if (this.raw._ === 'messageService') return ''
return this.client.getParseMode(parseMode).unparse(this.text, this.raw.entities ?? [])
}
/**

View file

@ -1,13 +1,10 @@
import { tl } from '@mtcute/core'
import { MessageEntity } from '../types'
/**
* Interface describing a message entity parser.
*
* mtcute comes with HTML parser inside `@mtcute/html-parser`
* and MarkdownV2 parser inside `@mtcute/markdown-parser`,
* implemented similar to how they are described
* in the [Bot API documentation](https://core.telegram.org/bots/api#formatting-options).
* and Markdown parser inside `@mtcute/markdown-parser`.
*
* You are also free to implement your own parser and register it with
* {@link TelegramClient.registerParseMode}.
@ -35,7 +32,7 @@ export interface IMessageEntityParser {
* @param text Plain text
* @param entities Message entities that should be added to the text
*/
unparse(text: string, entities: ReadonlyArray<MessageEntity>): string
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string
}
/**

View file

@ -588,9 +588,7 @@ export class Chat {
return new FormattedString(
this.client.getParseMode(parseMode).unparse(text, [
{
// eslint-disable-next-line
raw: undefined as any,
type: 'text_link',
_: 'messageEntityTextUrl',
offset: 0,
length: text.length,
url: `https://t.me/${this.username}`,

View file

@ -359,9 +359,7 @@ export class User {
return new FormattedString(
this.client.getParseMode(parseMode).unparse(text, [
{
// eslint-disable-next-line
raw: undefined as any,
type: 'text_mention',
_: 'messageEntityMentionName',
offset: 0,
length: text.length,
userId: this.raw.id,
@ -415,9 +413,7 @@ export class User {
return new FormattedString(
this.client.getParseMode(parseMode).unparse(text, [
{
// eslint-disable-next-line
raw: undefined as any,
type: 'text_link',
_: 'messageEntityTextUrl',
offset: 0,
length: text.length,
url: `tg://user?id=${this.id}&hash=${this.raw.accessHash.toString(16)}`,

View file

@ -100,8 +100,7 @@ export class Story {
if (this.raw.entities?.length) {
for (const ent of this.raw.entities) {
const parsed = MessageEntity._parse(ent)
if (parsed) this._entities.push(parsed)
this._entities.push(new MessageEntity(ent, this.raw.caption))
}
}
}

View file

@ -1,7 +1,7 @@
import { Parser } from 'htmlparser2'
import Long from 'long'
import type { FormattedString, IMessageEntityParser, MessageEntity, tl } from '@mtcute/client'
import type { FormattedString, IMessageEntityParser, tl } from '@mtcute/client'
const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/
@ -276,14 +276,14 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
return [plainText.replace(/\u00A0/g, ' '), entities]
}
unparse(text: string, entities: ReadonlyArray<MessageEntity>): string {
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string {
return this._unparse(text, entities)
}
// internal function that uses recursion to correctly process nested & overlapping entities
private _unparse(
text: string,
entities: ReadonlyArray<MessageEntity>,
entities: ReadonlyArray<tl.TypeMessageEntity>,
entitiesOffset = 0,
offset = 0,
length = text.length,
@ -334,59 +334,59 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
const substr = text.substr(relativeOffset, length)
if (!substr) continue
const type = entity.type
const type = entity._
let entityText
if (type === 'pre') {
if (type === 'messageEntityPre') {
entityText = substr
} else {
entityText = this._unparse(substr, entities, i + 1, offset + relativeOffset, length)
}
switch (type) {
case 'bold':
case 'italic':
case 'underline':
case 'strikethrough':
html.push(`<${type[0]}>${entityText}</${type[0]}>`)
case 'messageEntityBold':
case 'messageEntityItalic':
case 'messageEntityUnderline':
case 'messageEntityStrike':
case 'messageEntityCode':
case 'messageEntityBlockquote':
case 'messageEntitySpoiler':
{
const tag = (
{
messageEntityBold: 'b',
messageEntityItalic: 'i',
messageEntityUnderline: 'u',
messageEntityStrike: 's',
messageEntityCode: 'code',
messageEntityBlockquote: 'blockquote',
messageEntitySpoiler: 'spoiler',
} as const
)[type]
html.push(`<${tag}>${entityText}</${tag}>`)
}
break
case 'code':
case 'pre':
case 'messageEntityPre':
html.push(
`<${type}${entity.language ? ` language="${entity.language}"` : ''}>${
`<pre${entity.language ? ` language="${entity.language}"` : ''}>${
this._syntaxHighlighter && entity.language ?
this._syntaxHighlighter(entityText, entity.language) :
entityText
}</${type}>`,
}</pre>`,
)
break
case 'blockquote':
case 'spoiler':
html.push(`<${type}>${entityText}</${type}>`)
break
case 'email':
case 'messageEntityEmail':
html.push(`<a href="mailto:${entityText}">${entityText}</a>`)
break
case 'url':
case 'messageEntityUrl':
html.push(`<a href="${entityText}">${entityText}</a>`)
break
case 'text_link':
html.push(
`<a href="${HtmlMessageEntityParser.escape(
// todo improve typings
entity.url!,
true,
)}">${entityText}</a>`,
)
case 'messageEntityTextUrl':
html.push(`<a href="${HtmlMessageEntityParser.escape(entity.url, true)}">${entityText}</a>`)
break
case 'text_mention':
html.push(
// todo improve typings
`<a href="tg://user?id=${entity.userId!}">${entityText}</a>`,
)
case 'messageEntityMentionName':
html.push(`<a href="tg://user?id=${entity.userId}">${entityText}</a>`)
break
default:
skip = true

View file

@ -2,8 +2,7 @@ import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
import { FormattedString, MessageEntity, tl } from '@mtcute/client'
import { isPresent } from '@mtcute/client/utils'
import { FormattedString, tl } from '@mtcute/client'
import { html, HtmlMessageEntityParser } from '../src'
@ -21,16 +20,12 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
} as tl.TypeMessageEntity
}
const createEntities = (entities: tl.TypeMessageEntity[]): MessageEntity[] => {
return entities.map((it) => MessageEntity._parse(it)).filter(isPresent)
}
describe('HtmlMessageEntityParser', () => {
const parser = new HtmlMessageEntityParser()
describe('unparse', () => {
const test = (text: string, entities: tl.TypeMessageEntity[], expected: string, _parser = parser): void => {
expect(_parser.unparse(text, createEntities(entities))).eq(expected)
expect(_parser.unparse(text, entities)).eq(expected)
}
it('should return the same text if there are no entities or text', () => {

View file

@ -1,6 +1,6 @@
import Long from 'long'
import type { FormattedString, IMessageEntityParser, MessageEntity, tl } from '@mtcute/client'
import type { FormattedString, IMessageEntityParser, tl } from '@mtcute/client'
const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/
const EMOJI_REGEX = /^tg:\/\/emoji\?id=(-?\d+)/
@ -303,7 +303,7 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
return [result, entities]
}
unparse(text: string, entities: ReadonlyArray<MessageEntity>): string {
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string {
// keep track of positions of inserted escape symbols
const escaped: number[] = []
text = text.replace(TO_BE_ESCAPED, (s, pos: number) => {
@ -317,7 +317,7 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
const insert: InsertLater[] = []
for (const entity of entities) {
const type = entity.type
const type = entity._
let start = entity.offset
let end = start + entity.length
@ -345,25 +345,25 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
let endTag: string
switch (type) {
case 'bold':
case 'messageEntityBold':
startTag = endTag = TAG_BOLD
break
case 'italic':
case 'messageEntityItalic':
startTag = endTag = TAG_ITALIC
break
case 'underline':
case 'messageEntityUnderline':
startTag = endTag = TAG_UNDERLINE
break
case 'strikethrough':
case 'messageEntityStrike':
startTag = endTag = TAG_STRIKE
break
case 'spoiler':
case 'messageEntitySpoiler':
startTag = endTag = TAG_SPOILER
break
case 'code':
case 'messageEntityCode':
startTag = endTag = TAG_CODE
break
case 'pre':
case 'messageEntityPre':
startTag = TAG_PRE
if (entity.language) {
@ -373,17 +373,17 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
startTag += '\n'
endTag = '\n' + TAG_PRE
break
case 'text_link':
case 'messageEntityTextUrl':
startTag = '['
endTag = `](${entity.url})`
break
case 'text_mention':
case 'messageEntityMentionName':
startTag = '['
endTag = `](tg://user?id=${entity.userId})`
break
case 'emoji':
case 'messageEntityCustomEmoji':
startTag = '['
endTag = `](tg://emoji?id=${entity.emojiId!.toString()})`
endTag = `](tg://emoji?id=${entity.documentId.toString()})`
break
default:
continue

View file

@ -2,8 +2,7 @@ import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
import { FormattedString, MessageEntity, tl } from '@mtcute/client'
import { isPresent } from '@mtcute/client/utils'
import { FormattedString, tl } from '@mtcute/client'
import { MarkdownMessageEntityParser, md } from '../src'
@ -21,10 +20,6 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
} as tl.TypeMessageEntity // idc really, its not that important
}
const createEntities = (entities: tl.TypeMessageEntity[]): MessageEntity[] => {
return entities.map((it) => MessageEntity._parse(it)).filter(isPresent)
}
describe('MarkdownMessageEntityParser', () => {
const parser = new MarkdownMessageEntityParser()
@ -35,7 +30,7 @@ describe('MarkdownMessageEntityParser', () => {
expected: string | string[],
_parser = parser,
): void => {
const result = _parser.unparse(text, createEntities(entities))
const result = _parser.unparse(text, entities)
if (Array.isArray(expected)) {
expect(expected).to.include(result)