refactor: no more parse modes!

This commit is contained in:
alina 🌸 2023-11-01 20:24:00 +03:00
parent cfa7e8ef5c
commit 23a0e69942
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
37 changed files with 1022 additions and 1389 deletions

View file

@ -12,8 +12,8 @@
"test:all:ci": "pnpm run -r test", "test:all:ci": "pnpm run -r test",
"lint": "eslint .", "lint": "eslint .",
"lint:ci": "NODE_OPTIONS=\"--max_old_space_size=8192\" eslint --config .eslintrc.ci.js .", "lint:ci": "NODE_OPTIONS=\"--max_old_space_size=8192\" eslint --config .eslintrc.ci.js .",
"lint:tsc": "pnpm -r --filter=!crypto --parallel exec tsc --build", "lint:tsc": "pnpm -r --parallel exec tsc --build",
"lint:tsc:ci": "pnpm -r --filter=!crypto exec tsc --build", "lint:tsc:ci": "pnpm -r exec tsc --build",
"lint:dpdm": "dpdm -T --no-warning --no-tree --exit-code circular:1 packages/*", "lint:dpdm": "dpdm -T --no-warning --no-tree --exit-code circular:1 packages/*",
"lint:fix": "eslint --fix .", "lint:fix": "eslint --fix .",
"format": "prettier --write \"packages/**/*.ts\"", "format": "prettier --write \"packages/**/*.ts\"",

View file

@ -167,12 +167,6 @@ import { unpinAllMessages } from './methods/messages/unpin-all-messages.js'
import { unpinMessage } from './methods/messages/unpin-message.js' import { unpinMessage } from './methods/messages/unpin-message.js'
import { initTakeoutSession } from './methods/misc/init-takeout-session.js' import { initTakeoutSession } from './methods/misc/init-takeout-session.js'
import { _normalizePrivacyRules } from './methods/misc/normalize-privacy-rules.js' import { _normalizePrivacyRules } from './methods/misc/normalize-privacy-rules.js'
import {
getParseMode,
registerParseMode,
setDefaultParseMode,
unregisterParseMode,
} from './methods/parse-modes/parse-modes.js'
import { changeCloudPassword } from './methods/password/change-cloud-password.js' import { changeCloudPassword } from './methods/password/change-cloud-password.js'
import { enableCloudPassword } from './methods/password/enable-cloud-password.js' import { enableCloudPassword } from './methods/password/enable-cloud-password.js'
import { cancelPasswordEmail, resendPasswordEmail, verifyPasswordEmail } from './methods/password/password-email.js' import { cancelPasswordEmail, resendPasswordEmail, verifyPasswordEmail } from './methods/password/password-email.js'
@ -271,11 +265,9 @@ import {
Dialog, Dialog,
FileDownloadLocation, FileDownloadLocation,
FileDownloadParameters, FileDownloadParameters,
FormattedString,
ForumTopic, ForumTopic,
GameHighScore, GameHighScore,
HistoryReadUpdate, HistoryReadUpdate,
IMessageEntityParser,
InlineQuery, InlineQuery,
InputChatEventFilters, InputChatEventFilters,
InputDialogFolder, InputDialogFolder,
@ -288,6 +280,7 @@ import {
InputReaction, InputReaction,
InputStickerSet, InputStickerSet,
InputStickerSetItem, InputStickerSetItem,
InputText,
MaybeDynamic, MaybeDynamic,
Message, Message,
MessageEntity, MessageEntity,
@ -852,18 +845,6 @@ export interface TelegramClient extends BaseTelegramClient {
*/ */
url: string url: string
} }
/**
* Parse mode to use when parsing inline message text.
*
* Passing `null` will explicitly disable formatting.
*
* **Note**: inline results themselves *can not* have markup
* entities, only the messages that are sent once a result is clicked.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
}, },
): Promise<void> ): Promise<void>
/** /**
@ -2232,8 +2213,7 @@ export interface TelegramClient extends BaseTelegramClient {
*/ */
_normalizeInputMedia( _normalizeInputMedia(
media: InputMediaLike, media: InputMediaLike,
params: { params?: {
parseMode?: string | null
progressCallback?: (uploaded: number, total: number) => void progressCallback?: (uploaded: number, total: number) => void
uploadPeer?: tl.TypeInputPeer uploadPeer?: tl.TypeInputPeer
}, },
@ -2929,26 +2909,7 @@ export interface TelegramClient extends BaseTelegramClient {
* *
* When `media` is passed, `media.caption` is used instead * When `media` is passed, `media.caption` is used instead
*/ */
text?: string | FormattedString<string> text?: InputText
/**
* Parse mode to use to parse entities before sending the message.
*
*
* Passing `null` will explicitly disable formatting.
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*
* When `media` is passed, `media.entities` is used instead
*/
entities?: tl.TypeMessageEntity[]
/** /**
* New message media * New message media
@ -2998,26 +2959,7 @@ export interface TelegramClient extends BaseTelegramClient {
* *
* When `media` is passed, `media.caption` is used instead * When `media` is passed, `media.caption` is used instead
*/ */
text?: string | FormattedString<string> text?: InputText
/**
* Parse mode to use to parse entities before sending the message.
*
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*
* When `media` is passed, `media.entities` is used instead
*/
entities?: tl.TypeMessageEntity[]
/** /**
* New message media * New message media
@ -3068,8 +3010,6 @@ export interface TelegramClient extends BaseTelegramClient {
* Forward one or more messages by their IDs. * Forward one or more messages by their IDs.
* You can forward no more than 100 messages at once. * You can forward no more than 100 messages at once.
* *
* If a caption message was sent, it will be the first message in the resulting array.
*
* **Available**: both users and bots * **Available**: both users and bots
* *
* @param toChatId Destination chat ID, username, phone, `"me"` or `"self"` * @param toChatId Destination chat ID, username, phone, `"me"` or `"self"`
@ -3784,15 +3724,7 @@ export interface TelegramClient extends BaseTelegramClient {
* Can be used, for example. when using File IDs * Can be used, for example. when using File IDs
* or when using existing InputMedia objects. * or when using existing InputMedia objects.
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for `media`.
*
* Can be used, for example. when using File IDs
* or when using existing InputMedia objects.
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Function that will be called after some part has been uploaded. * Function that will be called after some part has been uploaded.
@ -3883,7 +3815,7 @@ export interface TelegramClient extends BaseTelegramClient {
*/ */
sendText( sendText(
chatId: InputPeerLike, chatId: InputPeerLike,
text: string | FormattedString<string>, text: InputText,
params?: CommonSendParams & { params?: CommonSendParams & {
/** /**
* For bots: inline or reply markup or an instruction * For bots: inline or reply markup or an instruction
@ -3891,14 +3823,6 @@ export interface TelegramClient extends BaseTelegramClient {
*/ */
replyMarkup?: ReplyMarkup replyMarkup?: ReplyMarkup
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Whether to disable links preview in this message * Whether to disable links preview in this message
*/ */
@ -4027,47 +3951,6 @@ export interface TelegramClient extends BaseTelegramClient {
* *
*/ */
_normalizePrivacyRules(rules: InputPrivacyRule[]): Promise<tl.TypeInputPrivacyRule[]> _normalizePrivacyRules(rules: InputPrivacyRule[]): Promise<tl.TypeInputPrivacyRule[]>
/**
* Register a given {@link IMessageEntityParser} as a parse mode
* for messages. When this method is first called, given parse
* mode is also set as default.
*
* **Available**: both users and bots
*
* @param parseMode Parse mode to register
* @throws MtClientError When the parse mode with a given name is already registered.
*/
registerParseMode(parseMode: IMessageEntityParser): void
/**
* Unregister a parse mode by its name.
* Will silently fail if given parse mode does not exist.
*
* Also updates the default parse mode to the next one available, if any
*
* **Available**: both users and bots
*
* @param name Name of the parse mode to unregister
*/
unregisterParseMode(name: string): void
/**
* Get a {@link IMessageEntityParser} registered under a given name (or a default one).
*
* **Available**: both users and bots
*
* @param name Name of the parse mode which parser to get.
* @throws MtClientError When the provided parse mode is not registered
* @throws MtClientError When `name` is omitted and there is no default parse mode
*/
getParseMode(name?: string | null): IMessageEntityParser
/**
* Set a given parse mode as a default one.
*
* **Available**: both users and bots
*
* @param name Name of the parse mode
* @throws MtClientError When given parse mode is not registered.
*/
setDefaultParseMode(name: string): void
/** /**
* Change your 2FA password * Change your 2FA password
* **Available**: 👤 users only * **Available**: 👤 users only
@ -4422,20 +4305,7 @@ export interface TelegramClient extends BaseTelegramClient {
/** /**
* Override caption for {@link media} * Override caption for {@link media}
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for {@link media}
*/
entities?: tl.TypeMessageEntity[]
/**
* Parse mode to use to parse entities before sending the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/** /**
* Interactive elements to add to the story * Interactive elements to add to the story
@ -4806,20 +4676,7 @@ export interface TelegramClient extends BaseTelegramClient {
/** /**
* Override caption for {@link media} * Override caption for {@link media}
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for {@link media}
*/
entities?: tl.TypeMessageEntity[]
/**
* Parse mode to use to parse entities before sending the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/** /**
* Whether to automatically pin this story to the profile * Whether to automatically pin this story to the profile
@ -5422,10 +5279,6 @@ export class TelegramClient extends BaseTelegramClient {
unpinMessage = unpinMessage.bind(null, this) unpinMessage = unpinMessage.bind(null, this)
initTakeoutSession = initTakeoutSession.bind(null, this) initTakeoutSession = initTakeoutSession.bind(null, this)
_normalizePrivacyRules = _normalizePrivacyRules.bind(null, this) _normalizePrivacyRules = _normalizePrivacyRules.bind(null, this)
registerParseMode = registerParseMode.bind(null, this)
unregisterParseMode = unregisterParseMode.bind(null, this)
getParseMode = getParseMode.bind(null, this)
setDefaultParseMode = setDefaultParseMode.bind(null, this)
changeCloudPassword = changeCloudPassword.bind(null, this) changeCloudPassword = changeCloudPassword.bind(null, this)
enableCloudPassword = enableCloudPassword.bind(null, this) enableCloudPassword = enableCloudPassword.bind(null, this)
verifyPasswordEmail = verifyPasswordEmail.bind(null, this) verifyPasswordEmail = verifyPasswordEmail.bind(null, this)

View file

@ -38,11 +38,9 @@ import {
Dialog, Dialog,
FileDownloadLocation, FileDownloadLocation,
FileDownloadParameters, FileDownloadParameters,
FormattedString,
ForumTopic, ForumTopic,
GameHighScore, GameHighScore,
HistoryReadUpdate, HistoryReadUpdate,
IMessageEntityParser,
InlineQuery, InlineQuery,
InputChatEventFilters, InputChatEventFilters,
InputDialogFolder, InputDialogFolder,
@ -55,6 +53,7 @@ import {
InputReaction, InputReaction,
InputStickerSet, InputStickerSet,
InputStickerSetItem, InputStickerSetItem,
InputText,
MaybeDynamic, MaybeDynamic,
Message, Message,
MessageEntity, MessageEntity,

View file

@ -96,23 +96,11 @@ export async function answerInlineQuery(
*/ */
url: string url: string
} }
/**
* Parse mode to use when parsing inline message text.
*
* Passing `null` will explicitly disable formatting.
*
* **Note**: inline results themselves *can not* have markup
* entities, only the messages that are sent once a result is clicked.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
}, },
): Promise<void> { ): Promise<void> {
const { cacheTime = 300, gallery, private: priv, nextOffset, switchPm, switchWebview, parseMode } = params ?? {} const { cacheTime = 300, gallery, private: priv, nextOffset, switchPm, switchWebview } = params ?? {}
const [defaultGallery, tlResults] = await BotInline._convertToTl(client, results, parseMode) const [defaultGallery, tlResults] = await BotInline._convertToTl(client, results)
await client.call({ await client.call({
_: 'messages.setInlineBotResults', _: 'messages.setInlineBotResults',

View file

@ -8,7 +8,7 @@ import { InputMediaLike } from '../../types/media/input-media.js'
import { extractFileName } from '../../utils/file-utils.js' import { extractFileName } from '../../utils/file-utils.js'
import { normalizeDate } from '../../utils/misc-utils.js' import { normalizeDate } from '../../utils/misc-utils.js'
import { encodeWaveform } from '../../utils/voice-utils.js' import { encodeWaveform } from '../../utils/voice-utils.js'
import { _parseEntities } from '../messages/parse-entities.js' import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _normalizeInputFile } from './normalize-input-file.js' import { _normalizeInputFile } from './normalize-input-file.js'
import { uploadFile } from './upload-file.js' import { uploadFile } from './upload-file.js'
@ -21,10 +21,9 @@ export async function _normalizeInputMedia(
client: BaseTelegramClient, client: BaseTelegramClient,
media: InputMediaLike, media: InputMediaLike,
params: { params: {
parseMode?: string | null
progressCallback?: (uploaded: number, total: number) => void progressCallback?: (uploaded: number, total: number) => void
uploadPeer?: tl.TypeInputPeer uploadPeer?: tl.TypeInputPeer
}, } = {},
uploadMedia = false, uploadMedia = false,
): Promise<tl.TypeInputMedia> { ): Promise<tl.TypeInputMedia> {
// my condolences to those poor souls who are going to maintain this (myself included) // my condolences to those poor souls who are going to maintain this (myself included)
@ -165,12 +164,7 @@ export async function _normalizeInputMedia(
}) })
if (media.solution) { if (media.solution) {
[solution, solutionEntities] = await _parseEntities( [solution, solutionEntities] = await _normalizeInputText(client, media.solution)
client,
media.solution,
params.parseMode,
media.solutionEntities,
)
} }
} }

View file

@ -1,9 +1,9 @@
import { BaseTelegramClient, tl } from '@mtcute/core' import { BaseTelegramClient, tl } from '@mtcute/core'
import { BotKeyboard, FormattedString, InputMediaLike, ReplyMarkup } from '../../types/index.js' import { BotKeyboard, InputMediaLike, InputText, ReplyMarkup } from '../../types/index.js'
import { normalizeInlineId } from '../../utils/inline-utils.js' import { normalizeInlineId } from '../../utils/inline-utils.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _parseEntities } from './parse-entities.js' import { _normalizeInputText } from '../misc/normalize-text.js'
/** /**
* Edit sent inline message text, media and reply markup. * Edit sent inline message text, media and reply markup.
@ -27,26 +27,7 @@ export async function editInlineMessage(
* *
* When `media` is passed, `media.caption` is used instead * When `media` is passed, `media.caption` is used instead
*/ */
text?: string | FormattedString<string> text?: InputText
/**
* Parse mode to use to parse entities before sending the message.
*
*
* Passing `null` will explicitly disable formatting.
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*
* When `media` is passed, `media.entities` is used instead
*/
entities?: tl.TypeMessageEntity[]
/** /**
* New message media * New message media
@ -93,15 +74,10 @@ export async function editInlineMessage(
// if there's no caption in input media (i.e. not present or undefined), // if there's no caption in input media (i.e. not present or undefined),
// user wants to keep current caption, thus `content` needs to stay `undefined` // user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) { if ('caption' in params.media && params.media.caption !== undefined) {
[content, entities] = await _parseEntities( [content, entities] = await _normalizeInputText(client, params.media.caption)
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
} }
} else if (params.text) { } else if (params.text) {
[content, entities] = await _parseEntities(client, params.text, params.parseMode, params.entities) [content, entities] = await _normalizeInputText(client, params.text)
} }
let retries = 3 let retries = 3

View file

@ -2,17 +2,17 @@ import { BaseTelegramClient, tl } from '@mtcute/core'
import { import {
BotKeyboard, BotKeyboard,
FormattedString,
InputMediaLike, InputMediaLike,
InputMessageId, InputMessageId,
InputText,
Message, Message,
normalizeInputMessageId, normalizeInputMessageId,
ReplyMarkup, ReplyMarkup,
} from '../../types/index.js' } from '../../types/index.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js' import { _findMessageInUpdate } from './find-in-update.js'
import { _parseEntities } from './parse-entities.js'
/** /**
* Edit message text, media, reply markup and schedule date. * Edit message text, media, reply markup and schedule date.
@ -29,26 +29,7 @@ export async function editMessage(
* *
* When `media` is passed, `media.caption` is used instead * When `media` is passed, `media.caption` is used instead
*/ */
text?: string | FormattedString<string> text?: InputText
/**
* Parse mode to use to parse entities before sending the message.
*
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*
* When `media` is passed, `media.entities` is used instead
*/
entities?: tl.TypeMessageEntity[]
/** /**
* New message media * New message media
@ -106,17 +87,12 @@ export async function editMessage(
// if there's no caption in input media (i.e. not present or undefined), // if there's no caption in input media (i.e. not present or undefined),
// user wants to keep current caption, thus `content` needs to stay `undefined` // user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) { if ('caption' in params.media && params.media.caption !== undefined) {
[content, entities] = await _parseEntities( [content, entities] = await _normalizeInputText(client, params.media.caption)
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
} }
} }
if (params.text) { if (params.text) {
[content, entities] = await _parseEntities(client, params.text, params.parseMode, params.entities) [content, entities] = await _normalizeInputText(client, params.text)
} }
const res = await client.call({ const res = await client.call({

View file

@ -1,7 +1,7 @@
import { BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core' import { BaseTelegramClient, MtArgumentError } from '@mtcute/core'
import { randomLong } from '@mtcute/core/utils.js' import { randomLong } from '@mtcute/core/utils.js'
import { FormattedString, InputMediaLike, InputPeerLike, Message, PeersIndex } from '../../types/index.js' import { InputPeerLike, Message, PeersIndex } from '../../types/index.js'
import { normalizeDate } from '../../utils/misc-utils.js' import { normalizeDate } from '../../utils/misc-utils.js'
import { assertIsUpdatesGroup } from '../../utils/updates-utils.js' import { assertIsUpdatesGroup } from '../../utils/updates-utils.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
@ -11,41 +11,6 @@ export interface ForwardMessageOptions {
/** Destination chat ID, username, phone, `"me"` or `"self"` */ /** Destination chat ID, username, phone, `"me"` or `"self"` */
toChatId: InputPeerLike toChatId: InputPeerLike
/**
* Optionally, a caption for your forwarded message(s).
* It will be sent as a separate message before the forwarded messages.
*
* You can either pass `caption` or `captionMedia`, passing both will
* result in an error
*/
caption?: string | FormattedString<string>
/**
* Optionally, a media caption for your forwarded message(s).
* It will be sent as a separate message before the forwarded messages.
*
* You can either pass `caption` or `captionMedia`, passing both will
* result in an error
*/
captionMedia?: InputMediaLike
/**
* Parse mode to use to parse entities in caption.
*
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities in caption to use instead
* of parsing via a parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Whether to forward silently (also applies to caption message). * Whether to forward silently (also applies to caption message).
*/ */
@ -102,8 +67,6 @@ export interface ForwardMessageOptions {
* Forward one or more messages by their IDs. * Forward one or more messages by their IDs.
* You can forward no more than 100 messages at once. * You can forward no more than 100 messages at once.
* *
* If a caption message was sent, it will be the first message in the resulting array.
*
* @param toChatId Destination chat ID, username, phone, `"me"` or `"self"` * @param toChatId Destination chat ID, username, phone, `"me"` or `"self"`
* @param fromChatId Source chat ID, username, phone, `"me"` or `"self"` * @param fromChatId Source chat ID, username, phone, `"me"` or `"self"`
* @param messages Message IDs * @param messages Message IDs

View file

@ -1,60 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core'
import { FormattedString } from '../../types/index.js'
import { normalizeToInputUser } from '../../utils/peer-utils.js'
import { getParseModesState } from '../parse-modes/_state.js'
import { resolvePeer } from '../users/resolve-peer.js'
const empty: [string, undefined] = ['', undefined]
/** @internal */
export async function _parseEntities(
client: BaseTelegramClient,
text?: string | FormattedString<string>,
mode?: string | null,
entities?: tl.TypeMessageEntity[],
): Promise<[string, tl.TypeMessageEntity[] | undefined]> {
if (!text) {
return empty
}
if (typeof text === 'object') {
mode = text.mode
text = text.value
}
if (!entities) {
const parseModesState = getParseModesState(client)
if (mode === undefined) {
mode = parseModesState.defaultParseMode
}
// either explicitly disabled or no available parser
if (!mode) return [text, []]
const modeImpl = parseModesState.parseModes.get(mode)
if (!modeImpl) {
throw new MtArgumentError(`Parse mode ${mode} is not registered.`)
}
[text, entities] = modeImpl.parse(text)
}
// replace mentionName entities with input ones
for (const ent of entities) {
if (ent._ === 'messageEntityMentionName') {
try {
const inputPeer = normalizeToInputUser(await resolvePeer(client, ent.userId), ent.userId)
// not a user
if (!inputPeer) continue
(ent as any)._ = 'inputMessageEntityMentionName'
;(ent as any).userId = inputPeer
} catch (e) {}
}
}
return [text, entities]
}

View file

@ -2,8 +2,10 @@ import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, tl } from '@mtcut
import { MtMessageNotFoundError } from '../../types/errors.js' import { MtMessageNotFoundError } from '../../types/errors.js'
import { Message } from '../../types/messages/message.js' import { Message } from '../../types/messages/message.js'
import { TextWithEntities } from '../../types/misc/entities.js'
import { InputPeerLike } from '../../types/peers/index.js' import { InputPeerLike } from '../../types/peers/index.js'
import { normalizeMessageId, normalizeToInputUser } from '../../utils/index.js' import { normalizeMessageId, normalizeToInputUser } from '../../utils/index.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _getDiscussionMessage } from './get-discussion-message.js' import { _getDiscussionMessage } from './get-discussion-message.js'
import { getMessages } from './get-messages.js' import { getMessages } from './get-messages.js'
@ -50,24 +52,9 @@ export interface CommonSendParams {
/** /**
* Quoted text. Must be exactly contained in the message * Quoted text. Must be exactly contained in the message
* being quoted to be accepted by the server * being quoted to be accepted by the server (as well as entities)
*/ */
quoteText?: string quote?: TextWithEntities
/**
* Entities contained in the quoted text.
* Must be exactly contained in the message
* being quoted to be accepted by the server
*/
quoteEntities?: tl.TypeMessageEntity[]
/**
* Parse mode to use to parse entities before sending the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/** /**
* Whether to send this message silently. * Whether to send this message silently.
@ -151,8 +138,8 @@ export async function _processCommonSendParameters(
_: 'inputReplyToMessage', _: 'inputReplyToMessage',
replyToMsgId: replyTo, replyToMsgId: replyTo,
replyToPeerId: replyToPeer, replyToPeerId: replyToPeer,
quoteText: params.quoteText, quoteText: params.quote?.text,
quoteEntities: params.quoteEntities, quoteEntities: params.quote?.entities as tl.TypeMessageEntity[],
} }
} else if (params.replyToStory) { } else if (params.replyToStory) {
tlReplyTo = { tlReplyTo = {

View file

@ -1,6 +1,6 @@
import { BaseTelegramClient, getMarkedPeerId, MtArgumentError, tl } from '@mtcute/core' import { BaseTelegramClient, getMarkedPeerId, MtArgumentError } from '@mtcute/core'
import { FormattedString, InputPeerLike, Message, MtMessageNotFoundError, ReplyMarkup } from '../../types/index.js' import { InputPeerLike, InputText, Message, MtMessageNotFoundError, ReplyMarkup } from '../../types/index.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { getMessages } from './get-messages.js' import { getMessages } from './get-messages.js'
import { CommonSendParams } from './send-common.js' import { CommonSendParams } from './send-common.js'
@ -15,24 +15,7 @@ export interface SendCopyParams extends CommonSendParams {
/** /**
* New message caption (only used for media) * New message caption (only used for media)
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Parse mode to use to parse `text` entities before sending
* the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/** /**
* For bots: inline or reply markup or an instruction * For bots: inline or reply markup or an instruction
@ -83,15 +66,26 @@ export async function sendCopy(
} }
if (msg.media && msg.media.type !== 'web_page' && msg.media.type !== 'invoice') { if (msg.media && msg.media.type !== 'web_page' && msg.media.type !== 'invoice') {
let caption: InputText | undefined = params.caption
if (!caption) {
if (msg.raw.entities?.length) {
caption = {
text: msg.raw.message,
entities: msg.raw.entities,
}
} else {
caption = msg.raw.message
}
}
return sendMedia( return sendMedia(
client, client,
toChatId, toChatId,
{ {
type: 'auto', type: 'auto',
file: msg.media.inputMedia, file: msg.media.inputMedia,
caption: params.caption ?? msg.raw.message, caption,
// we shouldn't use original entities if the user wants custom text
entities: params.entities ?? params.caption ? undefined : msg.raw.entities,
}, },
rest, rest,
) )

View file

@ -7,9 +7,9 @@ import { InputPeerLike, PeersIndex } from '../../types/peers/index.js'
import { normalizeDate } from '../../utils/misc-utils.js' import { normalizeDate } from '../../utils/misc-utils.js'
import { assertIsUpdatesGroup } from '../../utils/updates-utils.js' import { assertIsUpdatesGroup } from '../../utils/updates-utils.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _getDiscussionMessage } from './get-discussion-message.js' import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.js'
import { _processCommonSendParameters, CommonSendParams } from './send-common.js' import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
/** /**
@ -77,14 +77,11 @@ export async function sendMediaGroup(
true, true,
) )
const [message, entities] = await _parseEntities( const [message, entities] = await _normalizeInputText(
client, client,
// some types dont have `caption` field, and ts warns us, // some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly // but since it's JS, they'll just be `undefined` and properly handled by the method
// handled by _parseEntities method
(media as Extract<typeof media, { caption?: unknown }>).caption, (media as Extract<typeof media, { caption?: unknown }>).caption,
params.parseMode,
(media as Extract<typeof media, { entities?: unknown }>).entities,
) )
multiMedia.push({ multiMedia.push({

View file

@ -1,17 +1,17 @@
import { BaseTelegramClient, tl } from '@mtcute/core' import { BaseTelegramClient } from '@mtcute/core'
import { randomLong } from '@mtcute/core/utils.js' import { randomLong } from '@mtcute/core/utils.js'
import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.js' import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.js'
import { InputMediaLike } from '../../types/media/input-media.js' import { InputMediaLike } from '../../types/media/input-media.js'
import { Message } from '../../types/messages/message.js' import { Message } from '../../types/messages/message.js'
import { FormattedString } from '../../types/parser.js' import { InputText } from '../../types/misc/entities.js'
import { InputPeerLike } from '../../types/peers/index.js' import { InputPeerLike } from '../../types/peers/index.js'
import { normalizeDate } from '../../utils/misc-utils.js' import { normalizeDate } from '../../utils/misc-utils.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js' import { _findMessageInUpdate } from './find-in-update.js'
import { _getDiscussionMessage } from './get-discussion-message.js' import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.js'
import { _processCommonSendParameters, CommonSendParams } from './send-common.js' import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
/** /**
@ -50,15 +50,7 @@ export async function sendMedia(
* Can be used, for example. when using File IDs * Can be used, for example. when using File IDs
* or when using existing InputMedia objects. * or when using existing InputMedia objects.
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for `media`.
*
* Can be used, for example. when using File IDs
* or when using existing InputMedia objects.
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Function that will be called after some part has been uploaded. * Function that will be called after some part has been uploaded.
@ -82,14 +74,11 @@ export async function sendMedia(
const inputMedia = await _normalizeInputMedia(client, media, params) const inputMedia = await _normalizeInputMedia(client, media, params)
const [message, entities] = await _parseEntities( const [message, entities] = await _normalizeInputText(
client, client,
// some types dont have `caption` field, and ts warns us, // some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly // but since it's JS, they'll just be `undefined` and properly handled by the method
// handled by _parseEntities method
params.caption || (media as Extract<typeof media, { caption?: unknown }>).caption, params.caption || (media as Extract<typeof media, { caption?: unknown }>).caption,
params.parseMode,
params.entities || (media as Extract<typeof media, { entities?: unknown }>).entities,
) )
const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup)

View file

@ -1,6 +1,6 @@
import { BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core' import { BaseTelegramClient, MtArgumentError, tl } from '@mtcute/core'
import { InputPeerLike } from '../../index.js' import { InputPeerLike, TextWithEntities } from '../../index.js'
import { Message } from '../../types/messages/message.js' import { Message } from '../../types/messages/message.js'
import { sendMedia } from './send-media.js' import { sendMedia } from './send-media.js'
import { sendMediaGroup } from './send-media-group.js' import { sendMediaGroup } from './send-media-group.js'
@ -22,7 +22,7 @@ export type QuoteParamsFrom<T> = Omit<NonNullable<T>, 'quoteText' | 'quoteEntiti
end: number end: number
} }
function extractQuote(message: Message, from: number, to: number): [string, tl.TypeMessageEntity[] | undefined] { function extractQuote(message: Message, from: number, to: number): TextWithEntities {
const { raw } = message const { raw } = message
if (raw._ === 'messageService') throw new MtArgumentError('Cannot quote service message') if (raw._ === 'messageService') throw new MtArgumentError('Cannot quote service message')
@ -34,7 +34,7 @@ function extractQuote(message: Message, from: number, to: number): [string, tl.T
if (from >= to) throw new MtArgumentError('Invalid quote range') if (from >= to) throw new MtArgumentError('Invalid quote range')
if (!raw.entities) return [text.slice(from, to), undefined] if (!raw.entities) return { text: text.slice(from, to), entities: undefined }
const entities: tl.TypeMessageEntity[] = [] const entities: tl.TypeMessageEntity[] = []
@ -51,7 +51,7 @@ function extractQuote(message: Message, from: number, to: number): [string, tl.T
entities.push(newEnt) entities.push(newEnt)
} }
return [text.slice(from, to), entities] return { text: text.slice(from, to), entities }
} }
/** Send a text in reply to a given quote */ /** Send a text in reply to a given quote */
@ -66,7 +66,7 @@ export function quoteWithText(
const { toChatId = message.chat, start, end, text, ...params__ } = params const { toChatId = message.chat, start, end, text, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendText>[3]> const params_ = params__ as NonNullable<Parameters<typeof sendText>[3]>
params_.replyTo = message params_.replyTo = message
;[params_.quoteText, params_.quoteEntities] = extractQuote(message, params.start, params.end) params_.quote = extractQuote(message, params.start, params.end)
return sendText(client, toChatId, text, params_) return sendText(client, toChatId, text, params_)
} }
@ -83,7 +83,7 @@ export function quoteWithMedia(
const { toChatId = message.chat, start, end, media, ...params__ } = params const { toChatId = message.chat, start, end, media, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendMedia>[3]> const params_ = params__ as NonNullable<Parameters<typeof sendMedia>[3]>
params_.replyTo = message params_.replyTo = message
;[params_.quoteText, params_.quoteEntities] = extractQuote(message, params.start, params.end) params_.quote = extractQuote(message, params.start, params.end)
return sendMedia(client, toChatId, media, params_) return sendMedia(client, toChatId, media, params_)
} }
@ -100,7 +100,7 @@ export function quoteWithMediaGroup(
const { toChatId, start, end, medias, ...params__ } = params const { toChatId, start, end, medias, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendMediaGroup>[3]> const params_ = params__ as NonNullable<Parameters<typeof sendMediaGroup>[3]>
params_.replyTo = message params_.replyTo = message
;[params_.quoteText, params_.quoteEntities] = extractQuote(message, params.start, params.end) params_.quote = extractQuote(message, params.start, params.end)
return sendMediaGroup(client, message.chat.inputPeer, medias, params_) return sendMediaGroup(client, message.chat.inputPeer, medias, params_)
} }

View file

@ -3,16 +3,16 @@ import { randomLong } from '@mtcute/core/utils.js'
import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.js' import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.js'
import { Message } from '../../types/messages/message.js' import { Message } from '../../types/messages/message.js'
import { FormattedString } from '../../types/parser.js' import { InputText } from '../../types/misc/entities.js'
import { InputPeerLike, PeersIndex } from '../../types/peers/index.js' import { InputPeerLike, PeersIndex } from '../../types/peers/index.js'
import { normalizeDate } from '../../utils/misc-utils.js' import { normalizeDate } from '../../utils/misc-utils.js'
import { inputPeerToPeer } from '../../utils/peer-utils.js' import { inputPeerToPeer } from '../../utils/peer-utils.js'
import { createDummyUpdate } from '../../utils/updates-utils.js' import { createDummyUpdate } from '../../utils/updates-utils.js'
import { getAuthState } from '../auth/_state.js' import { getAuthState } from '../auth/_state.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js' import { _findMessageInUpdate } from './find-in-update.js'
import { _getDiscussionMessage } from './get-discussion-message.js' import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.js'
import { _processCommonSendParameters, CommonSendParams } from './send-common.js' import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
/** /**
@ -25,7 +25,7 @@ import { _processCommonSendParameters, CommonSendParams } from './send-common.js
export async function sendText( export async function sendText(
client: BaseTelegramClient, client: BaseTelegramClient,
chatId: InputPeerLike, chatId: InputPeerLike,
text: string | FormattedString<string>, text: InputText,
params?: CommonSendParams & { params?: CommonSendParams & {
/** /**
* For bots: inline or reply markup or an instruction * For bots: inline or reply markup or an instruction
@ -33,14 +33,6 @@ export async function sendText(
*/ */
replyMarkup?: ReplyMarkup replyMarkup?: ReplyMarkup
/**
* List of formatting entities to use instead of parsing via a
* parse mode.
*
* **Note:** Passing this makes the method ignore {@link parseMode}
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Whether to disable links preview in this message * Whether to disable links preview in this message
*/ */
@ -57,7 +49,7 @@ export async function sendText(
): Promise<Message> { ): Promise<Message> {
if (!params) params = {} if (!params) params = {}
const [message, entities] = await _parseEntities(client, text, params.parseMode, params.entities) const [message, entities] = await _normalizeInputText(client, text)
const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup) const replyMarkup = BotKeyboard._convertToTl(params.replyMarkup)
const { peer, replyTo } = await _processCommonSendParameters(client, chatId, params) const { peer, replyTo } = await _processCommonSendParameters(client, chatId, params)

View file

@ -0,0 +1,41 @@
import { BaseTelegramClient, tl } from '@mtcute/core'
import { InputText } from '../../types/misc/entities.js'
import { normalizeToInputUser } from '../../utils/peer-utils.js'
import { resolvePeer } from '../users/resolve-peer.js'
const empty: [string, undefined] = ['', undefined]
/** @internal */
export async function _normalizeInputText(
client: BaseTelegramClient,
input?: InputText,
): Promise<[string, tl.TypeMessageEntity[] | undefined]> {
if (!input) {
return empty
}
if (typeof input === 'string') {
return [input, undefined]
}
const { text, entities } = input
if (!entities) return [text, undefined]
// replace mentionName entities with input ones
for (const ent of entities) {
if (ent._ === 'messageEntityMentionName') {
try {
const inputPeer = normalizeToInputUser(await resolvePeer(client, ent.userId), ent.userId)
const ent_ = ent as unknown as tl.RawInputMessageEntityMentionName
ent_._ = 'inputMessageEntityMentionName'
ent_.userId = inputPeer
} catch (e) {
client.log.warn('Failed to resolve mention entity for %s: %s', ent.userId, e)
}
}
}
return [text, entities]
}

View file

@ -1,21 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { BaseTelegramClient } from '@mtcute/core'
import { IMessageEntityParser } from '../../types/index.js'
const STATE_SYMBOL = Symbol('parseModesState')
/** @internal */
export interface ParseModesState {
parseModes: Map<string, IMessageEntityParser>
defaultParseMode: string | null
}
/** @internal */
export function getParseModesState(client: BaseTelegramClient): ParseModesState {
// eslint-disable-next-line
return ((client as any)[STATE_SYMBOL] ??= {
parseModes: new Map(),
defaultParseMode: null,
} satisfies ParseModesState)
}

View file

@ -1,89 +0,0 @@
import { BaseTelegramClient, MtArgumentError } from '@mtcute/core'
import { IMessageEntityParser } from '../../types/index.js'
import { getParseModesState } from './_state.js'
/**
* Register a given {@link IMessageEntityParser} as a parse mode
* for messages. When this method is first called, given parse
* mode is also set as default.
*
* @param parseMode Parse mode to register
* @throws MtClientError When the parse mode with a given name is already registered.
*/
export function registerParseMode(client: BaseTelegramClient, parseMode: IMessageEntityParser): void {
const name = parseMode.name
const state = getParseModesState(client)
if (state.parseModes.has(name)) {
throw new MtArgumentError(`Parse mode ${name} is already registered. Unregister it first!`)
}
state.parseModes.set(name, parseMode)
if (!state.defaultParseMode) {
state.defaultParseMode = name
}
}
/**
* Unregister a parse mode by its name.
* Will silently fail if given parse mode does not exist.
*
* Also updates the default parse mode to the next one available, if any
*
* @param name Name of the parse mode to unregister
*/
export function unregisterParseMode(client: BaseTelegramClient, name: string): void {
const state = getParseModesState(client)
state.parseModes.delete(name)
if (state.defaultParseMode === name) {
const [first] = state.parseModes.keys()
state.defaultParseMode = first ?? null
}
}
/**
* Get a {@link IMessageEntityParser} registered under a given name (or a default one).
*
* @param name Name of the parse mode which parser to get.
* @throws MtClientError When the provided parse mode is not registered
* @throws MtClientError When `name` is omitted and there is no default parse mode
*/
export function getParseMode(client: BaseTelegramClient, name?: string | null): IMessageEntityParser {
const state = getParseModesState(client)
if (!name) {
if (!state.defaultParseMode) {
throw new MtArgumentError('There is no default parse mode')
}
name = state.defaultParseMode
}
const mode = state.parseModes.get(name)
if (!mode) {
throw new MtArgumentError(`Parse mode ${name} is not registered.`)
}
return mode
}
/**
* Set a given parse mode as a default one.
*
* @param name Name of the parse mode
* @throws MtClientError When given parse mode is not registered.
*/
export function setDefaultParseMode(client: BaseTelegramClient, name: string): void {
const state = getParseModesState(client)
if (!state.parseModes.has(name)) {
throw new MtArgumentError(`Parse mode ${name} is not registered.`)
}
state.defaultParseMode = name
}

View file

@ -1,9 +1,9 @@
import { BaseTelegramClient, tl } from '@mtcute/core' import { BaseTelegramClient, tl } from '@mtcute/core'
import { FormattedString, InputMediaLike, InputPeerLike, InputPrivacyRule, Story } from '../../types/index.js' import { InputMediaLike, InputPeerLike, InputPrivacyRule, InputText, Story } from '../../types/index.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _parseEntities } from '../messages/parse-entities.js'
import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js' import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _findStoryInUpdate } from './find-in-update.js' import { _findStoryInUpdate } from './find-in-update.js'
@ -35,20 +35,7 @@ export async function editStory(
/** /**
* Override caption for {@link media} * Override caption for {@link media}
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for {@link media}
*/
entities?: tl.TypeMessageEntity[]
/**
* Parse mode to use to parse entities before sending the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/** /**
* Interactive elements to add to the story * Interactive elements to add to the story
@ -70,22 +57,17 @@ export async function editStory(
let media: tl.TypeInputMedia | undefined = undefined let media: tl.TypeInputMedia | undefined = undefined
if (params.media) { if (params.media) {
media = await _normalizeInputMedia(client, params.media, params) media = await _normalizeInputMedia(client, params.media)
// if there's no caption in input media (i.e. not present or undefined), // if there's no caption in input media (i.e. not present or undefined),
// user wants to keep current caption, thus `content` needs to stay `undefined` // user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) { if ('caption' in params.media && params.media.caption !== undefined) {
[caption, entities] = await _parseEntities( [caption, entities] = await _normalizeInputText(client, params.media.caption)
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
} }
} }
if (params.caption) { if (params.caption) {
[caption, entities] = await _parseEntities(client, params.caption, params.parseMode, params.entities) [caption, entities] = await _normalizeInputText(client, params.caption)
} }
const privacyRules = params.privacyRules ? await _normalizePrivacyRules(client, params.privacyRules) : undefined const privacyRules = params.privacyRules ? await _normalizePrivacyRules(client, params.privacyRules) : undefined

View file

@ -1,10 +1,10 @@
import { BaseTelegramClient, tl } from '@mtcute/core' import { BaseTelegramClient, tl } from '@mtcute/core'
import { randomLong } from '@mtcute/core/utils.js' import { randomLong } from '@mtcute/core/utils.js'
import { FormattedString, InputMediaLike, InputPeerLike, InputPrivacyRule, Story } from '../../types/index.js' import { InputMediaLike, InputPeerLike, InputPrivacyRule, InputText, Story } from '../../types/index.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js' import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _parseEntities } from '../messages/parse-entities.js'
import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js' import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js' import { resolvePeer } from '../users/resolve-peer.js'
import { _findStoryInUpdate } from './find-in-update.js' import { _findStoryInUpdate } from './find-in-update.js'
@ -34,20 +34,7 @@ export async function sendStory(
/** /**
* Override caption for {@link media} * Override caption for {@link media}
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Override entities for {@link media}
*/
entities?: tl.TypeMessageEntity[]
/**
* Parse mode to use to parse entities before sending the message.
* Passing `null` will explicitly disable formatting.
*
* @default current default parse mode (if any).
*/
parseMode?: string | null
/** /**
* Whether to automatically pin this story to the profile * Whether to automatically pin this story to the profile
@ -89,19 +76,16 @@ export async function sendStory(
} }
} }
const inputMedia = await _normalizeInputMedia(client, media, params) const inputMedia = await _normalizeInputMedia(client, media)
const privacyRules = params.privacyRules ? const privacyRules = params.privacyRules ?
await _normalizePrivacyRules(client, params.privacyRules) : await _normalizePrivacyRules(client, params.privacyRules) :
[{ _: 'inputPrivacyValueAllowAll' } as const] [{ _: 'inputPrivacyValueAllowAll' } as const]
const [caption, entities] = await _parseEntities( const [caption, entities] = await _normalizeInputText(
client, client,
// some types dont have `caption` field, and ts warns us, // some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly // but since it's JS, they'll just be `undefined` and properly handled by the method
// handled by _parseEntities method
params.caption || (media as Extract<typeof media, { caption?: unknown }>).caption, params.caption || (media as Extract<typeof media, { caption?: unknown }>).caption,
params.parseMode,
params.entities || (media as Extract<typeof media, { entities?: unknown }>).entities,
) )
const res = await client.call({ const res = await client.call({

View file

@ -1,6 +1,7 @@
import { assertNever, BaseTelegramClient, tl } from '@mtcute/core' import { assertNever, BaseTelegramClient, tl } from '@mtcute/core'
import { _parseEntities } from '../../../methods/messages/parse-entities.js' import { _normalizeInputText } from '../../../methods/misc/normalize-text.js'
import { InputText } from '../../../types/misc/entities.js'
import { import {
InputMediaContact, InputMediaContact,
InputMediaGeo, InputMediaGeo,
@ -8,7 +9,6 @@ import {
InputMediaVenue, InputMediaVenue,
InputMediaWebpage, InputMediaWebpage,
} from '../../media/index.js' } from '../../media/index.js'
import { FormattedString } from '../../parser.js'
import { BotKeyboard, ReplyMarkup } from '../keyboards.js' import { BotKeyboard, ReplyMarkup } from '../keyboards.js'
/** /**
@ -20,13 +20,7 @@ export interface InputInlineMessageText {
/** /**
* Text of the message * Text of the message
*/ */
text: string | FormattedString<string> text: InputText
/**
* Text markup entities.
* If passed, parse mode is ignored
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Message reply markup * Message reply markup
@ -57,13 +51,7 @@ export interface InputInlineMessageMedia {
/** /**
* Caption for the media * Caption for the media
*/ */
text?: string | FormattedString<string> text?: InputText
/**
* Caption markup entities.
* If passed, parse mode is ignored
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Message reply markup * Message reply markup
@ -135,13 +123,7 @@ export interface InputInlineMessageWebpage extends InputMediaWebpage {
/** /**
* Text of the message * Text of the message
*/ */
text: string | FormattedString<string> text: InputText
/**
* Text markup entities.
* If passed, parse mode is ignored
*/
entities?: tl.TypeMessageEntity[]
/** /**
* Message reply markup * Message reply markup
@ -176,7 +158,7 @@ export namespace BotInlineMessage {
* @param params * @param params
*/ */
export function text( export function text(
text: string | FormattedString<string>, text: InputText,
params: Omit<InputInlineMessageText, 'type' | 'text'> = {}, params: Omit<InputInlineMessageText, 'type' | 'text'> = {},
): InputInlineMessageText { ): InputInlineMessageText {
const ret = params as tl.Mutable<InputInlineMessageText> const ret = params as tl.Mutable<InputInlineMessageText>
@ -266,11 +248,10 @@ export namespace BotInlineMessage {
export async function _convertToTl( export async function _convertToTl(
client: BaseTelegramClient, client: BaseTelegramClient,
obj: InputInlineMessage, obj: InputInlineMessage,
parseMode?: string | null,
): Promise<tl.TypeInputBotInlineMessage> { ): Promise<tl.TypeInputBotInlineMessage> {
switch (obj.type) { switch (obj.type) {
case 'text': { case 'text': {
const [message, entities] = await _parseEntities(client, obj.text, parseMode, obj.entities) const [message, entities] = await _normalizeInputText(client, obj.text)
return { return {
_: 'inputBotInlineMessageText', _: 'inputBotInlineMessageText',
@ -281,7 +262,7 @@ export namespace BotInlineMessage {
} }
} }
case 'media': { case 'media': {
const [message, entities] = await _parseEntities(client, obj.text, parseMode, obj.entities) const [message, entities] = await _normalizeInputText(client, obj.text)
return { return {
_: 'inputBotInlineMessageMediaAuto', _: 'inputBotInlineMessageMediaAuto',
@ -336,7 +317,7 @@ export namespace BotInlineMessage {
replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup), replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup),
} }
case 'webpage': { case 'webpage': {
const [message, entities] = await _parseEntities(client, obj.text, parseMode, obj.entities) const [message, entities] = await _normalizeInputText(client, obj.text)
return { return {
_: 'inputBotInlineMessageMediaWebPage', _: 'inputBotInlineMessageMediaWebPage',

View file

@ -725,7 +725,6 @@ export namespace BotInline {
export async function _convertToTl( export async function _convertToTl(
client: BaseTelegramClient, client: BaseTelegramClient,
results: InputInlineResult[], results: InputInlineResult[],
parseMode?: string | null,
): Promise<[boolean, tl.TypeInputBotInlineResult[]]> { ): Promise<[boolean, tl.TypeInputBotInlineResult[]]> {
const normalizeThumb = (obj: InputInlineResult, fallback?: string): tl.RawInputWebDocument | undefined => { const normalizeThumb = (obj: InputInlineResult, fallback?: string): tl.RawInputWebDocument | undefined => {
if (obj.type !== 'voice' && obj.type !== 'audio' && obj.type !== 'sticker' && obj.type !== 'game') { if (obj.type !== 'voice' && obj.type !== 'audio' && obj.type !== 'sticker' && obj.type !== 'game') {
@ -760,7 +759,7 @@ export namespace BotInline {
let sendMessage: tl.TypeInputBotInlineMessage let sendMessage: tl.TypeInputBotInlineMessage
if (obj.message) { if (obj.message) {
sendMessage = await BotInlineMessage._convertToTl(client, obj.message, parseMode) sendMessage = await BotInlineMessage._convertToTl(client, obj.message)
} else { } else {
let message = obj.title let message = obj.title
const entities: tl.TypeMessageEntity[] = [ const entities: tl.TypeMessageEntity[] = [
@ -817,7 +816,7 @@ export namespace BotInline {
let sendMessage: tl.TypeInputBotInlineMessage let sendMessage: tl.TypeInputBotInlineMessage
if (obj.message) { if (obj.message) {
sendMessage = await BotInlineMessage._convertToTl(client, obj.message, parseMode) sendMessage = await BotInlineMessage._convertToTl(client, obj.message)
if (sendMessage._ !== 'inputBotInlineMessageGame') { if (sendMessage._ !== 'inputBotInlineMessageGame') {
throw new MtArgumentError('game inline result must contain a game inline message') throw new MtArgumentError('game inline result must contain a game inline message')
@ -850,7 +849,7 @@ export namespace BotInline {
let sendMessage: tl.TypeInputBotInlineMessage let sendMessage: tl.TypeInputBotInlineMessage
if (obj.message) { if (obj.message) {
sendMessage = await BotInlineMessage._convertToTl(client, obj.message, parseMode) sendMessage = await BotInlineMessage._convertToTl(client, obj.message)
} else if (obj.type === 'venue') { } else if (obj.type === 'venue') {
if (obj.latitude && obj.longitude) { if (obj.latitude && obj.longitude) {
sendMessage = { sendMessage = {

View file

@ -7,7 +7,6 @@ export * from './files/index.js'
export * from './media/index.js' export * from './media/index.js'
export * from './messages/index.js' export * from './messages/index.js'
export * from './misc/index.js' export * from './misc/index.js'
export * from './parser.js'
export * from './peers/index.js' export * from './peers/index.js'
export * from './reactions/index.js' export * from './reactions/index.js'
export * from './stories/index.js' export * from './stories/index.js'

View file

@ -1,7 +1,7 @@
import { MaybeArray, tl } from '@mtcute/core' import { MaybeArray, tl } from '@mtcute/core'
import { InputText } from '../../types/misc/entities.js'
import { InputFileLike } from '../files/index.js' import { InputFileLike } from '../files/index.js'
import { FormattedString } from '../parser.js'
import { InputPeerLike } from '../peers/index.js' import { InputPeerLike } from '../peers/index.js'
import { VenueSource } from './venue.js' import { VenueSource } from './venue.js'
@ -9,13 +9,7 @@ export interface CaptionMixin {
/** /**
* Caption of the media * Caption of the media
*/ */
caption?: string | FormattedString<string> caption?: InputText
/**
* Caption entities of the media.
* If passed, parse mode is ignored
*/
entities?: tl.TypeMessageEntity[]
} }
export interface FileMixin { export interface FileMixin {
@ -541,13 +535,7 @@ export interface InputMediaQuiz extends Omit<InputMediaPoll, 'type'> {
/** /**
* Explanation of the quiz solution * Explanation of the quiz solution
*/ */
solution?: string | FormattedString<string> solution?: InputText
/**
* Format entities for `solution`.
* If used, parse mode is ignored.
*/
solutionEntities?: tl.TypeMessageEntity[]
} }
/** /**

View file

@ -1,11 +1,17 @@
import { tl } from '@mtcute/core' import { tl } from '@mtcute/core'
/** /**
* Interface describing some text with entities. * Formatted text with entities
*
* Primarily used as a return type for parsers.
*/ */
export interface TextWithEntities { export interface TextWithEntities {
readonly text: string readonly text: string
readonly entities: tl.TypeMessageEntity[] readonly entities?: tl.TypeMessageEntity[]
} }
/**
* Type to be used as a parameter for methods that accept
* a formatted text with entities.
*
* Can be either a plain string or an object with `text` and `entities` fields.
*/
export type InputText = string | TextWithEntities

View file

@ -1,3 +1,4 @@
export * from './entities.js'
export * from './input-privacy-rule.js' export * from './input-privacy-rule.js'
export * from './sticker-set.js' export * from './sticker-set.js'
export * from './takeout-session.js' export * from './takeout-session.js'

View file

@ -1,55 +0,0 @@
import { tl } from '@mtcute/core'
/**
* Interface describing a message entity parser.
*
* mtcute comes with HTML parser inside `@mtcute/html-parser`
* and Markdown parser inside `@mtcute/markdown-parser`.
*
* You are also free to implement your own parser and register it with
* {@link TelegramClient.registerParseMode}.
*/
export interface IMessageEntityParser {
/**
* Parser name, which will be used when registering it.
*/
name: string
/**
* Parse a string containing some text with formatting to plain text
* and message entities
*
* @param text Formatted text
* @returns A tuple containing plain text and a list of entities
*/
parse(text: string): [string, tl.TypeMessageEntity[]]
/**
* Add formatting to the text given the plain text and the entities.
*
* > **Note**: `unparse(parse(text)) === text` is not always true!
*
* @param text Plain text
* @param entities Message entities that should be added to the text
*/
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string
}
/**
* Raw string that will not be escaped when passing
* to tagged template helpers (like `html` and `md`)
*/
export class FormattedString<T extends string = never> {
/**
* @param value Value that the string holds
* @param mode Name of the parse mode used
*/
constructor(
readonly value: string,
readonly mode?: T,
) {}
toString(): string {
return this.value
}
}

View file

@ -2,7 +2,6 @@
📖 [API Reference](https://ref.mtcute.dev/modules/_mtcute_html_parser.html) 📖 [API Reference](https://ref.mtcute.dev/modules/_mtcute_html_parser.html)
HTML entities parser for mtcute HTML entities parser for mtcute
> **NOTE**: The syntax implemented here is **incompatible** with Bot API _HTML_. > **NOTE**: The syntax implemented here is **incompatible** with Bot API _HTML_.
@ -12,21 +11,20 @@ HTML entities parser for mtcute
## Features ## Features
- Supports all entities that Telegram supports - Supports all entities that Telegram supports
- Supports nested entities - Supports nested entities
- Proper newline handling (just like in real HTML) - Proper newline/whitespace handling (just like in real HTML)
- Automatic escaping of user input - [Interpolation](#interpolation)!
## Usage ## Usage
```ts ```ts
import { TelegramClient } from '@mtcute/client' import { html } from '@mtcute/html-parser'
import { HtmlMessageEntityParser, html } from '@mtcute/html-parser'
const tg = new TelegramClient({ ... })
tg.registerParseMode(new HtmlMessageEntityParser())
tg.sendText( tg.sendText(
'me', 'me',
html`Hello, <b>me</b>! Updates from the feed:<br>${await getUpdatesFromFeed()}` html`
Hello, <b>me</b>! Updates from the feed:<br>
${await getUpdatesFromFeed()}
`
) )
``` ```
@ -97,18 +95,20 @@ Overlapping entities are supported in `unparse()`, though.
| `<b>bold <i>and</b> italic</i>` | **bold _and_** italic<br>⚠️ <i>word "italic" is not actually italic!</i> | | `<b>bold <i>and</b> italic</i>` | **bold _and_** italic<br>⚠️ <i>word "italic" is not actually italic!</i> |
| `<b>bold <i>and</i></b><i> italic</i>`<br>⚠️ <i>this is how <code>unparse()</code> handles overlapping entities</i> | **bold _and_** _italic_ | | `<b>bold <i>and</i></b><i> italic</i>`<br>⚠️ <i>this is how <code>unparse()</code> handles overlapping entities</i> | **bold _and_** _italic_ |
## Escaping ## Interpolation
Escaping in this parser works exactly the same as in `htmlparser2`. Being a tagged template literal, `html` supports interpolation.
This means that you can keep `<>&` symbols as-is in some cases. However, when dealing with user input, it is always You can interpolate one of the following:
better to use [`HtmlMessageEntityParser.escape`](./classes/htmlmessageentityparser.html#escape) or, even better, - `string` - **will not** be parsed, and appended to plain text as-is
`html` helper: - In case you want the string to be parsed, use `html` as a simple function: <code>html\`... ${html('**bold**')} ...\`</code>
- `number` - will be converted to string and appended to plain text as-is
- `TextWithEntities` or `MessageEntity` - will add the text and its entities to the output. This is the type returned by `html` itself:
```ts
const bold = html`**bold**`
const text = html`Hello, ${bold}!`
```
- falsy value (i.e. `null`, `undefined`, `false`) - will be ignored
```typescript Note that because of interpolation, you almost never need to think about escaping anything,
import { html } from '@mtcute/html-parser' since the values are not even parsed as HTML, and are appended to the output as-is.
const username = 'Boris <&>'
const text = html`Hi, ${username}!`
console.log(text) // Hi, Boris &amp;lt;&amp;amp;&amp;gt;!
```

View file

@ -1,84 +1,27 @@
import { Parser } from 'htmlparser2' import { Parser } from 'htmlparser2'
import Long from 'long' import Long from 'long'
import type { FormattedString, IMessageEntityParser, MessageEntity, tl } from '@mtcute/client' import type { InputText, MessageEntity, TextWithEntities, tl } from '@mtcute/client'
const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/ const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/
/** /**
* Tagged template based helper for escaping entities in HTML * Escape a string to be safely used in HTML.
* *
* @example * > **Note**: this function is in most cases not needed, as `html` function
* ```typescript * > handles all `string`s passed to it automatically as plain text.
* const escaped = html`<b>${user.displayName}</b>`
* ```
*/ */
export function html( function escape(str: string, quote = false): string {
strings: TemplateStringsArray,
...sub: (string | FormattedString<'html'> | MessageEntity | boolean | undefined | null)[]
): FormattedString<'html'> {
let str = ''
sub.forEach((it, idx) => {
if (typeof it === 'boolean' || !it) return
if (typeof it === 'string') {
it = HtmlMessageEntityParser.escape(it, Boolean(str.match(/=['"]$/)))
} else if ('raw' in it) {
it = new HtmlMessageEntityParser().unparse(it.text, [it.raw])
} else {
if (it.mode && it.mode !== 'html') {
throw new Error(`Incompatible parse mode: ${it.mode}`)
}
it = it.value
}
str += strings[idx] + it
})
return { value: str + strings[strings.length - 1], mode: 'html' }
}
/**
* Syntax highlighter function used in {@link HtmlMessageEntityParser.unparse}
*
* Must be sync (this might change in the future) and must return valid HTML.
*/
export type SyntaxHighlighter = (code: string, language: string) => string
export interface HtmlMessageEntityParserOptions {
syntaxHighlighter?: SyntaxHighlighter
}
/**
* HTML MessageEntity parser.
*
* This class implements syntax very similar to one available
* in the Bot API ([documented here](https://core.telegram.org/bots/api#html-style))
* with some slight differences.
*/
export class HtmlMessageEntityParser implements IMessageEntityParser {
name = 'html'
private readonly _syntaxHighlighter?: SyntaxHighlighter
constructor(options?: HtmlMessageEntityParserOptions) {
this._syntaxHighlighter = options?.syntaxHighlighter
}
/**
* Escape the string so it can be safely used inside HTML
*
* @param str String to be escaped
* @param quote Whether `"` (double quote) should be escaped as `&quot;`
*/
static escape(str: string, quote = false): string {
str = str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') str = str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (quote) str = str.replace(/"/g, '&quot;') if (quote) str = str.replace(/"/g, '&quot;')
return str return str
} }
parse(text: string): [string, tl.TypeMessageEntity[]] { function parse(
strings: TemplateStringsArray | string,
...sub: (InputText | MessageEntity | boolean | number | undefined | null)[]
): TextWithEntities {
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {} const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
const entities: tl.TypeMessageEntity[] = [] const entities: tl.TypeMessageEntity[] = []
let plainText = '' let plainText = ''
@ -271,29 +214,63 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
}, },
}) })
parser.write(text) if (typeof strings === 'string') strings = [strings] as unknown as TemplateStringsArray
sub.forEach((it, idx) => {
parser.write(strings[idx])
if (typeof it === 'boolean' || !it) return
if (typeof it === 'string' || typeof it === 'number') {
pendingText += it
} else {
// TextWithEntities or MessageEntity
const text = it.text
const innerEntities = 'raw' in it ? [it.raw] : it.entities
processPendingText()
const baseOffset = plainText.length
pendingText += text
if (innerEntities) {
for (const ent of innerEntities) {
entities.push({ ...ent, offset: ent.offset + baseOffset })
}
}
}
})
parser.write(strings[strings.length - 1])
processPendingText(true) processPendingText(true)
return [plainText.replace(/\u00A0/g, ' '), entities] return {
text: plainText.replace(/\u00A0/g, ' '),
entities,
} }
}
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string { /** Options passed to `html.unparse` */
return this._unparse(text, entities) export interface HtmlUnparseOptions {
} /**
* Syntax highlighter to use when un-parsing `pre` tags with language
*/
syntaxHighlighter?: (code: string, language: string) => string
}
// internal function that uses recursion to correctly process nested & overlapping entities // internal function that uses recursion to correctly process nested & overlapping entities
private _unparse( function _unparse(
text: string, text: string,
entities: ReadonlyArray<tl.TypeMessageEntity>, entities: ReadonlyArray<tl.TypeMessageEntity>,
params: HtmlUnparseOptions,
entitiesOffset = 0, entitiesOffset = 0,
offset = 0, offset = 0,
length = text.length, length = text.length,
): string { ): string {
if (!text) return text if (!text) return text
if (!entities.length || entities.length === entitiesOffset) { if (!entities.length || entities.length === entitiesOffset) {
return HtmlMessageEntityParser.escape(text) return escape(text)
.replace(/\n/g, '<br>') .replace(/\n/g, '<br>')
.replace(/ {2,}/g, (match) => { .replace(/ {2,}/g, (match) => {
return '&nbsp;'.repeat(match.length) return '&nbsp;'.repeat(match.length)
@ -321,7 +298,7 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
if (relativeOffset > lastOffset) { if (relativeOffset > lastOffset) {
// add missing plain text // add missing plain text
html.push(HtmlMessageEntityParser.escape(text.substring(lastOffset, relativeOffset))) html.push(escape(text.substring(lastOffset, relativeOffset)))
} else if (relativeOffset < lastOffset) { } else if (relativeOffset < lastOffset) {
length -= lastOffset - relativeOffset length -= lastOffset - relativeOffset
relativeOffset = lastOffset relativeOffset = lastOffset
@ -343,7 +320,7 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
if (type === 'messageEntityPre') { if (type === 'messageEntityPre') {
entityText = substr entityText = substr
} else { } else {
entityText = this._unparse(substr, entities, i + 1, offset + relativeOffset, length) entityText = _unparse(substr, entities, params, i + 1, offset + relativeOffset, length)
} }
switch (type) { switch (type) {
@ -372,8 +349,8 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
case 'messageEntityPre': case 'messageEntityPre':
html.push( html.push(
`<pre${entity.language ? ` language="${entity.language}"` : ''}>${ `<pre${entity.language ? ` language="${entity.language}"` : ''}>${
this._syntaxHighlighter && entity.language ? params.syntaxHighlighter && entity.language ?
this._syntaxHighlighter(entityText, entity.language) : params.syntaxHighlighter(entityText, entity.language) :
entityText entityText
}</pre>`, }</pre>`,
) )
@ -385,7 +362,7 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
html.push(`<a href="${entityText}">${entityText}</a>`) html.push(`<a href="${entityText}">${entityText}</a>`)
break break
case 'messageEntityTextUrl': case 'messageEntityTextUrl':
html.push(`<a href="${HtmlMessageEntityParser.escape(entity.url, true)}">${entityText}</a>`) html.push(`<a href="${escape(entity.url, true)}">${entityText}</a>`)
break break
case 'messageEntityMentionName': case 'messageEntityMentionName':
html.push(`<a href="tg://user?id=${entity.userId}">${entityText}</a>`) html.push(`<a href="tg://user?id=${entity.userId}">${entityText}</a>`)
@ -398,8 +375,61 @@ export class HtmlMessageEntityParser implements IMessageEntityParser {
lastOffset = relativeOffset + (skip ? 0 : length) lastOffset = relativeOffset + (skip ? 0 : length)
} }
html.push(HtmlMessageEntityParser.escape(text.substr(lastOffset))) html.push(escape(text.substr(lastOffset)))
return html.join('') return html.join('')
}
} }
/**
* Add HTML formatting to the text given the plain text and entities contained in it.
*/
function unparse(input: InputText, options?: HtmlUnparseOptions): string {
if (typeof input === 'string') {
return _unparse(input, [], options ?? {})
}
return _unparse(input.text, input.entities ?? [], options ?? {})
}
// typedoc doesn't support this yet, so we'll have to do it manually
// https://github.com/TypeStrong/typedoc/issues/2436
export const html: {
/**
* Tagged template based HTML-to-entities parser function
*
* Additionally, `md` function has two static methods:
* - `html.escape` - escape a string to be safely used in HTML
* (should not be needed in most cases, as `html` function itself handles all `string`s
* passed to it automatically as plain text)
* - `html.unparse` - add HTML formatting to the text given the plain text and entities contained in it
*
* @example
* ```typescript
* const text = html`<b>${user.displayName}</b>`
* ```
*/
(
strings: TemplateStringsArray,
...sub: (InputText | MessageEntity | boolean | number | undefined | null)[]
): TextWithEntities
/**
* A variant taking a plain JS string as input
* and parsing it.
*
* Useful for cases when you already have a string
* (e.g. from some server) and want to parse it.
*
* @example
* ```typescript
* const string = '<b>hello</b>'
* const text = html(string)
* ```
*/
(string: string): TextWithEntities
escape: typeof escape
unparse: typeof unparse
} = Object.assign(parse, {
escape,
unparse,
})

View file

@ -2,9 +2,12 @@ import { expect } from 'chai'
import Long from 'long' import Long from 'long'
import { describe, it } from 'mocha' import { describe, it } from 'mocha'
import { FormattedString, tl } from '@mtcute/client' import { MessageEntity, TextWithEntities, tl } from '@mtcute/client'
import { html, HtmlMessageEntityParser } from '../src/index.js' // prettier has "html" special-cased which breaks the formatting
// this is not an issue when using normally, since we properly handle newlines/spaces,
// but here we want to test everything as it is
import { html as htm, HtmlUnparseOptions } from '../src/index.js'
const createEntity = <T extends tl.TypeMessageEntity['_']>( const createEntity = <T extends tl.TypeMessageEntity['_']>(
type: T, type: T,
@ -21,11 +24,14 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
} }
describe('HtmlMessageEntityParser', () => { describe('HtmlMessageEntityParser', () => {
const parser = new HtmlMessageEntityParser()
describe('unparse', () => { describe('unparse', () => {
const test = (text: string, entities: tl.TypeMessageEntity[], expected: string, _parser = parser): void => { const test = (
expect(_parser.unparse(text, entities)).eq(expected) text: string,
entities: tl.TypeMessageEntity[],
expected: string,
params?: HtmlUnparseOptions,
): void => {
expect(htm.unparse({ text, entities }, params)).eq(expected)
} }
it('should return the same text if there are no entities or text', () => { it('should return the same text if there are no entities or text', () => {
@ -197,10 +203,6 @@ describe('HtmlMessageEntityParser', () => {
}) })
it('should work with custom syntax highlighter', () => { it('should work with custom syntax highlighter', () => {
const parser = new HtmlMessageEntityParser({
syntaxHighlighter: (code, lang) => `lang: <b>${lang}</b><br>${code}`,
})
test( test(
'plain console.log("Hello, world!") some code plain', 'plain console.log("Hello, world!") some code plain',
[ [
@ -210,7 +212,9 @@ describe('HtmlMessageEntityParser', () => {
createEntity('messageEntityPre', 35, 9, { language: '' }), createEntity('messageEntityPre', 35, 9, { language: '' }),
], ],
'plain <pre language="javascript">lang: <b>javascript</b><br>console.log("Hello, world!")</pre> <pre>some code</pre> plain', 'plain <pre language="javascript">lang: <b>javascript</b><br>console.log("Hello, world!")</pre> <pre>some code</pre> plain',
parser, {
syntaxHighlighter: (code, lang) => `lang: <b>${lang}</b><br>${code}`,
},
) )
}) })
@ -226,15 +230,14 @@ describe('HtmlMessageEntityParser', () => {
}) })
describe('parse', () => { describe('parse', () => {
const test = (text: string, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => { const test = (text: TextWithEntities, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => {
const [_text, entities] = parser.parse(text) expect(text.text).eql(expectedText)
expect(_text).eql(expectedText) expect(text.entities ?? []).eql(expectedEntities)
expect(entities).eql(expectedEntities)
} }
it('should handle <b>, <i>, <u>, <s> tags', () => { it('should handle <b>, <i>, <u>, <s> tags', () => {
test( test(
'plain <b>bold</b> <i>italic</i> <u>underline</u> <s>strikethrough</s> plain', htm`plain <b>bold</b> <i>italic</i> <u>underline</u> <s>strikethrough</s> plain`,
[ [
createEntity('messageEntityBold', 6, 4), createEntity('messageEntityBold', 6, 4),
createEntity('messageEntityItalic', 11, 6), createEntity('messageEntityItalic', 11, 6),
@ -247,7 +250,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle <code>, <pre>, <blockquote>, <spoiler> tags', () => { it('should handle <code>, <pre>, <blockquote>, <spoiler> tags', () => {
test( test(
'plain <code>code</code> <pre>pre</pre> <blockquote>blockquote</blockquote> <spoiler>spoiler</spoiler> plain', htm`plain <code>code</code> <pre>pre</pre> <blockquote>blockquote</blockquote> <spoiler>spoiler</spoiler> plain`,
[ [
createEntity('messageEntityCode', 6, 4), createEntity('messageEntityCode', 6, 4),
createEntity('messageEntityPre', 11, 3, { language: '' }), createEntity('messageEntityPre', 11, 3, { language: '' }),
@ -260,7 +263,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle links and text mentions', () => { it('should handle links and text mentions', () => {
test( test(
'plain https://google.com <a href="https://google.com">google</a> @durov <a href="tg://user?id=36265675">Pavel Durov</a> plain', htm`plain https://google.com <a href="https://google.com">google</a> @durov <a href="tg://user?id=36265675">Pavel Durov</a> plain`,
[ [
createEntity('messageEntityTextUrl', 25, 6, { createEntity('messageEntityTextUrl', 25, 6, {
url: 'https://google.com', url: 'https://google.com',
@ -273,7 +276,7 @@ describe('HtmlMessageEntityParser', () => {
) )
test( test(
'<a href="tg://user?id=1234567&hash=aabbccddaabbccdd">user</a>', htm`<a href="tg://user?id=1234567&hash=aabbccddaabbccdd">user</a>`,
[ [
createEntity('inputMessageEntityMentionName', 0, 4, { createEntity('inputMessageEntityMentionName', 0, 4, {
userId: { userId: {
@ -289,7 +292,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle language in <pre>', () => { it('should handle language in <pre>', () => {
test( test(
'plain <pre language="javascript">console.log("Hello, world!")</pre> <pre>some code</pre> plain', htm`plain <pre language="javascript">console.log("Hello, world!")</pre> <pre>some code</pre> plain`,
[ [
createEntity('messageEntityPre', 6, 28, { createEntity('messageEntityPre', 6, 28, {
language: 'javascript', language: 'javascript',
@ -302,31 +305,31 @@ describe('HtmlMessageEntityParser', () => {
it('should ignore other tags inside <pre>', () => { it('should ignore other tags inside <pre>', () => {
test( test(
'<pre><b>bold</b> and not bold</pre>', htm`<pre><b>bold</b> and not bold</pre>`,
[createEntity('messageEntityPre', 0, 17, { language: '' })], [createEntity('messageEntityPre', 0, 17, { language: '' })],
'bold and not bold', 'bold and not bold',
) )
test( test(
'<pre><pre>pre inside pre</pre> so cool</pre>', htm`<pre><pre>pre inside pre</pre> so cool</pre>`,
[createEntity('messageEntityPre', 0, 22, { language: '' })], [createEntity('messageEntityPre', 0, 22, { language: '' })],
'pre inside pre so cool', 'pre inside pre so cool',
) )
}) })
it('should ignore newlines and indentation', () => { it('should ignore newlines and indentation', () => {
test('this is some text\n\nwith newlines', [], 'this is some text with newlines') test(htm`this is some text\n\nwith newlines`, [], 'this is some text with newlines')
test( test(
'<b>this is some text\n\nwith</b> newlines', htm`<b>this is some text\n\nwith</b> newlines`,
[createEntity('messageEntityBold', 0, 22)], [createEntity('messageEntityBold', 0, 22)],
'this is some text with newlines', 'this is some text with newlines',
) )
test( test(
'<b>this is some text ending with\n\n</b> newlines', htm`<b>this is some text ending with\n\n</b> newlines`,
[createEntity('messageEntityBold', 0, 29)], [createEntity('messageEntityBold', 0, 29)],
'this is some text ending with newlines', 'this is some text ending with newlines',
) )
test( test(
` htm`
this is some indented text this is some indented text
with newlines and with newlines and
<b> <b>
@ -341,7 +344,7 @@ describe('HtmlMessageEntityParser', () => {
it('should not ignore newlines and indentation in pre', () => { it('should not ignore newlines and indentation in pre', () => {
test( test(
'<pre>this is some text\n\nwith newlines</pre>', htm`<pre>this is some text\n\nwith newlines</pre>`,
[createEntity('messageEntityPre', 0, 32, { language: '' })], [createEntity('messageEntityPre', 0, 32, { language: '' })],
'this is some text\n\nwith newlines', 'this is some text\n\nwith newlines',
) )
@ -349,7 +352,7 @@ describe('HtmlMessageEntityParser', () => {
// fuck my life // fuck my life
const indent = ' ' const indent = ' '
test( test(
`<pre> htm`<pre>
this is some indented text this is some indented text
with newlines and with newlines and
<b> <b>
@ -376,9 +379,9 @@ describe('HtmlMessageEntityParser', () => {
}) })
it('should handle <br>', () => { it('should handle <br>', () => {
test('this is some text<br><br>with actual newlines', [], 'this is some text\n\nwith actual newlines') test(htm`this is some text<br><br>with actual newlines`, [], 'this is some text\n\nwith actual newlines')
test( test(
'<b>this is some text<br><br></b>with actual newlines', htm`<b>this is some text<br><br></b>with actual newlines`,
// note that the <br> (i.e. \n) is not included in the entity // note that the <br> (i.e. \n) is not included in the entity
// this is expected, and the result is the same // this is expected, and the result is the same
[createEntity('messageEntityBold', 0, 17)], [createEntity('messageEntityBold', 0, 17)],
@ -388,7 +391,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle &nbsp;', () => { it('should handle &nbsp;', () => {
test( test(
'one space, many&nbsp;&nbsp;&nbsp;&nbsp;spaces, and<br>a newline', htm`one space, many&nbsp;&nbsp;&nbsp;&nbsp;spaces, and<br>a newline`,
[], [],
'one space, many spaces, and\na newline', 'one space, many spaces, and\na newline',
) )
@ -396,19 +399,19 @@ describe('HtmlMessageEntityParser', () => {
it('should support entities on the edges', () => { it('should support entities on the edges', () => {
test( test(
'<b>Hello</b>, <b>world</b>', htm`<b>Hello</b>, <b>world</b>`,
[createEntity('messageEntityBold', 0, 5), createEntity('messageEntityBold', 7, 5)], [createEntity('messageEntityBold', 0, 5), createEntity('messageEntityBold', 7, 5)],
'Hello, world', 'Hello, world',
) )
}) })
it('should return empty array if there are no entities', () => { it('should return empty array if there are no entities', () => {
test('Hello, world', [], 'Hello, world') test(htm`Hello, world`, [], 'Hello, world')
}) })
it('should support entities followed by each other', () => { it('should support entities followed by each other', () => {
test( test(
'plain <b>Hello,</b><i> world</i> plain', htm`plain <b>Hello,</b><i> world</i> plain`,
[createEntity('messageEntityBold', 6, 6), createEntity('messageEntityItalic', 12, 6)], [createEntity('messageEntityBold', 6, 6), createEntity('messageEntityItalic', 12, 6)],
'plain Hello, world plain', 'plain Hello, world plain',
) )
@ -416,7 +419,7 @@ describe('HtmlMessageEntityParser', () => {
it('should support nested entities', () => { it('should support nested entities', () => {
test( test(
'<i>Welcome to the <b>gym zone</b>!</i>', htm`<i>Welcome to the <b>gym zone</b>!</i>`,
[createEntity('messageEntityBold', 15, 8), createEntity('messageEntityItalic', 0, 24)], [createEntity('messageEntityBold', 15, 8), createEntity('messageEntityItalic', 0, 24)],
'Welcome to the gym zone!', 'Welcome to the gym zone!',
) )
@ -424,22 +427,22 @@ describe('HtmlMessageEntityParser', () => {
it('should support nested entities with the same edges', () => { it('should support nested entities with the same edges', () => {
test( test(
'<i>Welcome to the <b>gym zone!</b></i>', htm`<i>Welcome to the <b>gym zone!</b></i>`,
[createEntity('messageEntityBold', 15, 9), createEntity('messageEntityItalic', 0, 24)], [createEntity('messageEntityBold', 15, 9), createEntity('messageEntityItalic', 0, 24)],
'Welcome to the gym zone!', 'Welcome to the gym zone!',
) )
test( test(
'<b>Welcome to the <i>gym zone!</i></b>', htm`<b>Welcome to the <i>gym zone!</i></b>`,
[createEntity('messageEntityItalic', 15, 9), createEntity('messageEntityBold', 0, 24)], [createEntity('messageEntityItalic', 15, 9), createEntity('messageEntityBold', 0, 24)],
'Welcome to the gym zone!', 'Welcome to the gym zone!',
) )
test( test(
'<i><b>Welcome</b> to the gym zone!</i>', htm`<i><b>Welcome</b> to the gym zone!</i>`,
[createEntity('messageEntityBold', 0, 7), createEntity('messageEntityItalic', 0, 24)], [createEntity('messageEntityBold', 0, 7), createEntity('messageEntityItalic', 0, 24)],
'Welcome to the gym zone!', 'Welcome to the gym zone!',
) )
test( test(
'<i><b>Welcome to the gym zone!</b></i>', htm`<i><b>Welcome to the gym zone!</b></i>`,
[createEntity('messageEntityBold', 0, 24), createEntity('messageEntityItalic', 0, 24)], [createEntity('messageEntityBold', 0, 24), createEntity('messageEntityItalic', 0, 24)],
'Welcome to the gym zone!', 'Welcome to the gym zone!',
) )
@ -447,7 +450,7 @@ describe('HtmlMessageEntityParser', () => {
it('should properly handle emojis', () => { it('should properly handle emojis', () => {
test( test(
"<i>best flower</i>: <b>🌸</b>. <i>don't</i> you even doubt it.", htm`<i>best flower</i>: <b>🌸</b>. <i>don't</i> you even doubt it.`,
[ [
createEntity('messageEntityItalic', 0, 11), createEntity('messageEntityItalic', 0, 11),
createEntity('messageEntityBold', 13, 2), createEntity('messageEntityBold', 13, 2),
@ -458,12 +461,12 @@ describe('HtmlMessageEntityParser', () => {
}) })
it('should handle non-escaped special symbols', () => { it('should handle non-escaped special symbols', () => {
test('<&> <b>< & ></b> <&>', [createEntity('messageEntityBold', 4, 5)], '<&> < & > <&>') test(htm`<&> <b>< & ></b> <&>`, [createEntity('messageEntityBold', 4, 5)], '<&> < & > <&>')
}) })
it('should unescape special symbols', () => { it('should unescape special symbols', () => {
test( test(
'&lt;&amp;&gt; <b>&lt; &amp; &gt;</b> &lt;&amp;&gt; <a href="/?a=&quot;hello&quot;&amp;b">link</a>', htm`&lt;&amp;&gt; <b>&lt; &amp; &gt;</b> &lt;&amp;&gt; <a href="/?a=&quot;hello&quot;&amp;b">link</a>`,
[ [
createEntity('messageEntityBold', 4, 5), createEntity('messageEntityBold', 4, 5),
createEntity('messageEntityTextUrl', 14, 4, { createEntity('messageEntityTextUrl', 14, 4, {
@ -475,44 +478,96 @@ describe('HtmlMessageEntityParser', () => {
}) })
it('should ignore other tags', () => { it('should ignore other tags', () => {
test('<script>alert(1)</script>', [], 'alert(1)') test(htm`<script>alert(1)</script>`, [], 'alert(1)')
}) })
it('should ignore empty urls', () => { it('should ignore empty urls', () => {
test('<a href="">link</a> <a>link</a>', [], 'link link') test(htm`<a href="">link</a> <a>link</a>`, [], 'link link')
})
}) })
describe('template', () => { describe('template', () => {
it('should work as a tagged template literal', () => { it('should add plain strings as is', () => {
const unsafeString = '<&>' test(
htm`some text ${'<b>not bold yea</b>'} some more text`,
expect(html`${unsafeString}`.value).eq('&lt;&amp;&gt;') [],
expect(html`${unsafeString} <b>text</b>`.value).eq('&lt;&amp;&gt; <b>text</b>') 'some text <b>not bold yea</b> some more text',
expect(html`<b>text</b> ${unsafeString}`.value).eq('<b>text</b> &lt;&amp;&gt;') )
expect(html`<b>${unsafeString}</b>`.value).eq('<b>&lt;&amp;&gt;</b>')
}) })
it('should skip with FormattedString', () => { it('should skip falsy values', () => {
const unsafeString2 = '<&>' test(htm`some text ${null} some ${false} more text`, [], 'some text some more text')
const unsafeString = new FormattedString('<&>')
expect(html`${unsafeString}`.value).eq('<&>')
expect(html`${unsafeString} ${unsafeString2}`.value).eq('<&> &lt;&amp;&gt;')
expect(html`${unsafeString} <b>text</b>`.value).eq('<&> <b>text</b>')
expect(html`<b>text</b> ${unsafeString}`.value).eq('<b>text</b> <&>')
expect(html`<b>${unsafeString}</b>`.value).eq('<b><&></b>')
expect(html`<b>${unsafeString} ${unsafeString2}</b>`.value).eq('<b><&> &lt;&amp;&gt;</b>')
}) })
it('should error with incompatible FormattedString', () => { it('should process entities', () => {
const unsafeString = new FormattedString('<&>', 'html') const inner = htm`<b>bold</b>`
const unsafeString2 = new FormattedString('<&>', 'some-other-mode') test(
htm`some text ${inner} some more text`,
[createEntity('messageEntityBold', 10, 4)],
'some text bold some more text',
)
test(
htm`some text ${inner} some more ${inner} text`,
[createEntity('messageEntityBold', 10, 4), createEntity('messageEntityBold', 25, 4)],
'some text bold some more bold text',
)
})
expect(() => html`${unsafeString}`.value).not.throw(Error) it('should process entities on edges', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment test(
// @ts-expect-error htm`${htm`<b>bold</b>`} and ${htm`<i>italic</i>`}`,
expect(() => html`${unsafeString2}`.value).throw(Error) [createEntity('messageEntityBold', 0, 4), createEntity('messageEntityItalic', 9, 6)],
'bold and italic',
)
})
it('should process nested entities', () => {
test(
htm`<b>bold ${htm`<i>bold italic</i>`} more bold</b>`,
[createEntity('messageEntityItalic', 5, 11), createEntity('messageEntityBold', 0, 26)],
'bold bold italic more bold',
)
test(
htm`<b>bold ${htm`<i>bold italic</i> <u>and some underline</u>`} more bold</b>`,
[
createEntity('messageEntityItalic', 5, 11),
createEntity('messageEntityUnderline', 17, 18),
createEntity('messageEntityBold', 0, 45),
],
'bold bold italic and some underline more bold',
)
test(
htm`<b>${htm`<i>bold italic <u>underline</u></i>`}</b>`,
[
createEntity('messageEntityUnderline', 12, 9),
createEntity('messageEntityItalic', 0, 21),
createEntity('messageEntityBold', 0, 21),
],
'bold italic underline',
)
})
it('should process MessageEntity', () => {
test(
htm`<b>bold ${new MessageEntity(
createEntity('messageEntityItalic', 0, 11),
'bold italic',
)} more bold</b>`,
[createEntity('messageEntityItalic', 5, 11), createEntity('messageEntityBold', 0, 26)],
'bold bold italic more bold',
)
})
it('should support simple function usage', () => {
// assuming we are receiving it e.g. from a server
const someHtml = '<b>bold</b>'
test(htm(someHtml), [createEntity('messageEntityBold', 0, 4)], 'bold')
test(
htm`text ${htm(someHtml)} more text`,
[createEntity('messageEntityBold', 5, 4)],
'text bold more text',
)
})
}) })
}) })
}) })

View file

@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { FormattedString } from '@mtcute/client' import type { tl } from '@mtcute/client'
type Values<T> = T[keyof T] type Values<T> = T[keyof T]
type SafeGet<T, K extends string> = T extends Record<K, unknown> ? T[K] : never type SafeGet<T, K extends string> = T extends Record<K, unknown> ? T[K] : never
@ -8,7 +8,18 @@ type SafeGet<T, K extends string> = T extends Record<K, unknown> ? T[K] : never
/** /**
* Literal translated value, represented by (optionally formatted) string * Literal translated value, represented by (optionally formatted) string
*/ */
export type I18nValueLiteral = string | FormattedString<string> export type I18nValueLiteral =
| string
| {
readonly text: string
readonly entities?: tl.TypeMessageEntity[]
}
// ^ we're not using InputText from @mtcute/client because it's a type-only dependency
// and may not be available at runtime, and we don't want it to be `any`
//
// we check if this is assignable to InputText in tests, so it's fine
/** /**
* Dynamic translated value, represented by a * Dynamic translated value, represented by a
* function resolving to a literal one * function resolving to a literal one
@ -59,7 +70,7 @@ export type MtcuteI18nFunction<Strings, Input> = <K extends NestedKeysDelimited<
lang: Input | string | null, lang: Input | string | null,
key: K, key: K,
...params: ExtractParameter<Strings, K> ...params: ExtractParameter<Strings, K>
) => string | FormattedString<string> ) => I18nValueLiteral
/** /**
* Wrapper type for i18n object containing strings for a language * Wrapper type for i18n object containing strings for a language

View file

@ -14,7 +14,7 @@ export function createI18nStringsIndex(strings: I18nStrings): Record<string, I18
for (const key in obj) { for (const key in obj) {
const val = obj[key] const val = obj[key]
if (typeof val === 'object' && !('value' in val)) { if (typeof val === 'object' && !('text' in val)) {
add(val, prefix + key + '.') add(val, prefix + key + '.')
} else { } else {
ret[prefix + key] = val as string ret[prefix + key] = val as string

View file

@ -2,14 +2,17 @@
// This is a test for TypeScript typings // This is a test for TypeScript typings
// This file is never executed, only compiled // This file is never executed, only compiled
import { Message } from '@mtcute/client' import { InputText, Message } from '@mtcute/client'
import { createMtcuteI18n, OtherLanguageWrap } from '../src/index.js' import { createMtcuteI18n, OtherLanguageWrap } from '../src/index.js'
declare const someInputText: InputText
const en = { const en = {
basic: { basic: {
hello: 'Hello', hello: 'Hello',
world: () => 'World', world: () => 'World',
welcome: (name: string) => `Welcome ${name}`, welcome: (name: string) => `Welcome ${name}`,
test: someInputText,
}, },
} }

View file

@ -8,22 +8,23 @@ Markdown entities parser for mtcute
> >
> Please read [Syntax](#syntax) below for a detailed explanation > Please read [Syntax](#syntax) below for a detailed explanation
> **Note**: ## Features
> It is generally recommended to use `@mtcute/html-parser` instead, - Supports all entities that Telegram supports
> as it is easier to use and is more readable in most cases - Supports nested and overlapping entities
- Supports dedentation
- [Interpolation](#interpolation)!
## Usage ## Usage
```typescript ```typescript
import { TelegramClient } from '@mtcute/client' import { md } from '@mtcute/markdown-parser'
import { MarkdownMessageEntityParser, md } from '@mtcute/markdown-parser'
const tg = new TelegramClient({ ... })
tg.registerParseMode(new MarkdownMessageEntityParser())
tg.sendText( tg.sendText(
'me', 'me',
md`Hello, **me**! Updates from the feed:\n${await getUpdatesFromFeed()}` md`
Hello, **me**! Updates from the feed:
${await getUpdatesFromFeed()}
`
) )
``` ```
@ -118,27 +119,20 @@ tags just as start/end markers, and not in terms of nesting.
| `**Welcome back, __User__!**` | **Welcome back, _User_!** | `<b>Welcome back, <i>User</i>!</b>` | | `**Welcome back, __User__!**` | **Welcome back, _User_!** | `<b>Welcome back, <i>User</i>!</b>` |
| `**bold __and** italic__` | **bold _and_** _italic_ | `<b>bold <i>and</i></b><i> italic</i>` | | `**bold __and** italic__` | **bold _and_** _italic_ | `<b>bold <i>and</i></b><i> italic</i>` |
## Escaping ## Interpolation
Often, you may want to escape the text in a way it is not processed as an entity. Being a tagged template literal, `md` supports interpolation.
To escape any character, prepend it with ` \ ` (backslash). Escaped characters are added to output as-is. You can interpolate one of the following:
- `string` - **will not** be parsed, and appended to plain text as-is
- In case you want the string to be parsed, use `md` as a simple function: <code>md\`... ${md('**bold**')} ...\`</code>
- `number` - will be converted to string and appended to plain text as-is
- `TextWithEntities` or `MessageEntity` - will add the text and its entities to the output. This is the type returned by `md` itself:
```ts
const bold = md`**bold**`
const text = md`Hello, ${bold}!`
```
- falsy value (i.e. `null`, `undefined`, `false`) - will be ignored
Inline entities and links inside code entities (both inline and pre) are not processed, so you only need to escape Because of interpolation, you almost never need to think about escaping anything,
closing tags. since the values are not even parsed as Markdown, and are appended to the output as-is.
> **Note**: backslash itself must be escaped like this: ` \\ ` (double backslash).
>
> This will look pretty bad in real code, so use escaping only when really needed, and use
> [`MarkdownMessageEntityParser.escape`](./classes/markdownmessageentityparser.html#escape) or `md` or
> other parse modes (like HTML one provided by [`@mtcute/html-parser`](../html-parser/index.html))) instead.
> In theory, you could escape every single non-markup character, but why would you want to do that 😜
| Code | Result (visual) | Result (as HTML) |
|----------------------------------------|--------------------------------|---------------------------------------------------------|
| `\_\_not italic\_\_` | \_\_not italic\_\_ | `__not italic__` |
| `__italic \_ text__` | _italic \_ text_ | `<i>italic _ text </i>` |
| <code>\`__not italic__\`</code> | `__not italic__` | `<code>__not italic__</code>` |
| <code>C:\\\\Users\\\\Guest</code> | C:\Users\Guest | `C:\Users\Guest` |
| <code>\`var a = \\\`hello\\\`\`</code> | <code>var a = \`hello\`</code> | <code>&lt;code&gt;var a = \`hello\`&lt;/code&gt;</code> |

View file

@ -1,6 +1,6 @@
import Long from 'long' import Long from 'long'
import type { FormattedString, IMessageEntityParser, MessageEntity, tl } from '@mtcute/client' import type { InputText, MessageEntity, TextWithEntities, tl } from '@mtcute/client'
const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/ const MENTION_REGEX = /^tg:\/\/user\?id=(\d+)(?:&hash=(-?[0-9a-fA-F]+)(?:&|$)|&|$)/
const EMOJI_REGEX = /^tg:\/\/emoji\?id=(-?\d+)/ const EMOJI_REGEX = /^tg:\/\/emoji\?id=(-?\d+)/
@ -16,61 +16,128 @@ const TAG_PRE = '```'
const TO_BE_ESCAPED = /[*_\-~`[\\\]|]/g const TO_BE_ESCAPED = /[*_\-~`[\\\]|]/g
/** /**
* Tagged template based helper for escaping entities in Markdown * Escape a string to be safely used in Markdown.
* *
* @example * > **Note**: this function is in most cases not needed, as `md` function
* ```typescript * > handles all `string`s passed to it automatically as plain text.
* const escaped = md`**${user.displayName}**`
* ```
*/ */
export function md( function escape(str: string): string {
strings: TemplateStringsArray, return str.replace(TO_BE_ESCAPED, (s) => '\\' + s)
...sub: (string | FormattedString<'markdown'> | MessageEntity | boolean | undefined | null)[]
): FormattedString<'markdown'> {
let str = ''
sub.forEach((it, idx) => {
if (typeof it === 'boolean' || !it) return
if (typeof it === 'string') it = MarkdownMessageEntityParser.escape(it)
else if ('raw' in it) {
it = new MarkdownMessageEntityParser().unparse(it.text, [it.raw])
} else {
if (it.mode && it.mode !== 'markdown') {
throw new Error(`Incompatible parse mode: ${it.mode}`)
}
it = it.value
}
str += strings[idx] + it
})
return { value: str + strings[strings.length - 1], mode: 'markdown' }
} }
/** /**
* Markdown MessageEntity parser. * Add Markdown formatting to the text given the plain text and entities contained in it.
*
* This class is **not** compatible with the Bot API Markdown nor MarkdownV2,
* please read the [documentation](../) to learn about syntax.
*/ */
export class MarkdownMessageEntityParser implements IMessageEntityParser { function unparse(input: InputText): string {
name = 'markdown' if (typeof input === 'string') return escape(input)
/** let text = input.text
* const entities = input.entities ?? []
* @param str String to be escaped
*/
/* istanbul ignore next */ // keep track of positions of inserted escape symbols
static escape(str: string): string { const escaped: number[] = []
// this code doesn't really need to be tested since it's just text = text.replace(TO_BE_ESCAPED, (s, pos: number) => {
// a simplified version of what is used in .unparse() escaped.push(pos)
return str.replace(TO_BE_ESCAPED, (s) => '\\' + s)
return '\\' + s
})
const hasEscaped = escaped.length > 0
type InsertLater = [number, string]
const insert: InsertLater[] = []
for (const entity of entities) {
const type = entity._
let start = entity.offset
let end = start + entity.length
if (start > text.length) continue
if (start < 0) start = 0
if (end > text.length) end = text.length
if (hasEscaped) {
// determine number of escape chars since the beginning of the string
let escapedPos = 0
while (escapedPos < escaped.length && escaped[escapedPos] < start) {
escapedPos += 1
}
start += escapedPos
while (escapedPos < escaped.length && escaped[escapedPos] <= end) {
escapedPos += 1
}
end += escapedPos
} }
parse(text: string): [string, tl.TypeMessageEntity[]] { let startTag
let endTag: string
switch (type) {
case 'messageEntityBold':
startTag = endTag = TAG_BOLD
break
case 'messageEntityItalic':
startTag = endTag = TAG_ITALIC
break
case 'messageEntityUnderline':
startTag = endTag = TAG_UNDERLINE
break
case 'messageEntityStrike':
startTag = endTag = TAG_STRIKE
break
case 'messageEntitySpoiler':
startTag = endTag = TAG_SPOILER
break
case 'messageEntityCode':
startTag = endTag = TAG_CODE
break
case 'messageEntityPre':
startTag = TAG_PRE
if (entity.language) {
startTag += entity.language
}
startTag += '\n'
endTag = '\n' + TAG_PRE
break
case 'messageEntityTextUrl':
startTag = '['
endTag = `](${entity.url})`
break
case 'messageEntityMentionName':
startTag = '['
endTag = `](tg://user?id=${entity.userId})`
break
case 'messageEntityCustomEmoji':
startTag = '['
endTag = `](tg://emoji?id=${entity.documentId.toString()})`
break
default:
continue
}
insert.push([start, startTag])
insert.push([end, endTag])
}
// sort by offset desc
insert.sort((a, b) => b[0] - a[0])
for (const [offset, tag] of insert) {
text = text.substr(0, offset) + tag + text.substr(offset)
}
return text
}
function parse(
strings: TemplateStringsArray | string,
...sub: (InputText | MessageEntity | boolean | number | undefined | null)[]
): TextWithEntities {
const entities: tl.TypeMessageEntity[] = [] const entities: tl.TypeMessageEntity[] = []
const len = text.length
let result = '' let result = ''
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {} const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
@ -79,6 +146,8 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
let insidePre = false let insidePre = false
let insideLink = false let insideLink = false
function feed(text: string) {
const len = text.length
let pos = 0 let pos = 0
while (pos < len) { while (pos < len) {
@ -297,111 +366,106 @@ export class MarkdownMessageEntityParser implements IMessageEntityParser {
} }
} }
if (c === '\n') {
if (pos !== 0) {
result += '\n'
}
const nonWhitespace = text.slice(pos + 1).search(/\S/)
if (nonWhitespace !== -1) {
pos += nonWhitespace + 1
} else {
pos = len
result = result.trimEnd()
}
continue
}
// nothing matched => normal character // nothing matched => normal character
result += c result += c
pos += 1 pos += 1
} }
return [result, entities]
} }
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string { if (typeof strings === 'string') strings = [strings] as unknown as TemplateStringsArray
// keep track of positions of inserted escape symbols
const escaped: number[] = []
text = text.replace(TO_BE_ESCAPED, (s, pos: number) => {
escaped.push(pos)
return '\\' + s sub.forEach((it, idx) => {
feed(strings[idx])
if (typeof it === 'boolean' || !it) return
if (typeof it === 'string' || typeof it === 'number') {
result += it
} else {
// TextWithEntities or MessageEntity
const text = it.text
const innerEntities = 'raw' in it ? [it.raw] : it.entities
const baseOffset = result.length
result += text
if (innerEntities) {
for (const ent of innerEntities) {
entities.push({ ...ent, offset: ent.offset + baseOffset })
}
}
}
}) })
const hasEscaped = escaped.length > 0
type InsertLater = [number, string] feed(strings[strings.length - 1])
const insert: InsertLater[] = []
for (const entity of entities) { for (const [name, stack] of Object.entries(stacks)) {
const type = entity._ if (stack.length) {
throw new Error(`Unterminated ${name} entity`)
let start = entity.offset
let end = start + entity.length
if (start > text.length) continue
if (start < 0) start = 0
if (end > text.length) end = text.length
if (hasEscaped) {
// determine number of escape chars since the beginning of the string
let escapedPos = 0
while (escapedPos < escaped.length && escaped[escapedPos] < start) {
escapedPos += 1
} }
start += escapedPos
while (escapedPos < escaped.length && escaped[escapedPos] <= end) {
escapedPos += 1
}
end += escapedPos
} }
let startTag return {
let endTag: string text: result,
entities,
switch (type) {
case 'messageEntityBold':
startTag = endTag = TAG_BOLD
break
case 'messageEntityItalic':
startTag = endTag = TAG_ITALIC
break
case 'messageEntityUnderline':
startTag = endTag = TAG_UNDERLINE
break
case 'messageEntityStrike':
startTag = endTag = TAG_STRIKE
break
case 'messageEntitySpoiler':
startTag = endTag = TAG_SPOILER
break
case 'messageEntityCode':
startTag = endTag = TAG_CODE
break
case 'messageEntityPre':
startTag = TAG_PRE
if (entity.language) {
startTag += entity.language
}
startTag += '\n'
endTag = '\n' + TAG_PRE
break
case 'messageEntityTextUrl':
startTag = '['
endTag = `](${entity.url})`
break
case 'messageEntityMentionName':
startTag = '['
endTag = `](tg://user?id=${entity.userId})`
break
case 'messageEntityCustomEmoji':
startTag = '['
endTag = `](tg://emoji?id=${entity.documentId.toString()})`
break
default:
continue
}
insert.push([start, startTag])
insert.push([end, endTag])
}
// sort by offset desc
insert.sort((a, b) => b[0] - a[0])
for (const [offset, tag] of insert) {
text = text.substr(0, offset) + tag + text.substr(offset)
}
return text
} }
} }
// typedoc doesn't support this yet, so we'll have to do it manually
// https://github.com/TypeStrong/typedoc/issues/2436
export const md: {
/**
* Tagged template based Markdown-to-entities parser function
*
* Additionally, `md` function has two static methods:
* - `md.escape` - escape a string to be safely used in Markdown
* (should not be needed in most cases, as `md` function itself handles all `string`s
* passed to it automatically as plain text)
* - `md.unparse` - add Markdown formatting to the text given the plain text and entities contained in it
*
* @example
* ```typescript
* const text = md`**${user.displayName}**`
* ```
*/
(
strings: TemplateStringsArray,
...sub: (InputText | MessageEntity | boolean | number | undefined | null)[]
): TextWithEntities
/**
* A variant taking a plain JS string as input
* and parsing it.
*
* Useful for cases when you already have a string
* (e.g. from some server) and want to parse it.
*
* @example
* ```typescript
* const string = '**hello**'
* const text = md(string)
* ```
*/
(string: string): TextWithEntities
escape: typeof escape
unparse: typeof unparse
} = Object.assign(parse, {
escape,
unparse,
})

View file

@ -2,9 +2,10 @@ import { expect } from 'chai'
import Long from 'long' import Long from 'long'
import { describe, it } from 'mocha' import { describe, it } from 'mocha'
import { FormattedString, tl } from '@mtcute/client' import { MessageEntity, TextWithEntities, tl } from '@mtcute/client'
import { MarkdownMessageEntityParser, md } from '../src/index.js' // md is special cased in prettier, we don't want that here
import { md as md_ } from '../src/index.js'
const createEntity = <T extends tl.TypeMessageEntity['_']>( const createEntity = <T extends tl.TypeMessageEntity['_']>(
type: T, type: T,
@ -21,16 +22,9 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
} }
describe('MarkdownMessageEntityParser', () => { describe('MarkdownMessageEntityParser', () => {
const parser = new MarkdownMessageEntityParser()
describe('unparse', () => { describe('unparse', () => {
const test = ( const test = (text: string, entities: tl.TypeMessageEntity[], expected: string | string[]): void => {
text: string, const result = md_.unparse({ text, entities })
entities: tl.TypeMessageEntity[],
expected: string | string[],
_parser = parser,
): void => {
const result = _parser.unparse(text, entities)
if (Array.isArray(expected)) { if (Array.isArray(expected)) {
expect(expected).to.include(result) expect(expected).to.include(result)
@ -240,9 +234,9 @@ describe('MarkdownMessageEntityParser', () => {
if (!Array.isArray(texts)) texts = [texts] if (!Array.isArray(texts)) texts = [texts]
for (const text of texts) { for (const text of texts) {
const [_text, entities] = parser.parse(text) const res = md_(text)
expect(_text).eql(expectedText) expect(res.text).eql(expectedText)
expect(entities).eql(expectedEntities) expect(res.entities ?? []).eql(expectedEntities)
} }
} }
@ -492,29 +486,8 @@ describe('MarkdownMessageEntityParser', () => {
test('[link]() [link]', [], 'link [link]') test('[link]() [link]', [], 'link [link]')
}) })
it('should ignore unclosed tags', () => {
test('plain ```\npre closed with single backtick`', [], 'plain pre closed with single backtick`')
test('plain ```\npre closed with single backtick\n`', [], 'plain pre closed with single backtick\n`')
test('plain ```\npre closed with double backticks`', [], 'plain pre closed with double backticks`')
test('plain ```\npre closed with double backticks\n`', [], 'plain pre closed with double backticks\n`')
test('plain __italic but unclosed', [], 'plain italic but unclosed')
test('plain __italic and **also bold but both unclosed', [], 'plain italic and also bold but both unclosed')
test(
'plain __italic and **also bold but italic closed__',
[createEntity('messageEntityItalic', 6, 38)],
'plain italic and also bold but italic closed',
)
test(
'plain __italic and **also bold but bold closed**',
[createEntity('messageEntityBold', 17, 25)],
'plain italic and also bold but bold closed',
)
})
describe('malformed input', () => { describe('malformed input', () => {
const testThrows = (input: string) => expect(() => parser.parse(input)).throws(Error) const testThrows = (input: string) => expect(() => md_(input)).throws(Error)
it('should throw an error on malformed links', () => { it('should throw an error on malformed links', () => {
testThrows('plain [link](https://google.com but unclosed') testThrows('plain [link](https://google.com but unclosed')
@ -524,37 +497,95 @@ describe('MarkdownMessageEntityParser', () => {
testThrows('plain ```pre without linebreaks```') testThrows('plain ```pre without linebreaks```')
testThrows('plain ``` pre without linebreaks but with spaces instead ```') testThrows('plain ``` pre without linebreaks but with spaces instead ```')
}) })
it('should throw an error on unterminated entity', () => {
testThrows('plain **bold but unclosed')
testThrows('plain **bold and __also italic but unclosed')
})
}) })
}) })
describe('template', () => { describe('template', () => {
it('should work as a tagged template literal', () => { const test = (text: TextWithEntities, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => {
const unsafeString = '__[]__' expect(text.text).eql(expectedText)
expect(text.entities ?? []).eql(expectedEntities)
}
expect(md`${unsafeString}`.value).eq('\\_\\_\\[\\]\\_\\_') it('should add plain strings as is', () => {
expect(md`${unsafeString} **text**`.value).eq('\\_\\_\\[\\]\\_\\_ **text**') test(md_`${'**plain**'}`, [], '**plain**')
expect(md`**text** ${unsafeString}`.value).eq('**text** \\_\\_\\[\\]\\_\\_')
expect(md`**${unsafeString}**`.value).eq('**\\_\\_\\[\\]\\_\\_**')
}) })
it('should skip with FormattedString', () => { it('should skip falsy values', () => {
const unsafeString2 = '__[]__' test(md_`some text ${null} more text ${false}`, [], 'some text more text ')
const unsafeString = new FormattedString('__[]__')
expect(md`${unsafeString}`.value).eq('__[]__')
expect(md`${unsafeString} ${unsafeString2}`.value).eq('__[]__ \\_\\_\\[\\]\\_\\_')
expect(md`${unsafeString} **text**`.value).eq('__[]__ **text**')
expect(md`**text** ${unsafeString}`.value).eq('**text** __[]__')
expect(md`**${unsafeString} ${unsafeString2}**`.value).eq('**__[]__ \\_\\_\\[\\]\\_\\_**')
}) })
it('should error with incompatible FormattedString', () => { it('should properly dedent', () => {
const unsafeString = new FormattedString('<&>', 'markdown') test(
const unsafeString2 = new FormattedString('<&>', 'some-other-mode') md_`
some text
**bold**
more text
`,
[createEntity('messageEntityBold', 10, 4)],
'some text\nbold\nmore text',
)
})
expect(() => md`${unsafeString}`.value).not.throw(Error) it('should process entities', () => {
// @ts-expect-error this is intentional const inner = md_`**bold**`
expect(() => md`${unsafeString2}`.value).throw(Error)
test(
md_`some text ${inner} some more text`,
[createEntity('messageEntityBold', 10, 4)],
'some text bold some more text',
)
test(
md_`some text ${inner} some more ${inner} text`,
[createEntity('messageEntityBold', 10, 4), createEntity('messageEntityBold', 25, 4)],
'some text bold some more bold text',
)
})
it('should process entities on edges', () => {
test(
md_`${md_`**bold**`} and ${md_`__italic__`}`,
[createEntity('messageEntityBold', 0, 4), createEntity('messageEntityItalic', 9, 6)],
'bold and italic',
)
})
it('should process nested entities', () => {
test(
md_`**bold ${md_`__bold italic__`} more bold**`,
[createEntity('messageEntityItalic', 5, 11), createEntity('messageEntityBold', 0, 26)],
'bold bold italic more bold',
)
test(
md_`**bold ${md_`__bold italic__ --and some underline--`} more bold**`,
[
createEntity('messageEntityItalic', 5, 11),
createEntity('messageEntityUnderline', 17, 18),
createEntity('messageEntityBold', 0, 45),
],
'bold bold italic and some underline more bold',
)
test(
md_`**${md_`__bold italic --underline--__`}**`,
[
createEntity('messageEntityUnderline', 12, 9),
createEntity('messageEntityItalic', 0, 21),
createEntity('messageEntityBold', 0, 21),
],
'bold italic underline',
)
})
it('should process MessageEntity', () => {
test(
md_`**bold ${new MessageEntity(createEntity('messageEntityItalic', 0, 11), 'bold italic')} more bold**`,
[createEntity('messageEntityItalic', 5, 11), createEntity('messageEntityBold', 0, 26)],
'bold bold italic more bold',
)
}) })
}) })
}) })

View file

@ -2,8 +2,6 @@ import { createRequire } from 'module'
import { createInterface, Interface as RlInterface } from 'readline' import { createInterface, Interface as RlInterface } from 'readline'
import { TelegramClient, TelegramClientOptions } from '@mtcute/client' import { TelegramClient, TelegramClientOptions } from '@mtcute/client'
import { HtmlMessageEntityParser } from '@mtcute/html-parser'
import { MarkdownMessageEntityParser } from '@mtcute/markdown-parser'
import { SqliteStorage } from '@mtcute/sqlite' import { SqliteStorage } from '@mtcute/sqlite'
export * from '@mtcute/client' export * from '@mtcute/client'
@ -23,16 +21,6 @@ try {
} catch (e) {} } catch (e) {}
export interface NodeTelegramClientOptions extends Omit<TelegramClientOptions, 'storage'> { export interface NodeTelegramClientOptions extends Omit<TelegramClientOptions, 'storage'> {
/**
* Default parse mode to use.
*
* Both HTML and Markdown parse modes are
* registered automatically.
*
* @default `html`
*/
defaultParseMode?: 'html' | 'markdown'
/** /**
* Storage to use. * Storage to use.
* *
@ -66,13 +54,6 @@ export class NodeTelegramClient extends TelegramClient {
new SqliteStorage(opts.storage) : new SqliteStorage(opts.storage) :
opts.storage ?? new SqliteStorage('client.session'), opts.storage ?? new SqliteStorage('client.session'),
}) })
this.registerParseMode(new HtmlMessageEntityParser())
this.registerParseMode(new MarkdownMessageEntityParser())
if (opts.defaultParseMode) {
this.setDefaultParseMode(opts.defaultParseMode)
}
} }
private _rl?: RlInterface private _rl?: RlInterface