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",
"lint": "eslint .",
"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:ci": "pnpm -r --filter=!crypto exec tsc --build",
"lint:tsc": "pnpm -r --parallel 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:fix": "eslint --fix .",
"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 { initTakeoutSession } from './methods/misc/init-takeout-session.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 { enableCloudPassword } from './methods/password/enable-cloud-password.js'
import { cancelPasswordEmail, resendPasswordEmail, verifyPasswordEmail } from './methods/password/password-email.js'
@ -271,11 +265,9 @@ import {
Dialog,
FileDownloadLocation,
FileDownloadParameters,
FormattedString,
ForumTopic,
GameHighScore,
HistoryReadUpdate,
IMessageEntityParser,
InlineQuery,
InputChatEventFilters,
InputDialogFolder,
@ -288,6 +280,7 @@ import {
InputReaction,
InputStickerSet,
InputStickerSetItem,
InputText,
MaybeDynamic,
Message,
MessageEntity,
@ -852,18 +845,6 @@ export interface TelegramClient extends BaseTelegramClient {
*/
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>
/**
@ -2232,8 +2213,7 @@ export interface TelegramClient extends BaseTelegramClient {
*/
_normalizeInputMedia(
media: InputMediaLike,
params: {
parseMode?: string | null
params?: {
progressCallback?: (uploaded: number, total: number) => void
uploadPeer?: tl.TypeInputPeer
},
@ -2929,26 +2909,7 @@ export interface TelegramClient extends BaseTelegramClient {
*
* When `media` is passed, `media.caption` is used instead
*/
text?: string | FormattedString<string>
/**
* 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[]
text?: InputText
/**
* New message media
@ -2998,26 +2959,7 @@ export interface TelegramClient extends BaseTelegramClient {
*
* When `media` is passed, `media.caption` is used instead
*/
text?: string | FormattedString<string>
/**
* 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[]
text?: InputText
/**
* New message media
@ -3068,8 +3010,6 @@ export interface TelegramClient extends BaseTelegramClient {
* Forward one or more messages by their IDs.
* 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
*
* @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
* or when using existing InputMedia objects.
*/
caption?: string | FormattedString<string>
/**
* Override entities for `media`.
*
* Can be used, for example. when using File IDs
* or when using existing InputMedia objects.
*/
entities?: tl.TypeMessageEntity[]
caption?: InputText
/**
* Function that will be called after some part has been uploaded.
@ -3883,7 +3815,7 @@ export interface TelegramClient extends BaseTelegramClient {
*/
sendText(
chatId: InputPeerLike,
text: string | FormattedString<string>,
text: InputText,
params?: CommonSendParams & {
/**
* For bots: inline or reply markup or an instruction
@ -3891,14 +3823,6 @@ export interface TelegramClient extends BaseTelegramClient {
*/
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
*/
@ -4027,47 +3951,6 @@ export interface TelegramClient extends BaseTelegramClient {
*
*/
_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
* **Available**: 👤 users only
@ -4422,20 +4305,7 @@ export interface TelegramClient extends BaseTelegramClient {
/**
* Override caption for {@link media}
*/
caption?: string | FormattedString<string>
/**
* 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
caption?: InputText
/**
* Interactive elements to add to the story
@ -4806,20 +4676,7 @@ export interface TelegramClient extends BaseTelegramClient {
/**
* Override caption for {@link media}
*/
caption?: string | FormattedString<string>
/**
* 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
caption?: InputText
/**
* Whether to automatically pin this story to the profile
@ -5422,10 +5279,6 @@ export class TelegramClient extends BaseTelegramClient {
unpinMessage = unpinMessage.bind(null, this)
initTakeoutSession = initTakeoutSession.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)
enableCloudPassword = enableCloudPassword.bind(null, this)
verifyPasswordEmail = verifyPasswordEmail.bind(null, this)

View file

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

View file

@ -96,23 +96,11 @@ export async function answerInlineQuery(
*/
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> {
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({
_: 'messages.setInlineBotResults',

View file

@ -8,7 +8,7 @@ import { InputMediaLike } from '../../types/media/input-media.js'
import { extractFileName } from '../../utils/file-utils.js'
import { normalizeDate } from '../../utils/misc-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 { _normalizeInputFile } from './normalize-input-file.js'
import { uploadFile } from './upload-file.js'
@ -21,10 +21,9 @@ export async function _normalizeInputMedia(
client: BaseTelegramClient,
media: InputMediaLike,
params: {
parseMode?: string | null
progressCallback?: (uploaded: number, total: number) => void
uploadPeer?: tl.TypeInputPeer
},
} = {},
uploadMedia = false,
): Promise<tl.TypeInputMedia> {
// 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) {
[solution, solutionEntities] = await _parseEntities(
client,
media.solution,
params.parseMode,
media.solutionEntities,
)
[solution, solutionEntities] = await _normalizeInputText(client, media.solution)
}
}

View file

@ -1,9 +1,9 @@
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 { _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.
@ -27,26 +27,7 @@ export async function editInlineMessage(
*
* When `media` is passed, `media.caption` is used instead
*/
text?: string | FormattedString<string>
/**
* 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[]
text?: InputText
/**
* 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),
// user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) {
[content, entities] = await _parseEntities(
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
[content, entities] = await _normalizeInputText(client, params.media.caption)
}
} 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

View file

@ -2,17 +2,17 @@ import { BaseTelegramClient, tl } from '@mtcute/core'
import {
BotKeyboard,
FormattedString,
InputMediaLike,
InputMessageId,
InputText,
Message,
normalizeInputMessageId,
ReplyMarkup,
} from '../../types/index.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js'
import { _parseEntities } from './parse-entities.js'
/**
* 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
*/
text?: string | FormattedString<string>
/**
* 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[]
text?: InputText
/**
* 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),
// user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) {
[content, entities] = await _parseEntities(
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
[content, entities] = await _normalizeInputText(client, params.media.caption)
}
}
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({

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 { 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 { assertIsUpdatesGroup } from '../../utils/updates-utils.js'
import { resolvePeer } from '../users/resolve-peer.js'
@ -11,41 +11,6 @@ export interface ForwardMessageOptions {
/** Destination chat ID, username, phone, `"me"` or `"self"` */
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).
*/
@ -102,8 +67,6 @@ export interface ForwardMessageOptions {
* Forward one or more messages by their IDs.
* 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 fromChatId Source chat ID, username, phone, `"me"` or `"self"`
* @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 { Message } from '../../types/messages/message.js'
import { TextWithEntities } from '../../types/misc/entities.js'
import { InputPeerLike } from '../../types/peers/index.js'
import { normalizeMessageId, normalizeToInputUser } from '../../utils/index.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _getDiscussionMessage } from './get-discussion-message.js'
import { getMessages } from './get-messages.js'
@ -50,24 +52,9 @@ export interface CommonSendParams {
/**
* 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
/**
* 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
quote?: TextWithEntities
/**
* Whether to send this message silently.
@ -151,8 +138,8 @@ export async function _processCommonSendParameters(
_: 'inputReplyToMessage',
replyToMsgId: replyTo,
replyToPeerId: replyToPeer,
quoteText: params.quoteText,
quoteEntities: params.quoteEntities,
quoteText: params.quote?.text,
quoteEntities: params.quote?.entities as tl.TypeMessageEntity[],
}
} else if (params.replyToStory) {
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 { getMessages } from './get-messages.js'
import { CommonSendParams } from './send-common.js'
@ -15,24 +15,7 @@ export interface SendCopyParams extends CommonSendParams {
/**
* New message caption (only used for media)
*/
caption?: string | FormattedString<string>
/**
* 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[]
caption?: InputText
/**
* 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') {
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(
client,
toChatId,
{
type: 'auto',
file: msg.media.inputMedia,
caption: params.caption ?? msg.raw.message,
// we shouldn't use original entities if the user wants custom text
entities: params.entities ?? params.caption ? undefined : msg.raw.entities,
caption,
},
rest,
)

View file

@ -7,9 +7,9 @@ import { InputPeerLike, PeersIndex } from '../../types/peers/index.js'
import { normalizeDate } from '../../utils/misc-utils.js'
import { assertIsUpdatesGroup } from '../../utils/updates-utils.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.js'
import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
/**
@ -77,14 +77,11 @@ export async function sendMediaGroup(
true,
)
const [message, entities] = await _parseEntities(
const [message, entities] = await _normalizeInputText(
client,
// some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly
// handled by _parseEntities method
// but since it's JS, they'll just be `undefined` and properly handled by the method
(media as Extract<typeof media, { caption?: unknown }>).caption,
params.parseMode,
(media as Extract<typeof media, { entities?: unknown }>).entities,
)
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 { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.js'
import { InputMediaLike } from '../../types/media/input-media.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 { normalizeDate } from '../../utils/misc-utils.js'
import { _normalizeInputMedia } from '../files/normalize-input-media.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js'
import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.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
* or when using existing InputMedia objects.
*/
caption?: string | FormattedString<string>
/**
* Override entities for `media`.
*
* Can be used, for example. when using File IDs
* or when using existing InputMedia objects.
*/
entities?: tl.TypeMessageEntity[]
caption?: InputText
/**
* 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 [message, entities] = await _parseEntities(
const [message, entities] = await _normalizeInputText(
client,
// some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly
// handled by _parseEntities method
// but since it's JS, they'll just be `undefined` and properly handled by the method
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)

View file

@ -1,6 +1,6 @@
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 { sendMedia } from './send-media.js'
import { sendMediaGroup } from './send-media-group.js'
@ -22,7 +22,7 @@ export type QuoteParamsFrom<T> = Omit<NonNullable<T>, 'quoteText' | 'quoteEntiti
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
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 (!raw.entities) return [text.slice(from, to), undefined]
if (!raw.entities) return { text: text.slice(from, to), entities: undefined }
const entities: tl.TypeMessageEntity[] = []
@ -51,7 +51,7 @@ function extractQuote(message: Message, from: number, to: number): [string, tl.T
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 */
@ -66,7 +66,7 @@ export function quoteWithText(
const { toChatId = message.chat, start, end, text, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendText>[3]>
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_)
}
@ -83,7 +83,7 @@ export function quoteWithMedia(
const { toChatId = message.chat, start, end, media, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendMedia>[3]>
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_)
}
@ -100,7 +100,7 @@ export function quoteWithMediaGroup(
const { toChatId, start, end, medias, ...params__ } = params
const params_ = params__ as NonNullable<Parameters<typeof sendMediaGroup>[3]>
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_)
}

View file

@ -3,16 +3,16 @@ import { randomLong } from '@mtcute/core/utils.js'
import { BotKeyboard, ReplyMarkup } from '../../types/bots/keyboards.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 { normalizeDate } from '../../utils/misc-utils.js'
import { inputPeerToPeer } from '../../utils/peer-utils.js'
import { createDummyUpdate } from '../../utils/updates-utils.js'
import { getAuthState } from '../auth/_state.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _findMessageInUpdate } from './find-in-update.js'
import { _getDiscussionMessage } from './get-discussion-message.js'
import { _parseEntities } from './parse-entities.js'
import { _processCommonSendParameters, CommonSendParams } from './send-common.js'
/**
@ -25,7 +25,7 @@ import { _processCommonSendParameters, CommonSendParams } from './send-common.js
export async function sendText(
client: BaseTelegramClient,
chatId: InputPeerLike,
text: string | FormattedString<string>,
text: InputText,
params?: CommonSendParams & {
/**
* For bots: inline or reply markup or an instruction
@ -33,14 +33,6 @@ export async function sendText(
*/
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
*/
@ -57,7 +49,7 @@ export async function sendText(
): Promise<Message> {
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 { 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 { 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 { _parseEntities } from '../messages/parse-entities.js'
import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _findStoryInUpdate } from './find-in-update.js'
@ -35,20 +35,7 @@ export async function editStory(
/**
* Override caption for {@link media}
*/
caption?: string | FormattedString<string>
/**
* 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
caption?: InputText
/**
* Interactive elements to add to the story
@ -70,22 +57,17 @@ export async function editStory(
let media: tl.TypeInputMedia | undefined = undefined
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),
// user wants to keep current caption, thus `content` needs to stay `undefined`
if ('caption' in params.media && params.media.caption !== undefined) {
[caption, entities] = await _parseEntities(
client,
params.media.caption,
params.parseMode,
params.media.entities,
)
[caption, entities] = await _normalizeInputText(client, params.media.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

View file

@ -1,10 +1,10 @@
import { BaseTelegramClient, tl } from '@mtcute/core'
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 { _parseEntities } from '../messages/parse-entities.js'
import { _normalizePrivacyRules } from '../misc/normalize-privacy-rules.js'
import { _normalizeInputText } from '../misc/normalize-text.js'
import { resolvePeer } from '../users/resolve-peer.js'
import { _findStoryInUpdate } from './find-in-update.js'
@ -34,20 +34,7 @@ export async function sendStory(
/**
* Override caption for {@link media}
*/
caption?: string | FormattedString<string>
/**
* 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
caption?: InputText
/**
* 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 ?
await _normalizePrivacyRules(client, params.privacyRules) :
[{ _: 'inputPrivacyValueAllowAll' } as const]
const [caption, entities] = await _parseEntities(
const [caption, entities] = await _normalizeInputText(
client,
// some types dont have `caption` field, and ts warns us,
// but since it's JS, they'll just be `undefined` and properly
// handled by _parseEntities method
// but since it's JS, they'll just be `undefined` and properly handled by the method
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({

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,17 @@
import { tl } from '@mtcute/core'
/**
* Interface describing some text with entities.
*
* Primarily used as a return type for parsers.
* Formatted text with entities
*/
export interface TextWithEntities {
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 './sticker-set.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)
HTML entities parser for mtcute
> **NOTE**: The syntax implemented here is **incompatible** with Bot API _HTML_.
@ -12,21 +11,20 @@ HTML entities parser for mtcute
## Features
- Supports all entities that Telegram supports
- Supports nested entities
- Proper newline handling (just like in real HTML)
- Automatic escaping of user input
- Proper newline/whitespace handling (just like in real HTML)
- [Interpolation](#interpolation)!
## Usage
```ts
import { TelegramClient } from '@mtcute/client'
import { HtmlMessageEntityParser, html } from '@mtcute/html-parser'
const tg = new TelegramClient({ ... })
tg.registerParseMode(new HtmlMessageEntityParser())
import { html } from '@mtcute/html-parser'
tg.sendText(
'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</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
better to use [`HtmlMessageEntityParser.escape`](./classes/htmlmessageentityparser.html#escape) or, even better,
`html` helper:
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 `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
import { html } from '@mtcute/html-parser'
const username = 'Boris <&>'
const text = html`Hi, ${username}!`
console.log(text) // Hi, Boris &amp;lt;&amp;amp;&amp;gt;!
```
Note that because of interpolation, you almost never need to think about escaping anything,
since the values are not even parsed as HTML, and are appended to the output as-is.

View file

@ -1,405 +1,435 @@
import { Parser } from 'htmlparser2'
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]+)(?:&|$)|&|$)/
/**
* Tagged template based helper for escaping entities in HTML
* Escape a string to be safely used in HTML.
*
* @example
* ```typescript
* const escaped = html`<b>${user.displayName}</b>`
* ```
* > **Note**: this function is in most cases not needed, as `html` function
* > handles all `string`s passed to it automatically as plain text.
*/
export function html(
strings: TemplateStringsArray,
...sub: (string | FormattedString<'html'> | MessageEntity | boolean | undefined | null)[]
): FormattedString<'html'> {
let str = ''
sub.forEach((it, idx) => {
if (typeof it === 'boolean' || !it) return
function escape(str: string, quote = false): string {
str = str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
if (quote) str = str.replace(/"/g, '&quot;')
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' }
return str
}
/**
* 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
function parse(
strings: TemplateStringsArray | string,
...sub: (InputText | MessageEntity | boolean | number | undefined | null)[]
): TextWithEntities {
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
const entities: tl.TypeMessageEntity[] = []
let plainText = ''
let pendingText = ''
export interface HtmlMessageEntityParserOptions {
syntaxHighlighter?: SyntaxHighlighter
}
function processPendingText(tagEnd = false) {
if (!pendingText.length) return
/**
* 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'
if (!stacks.pre?.length) {
pendingText = pendingText.replace(/[^\S\u00A0]+/gs, ' ')
private readonly _syntaxHighlighter?: SyntaxHighlighter
if (tagEnd) pendingText = pendingText.trimEnd()
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;')
if (quote) str = str.replace(/"/g, '&quot;')
return str
}
parse(text: string): [string, tl.TypeMessageEntity[]] {
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
const entities: tl.TypeMessageEntity[] = []
let plainText = ''
let pendingText = ''
function processPendingText(tagEnd = false) {
if (!pendingText.length) return
if (!stacks.pre?.length) {
pendingText = pendingText.replace(/[^\S\u00A0]+/gs, ' ')
if (tagEnd) pendingText = pendingText.trimEnd()
if (!plainText.length || plainText.match(/\s$/)) {
pendingText = pendingText.trimStart()
}
if (!plainText.length || plainText.match(/\s$/)) {
pendingText = pendingText.trimStart()
}
for (const ents of Object.values(stacks)) {
for (const ent of ents) {
ent.length += pendingText.length
}
}
plainText += pendingText
pendingText = ''
}
const parser = new Parser({
onopentag(name, attribs) {
name = name.toLowerCase()
for (const ents of Object.values(stacks)) {
for (const ent of ents) {
ent.length += pendingText.length
}
}
processPendingText()
plainText += pendingText
pendingText = ''
}
// ignore tags inside pre (except pre)
if (name !== 'pre' && stacks.pre?.length) return
const parser = new Parser({
onopentag(name, attribs) {
name = name.toLowerCase()
let entity: tl.TypeMessageEntity
processPendingText()
switch (name) {
case 'br':
plainText += '\n'
// ignore tags inside pre (except pre)
if (name !== 'pre' && stacks.pre?.length) return
return
case 'b':
case 'strong':
entity = {
_: 'messageEntityBold',
offset: plainText.length,
length: 0,
}
break
case 'i':
case 'em':
entity = {
_: 'messageEntityItalic',
offset: plainText.length,
length: 0,
}
break
case 'u':
entity = {
_: 'messageEntityUnderline',
offset: plainText.length,
length: 0,
}
break
case 's':
case 'del':
case 'strike':
entity = {
_: 'messageEntityStrike',
offset: plainText.length,
length: 0,
}
break
case 'blockquote':
entity = {
_: 'messageEntityBlockquote',
offset: plainText.length,
length: 0,
}
break
case 'code':
entity = {
_: 'messageEntityCode',
offset: plainText.length,
length: 0,
}
break
case 'pre':
entity = {
_: 'messageEntityPre',
offset: plainText.length,
length: 0,
language: attribs.language ?? '',
}
break
case 'spoiler':
case 'tg-spoiler':
entity = {
_: 'messageEntitySpoiler',
offset: plainText.length,
length: 0,
}
break
let entity: tl.TypeMessageEntity
case 'emoji':
case 'tg-emoji': {
const id = attribs.id || attribs['emoji-id']
if (!id || !id.match(/^-?\d+$/)) return
switch (name) {
case 'br':
plainText += '\n'
entity = {
_: 'messageEntityCustomEmoji',
offset: plainText.length,
length: 0,
documentId: Long.fromString(id),
}
break
return
case 'b':
case 'strong':
entity = {
_: 'messageEntityBold',
offset: plainText.length,
length: 0,
}
case 'a': {
let url = attribs.href
if (!url) return
break
case 'i':
case 'em':
entity = {
_: 'messageEntityItalic',
offset: plainText.length,
length: 0,
}
break
case 'u':
entity = {
_: 'messageEntityUnderline',
offset: plainText.length,
length: 0,
}
break
case 's':
case 'del':
case 'strike':
entity = {
_: 'messageEntityStrike',
offset: plainText.length,
length: 0,
}
break
case 'blockquote':
entity = {
_: 'messageEntityBlockquote',
offset: plainText.length,
length: 0,
}
break
case 'code':
entity = {
_: 'messageEntityCode',
offset: plainText.length,
length: 0,
}
break
case 'pre':
entity = {
_: 'messageEntityPre',
offset: plainText.length,
length: 0,
language: attribs.language ?? '',
}
break
case 'spoiler':
case 'tg-spoiler':
entity = {
_: 'messageEntitySpoiler',
offset: plainText.length,
length: 0,
}
break
const mention = MENTION_REGEX.exec(url)
case 'emoji':
case 'tg-emoji': {
const id = attribs.id || attribs['emoji-id']
if (!id || !id.match(/^-?\d+$/)) return
if (mention) {
const id = parseInt(mention[1])
const accessHash = mention[2]
entity = {
_: 'messageEntityCustomEmoji',
offset: plainText.length,
length: 0,
documentId: Long.fromString(id),
}
break
}
case 'a': {
let url = attribs.href
if (!url) return
if (accessHash) {
entity = {
_: 'inputMessageEntityMentionName',
offset: plainText.length,
length: 0,
userId: {
_: 'inputUser',
userId: id,
accessHash: Long.fromString(accessHash, false, 16),
},
}
} else {
entity = {
_: 'messageEntityMentionName',
offset: plainText.length,
length: 0,
userId: id,
}
}
} else {
if (url.match(/^\/\//)) url = 'http:' + url
const mention = MENTION_REGEX.exec(url)
if (mention) {
const id = parseInt(mention[1])
const accessHash = mention[2]
if (accessHash) {
entity = {
_: 'messageEntityTextUrl',
_: 'inputMessageEntityMentionName',
offset: plainText.length,
length: 0,
url,
userId: {
_: 'inputUser',
userId: id,
accessHash: Long.fromString(accessHash, false, 16),
},
}
} else {
entity = {
_: 'messageEntityMentionName',
offset: plainText.length,
length: 0,
userId: id,
}
}
break
}
default:
return
}
} else {
if (url.match(/^\/\//)) url = 'http:' + url
if (!(name in stacks)) {
stacks[name] = []
}
stacks[name].push(entity)
},
onclosetag(name: string) {
processPendingText(true)
name = name.toLowerCase()
// ignore tags inside pre (except pre)
if (name !== 'pre' && stacks.pre?.length) return
const entity = stacks[name]?.pop()
if (!entity) return // unmatched close tag
// ignore nested pre-s
if (name !== 'pre' || !stacks.pre?.length) {
entities.push(entity)
}
},
ontext(data) {
pendingText += data
},
})
parser.write(text)
processPendingText(true)
return [plainText.replace(/\u00A0/g, ' '), entities]
}
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string {
return this._unparse(text, entities)
}
// internal function that uses recursion to correctly process nested & overlapping entities
private _unparse(
text: string,
entities: ReadonlyArray<tl.TypeMessageEntity>,
entitiesOffset = 0,
offset = 0,
length = text.length,
): string {
if (!text) return text
if (!entities.length || entities.length === entitiesOffset) {
return HtmlMessageEntityParser.escape(text)
.replace(/\n/g, '<br>')
.replace(/ {2,}/g, (match) => {
return '&nbsp;'.repeat(match.length)
})
}
const end = offset + length
const html: string[] = []
let lastOffset = 0
for (let i = entitiesOffset; i < entities.length; i++) {
const entity = entities[i]
if (entity.offset >= end) break
let entOffset = entity.offset
let length = entity.length
if (entOffset < 0) {
length += entOffset
entOffset = 0
}
let relativeOffset = entOffset - offset
if (relativeOffset > lastOffset) {
// add missing plain text
html.push(HtmlMessageEntityParser.escape(text.substring(lastOffset, relativeOffset)))
} else if (relativeOffset < lastOffset) {
length -= lastOffset - relativeOffset
relativeOffset = lastOffset
}
if (length <= 0 || relativeOffset >= end || relativeOffset < 0) {
continue
}
let skip = false
const substr = text.substr(relativeOffset, length)
if (!substr) continue
const type = entity._
let entityText
if (type === 'messageEntityPre') {
entityText = substr
} else {
entityText = this._unparse(substr, entities, i + 1, offset + relativeOffset, length)
}
switch (type) {
case 'messageEntityBold':
case 'messageEntityItalic':
case 'messageEntityUnderline':
case 'messageEntityStrike':
case 'messageEntityCode':
case 'messageEntityBlockquote':
case 'messageEntitySpoiler':
{
const tag = (
{
messageEntityBold: 'b',
messageEntityItalic: 'i',
messageEntityUnderline: 'u',
messageEntityStrike: 's',
messageEntityCode: 'code',
messageEntityBlockquote: 'blockquote',
messageEntitySpoiler: 'spoiler',
} as const
)[type]
html.push(`<${tag}>${entityText}</${tag}>`)
entity = {
_: 'messageEntityTextUrl',
offset: plainText.length,
length: 0,
url,
}
}
break
case 'messageEntityPre':
html.push(
`<pre${entity.language ? ` language="${entity.language}"` : ''}>${
this._syntaxHighlighter && entity.language ?
this._syntaxHighlighter(entityText, entity.language) :
entityText
}</pre>`,
)
break
case 'messageEntityEmail':
html.push(`<a href="mailto:${entityText}">${entityText}</a>`)
break
case 'messageEntityUrl':
html.push(`<a href="${entityText}">${entityText}</a>`)
break
case 'messageEntityTextUrl':
html.push(`<a href="${HtmlMessageEntityParser.escape(entity.url, true)}">${entityText}</a>`)
break
case 'messageEntityMentionName':
html.push(`<a href="tg://user?id=${entity.userId}">${entityText}</a>`)
break
}
default:
skip = true
break
return
}
lastOffset = relativeOffset + (skip ? 0 : length)
if (!(name in stacks)) {
stacks[name] = []
}
stacks[name].push(entity)
},
onclosetag(name: string) {
processPendingText(true)
name = name.toLowerCase()
// ignore tags inside pre (except pre)
if (name !== 'pre' && stacks.pre?.length) return
const entity = stacks[name]?.pop()
if (!entity) return // unmatched close tag
// ignore nested pre-s
if (name !== 'pre' || !stacks.pre?.length) {
entities.push(entity)
}
},
ontext(data) {
pendingText += data
},
})
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 })
}
}
}
})
html.push(HtmlMessageEntityParser.escape(text.substr(lastOffset)))
parser.write(strings[strings.length - 1])
return html.join('')
processPendingText(true)
return {
text: plainText.replace(/\u00A0/g, ' '),
entities,
}
}
/** Options passed to `html.unparse` */
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
function _unparse(
text: string,
entities: ReadonlyArray<tl.TypeMessageEntity>,
params: HtmlUnparseOptions,
entitiesOffset = 0,
offset = 0,
length = text.length,
): string {
if (!text) return text
if (!entities.length || entities.length === entitiesOffset) {
return escape(text)
.replace(/\n/g, '<br>')
.replace(/ {2,}/g, (match) => {
return '&nbsp;'.repeat(match.length)
})
}
const end = offset + length
const html: string[] = []
let lastOffset = 0
for (let i = entitiesOffset; i < entities.length; i++) {
const entity = entities[i]
if (entity.offset >= end) break
let entOffset = entity.offset
let length = entity.length
if (entOffset < 0) {
length += entOffset
entOffset = 0
}
let relativeOffset = entOffset - offset
if (relativeOffset > lastOffset) {
// add missing plain text
html.push(escape(text.substring(lastOffset, relativeOffset)))
} else if (relativeOffset < lastOffset) {
length -= lastOffset - relativeOffset
relativeOffset = lastOffset
}
if (length <= 0 || relativeOffset >= end || relativeOffset < 0) {
continue
}
let skip = false
const substr = text.substr(relativeOffset, length)
if (!substr) continue
const type = entity._
let entityText
if (type === 'messageEntityPre') {
entityText = substr
} else {
entityText = _unparse(substr, entities, params, i + 1, offset + relativeOffset, length)
}
switch (type) {
case 'messageEntityBold':
case 'messageEntityItalic':
case 'messageEntityUnderline':
case 'messageEntityStrike':
case 'messageEntityCode':
case 'messageEntityBlockquote':
case 'messageEntitySpoiler':
{
const tag = (
{
messageEntityBold: 'b',
messageEntityItalic: 'i',
messageEntityUnderline: 'u',
messageEntityStrike: 's',
messageEntityCode: 'code',
messageEntityBlockquote: 'blockquote',
messageEntitySpoiler: 'spoiler',
} as const
)[type]
html.push(`<${tag}>${entityText}</${tag}>`)
}
break
case 'messageEntityPre':
html.push(
`<pre${entity.language ? ` language="${entity.language}"` : ''}>${
params.syntaxHighlighter && entity.language ?
params.syntaxHighlighter(entityText, entity.language) :
entityText
}</pre>`,
)
break
case 'messageEntityEmail':
html.push(`<a href="mailto:${entityText}">${entityText}</a>`)
break
case 'messageEntityUrl':
html.push(`<a href="${entityText}">${entityText}</a>`)
break
case 'messageEntityTextUrl':
html.push(`<a href="${escape(entity.url, true)}">${entityText}</a>`)
break
case 'messageEntityMentionName':
html.push(`<a href="tg://user?id=${entity.userId}">${entityText}</a>`)
break
default:
skip = true
break
}
lastOffset = relativeOffset + (skip ? 0 : length)
}
html.push(escape(text.substr(lastOffset)))
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 { 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['_']>(
type: T,
@ -21,11 +24,14 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
}
describe('HtmlMessageEntityParser', () => {
const parser = new HtmlMessageEntityParser()
describe('unparse', () => {
const test = (text: string, entities: tl.TypeMessageEntity[], expected: string, _parser = parser): void => {
expect(_parser.unparse(text, entities)).eq(expected)
const test = (
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', () => {
@ -197,10 +203,6 @@ describe('HtmlMessageEntityParser', () => {
})
it('should work with custom syntax highlighter', () => {
const parser = new HtmlMessageEntityParser({
syntaxHighlighter: (code, lang) => `lang: <b>${lang}</b><br>${code}`,
})
test(
'plain console.log("Hello, world!") some code plain',
[
@ -210,7 +212,9 @@ describe('HtmlMessageEntityParser', () => {
createEntity('messageEntityPre', 35, 9, { language: '' }),
],
'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', () => {
const test = (text: string, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => {
const [_text, entities] = parser.parse(text)
expect(_text).eql(expectedText)
expect(entities).eql(expectedEntities)
const test = (text: TextWithEntities, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => {
expect(text.text).eql(expectedText)
expect(text.entities ?? []).eql(expectedEntities)
}
it('should handle <b>, <i>, <u>, <s> tags', () => {
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('messageEntityItalic', 11, 6),
@ -247,7 +250,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle <code>, <pre>, <blockquote>, <spoiler> tags', () => {
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('messageEntityPre', 11, 3, { language: '' }),
@ -260,7 +263,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle links and text mentions', () => {
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, {
url: 'https://google.com',
@ -273,7 +276,7 @@ describe('HtmlMessageEntityParser', () => {
)
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, {
userId: {
@ -289,7 +292,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle language in <pre>', () => {
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, {
language: 'javascript',
@ -302,31 +305,31 @@ describe('HtmlMessageEntityParser', () => {
it('should ignore other tags inside <pre>', () => {
test(
'<pre><b>bold</b> and not bold</pre>',
htm`<pre><b>bold</b> and not bold</pre>`,
[createEntity('messageEntityPre', 0, 17, { language: '' })],
'bold and not bold',
)
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: '' })],
'pre inside pre so cool',
)
})
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(
'<b>this is some text\n\nwith</b> newlines',
htm`<b>this is some text\n\nwith</b> newlines`,
[createEntity('messageEntityBold', 0, 22)],
'this is some text with newlines',
)
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)],
'this is some text ending with newlines',
)
test(
`
htm`
this is some indented text
with newlines and
<b>
@ -341,7 +344,7 @@ describe('HtmlMessageEntityParser', () => {
it('should not ignore newlines and indentation in pre', () => {
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: '' })],
'this is some text\n\nwith newlines',
)
@ -349,7 +352,7 @@ describe('HtmlMessageEntityParser', () => {
// fuck my life
const indent = ' '
test(
`<pre>
htm`<pre>
this is some indented text
with newlines and
<b>
@ -376,9 +379,9 @@ describe('HtmlMessageEntityParser', () => {
})
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(
'<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
// this is expected, and the result is the same
[createEntity('messageEntityBold', 0, 17)],
@ -388,7 +391,7 @@ describe('HtmlMessageEntityParser', () => {
it('should handle &nbsp;', () => {
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',
)
@ -396,19 +399,19 @@ describe('HtmlMessageEntityParser', () => {
it('should support entities on the edges', () => {
test(
'<b>Hello</b>, <b>world</b>',
htm`<b>Hello</b>, <b>world</b>`,
[createEntity('messageEntityBold', 0, 5), createEntity('messageEntityBold', 7, 5)],
'Hello, world',
)
})
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', () => {
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)],
'plain Hello, world plain',
)
@ -416,7 +419,7 @@ describe('HtmlMessageEntityParser', () => {
it('should support nested entities', () => {
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)],
'Welcome to the gym zone!',
)
@ -424,22 +427,22 @@ describe('HtmlMessageEntityParser', () => {
it('should support nested entities with the same edges', () => {
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)],
'Welcome to the gym zone!',
)
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)],
'Welcome to the gym zone!',
)
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)],
'Welcome to the gym zone!',
)
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)],
'Welcome to the gym zone!',
)
@ -447,7 +450,7 @@ describe('HtmlMessageEntityParser', () => {
it('should properly handle emojis', () => {
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('messageEntityBold', 13, 2),
@ -458,12 +461,12 @@ describe('HtmlMessageEntityParser', () => {
})
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', () => {
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('messageEntityTextUrl', 14, 4, {
@ -475,44 +478,96 @@ describe('HtmlMessageEntityParser', () => {
})
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', () => {
test('<a href="">link</a> <a>link</a>', [], 'link link')
})
})
describe('template', () => {
it('should work as a tagged template literal', () => {
const unsafeString = '<&>'
expect(html`${unsafeString}`.value).eq('&lt;&amp;&gt;')
expect(html`${unsafeString} <b>text</b>`.value).eq('&lt;&amp;&gt; <b>text</b>')
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>')
test(htm`<a href="">link</a> <a>link</a>`, [], 'link link')
})
it('should skip with FormattedString', () => {
const unsafeString2 = '<&>'
const unsafeString = new FormattedString('<&>')
describe('template', () => {
it('should add plain strings as is', () => {
test(
htm`some text ${'<b>not bold yea</b>'} some more text`,
[],
'some text <b>not bold yea</b> some more text',
)
})
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 skip falsy values', () => {
test(htm`some text ${null} some ${false} more text`, [], 'some text some more text')
})
it('should error with incompatible FormattedString', () => {
const unsafeString = new FormattedString('<&>', 'html')
const unsafeString2 = new FormattedString('<&>', 'some-other-mode')
it('should process entities', () => {
const inner = htm`<b>bold</b>`
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)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
expect(() => html`${unsafeString2}`.value).throw(Error)
it('should process entities on edges', () => {
test(
htm`${htm`<b>bold</b>`} and ${htm`<i>italic</i>`}`,
[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 */
import type { FormattedString } from '@mtcute/client'
import type { tl } from '@mtcute/client'
type Values<T> = T[keyof T]
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
*/
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
* function resolving to a literal one
@ -59,7 +70,7 @@ export type MtcuteI18nFunction<Strings, Input> = <K extends NestedKeysDelimited<
lang: Input | string | null,
key: K,
...params: ExtractParameter<Strings, K>
) => string | FormattedString<string>
) => I18nValueLiteral
/**
* 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) {
const val = obj[key]
if (typeof val === 'object' && !('value' in val)) {
if (typeof val === 'object' && !('text' in val)) {
add(val, prefix + key + '.')
} else {
ret[prefix + key] = val as string

View file

@ -2,14 +2,17 @@
// This is a test for TypeScript typings
// 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'
declare const someInputText: InputText
const en = {
basic: {
hello: 'Hello',
world: () => 'World',
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
> **Note**:
> It is generally recommended to use `@mtcute/html-parser` instead,
> as it is easier to use and is more readable in most cases
## Features
- Supports all entities that Telegram supports
- Supports nested and overlapping entities
- Supports dedentation
- [Interpolation](#interpolation)!
## Usage
```typescript
import { TelegramClient } from '@mtcute/client'
import { MarkdownMessageEntityParser, md } from '@mtcute/markdown-parser'
const tg = new TelegramClient({ ... })
tg.registerParseMode(new MarkdownMessageEntityParser())
import { md } from '@mtcute/markdown-parser'
tg.sendText(
'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>` |
| `**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
closing tags.
> **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> |
Because of interpolation, you almost never need to think about escaping anything,
since the values are not even parsed as Markdown, and are appended to the output as-is.

View file

@ -1,6 +1,6 @@
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 EMOJI_REGEX = /^tg:\/\/emoji\?id=(-?\d+)/
@ -16,69 +16,138 @@ const TAG_PRE = '```'
const TO_BE_ESCAPED = /[*_\-~`[\\\]|]/g
/**
* Tagged template based helper for escaping entities in Markdown
* Escape a string to be safely used in Markdown.
*
* @example
* ```typescript
* const escaped = md`**${user.displayName}**`
* ```
* > **Note**: this function is in most cases not needed, as `md` function
* > handles all `string`s passed to it automatically as plain text.
*/
export function md(
strings: TemplateStringsArray,
...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' }
function escape(str: string): string {
return str.replace(TO_BE_ESCAPED, (s) => '\\' + s)
}
/**
* Markdown MessageEntity parser.
*
* This class is **not** compatible with the Bot API Markdown nor MarkdownV2,
* please read the [documentation](../) to learn about syntax.
* Add Markdown formatting to the text given the plain text and entities contained in it.
*/
export class MarkdownMessageEntityParser implements IMessageEntityParser {
name = 'markdown'
function unparse(input: InputText): string {
if (typeof input === 'string') return escape(input)
/**
*
* @param str String to be escaped
*/
let text = input.text
const entities = input.entities ?? []
/* istanbul ignore next */
static escape(str: string): string {
// this code doesn't really need to be tested since it's just
// a simplified version of what is used in .unparse()
return str.replace(TO_BE_ESCAPED, (s) => '\\' + s)
// 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
})
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
}
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])
}
parse(text: string): [string, tl.TypeMessageEntity[]] {
const entities: tl.TypeMessageEntity[] = []
// 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[] = []
let result = ''
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
let insideCode = false
let insidePre = false
let insideLink = false
function feed(text: string) {
const len = text.length
let result = ''
const stacks: Record<string, tl.Mutable<tl.TypeMessageEntity>[]> = {}
let insideCode = false
let insidePre = false
let insideLink = false
let pos = 0
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
result += c
pos += 1
}
return [result, entities]
}
unparse(text: string, entities: ReadonlyArray<tl.TypeMessageEntity>): string {
// keep track of positions of inserted escape symbols
const escaped: number[] = []
text = text.replace(TO_BE_ESCAPED, (s, pos: number) => {
escaped.push(pos)
if (typeof strings === 'string') strings = [strings] as unknown as TemplateStringsArray
return '\\' + s
})
const hasEscaped = escaped.length > 0
sub.forEach((it, idx) => {
feed(strings[idx])
type InsertLater = [number, string]
const insert: InsertLater[] = []
if (typeof it === 'boolean' || !it) return
for (const entity of entities) {
const type = entity._
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
let start = entity.offset
let end = start + entity.length
const baseOffset = result.length
result += text
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
if (innerEntities) {
for (const ent of innerEntities) {
entities.push({ ...ent, offset: ent.offset + baseOffset })
}
start += escapedPos
while (escapedPos < escaped.length && escaped[escapedPos] <= end) {
escapedPos += 1
}
end += escapedPos
}
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])
feed(strings[strings.length - 1])
for (const [offset, tag] of insert) {
text = text.substr(0, offset) + tag + text.substr(offset)
for (const [name, stack] of Object.entries(stacks)) {
if (stack.length) {
throw new Error(`Unterminated ${name} entity`)
}
}
return text
return {
text: result,
entities,
}
}
// 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 { 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['_']>(
type: T,
@ -21,16 +22,9 @@ const createEntity = <T extends tl.TypeMessageEntity['_']>(
}
describe('MarkdownMessageEntityParser', () => {
const parser = new MarkdownMessageEntityParser()
describe('unparse', () => {
const test = (
text: string,
entities: tl.TypeMessageEntity[],
expected: string | string[],
_parser = parser,
): void => {
const result = _parser.unparse(text, entities)
const test = (text: string, entities: tl.TypeMessageEntity[], expected: string | string[]): void => {
const result = md_.unparse({ text, entities })
if (Array.isArray(expected)) {
expect(expected).to.include(result)
@ -240,9 +234,9 @@ describe('MarkdownMessageEntityParser', () => {
if (!Array.isArray(texts)) texts = [texts]
for (const text of texts) {
const [_text, entities] = parser.parse(text)
expect(_text).eql(expectedText)
expect(entities).eql(expectedEntities)
const res = md_(text)
expect(res.text).eql(expectedText)
expect(res.entities ?? []).eql(expectedEntities)
}
}
@ -492,29 +486,8 @@ describe('MarkdownMessageEntityParser', () => {
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', () => {
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', () => {
testThrows('plain [link](https://google.com but unclosed')
@ -524,37 +497,95 @@ describe('MarkdownMessageEntityParser', () => {
testThrows('plain ```pre without linebreaks```')
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', () => {
it('should work as a tagged template literal', () => {
const unsafeString = '__[]__'
const test = (text: TextWithEntities, expectedEntities: tl.TypeMessageEntity[], expectedText: string): void => {
expect(text.text).eql(expectedText)
expect(text.entities ?? []).eql(expectedEntities)
}
expect(md`${unsafeString}`.value).eq('\\_\\_\\[\\]\\_\\_')
expect(md`${unsafeString} **text**`.value).eq('\\_\\_\\[\\]\\_\\_ **text**')
expect(md`**text** ${unsafeString}`.value).eq('**text** \\_\\_\\[\\]\\_\\_')
expect(md`**${unsafeString}**`.value).eq('**\\_\\_\\[\\]\\_\\_**')
it('should add plain strings as is', () => {
test(md_`${'**plain**'}`, [], '**plain**')
})
it('should skip with FormattedString', () => {
const unsafeString2 = '__[]__'
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 skip falsy values', () => {
test(md_`some text ${null} more text ${false}`, [], 'some text more text ')
})
it('should error with incompatible FormattedString', () => {
const unsafeString = new FormattedString('<&>', 'markdown')
const unsafeString2 = new FormattedString('<&>', 'some-other-mode')
it('should properly dedent', () => {
test(
md_`
some text
**bold**
more text
`,
[createEntity('messageEntityBold', 10, 4)],
'some text\nbold\nmore text',
)
})
expect(() => md`${unsafeString}`.value).not.throw(Error)
// @ts-expect-error this is intentional
expect(() => md`${unsafeString2}`.value).throw(Error)
it('should process entities', () => {
const inner = md_`**bold**`
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 { TelegramClient, TelegramClientOptions } from '@mtcute/client'
import { HtmlMessageEntityParser } from '@mtcute/html-parser'
import { MarkdownMessageEntityParser } from '@mtcute/markdown-parser'
import { SqliteStorage } from '@mtcute/sqlite'
export * from '@mtcute/client'
@ -23,16 +21,6 @@ try {
} catch (e) {}
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.
*
@ -66,13 +54,6 @@ export class NodeTelegramClient extends TelegramClient {
new SqliteStorage(opts.storage) :
opts.storage ?? new SqliteStorage('client.session'),
})
this.registerParseMode(new HtmlMessageEntityParser())
this.registerParseMode(new MarkdownMessageEntityParser())
if (opts.defaultParseMode) {
this.setDefaultParseMode(opts.defaultParseMode)
}
}
private _rl?: RlInterface