diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 447121d1..d7bc388c 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -73,6 +73,7 @@ import { _parseEntities } from './methods/messages/parse-entities' import { pinMessage } from './methods/messages/pin-message' import { searchGlobal } from './methods/messages/search-global' import { searchMessages } from './methods/messages/search-messages' +import { sendTyping } from './methods/messages/send-chat-action' import { sendMediaGroup } from './methods/messages/send-media-group' import { sendMedia } from './methods/messages/send-media' import { sendText } from './methods/messages/send-text' @@ -105,6 +106,7 @@ import { getCommonChats } from './methods/users/get-common-chats' import { getMe } from './methods/users/get-me' import { getUsers } from './methods/users/get-users' import { resolvePeer } from './methods/users/resolve-peer' +import { setOffline } from './methods/users/set-offline' import { IMessageEntityParser } from './parser' import { Readable } from 'stream' import { @@ -1673,6 +1675,50 @@ export interface TelegramClient extends BaseTelegramClient { chunkSize?: number } ): AsyncIterableIterator + /** + * Sends a current user/bot typing event + * to a conversation partner or group. + * + * This status is set for 6 seconds, and is + * automatically cancelled if you send a + * message. + * + * @param chatId Chat ID + * @param action + * (default: `'typing'`) + * Chat action: + * - `typing` - user is typing + * - `cancel` to cancel previously sent event + * - `record_video` - user is recording a video + * - `upload_video` - user is uploading a video + * - `record_voice` - user is recording a voice note + * - `upload_voice` - user is uploading a voice note + * - `upload_photo` - user is uploading a photo + * - `upload_document` - user is sending a document + * + * @param progress For `upload_*` actions, progress of the upload (optional) + */ + sendChatAction( + chatId: InputPeerLike, + action?: + | 'typing' + | 'cancel' + | 'record_video' + | 'upload_video' + | 'record_voice' + | 'upload_voice' + | 'upload_photo' + | 'upload_document' + | 'geo' + | 'contact' + | 'game' + | 'record_round' + | 'upload_round' + | 'group_call' + | 'history_import' + | tl.TypeSendMessageAction, + progress?: number + ): Promise /** * Send a group of media. * @@ -2169,6 +2215,12 @@ export interface TelegramClient extends BaseTelegramClient { resolvePeer( peerId: InputPeerLike ): Promise + /** + * Change user status to offline or online + * + * @param offline (default: `true`) Whether the user is currently offline + */ + setOffline(offline?: boolean): Promise } /** @internal */ export class TelegramClient extends BaseTelegramClient { @@ -2273,6 +2325,7 @@ export class TelegramClient extends BaseTelegramClient { pinMessage = pinMessage searchGlobal = searchGlobal searchMessages = searchMessages + sendChatAction = sendTyping sendMediaGroup = sendMediaGroup sendMedia = sendMedia sendText = sendText @@ -2301,4 +2354,5 @@ export class TelegramClient extends BaseTelegramClient { getMe = getMe getUsers = getUsers resolvePeer = resolvePeer + setOffline = setOffline } diff --git a/packages/client/src/methods/messages/send-typing.ts b/packages/client/src/methods/messages/send-typing.ts new file mode 100644 index 00000000..372dd248 --- /dev/null +++ b/packages/client/src/methods/messages/send-typing.ts @@ -0,0 +1,81 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { InputPeerLike } from '../../types' +import { TypingStatus } from '../../types/peers/typing-status' +import { normalizeToInputPeer } from '../../utils/peer-utils' + +/** + * Sends a current user/bot typing event + * to a conversation partner or group. + * + * This status is set for 6 seconds, and is + * automatically cancelled if you send a + * message. + * + * @param chatId Chat ID + * @param status Typing status + * @param progress For `upload_*` and actions, progress of the upload + * @internal + */ +export async function sendTyping( + this: TelegramClient, + chatId: InputPeerLike, + status: TypingStatus | tl.TypeSendMessageAction = 'typing', + progress = 0 +): Promise { + if (typeof status === 'string') { + switch (status) { + case 'typing': + status = { _: 'sendMessageTypingAction' } + break; + case 'cancel': + status = { _: 'sendMessageCancelAction' } + break; + case 'record_video': + status = { _: 'sendMessageRecordVideoAction' } + break; + case 'upload_video': + status = { _: 'sendMessageUploadVideoAction', progress } + break; + case 'record_voice': + status = { _: 'sendMessageRecordAudioAction' } + break; + case 'upload_voice': + status = { _: 'sendMessageUploadAudioAction', progress } + break; + case 'upload_photo': + status = { _: 'sendMessageUploadPhotoAction', progress } + break; + case 'upload_document': + status = { _: 'sendMessageUploadDocumentAction', progress } + break; + case 'geo': + status = { _: 'sendMessageGeoLocationAction' } + break; + case 'contact': + status = { _: 'sendMessageChooseContactAction' } + break; + case 'game': + status = { _: 'sendMessageGamePlayAction' } + break; + case 'record_round': + status = { _: 'sendMessageRecordRoundAction' } + break; + case 'upload_round': + status = { _: 'sendMessageUploadRoundAction', progress } + break; + case 'speak_call': + status = { _: 'speakingInGroupCallAction' } + break; + case 'history_import': + status = { _: 'sendMessageHistoryImportAction', progress } + break; + } + } + + await this.call({ + _: 'messages.setTyping', + peer: normalizeToInputPeer(await this.resolvePeer(chatId)), + action: status + }) +} diff --git a/packages/client/src/methods/users/set-offline.ts b/packages/client/src/methods/users/set-offline.ts new file mode 100644 index 00000000..47a0c937 --- /dev/null +++ b/packages/client/src/methods/users/set-offline.ts @@ -0,0 +1,17 @@ +import { TelegramClient } from '../../client' + +/** + * Change user status to offline or online + * + * @param offline Whether the user is currently offline + * @internal + */ +export async function setOffline( + this: TelegramClient, + offline = true +): Promise { + await this.call({ + _: 'account.updateStatus', + offline + }) +} diff --git a/packages/client/src/types/peers/typing-status.ts b/packages/client/src/types/peers/typing-status.ts new file mode 100644 index 00000000..680d2efe --- /dev/null +++ b/packages/client/src/types/peers/typing-status.ts @@ -0,0 +1,38 @@ +/** + * User typing status. Used to provide detailed + * information about chat partners' actions: + * typing messages, recording/uploading attachments, etc. + * + * Can be: + * - `typing`: User is typing + * - `cancel`: User is not doing anything (used to cancel previously sent status) + * - `record_video`: User is recording a video + * - `upload_video`: User is uploading a video + * - `record_voice`: User is recording a voice message + * - `upload_voice`: User is uploading a voice message + * - `upload_photo`: User is uploading a photo + * - `upload_document`: User is uploading a document + * - `geo`: User is choosing a geolocation to share + * - `contact`: User is choosing a contact to share + * - `game`: User is playing a game + * - `record_round`: User is recording a round video message + * - `upload_round`: User is uploading a round video message + * - `speak_call`: *undocumented* User is speaking in a group call + * - `history_import`: *undocumented* User is importing history + */ +export type TypingStatus = + | 'typing' + | 'cancel' + | 'record_video' + | 'upload_video' + | 'record_voice' + | 'upload_voice' + | 'upload_photo' + | 'upload_document' + | 'geo' + | 'contact' + | 'game' + | 'record_round' + | 'upload_round' + | 'speak_call' + | 'history_import' diff --git a/packages/client/src/types/peers/user.ts b/packages/client/src/types/peers/user.ts index 09de7e1b..e2a08f42 100644 --- a/packages/client/src/types/peers/user.ts +++ b/packages/client/src/types/peers/user.ts @@ -25,12 +25,12 @@ export namespace User { | 'within_month' | 'long_time_ago' | 'bot' -} -interface ParsedStatus { - status: User.Status - lastOnline: Date | null - nextOffline: Date | null + export interface ParsedStatus { + status: User.Status + lastOnline: Date | null + nextOffline: Date | null + } } export class User { @@ -117,40 +117,43 @@ export class User { return this._user.lastName ?? null } - private _parsedStatus?: ParsedStatus - - private _parseStatus() { - let status: User.Status + static parseStatus(status: tl.TypeUserStatus, bot = false): User.ParsedStatus { + let ret: User.Status let date: Date - const us = this._user.status + const us = status if (!us) { - status = 'long_time_ago' - } else if (this._user.bot) { - status = 'bot' + ret = 'long_time_ago' + } else if (bot) { + ret = 'bot' } else if (us._ === 'userStatusOnline') { - status = 'online' + ret = 'online' date = new Date(us.expires * 1000) } else if (us._ === 'userStatusOffline') { - status = 'offline' + ret = 'offline' date = new Date(us.wasOnline * 1000) } else if (us._ === 'userStatusRecently') { - status = 'recently' + ret = 'recently' } else if (us._ === 'userStatusLastWeek') { - status = 'within_week' + ret = 'within_week' } else if (us._ === 'userStatusLastMonth') { - status = 'within_month' + ret = 'within_month' } else { - status = 'long_time_ago' + ret = 'long_time_ago' } - this._parsedStatus = { - status, - lastOnline: status === 'offline' ? date! : null, - nextOffline: status === 'online' ? date! : null, + return { + status: ret, + lastOnline: ret === 'offline' ? date! : null, + nextOffline: ret === 'online' ? date! : null, } } + private _parsedStatus?: User.ParsedStatus + private _parseStatus() { + this._parsedStatus = User.parseStatus(this._user.status!, this._user.bot) + } + /** User's Last Seen & Online status */ get status(): User.Status { if (!this._parsedStatus) this._parseStatus() diff --git a/packages/dispatcher/scripts/update-types.txt b/packages/dispatcher/scripts/update-types.txt index 0d926905..0b05ccc8 100644 --- a/packages/dispatcher/scripts/update-types.txt +++ b/packages/dispatcher/scripts/update-types.txt @@ -9,3 +9,5 @@ chosen_inline_result = ChosenInlineResult callback_query = CallbackQuery poll: PollUpdate = PollUpdate poll_vote = PollVoteUpdate +user_status: UserStatusUpdate = UserStatusUpdate +user_typing = UserTypingUpdate diff --git a/packages/dispatcher/src/builders.ts b/packages/dispatcher/src/builders.ts index f9e6ad3d..a7f4bb73 100644 --- a/packages/dispatcher/src/builders.ts +++ b/packages/dispatcher/src/builders.ts @@ -10,6 +10,8 @@ import { CallbackQueryHandler, PollUpdateHandler, PollVoteHandler, + UserStatusUpdateHandler, + UserTypingHandler, } from './handler' // end-codegen-imports import { filters, UpdateFilter } from './filters' @@ -18,6 +20,8 @@ import { ChatMemberUpdate } from './updates' import { ChosenInlineResult } from './updates/chosen-inline-result' import { PollUpdate } from './updates/poll-update' import { PollVoteUpdate } from './updates/poll-vote' +import { UserStatusUpdate } from './updates/user-status-update' +import { UserTypingUpdate } from './updates/user-typing-update' function _create( type: T['type'], @@ -291,5 +295,62 @@ export namespace handlers { return _create('poll_vote', filter, handler) } + /** + * Create an user status update handler + * + * @param handler User status update handler + */ + export function userStatusUpdate( + handler: UserStatusUpdateHandler['callback'] + ): UserStatusUpdateHandler + + /** + * Create an user status update handler with a filter + * + * @param filter Update filter + * @param handler User status update handler + */ + export function userStatusUpdate( + filter: UpdateFilter, + handler: UserStatusUpdateHandler< + filters.Modify + >['callback'] + ): UserStatusUpdateHandler + + /** @internal */ + export function userStatusUpdate( + filter: any, + handler?: any + ): UserStatusUpdateHandler { + return _create('user_status', filter, handler) + } + + /** + * Create an user typing handler + * + * @param handler User typing handler + */ + export function userTyping( + handler: UserTypingHandler['callback'] + ): UserTypingHandler + + /** + * Create an user typing handler with a filter + * + * @param filter Update filter + * @param handler User typing handler + */ + export function userTyping( + filter: UpdateFilter, + handler: UserTypingHandler< + filters.Modify + >['callback'] + ): UserTypingHandler + + /** @internal */ + export function userTyping(filter: any, handler?: any): UserTypingHandler { + return _create('user_typing', filter, handler) + } + // end-codegen } diff --git a/packages/dispatcher/src/dispatcher.ts b/packages/dispatcher/src/dispatcher.ts index 1adaa8bf..4cacad61 100644 --- a/packages/dispatcher/src/dispatcher.ts +++ b/packages/dispatcher/src/dispatcher.ts @@ -24,6 +24,8 @@ import { CallbackQueryHandler, PollUpdateHandler, PollVoteHandler, + UserStatusUpdateHandler, + UserTypingHandler, } from './handler' // end-codegen-imports import { filters, UpdateFilter } from './filters' @@ -32,6 +34,8 @@ import { ChatMemberUpdate } from './updates' import { ChosenInlineResult } from './updates/chosen-inline-result' import { PollUpdate } from './updates/poll-update' import { PollVoteUpdate } from './updates/poll-vote' +import { UserStatusUpdate } from './updates/user-status-update' +import { UserTypingUpdate } from './updates/user-typing-update' const noop = () => {} @@ -67,6 +71,10 @@ const callbackQueryParser: UpdateParser = [ 'callback_query', (client, upd, users) => new CallbackQuery(client, upd as any, users), ] +const userTypingParser: UpdateParser = [ + 'user_typing', + (client, upd) => new UserTypingUpdate(client, upd as any) +] const PARSERS: Partial< Record<(tl.TypeUpdate | tl.TypeMessage)['_'], UpdateParser> @@ -100,10 +108,17 @@ const PARSERS: Partial< 'poll_vote', (client, upd, users) => new PollVoteUpdate(client, upd as any, users), ], + updateUserStatus: [ + 'user_status', + (client, upd) => new UserStatusUpdate(client, upd as any), + ], + updateChannelUserTyping: userTypingParser, + updateChatUserTyping: userTypingParser, + updateUserTyping: userTypingParser, } /** - * The dispatcher + * Updates dispatcher */ export class Dispatcher { private _groups: Record = {} @@ -667,5 +682,66 @@ export class Dispatcher { this._addKnownHandler('pollVote', filter, handler, group) } + /** + * Register an user status update handler without any filters + * + * @param handler User status update handler + * @param group Handler group index + * @internal + */ + onUserStatusUpdate( + handler: UserStatusUpdateHandler['callback'], + group?: number + ): void + + /** + * Register an user status update handler with a filter + * + * @param filter Update filter + * @param handler User status update handler + * @param group Handler group index + */ + onUserStatusUpdate( + filter: UpdateFilter, + handler: UserStatusUpdateHandler< + filters.Modify + >['callback'], + group?: number + ): void + + /** @internal */ + onUserStatusUpdate(filter: any, handler?: any, group?: number): void { + this._addKnownHandler('userStatusUpdate', filter, handler, group) + } + + /** + * Register an user typing handler without any filters + * + * @param handler User typing handler + * @param group Handler group index + * @internal + */ + onUserTyping(handler: UserTypingHandler['callback'], group?: number): void + + /** + * Register an user typing handler with a filter + * + * @param filter Update filter + * @param handler User typing handler + * @param group Handler group index + */ + onUserTyping( + filter: UpdateFilter, + handler: UserTypingHandler< + filters.Modify + >['callback'], + group?: number + ): void + + /** @internal */ + onUserTyping(filter: any, handler?: any, group?: number): void { + this._addKnownHandler('userTyping', filter, handler, group) + } + // end-codegen } diff --git a/packages/dispatcher/src/handler.ts b/packages/dispatcher/src/handler.ts index 8c1bd4ab..17f7df90 100644 --- a/packages/dispatcher/src/handler.ts +++ b/packages/dispatcher/src/handler.ts @@ -11,6 +11,8 @@ import { ChatMemberUpdate } from './updates' import { ChosenInlineResult } from './updates/chosen-inline-result' import { PollUpdate } from './updates/poll-update' import { PollVoteUpdate } from './updates/poll-vote' +import { UserStatusUpdate } from './updates/user-status-update' +import { UserTypingUpdate } from './updates/user-typing-update' interface BaseUpdateHandler { type: Type @@ -73,6 +75,14 @@ export type PollVoteHandler = ParsedUpdateHandler< 'poll_vote', T > +export type UserStatusUpdateHandler = ParsedUpdateHandler< + 'user_status', + T +> +export type UserTypingHandler = ParsedUpdateHandler< + 'user_typing', + T +> export type UpdateHandler = | RawUpdateHandler @@ -84,5 +94,7 @@ export type UpdateHandler = | CallbackQueryHandler | PollUpdateHandler | PollVoteHandler + | UserStatusUpdateHandler + | UserTypingHandler // end-codegen diff --git a/packages/dispatcher/src/updates/user-status-update.ts b/packages/dispatcher/src/updates/user-status-update.ts new file mode 100644 index 00000000..b53a9d71 --- /dev/null +++ b/packages/dispatcher/src/updates/user-status-update.ts @@ -0,0 +1,66 @@ +import { TelegramClient, User } from '@mtcute/client' +import { tl } from '@mtcute/tl' +import { makeInspectable } from '@mtcute/client/src/types/utils' + +/** + * User status has changed + */ +export class UserStatusUpdate { + readonly client: TelegramClient + readonly raw: tl.RawUpdateUserStatus + + constructor( + client: TelegramClient, + raw: tl.RawUpdateUserStatus + ) { + this.client = client + this.raw = raw + } + + /** + * ID of the user whose status has updated + */ + get userId(): number { + return this.raw.userId + } + + private _parsedStatus?: User.ParsedStatus + private _parseStatus() { + this._parsedStatus = User.parseStatus(this.raw.status) + } + + /** + * User's new Last Seen & Online status + */ + get status(): User.Status { + if (!this._parsedStatus) this._parseStatus() + return this._parsedStatus!.status + } + + /** + * Last time this user was seen online. + * Only available if {@link status} is `offline` + */ + get lastOnline(): Date | null { + if (!this._parsedStatus) this._parseStatus() + return this._parsedStatus!.lastOnline + } + + /** + * Time when this user will automatically go offline. + * Only available if {@link status} is `online` + */ + get nextOffline(): Date | null { + if (!this._parsedStatus) this._parseStatus() + return this._parsedStatus!.nextOffline + } + + /** + * Fetch information about the user + */ + getUser(): Promise { + return this.client.getUsers(this.raw.userId) + } +} + +makeInspectable(UserStatusUpdate) diff --git a/packages/dispatcher/src/updates/user-typing-update.ts b/packages/dispatcher/src/updates/user-typing-update.ts new file mode 100644 index 00000000..f0fd9f2a --- /dev/null +++ b/packages/dispatcher/src/updates/user-typing-update.ts @@ -0,0 +1,117 @@ +import { BasicPeerType, Chat, MtCuteUnsupportedError, TelegramClient, User } from '@mtcute/client' +import { tl } from '@mtcute/tl' +import { getBarePeerId, MAX_CHANNEL_ID } from '@mtcute/core' +import { TypingStatus } from '@mtcute/client/src/types/peers/typing-status' +import { makeInspectable } from '@mtcute/client/src/types/utils' + +/** + * User's typing status has changed. + * + * This update is valid for 6 seconds. + */ +export class UserTypingUpdate { + readonly client: TelegramClient + readonly raw: + | tl.RawUpdateUserTyping + | tl.RawUpdateChatUserTyping + | tl.RawUpdateChannelUserTyping + + constructor(client: TelegramClient, raw: UserTypingUpdate['raw']) { + this.client = client + this.raw = raw + } + + /** + * ID of the user whose typing status changed + */ + get userId(): number { + return this.raw._ === 'updateUserTyping' + ? this.raw.userId + : getBarePeerId(this.raw.fromId) + } + + /** + * Marked ID of the chat where the user is typing, + * + * If the user is typing in PMs, this will + * equal to {@link userId} + */ + get chatId(): number { + switch (this.raw._) { + case 'updateUserTyping': + return this.raw.userId + case 'updateChatUserTyping': + return -this.raw.chatId + case 'updateChannelUserTyping': + return MAX_CHANNEL_ID - this.raw.channelId + } + } + + /** + * Type of the chat where this event has occurred + */ + get chatType(): BasicPeerType { + switch (this.raw._) { + case 'updateUserTyping': + return 'user' + case 'updateChatUserTyping': + return 'chat' + case 'updateChannelUserTyping': + return 'channel' + } + } + + /** + * Current typing status + */ + get status(): TypingStatus { + switch (this.raw.action._) { + case 'sendMessageTypingAction': + return 'typing' + case 'sendMessageCancelAction': + return 'cancel' + case 'sendMessageRecordVideoAction': + return 'record_video' + case 'sendMessageUploadVideoAction': + return 'upload_video' + case 'sendMessageRecordAudioAction': + return 'record_voice' + case 'sendMessageUploadAudioAction': + return 'upload_voice' + case 'sendMessageUploadPhotoAction': + return 'upload_photo' + case 'sendMessageUploadDocumentAction': + return 'upload_document' + case 'sendMessageGeoLocationAction': + return 'geo' + case 'sendMessageChooseContactAction': + return 'contact' + case 'sendMessageRecordRoundAction': + return 'record_round' + case 'sendMessageUploadRoundAction': + return 'upload_round' + case 'speakingInGroupCallAction': + return 'speak_call' + case 'sendMessageHistoryImportAction': + return 'history_import' + } + + throw new MtCuteUnsupportedError() + } + + /** + * Fetch the user whose typing status has changed + */ + getUser(): Promise { + return this.client.getUsers(this.userId) + } + + /** + * Fetch the chat where the update has happenned + */ + getChat(): Promise { + return this.client.getChat(this.chatId) + } +} + +makeInspectable(UserTypingUpdate)