diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2bcf8de4..d2f3bc04 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -15,6 +15,7 @@ import { signIn } from './methods/auth/sign-in' import { signUp } from './methods/auth/sign-up' import { startTest } from './methods/auth/start-test' import { start } from './methods/auth/start' +import { answerInlineQuery } from './methods/bots/answer-inline-query' import { addChatMembers } from './methods/chats/add-chat-members' import { archiveChats } from './methods/chats/archive-chats' import { createChannel } from './methods/chats/create-channel' @@ -104,6 +105,7 @@ import { FileDownloadParameters, InputChatPermissions, InputFileLike, + InputInlineResult, InputMediaLike, InputPeerLike, MaybeDynamic, @@ -376,13 +378,108 @@ export interface TelegramClient extends BaseTelegramClient { /** * Whether to "catch up" (load missed updates). - * Note: you should register your handlers - * before calling `start()` + * Only applicable if the saved session already + * contained authorization and updates state. * - * Defaults to true. + * Note: you should register your handlers + * before calling `start()`, otherwise they will + * not be called. + * + * Note: In case the storage was not properly + * closed the last time, "catching up" might + * result in duplicate updates. + * + * Defaults to `false`. */ catchUp?: boolean }): Promise + /** + * Answer an inline query. + * + * @param queryId Inline query ID + * @param results Results of the query + * @param params Additional parameters + */ + answerInlineQuery( + queryId: tl.Long, + results: InputInlineResult[], + params?: { + /** + * Maximum number of time in seconds that the results of the + * query may be cached on the server for. + * + * Defaults to `300` + */ + cacheTime?: number + + /** + * Whether the results should be displayed as a gallery instead + * of a vertical list. Only applicable to some media types. + * + * Defaults to `false` + */ + gallery?: boolean + + /** + * Whether the results should only be cached on the server + * for the user who sent the query. + * + * Defaults to `false` + */ + private?: boolean + + /** + * Next pagination offset (up to 64 bytes). + * + * When user has reached the end of the current results, + * it will re-send the inline query with the same text, but + * with `offset` set to this value. + * + * If omitted or empty string is provided, it is assumed that + * there are no more results. + */ + nextOffset?: string + + /** + * If passed, clients will display a button before any other results, + * that when clicked switches the user to a private chat with the bot + * and sends the bot `/start ${parameter}`. + * + * An example from the Bot API docs: + * + * An inline bot that sends YouTube videos can ask the user to connect + * the bot to their YouTube account to adapt search results accordingly. + * To do this, it displays a "Connect your YouTube account" button above + * the results, or even before showing any. The user presses the button, + * switches to a private chat with the bot and, in doing so, passes a start + * parameter that instructs the bot to return an oauth link. Once done, the + * bot can offer a switch_inline button so that the user can easily return to + * the chat where they wanted to use the bot's inline capabilities + */ + switchPm?: { + /** + * Text of the button + */ + text: string + + /** + * Parameter for `/start` command + */ + parameter: string + } + + /** + * Parse mode to use when parsing inline message text. + * Defaults to current default parse mode (if any). + * + * 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. + */ + parseMode?: string | null + } + ): Promise /** * Add new members to a group, supergroup or channel. * @@ -1883,6 +1980,7 @@ export class TelegramClient extends BaseTelegramClient { signUp = signUp startTest = startTest start = start + answerInlineQuery = answerInlineQuery addChatMembers = addChatMembers archiveChats = archiveChats createChannel = createChannel diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index 5f738c69..25f278dd 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -27,6 +27,7 @@ import { Message, ReplyMarkup, InputMediaLike, + InputInlineResult, TakeoutSession, StickerSet } from '../types' diff --git a/packages/client/src/methods/bots/answer-inline-query.ts b/packages/client/src/methods/bots/answer-inline-query.ts new file mode 100644 index 00000000..2cc6da70 --- /dev/null +++ b/packages/client/src/methods/bots/answer-inline-query.ts @@ -0,0 +1,112 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { BotInline, InputInlineResult } from '../../types' + +/** + * Answer an inline query. + * + * @param queryId Inline query ID + * @param results Results of the query + * @param params Additional parameters + * @internal + */ +export async function answerInlineQuery( + this: TelegramClient, + queryId: tl.Long, + results: InputInlineResult[], + params?: { + /** + * Maximum number of time in seconds that the results of the + * query may be cached on the server for. + * + * Defaults to `300` + */ + cacheTime?: number + + /** + * Whether the results should be displayed as a gallery instead + * of a vertical list. Only applicable to some media types. + * + * Defaults to `false` + */ + gallery?: boolean + + /** + * Whether the results should only be cached on the server + * for the user who sent the query. + * + * Defaults to `false` + */ + private?: boolean + + /** + * Next pagination offset (up to 64 bytes). + * + * When user has reached the end of the current results, + * it will re-send the inline query with the same text, but + * with `offset` set to this value. + * + * If omitted or empty string is provided, it is assumed that + * there are no more results. + */ + nextOffset?: string + + /** + * If passed, clients will display a button before any other results, + * that when clicked switches the user to a private chat with the bot + * and sends the bot `/start ${parameter}`. + * + * An example from the Bot API docs: + * + * An inline bot that sends YouTube videos can ask the user to connect + * the bot to their YouTube account to adapt search results accordingly. + * To do this, it displays a "Connect your YouTube account" button above + * the results, or even before showing any. The user presses the button, + * switches to a private chat with the bot and, in doing so, passes a start + * parameter that instructs the bot to return an oauth link. Once done, the + * bot can offer a switch_inline button so that the user can easily return to + * the chat where they wanted to use the bot's inline capabilities + */ + switchPm?: { + /** + * Text of the button + */ + text: string + + /** + * Parameter for `/start` command + */ + parameter: string + } + + /** + * Parse mode to use when parsing inline message text. + * Defaults to current default parse mode (if any). + * + * 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. + */ + parseMode?: string | null + } +): Promise { + if (!params) params = {} + + const tlResults = await Promise.all(results.map(it => BotInline._convertToTl(this, it, params!.parseMode))) + + await this.call({ + _: 'messages.setInlineBotResults', + queryId, + results: tlResults, + cacheTime: params.cacheTime ?? 300, + gallery: params.gallery, + private: params.private, + nextOffset: params.nextOffset, + switchPm: params.switchPm ? { + _: 'inlineBotSwitchPM', + text: params.switchPm.text, + startParam: params.switchPm.parameter + } : undefined + }) +} diff --git a/packages/client/src/types/bots/index.ts b/packages/client/src/types/bots/index.ts index 08233d23..c27980db 100644 --- a/packages/client/src/types/bots/index.ts +++ b/packages/client/src/types/bots/index.ts @@ -1 +1,3 @@ export * from './keyboards' +export * from './inline-query' +export * from './input' diff --git a/packages/client/src/types/bots/inline-query.ts b/packages/client/src/types/bots/inline-query.ts new file mode 100644 index 00000000..7ceb9427 --- /dev/null +++ b/packages/client/src/types/bots/inline-query.ts @@ -0,0 +1,111 @@ +import { makeInspectable } from '@mtcute/client/src/types/utils' +import { tl } from '@mtcute/tl' +import { PeerType, User } from '../peers' +import { TelegramClient } from '../../client' +import { Location } from '../media' +import { InputInlineResult } from './input' + +const PEER_TYPE_MAP: Record = { + inlineQueryPeerTypeBroadcast: 'channel', + inlineQueryPeerTypeChat: 'group', + inlineQueryPeerTypeMegagroup: 'supergroup', + inlineQueryPeerTypePM: 'user', + inlineQueryPeerTypeSameBotPM: 'bot', +} + +export class InlineQuery { + readonly client: TelegramClient + readonly raw: tl.RawUpdateBotInlineQuery + + /** Map of users in this message. Mainly for internal use */ + readonly _users: Record + + constructor( + client: TelegramClient, + raw: tl.RawUpdateBotInlineQuery, + users: Record + ) { + this.client = client + this.raw = raw + this._users = users + } + + /** + * Unique query ID + */ + get id(): tl.Long { + return this.raw.queryId + } + + private _user?: User + /** + * User who sent this query + */ + get user(): User { + if (!this._user) { + this._user = new User(this.client, this._users[this.raw.userId]) + } + + return this._user + } + + /** + * Text of the query (0-512 characters) + */ + get query(): string { + return this.raw.query + } + + private _location?: Location + /** + * Attached geolocation. + * + * Only used in case the bot requested user location + */ + get location(): Location | null { + if (this.raw.geo?._ !== 'geoPoint') return null + + if (!this._location) { + this._location = new Location(this.raw.geo) + } + + return this._location + } + + /** + * Inline query scroll offset, controlled by the bot + */ + get offset(): string { + return this.raw.offset + } + + /** + * Peer type from which this query was sent. + * + * Can be: + * - `bot`: Query was sent in this bot's PM + * - `user`: Query was sent in somebody's PM + * - `group`: Query was sent in a legacy group + * - `supergroup`: Query was sent in a supergroup + * - `channel`: Query was sent in a channel + * - `null`, in case this information is not available + */ + get peerType(): PeerType | null { + return this.raw.peerType ? PEER_TYPE_MAP[this.raw.peerType._] : null + } + + /** + * Answer to this inline query + * + * @param results Inline results + * @param params Additional parameters + */ + async answer( + results: InputInlineResult[], + params: Parameters[2] + ): Promise { + return this.client.answerInlineQuery(this.raw.queryId, results, params) + } +} + +makeInspectable(InlineQuery) diff --git a/packages/client/src/types/bots/input/index.ts b/packages/client/src/types/bots/input/index.ts new file mode 100644 index 00000000..12413a8a --- /dev/null +++ b/packages/client/src/types/bots/input/index.ts @@ -0,0 +1,2 @@ +export * from './input-inline-message' +export * from './input-inline-result' diff --git a/packages/client/src/types/bots/input/input-inline-message.ts b/packages/client/src/types/bots/input/input-inline-message.ts new file mode 100644 index 00000000..71fbefe9 --- /dev/null +++ b/packages/client/src/types/bots/input/input-inline-message.ts @@ -0,0 +1,65 @@ +import { tl } from '@mtcute/tl' +import { BotKeyboard, ReplyMarkup } from '../keyboards' +import { TelegramClient } from '../../../client' + +export interface InputInlineMessageText { + type: 'text' + + /** + * Text of the message + */ + text: string + + /** + * Text markup entities. + * If passed, parse mode is ignored + */ + entities?: tl.TypeMessageEntity[] + + /** + * Message reply markup + */ + replyMarkup?: ReplyMarkup + + /** + * Whether to disable links preview in this message + */ + disableWebPreview?: boolean +} + +export type InputInlineMessage = + | InputInlineMessageText + +export namespace BotInlineMessage { + export function text ( + text: string, + params?: Omit, + ): InputInlineMessageText { + return { + type: 'text', + text, + ...( + params || {} + ), + } + } + + export async function _convertToTl ( + client: TelegramClient, + obj: InputInlineMessage, + parseMode?: string | null, + ): Promise { + if (obj.type === 'text') { + const [message, entities] = await client['_parseEntities'](obj.text, parseMode, obj.entities) + + return { + _: 'inputBotInlineMessageText', + message, + entities, + replyMarkup: BotKeyboard._convertToTl(obj.replyMarkup) + } + } + + return obj as never + } +} diff --git a/packages/client/src/types/bots/input/input-inline-result.ts b/packages/client/src/types/bots/input/input-inline-result.ts new file mode 100644 index 00000000..519b5458 --- /dev/null +++ b/packages/client/src/types/bots/input/input-inline-result.ts @@ -0,0 +1,149 @@ +import { tl } from '@mtcute/tl' +import { BotInlineMessage, InputInlineMessage } from './input-inline-message' +import { TelegramClient } from '../../../client' + +interface BaseInputInlineResult { + /** + * Unique ID of the result + */ + id: string + + /** + * Message to send when the result is selected. + * + * By default, is automatically generated, + * and details about how it is generated can be found + * in subclasses' description + */ + message?: InputInlineMessage +} + +/** + * Represents an input article. + * + * If `message` is not provided, a {@link InputInlineMessageText} is created + * with web preview enabled and text generated as follows: + * ``` + * {{#if url}} + * {{title}} + * {{else}} + * {{title}} + * {{/if}} + * {{#if description}} + * {{description}} + * {{/if}} + * ``` + * > Handlebars syntax is used. HTML tags are used to signify entities, + * > but in fact raw TL entity objects are created + */ +export interface InputInlineResultArticle extends BaseInputInlineResult { + type: 'article' + + /** + * Title of the result (must not be empty) + */ + title: string + + /** + * Description of the result + */ + description?: string + + /** + * URL of the article + */ + url?: string + + /** + * Whether to prevent article URL from + * displaying by the client + * + * Defaults to `false` + */ + hideUrl?: boolean + + /** + * Article thumbnail URL (only jpeg). + */ + thumb?: string | tl.RawInputWebDocument +} + +export type InputInlineResult = InputInlineResultArticle + +export namespace BotInline { + export function article( + params: Omit + ): InputInlineResultArticle { + return { + type: 'article', + ...params, + } + } + + export async function _convertToTl( + client: TelegramClient, + obj: InputInlineResult, + parseMode?: string | null + ): Promise { + if (obj.type === 'article') { + let sendMessage: tl.TypeInputBotInlineMessage + if (obj.message) { + sendMessage = await BotInlineMessage._convertToTl(client, obj.message, parseMode) + } else { + let message = obj.title + const entities: tl.TypeMessageEntity[] = [ + { + _: 'messageEntityBold', + offset: 0, + length: message.length + } + ] + + if (obj.url) { + entities.push({ + _: 'messageEntityTextUrl', + url: obj.url, + offset: 0, + length: message.length + }) + } + + if (obj.description) { + message += '\n' + obj.description + } + + sendMessage = { + _: 'inputBotInlineMessageText', + message, + entities + } + } + + return { + _: 'inputBotInlineResult', + id: obj.id, + type: obj.type, + title: obj.title, + description: obj.description, + url: obj.hideUrl ? undefined : obj.url, + content: obj.url && obj.hideUrl ? { + _: 'inputWebDocument', + url: obj.url, + mimeType: 'text/html', + size: 0, + attributes: [] + } : undefined, + thumb: typeof obj.thumb === 'string' ? { + _: 'inputWebDocument', + size: 0, + url: obj.thumb, + mimeType: 'image/jpeg', + attributes: [], + } : obj.thumb, + sendMessage + } + } + + return obj as never + } +} diff --git a/packages/client/src/types/media/input-media.ts b/packages/client/src/types/media/input-media.ts index 53a1a49c..64cea9f3 100644 --- a/packages/client/src/types/media/input-media.ts +++ b/packages/client/src/types/media/input-media.ts @@ -14,7 +14,7 @@ interface BaseInputMedia { /** * Caption entities of the media. - * If passed, {@link caption} is ignored + * If passed, parse mode is ignored */ entities?: tl.TypeMessageEntity[] diff --git a/packages/dispatcher/src/builders.ts b/packages/dispatcher/src/builders.ts index e0245600..e43b059d 100644 --- a/packages/dispatcher/src/builders.ts +++ b/packages/dispatcher/src/builders.ts @@ -1,9 +1,35 @@ -import { ChatMemberUpdateHandler, NewMessageHandler, RawUpdateHandler } from './handler' +import { + ChatMemberUpdateHandler, + InlineQueryHandler, + NewMessageHandler, + RawUpdateHandler, + UpdateHandler, +} from './handler' import { filters, UpdateFilter } from './filters' -import { Message } from '@mtcute/client' +import { InlineQuery, Message } from '@mtcute/client' import { ChatMemberUpdate } from './updates' +function _create( + type: T['type'], + filter: any, + handler?: any +): T { + if (handler) { + return { + type, + check: filter, + callback: handler + } as any + } + + return { + type, + callback: filter + } as any +} + export namespace handlers { + /** * Create a {@link RawUpdateHandler} * @@ -25,18 +51,7 @@ export namespace handlers { ): RawUpdateHandler export function rawUpdate(filter: any, handler?: any): RawUpdateHandler { - if (handler) { - return { - type: 'raw', - check: filter, - callback: handler - } - } - - return { - type: 'raw', - callback: filter - } + return _create('raw', filter, handler) } /** @@ -63,18 +78,7 @@ export namespace handlers { filter: any, handler?: any ): NewMessageHandler { - if (handler) { - return { - type: 'new_message', - check: filter, - callback: handler, - } - } - - return { - type: 'new_message', - callback: filter, - } + return _create('new_message', filter, handler) } /** @@ -101,17 +105,33 @@ export namespace handlers { filter: any, handler?: any ): ChatMemberUpdateHandler { - if (handler) { - return { - type: 'chat_member', - check: filter, - callback: handler, - } - } + return _create('chat_member', filter, handler) + } - return { - type: 'chat_member', - callback: filter, - } + /** + * Create an inline query handler + * + * @param handler Inline query handler + */ + export function inlineQuery( + handler: InlineQueryHandler['callback'] + ): InlineQueryHandler + + /** + * Create an inline query with a filter + * + * @param filter Inline query update filter + * @param handler Inline query handler + */ + export function inlineQuery( + filter: UpdateFilter, + handler: InlineQueryHandler>['callback'] + ): InlineQueryHandler + + export function inlineQuery( + filter: any, + handler?: any + ): InlineQueryHandler { + return _create('inline_query', filter, handler) } } diff --git a/packages/dispatcher/src/dispatcher.ts b/packages/dispatcher/src/dispatcher.ts index 9eeaba49..20af3ffb 100644 --- a/packages/dispatcher/src/dispatcher.ts +++ b/packages/dispatcher/src/dispatcher.ts @@ -1,4 +1,9 @@ -import { Message, MtCuteArgumentError, TelegramClient } from '@mtcute/client' +import { + InlineQuery, + Message, + MtCuteArgumentError, + TelegramClient, +} from '@mtcute/client' import { tl } from '@mtcute/tl' import { ContinuePropagation, @@ -7,7 +12,7 @@ import { StopPropagation, } from './propagation' import { - ChatMemberUpdateHandler, + ChatMemberUpdateHandler, InlineQueryHandler, NewMessageHandler, RawUpdateHandler, UpdateHandler, @@ -18,6 +23,54 @@ import { ChatMemberUpdate } from './updates' const noop = () => {} +type ParserFunction = ( + client: TelegramClient, + upd: tl.TypeUpdate | tl.TypeMessage, + users: Record, + chats: Record +) => any +type UpdateParser = [Exclude, ParserFunction] + +const baseMessageParser: ParserFunction = ( + client: TelegramClient, + upd, + users, + chats +) => + new Message( + client, + tl.isAnyMessage(upd) ? upd : (upd as any).message, + users, + chats + ) + +const newMessageParser: UpdateParser = ['new_message', baseMessageParser] +const editMessageParser: UpdateParser = ['edit_message', baseMessageParser] +const chatMemberParser: UpdateParser = [ + 'chat_member', + (client, upd, users, chats) => + new ChatMemberUpdate(client, upd as any, users, chats), +] + +const PARSERS: Partial< + Record<(tl.TypeUpdate | tl.TypeMessage)['_'], UpdateParser> +> = { + message: newMessageParser, + messageEmpty: newMessageParser, + messageService: newMessageParser, + updateNewMessage: newMessageParser, + updateNewChannelMessage: newMessageParser, + updateNewScheduledMessage: newMessageParser, + updateEditMessage: editMessageParser, + updateEditChannelMessage: editMessageParser, + updateChatParticipant: chatMemberParser, + updateChannelParticipant: chatMemberParser, + updateBotInlineQuery: [ + 'inline_query', + (client, upd, users) => new InlineQuery(client, upd as any, users), + ], +} + /** * The dispatcher */ @@ -115,36 +168,10 @@ export class Dispatcher { if (!this._client) return const isRawMessage = tl.isAnyMessage(update) - - let message: Message | null = null - if ( - update._ === 'updateNewMessage' || - update._ === 'updateNewChannelMessage' || - update._ === 'updateNewScheduledMessage' || - update._ === 'updateEditMessage' || - update._ === 'updateEditChannelMessage' || - isRawMessage - ) { - message = new Message( - this._client, - isRawMessage ? update : (update as any).message, - users, - chats - ) - } - - let chatMember: ChatMemberUpdate | null = null - if ( - update._ === 'updateChatParticipant' || - update._ === 'updateChannelParticipant' - ) { - chatMember = new ChatMemberUpdate( - this._client, - update, - users, - chats - ) - } + const pair = PARSERS[update._] + const parsed = pair + ? pair[1](this._client, update, users, chats) + : undefined outer: for (const grp of this._groupsOrder) { for (const handler of this._groups[grp]) { @@ -168,19 +195,12 @@ export class Dispatcher { chats ) } else if ( - handler.type === 'new_message' && - message && + pair && + handler.type === pair[0] && (!handler.check || - (await handler.check(message, this._client))) + (await handler.check(parsed, this._client))) ) { - result = await handler.callback(message, this._client) - } else if ( - handler.type === 'chat_member' && - chatMember && - (!handler.check || - (await handler.check(chatMember, this._client))) - ) { - result = await handler.callback(chatMember, this._client) + result = await handler.callback(parsed, this._client) } else continue if (result === ContinuePropagation) continue @@ -407,7 +427,7 @@ export class Dispatcher { } /** - * Register a chat member update filter without any filters. + * Register a chat member update handler without any filters. * * @param handler Update handler * @param group Handler group index @@ -437,4 +457,36 @@ export class Dispatcher { onChatMemberUpdate(filter: any, handler?: any, group?: number): void { this._addKnownHandler('chatMemberUpdate', filter, handler, group) } + + /** + * Register an inline query handler without any filters. + * + * @param handler Update handler + * @param group Handler group index + * @internal + */ + onInlineQuery( + handler: InlineQueryHandler['callback'], + group?: number + ): void + + /** + * Register an inline query handler with a given filter + * + * @param filter Update filter + * @param handler Update handler + * @param group Handler group index + */ + onInlineQuery( + filter: UpdateFilter, + handler: InlineQueryHandler< + filters.Modify + >['callback'], + group?: number + ): void + + /** @internal */ + onInlineQuery(filter: any, handler?: any, group?: number): void { + this._addKnownHandler('inlineQuery', filter, handler, group) + } } diff --git a/packages/dispatcher/src/handler.ts b/packages/dispatcher/src/handler.ts index 770b643e..8f11dadc 100644 --- a/packages/dispatcher/src/handler.ts +++ b/packages/dispatcher/src/handler.ts @@ -1,4 +1,4 @@ -import { MaybeAsync, Message, TelegramClient } from '@mtcute/client' +import { MaybeAsync, Message, TelegramClient, InlineQuery } from '@mtcute/client' import { tl } from '@mtcute/tl' import { PropagationSymbol } from './propagation' import { ChatMemberUpdate } from './updates' @@ -39,12 +39,19 @@ export type NewMessageHandler = ParsedUpdateHandler< 'new_message', T > +export type EditMessageHandler = ParsedUpdateHandler< + 'edit_message', + T +> export type ChatMemberUpdateHandler = ParsedUpdateHandler< 'chat_member', T > +export type InlineQueryHandler = ParsedUpdateHandler<'inline_query', T> export type UpdateHandler = | RawUpdateHandler | NewMessageHandler + | EditMessageHandler | ChatMemberUpdateHandler + | InlineQueryHandler