diff --git a/packages/client/src/methods/messages/translate-message.ts b/packages/client/src/methods/messages/translate-message.ts index 5716d873..e992c714 100644 --- a/packages/client/src/methods/messages/translate-message.ts +++ b/packages/client/src/methods/messages/translate-message.ts @@ -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))] } diff --git a/packages/client/src/types/auth/terms-of-service.ts b/packages/client/src/types/auth/terms-of-service.ts index 6fcd90ae..392a126a 100644 --- a/packages/client/src/types/auth/terms-of-service.ts +++ b/packages/client/src/types/auth/terms-of-service.ts @@ -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 { - 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))) } } diff --git a/packages/client/src/types/media/poll.ts b/packages/client/src/types/media/poll.ts index 0a6c870f..6ecdf86a 100644 --- a/packages/client/src/types/media/poll.ts +++ b/packages/client/src/types/media/poll.ts @@ -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) } /** diff --git a/packages/client/src/types/messages/draft-message.ts b/packages/client/src/types/messages/draft-message.ts index 3687fc4c..7b7ae49c 100644 --- a/packages/client/src/types/messages/draft-message.ts +++ b/packages/client/src/types/messages/draft-message.ts @@ -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)) } } } diff --git a/packages/client/src/types/messages/message-entity.ts b/packages/client/src/types/messages/message-entity.ts index aba1ab52..f7fafbf7 100644 --- a/packages/client/src/types/messages/message-entity.ts +++ b/packages/client/src/types/messages/message-entity.ts @@ -2,34 +2,13 @@ import { tl } from '@mtcute/core' import { makeInspectable } from '../../utils' -const entityToType: Partial> = { - 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 * - 'underline': underlined text. * - 'strikethrough': strikethrough 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 { - /** - * 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( + kind: T, + ): this is MessageEntity & { content: Extract; kind: T } { + return this.params.kind === kind } } diff --git a/packages/client/src/types/messages/message.ts b/packages/client/src/types/messages/message.ts index 91ae9c93..9aa5def3 100644 --- a/packages/client/src/types/messages/message.ts +++ b/packages/client/src/types/messages/message.ts @@ -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 ?? []) } /** diff --git a/packages/client/src/types/parser.ts b/packages/client/src/types/parser.ts index 3bc68b95..41be3501 100644 --- a/packages/client/src/types/parser.ts +++ b/packages/client/src/types/parser.ts @@ -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): string + unparse(text: string, entities: ReadonlyArray): string } /** diff --git a/packages/client/src/types/peers/chat.ts b/packages/client/src/types/peers/chat.ts index 7dff3b95..204ed578 100644 --- a/packages/client/src/types/peers/chat.ts +++ b/packages/client/src/types/peers/chat.ts @@ -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}`, diff --git a/packages/client/src/types/peers/user.ts b/packages/client/src/types/peers/user.ts index 57541fc6..e436260f 100644 --- a/packages/client/src/types/peers/user.ts +++ b/packages/client/src/types/peers/user.ts @@ -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)}`, diff --git a/packages/client/src/types/stories/story.ts b/packages/client/src/types/stories/story.ts index 171edb6f..108a601e 100644 --- a/packages/client/src/types/stories/story.ts +++ b/packages/client/src/types/stories/story.ts @@ -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)) } } } diff --git a/packages/html-parser/src/index.ts b/packages/html-parser/src/index.ts index b8b3a808..24d8781e 100644 --- a/packages/html-parser/src/index.ts +++ b/packages/html-parser/src/index.ts @@ -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): string { + unparse(text: string, entities: ReadonlyArray): string { return this._unparse(text, entities) } // internal function that uses recursion to correctly process nested & overlapping entities private _unparse( text: string, - entities: ReadonlyArray, + entities: ReadonlyArray, 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}`) + 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}`) + } break - case 'code': - case 'pre': + case 'messageEntityPre': html.push( - `<${type}${entity.language ? ` language="${entity.language}"` : ''}>${ + `${ this._syntaxHighlighter && entity.language ? this._syntaxHighlighter(entityText, entity.language) : entityText - }`, + }`, ) break - case 'blockquote': - case 'spoiler': - html.push(`<${type}>${entityText}`) - break - case 'email': + case 'messageEntityEmail': html.push(`${entityText}`) break - case 'url': + case 'messageEntityUrl': html.push(`${entityText}`) break - case 'text_link': - html.push( - `${entityText}`, - ) + case 'messageEntityTextUrl': + html.push(`${entityText}`) break - case 'text_mention': - html.push( - // todo improve typings - - `${entityText}`, - ) + case 'messageEntityMentionName': + html.push(`${entityText}`) break default: skip = true diff --git a/packages/html-parser/tests/html-parser.spec.ts b/packages/html-parser/tests/html-parser.spec.ts index 50b2ce93..5e477dfc 100644 --- a/packages/html-parser/tests/html-parser.spec.ts +++ b/packages/html-parser/tests/html-parser.spec.ts @@ -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 = ( } 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', () => { diff --git a/packages/markdown-parser/src/index.ts b/packages/markdown-parser/src/index.ts index 22c6b11d..4edfb83d 100644 --- a/packages/markdown-parser/src/index.ts +++ b/packages/markdown-parser/src/index.ts @@ -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): string { + unparse(text: string, entities: ReadonlyArray): 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 diff --git a/packages/markdown-parser/tests/markdown-parser.spec.ts b/packages/markdown-parser/tests/markdown-parser.spec.ts index a837ddc4..eddc9dea 100644 --- a/packages/markdown-parser/tests/markdown-parser.spec.ts +++ b/packages/markdown-parser/tests/markdown-parser.spec.ts @@ -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 = ( } 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)