diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 903d42e3..1219bc2a 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -56,6 +56,7 @@ import { downloadAsBuffer } from './methods/files/download-buffer' import { downloadToFile } from './methods/files/download-file' import { downloadAsIterable } from './methods/files/download-iterable' import { downloadAsStream } from './methods/files/download-stream' +import { _normalizeFileToDocument } from './methods/files/normalize-file-to-document' import { _normalizeInputFile } from './methods/files/normalize-input-file' import { _normalizeInputMedia } from './methods/files/normalize-input-media' import { uploadFile } from './methods/files/upload-file' @@ -84,8 +85,12 @@ import { setDefaultParseMode, unregisterParseMode, } from './methods/parse-modes/parse-modes' +import { addStickerToSet } from './methods/stickers/add-sticker-to-set' +import { createStickerSet } from './methods/stickers/create-sticker-set' +import { deleteStickerFromSet } from './methods/stickers/delete-sticker-from-set' import { getInstalledStickers } from './methods/stickers/get-installed-stickers' import { getStickerSet } from './methods/stickers/get-sticker-set' +import { moveStickerInSet } from './methods/stickers/move-sticker-in-set' import { _fetchUpdatesState, _handleUpdate, @@ -112,6 +117,7 @@ import { InputInlineResult, InputMediaLike, InputPeerLike, + InputStickerSetItem, MaybeDynamic, Message, PartialExcept, @@ -127,6 +133,7 @@ import { } from './types' import { MaybeArray, MaybeAsync, TelegramConnection } from '@mtcute/core' import { Lock } from './utils/lock' +import { tdFileId } from '@mtcute/file-id' export interface TelegramClient extends BaseTelegramClient { /** @@ -1973,6 +1980,116 @@ export interface TelegramClient extends BaseTelegramClient { * @throws MtCuteError When given parse mode is not registered. */ setDefaultParseMode(name: string): void + /** + * Add a sticker to a sticker set. + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param id Sticker set short name or TL object with input sticker set + * @param sticker Sticker to be added + * @param params + * @returns Modfiied sticker set + */ + addStickerToSet( + id: string | tl.TypeInputStickerSet, + sticker: InputStickerSetItem, + params?: { + /** + * Upload progress callback + * + * @param uploaded Number of bytes uploaded + * @param total Total file size + */ + progressCallback?: (uploaded: number, total: number) => void + } + ): Promise + /** + * Create a new sticker set (only for bots) + * + * Only for bots. + * + * @param params + * @returns Newly created sticker set + */ + createStickerSet(params: { + /** + * Owner of the sticker set (must be user) + */ + owner: InputPeerLike + + /** + * Title of the sticker set (1-64 chars) + */ + title: string + + /** + * Short name of the sticker set. + * Can only contain English letters, digits and underscores + * (i.e. must match `/^[a-zA-Z0-9_]+$/), + * and must end with `_by_` (`` is + * case-insensitive). + */ + shortName: string + + /** + * Whether this is a set of masks + */ + masks?: boolean + + /** + * Whether this is a set of animated stickers + */ + animated?: boolean + + /** + * List of stickers to be immediately added into the pack. + * There must be at least one sticker in this list. + */ + stickers: InputStickerSetItem[] + + /** + * Thumbnail for the set. + * + * The file must be either a `.png` file + * up to 128kb, having size of exactly `100x100` px, + * or a `.tgs` file up to 32kb. + * + * If not set, Telegram will use the first sticker + * in the sticker set as the thumbnail + */ + thumb?: InputFileLike + + /** + * Upload progress callback. + * + * @param idx Index of the sticker + * @param uploaded Number of bytes uploaded + * @param total Total file size + */ + progressCallback?: ( + idx: number, + uploaded: number, + total: number + ) => void + }): Promise + /** + * Delete a sticker from a sticker set + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * TDLib and Bot API compatible File ID, or a + * TL object representing a sticker to be removed + * @returns Modfiied sticker set + */ + deleteStickerFromSet( + sticker: + | string + | tdFileId.RawFullRemoteFileLocation + | tl.TypeInputDocument + ): Promise /** * Get a list of all installed sticker packs * @@ -1991,6 +2108,26 @@ export interface TelegramClient extends BaseTelegramClient { getStickerSet( id: string | { dice: string } | tl.TypeInputStickerSet ): Promise + /** + * Move a sticker in a sticker set + * to another position + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * TDLib and Bot API compatible File ID, or a + * TL object representing a sticker to be removed + * @param position New sticker position (starting from 0) + * @returns Modfiied sticker set + */ + moveStickerInSet( + sticker: + | string + | tdFileId.RawFullRemoteFileLocation + | tl.TypeInputDocument, + position: number + ): Promise /** * Base function for update handling. Replace or override this function * and implement your own update handler, and call this function @@ -2147,6 +2284,7 @@ export class TelegramClient extends BaseTelegramClient { downloadToFile = downloadToFile downloadAsIterable = downloadAsIterable downloadAsStream = downloadAsStream + protected _normalizeFileToDocument = _normalizeFileToDocument protected _normalizeInputFile = _normalizeInputFile protected _normalizeInputMedia = _normalizeInputMedia uploadFile = uploadFile @@ -2173,8 +2311,12 @@ export class TelegramClient extends BaseTelegramClient { unregisterParseMode = unregisterParseMode getParseMode = getParseMode setDefaultParseMode = setDefaultParseMode + addStickerToSet = addStickerToSet + createStickerSet = createStickerSet + deleteStickerFromSet = deleteStickerFromSet getInstalledStickers = getInstalledStickers getStickerSet = getStickerSet + moveStickerInSet = moveStickerInSet protected _fetchUpdatesState = _fetchUpdatesState protected _loadStorage = _loadStorage protected _saveStorage = _saveStorage diff --git a/packages/client/src/methods/_imports.ts b/packages/client/src/methods/_imports.ts index 25f278dd..eb215449 100644 --- a/packages/client/src/methods/_imports.ts +++ b/packages/client/src/methods/_imports.ts @@ -28,6 +28,7 @@ import { ReplyMarkup, InputMediaLike, InputInlineResult, + InputStickerSetItem, TakeoutSession, StickerSet } from '../types' @@ -37,3 +38,6 @@ import { MaybeArray, MaybeAsync, TelegramConnection } from '@mtcute/core' // @copy import { Lock } from '../utils/lock' + +// @copy +import { tdFileId } from '@mtcute/file-id' diff --git a/packages/client/src/methods/files/normalize-file-to-document.ts b/packages/client/src/methods/files/normalize-file-to-document.ts new file mode 100644 index 00000000..03afa99f --- /dev/null +++ b/packages/client/src/methods/files/normalize-file-to-document.ts @@ -0,0 +1,25 @@ +import { TelegramClient } from '../../client' +import { InputFileLike } from '../../types' +import { tl } from '@mtcute/tl' +import { assertTypeIs } from '../../utils/type-assertion' + +/** + * @internal + */ +export async function _normalizeFileToDocument( + this: TelegramClient, + file: InputFileLike, + params: { + progressCallback?: (uploaded: number, total: number) => void + }, +): Promise { + const media = await this._normalizeInputMedia({ + type: 'document', + file, + }, params, true) + + assertTypeIs('createStickerSet', media, 'inputMediaDocument') + assertTypeIs('createStickerSet', media.id, 'inputDocument') + + return media.id +} diff --git a/packages/client/src/methods/stickers/add-sticker-to-set.ts b/packages/client/src/methods/stickers/add-sticker-to-set.ts new file mode 100644 index 00000000..45928f56 --- /dev/null +++ b/packages/client/src/methods/stickers/add-sticker-to-set.ts @@ -0,0 +1,65 @@ +import { TelegramClient } from '../../client' +import { InputFileLike, InputStickerSetItem, StickerSet } from '../../types' +import { tl } from '@mtcute/tl' + +const MASK_POS = { + forehead: 0, + eyes: 1, + mouth: 2, + chin: 3, +} as const + +/** + * Add a sticker to a sticker set. + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param id Sticker set short name or TL object with input sticker set + * @param sticker Sticker to be added + * @param params + * @returns Modfiied sticker set + * @internal + */ +export async function addStickerToSet( + this: TelegramClient, + id: string | tl.TypeInputStickerSet, + sticker: InputStickerSetItem, + params?: { + /** + * Upload progress callback + * + * @param uploaded Number of bytes uploaded + * @param total Total file size + */ + progressCallback?: (uploaded: number, total: number) => void + }, +): Promise { + if (typeof id === 'string') { + id = { + _: 'inputStickerSetShortName', + shortName: id + } + } + + const res = await this.call({ + _: 'stickers.addStickerToSet', + stickerset: id, + sticker: { + _: 'inputStickerSetItem', + document: await this._normalizeFileToDocument(sticker.file, params ?? {}), + emoji: sticker.emojis, + maskCoords: sticker.maskPosition + ? { + _: 'maskCoords', + n: MASK_POS[sticker.maskPosition.point], + x: sticker.maskPosition.x, + y: sticker.maskPosition.y, + zoom: sticker.maskPosition.scale, + } + : undefined, + } + }) + + return new StickerSet(this, res) +} diff --git a/packages/client/src/methods/stickers/create-sticker-set.ts b/packages/client/src/methods/stickers/create-sticker-set.ts new file mode 100644 index 00000000..aa7c6c5c --- /dev/null +++ b/packages/client/src/methods/stickers/create-sticker-set.ts @@ -0,0 +1,135 @@ +import { TelegramClient } from '../../client' +import { + InputFileLike, + InputPeerLike, + InputStickerSetItem, + MtCuteInvalidPeerTypeError, + StickerSet, +} from '../../types' +import { tl } from '@mtcute/tl' +import { normalizeToInputUser } from '../../utils/peer-utils' + +const MASK_POS = { + forehead: 0, + eyes: 1, + mouth: 2, + chin: 3, +} as const + +/** + * Create a new sticker set (only for bots) + * + * Only for bots. + * + * @param params + * @returns Newly created sticker set + * @internal + */ +export async function createStickerSet( + this: TelegramClient, + params: { + /** + * Owner of the sticker set (must be user) + */ + owner: InputPeerLike + + /** + * Title of the sticker set (1-64 chars) + */ + title: string + + /** + * Short name of the sticker set. + * Can only contain English letters, digits and underscores + * (i.e. must match `/^[a-zA-Z0-9_]+$/), + * and must end with `_by_` (`` is + * case-insensitive). + */ + shortName: string + + /** + * Whether this is a set of masks + */ + masks?: boolean + + /** + * Whether this is a set of animated stickers + */ + animated?: boolean + + /** + * List of stickers to be immediately added into the pack. + * There must be at least one sticker in this list. + */ + stickers: InputStickerSetItem[] + + /** + * Thumbnail for the set. + * + * The file must be either a `.png` file + * up to 128kb, having size of exactly `100x100` px, + * or a `.tgs` file up to 32kb. + * + * If not set, Telegram will use the first sticker + * in the sticker set as the thumbnail + */ + thumb?: InputFileLike + + /** + * Upload progress callback. + * + * @param idx Index of the sticker + * @param uploaded Number of bytes uploaded + * @param total Total file size + */ + progressCallback?: ( + idx: number, + uploaded: number, + total: number + ) => void + } +): Promise { + const owner = normalizeToInputUser(await this.resolvePeer(params.owner)) + if (!owner) throw new MtCuteInvalidPeerTypeError(params.owner, 'user') + + const inputStickers: tl.TypeInputStickerSetItem[] = [] + + let i = 0 + for (const sticker of params.stickers) { + const progressCallback = params.progressCallback?.bind(null, i) + + inputStickers.push({ + _: 'inputStickerSetItem', + document: await this._normalizeFileToDocument(sticker.file, { + progressCallback, + }), + emoji: sticker.emojis, + maskCoords: sticker.maskPosition + ? { + _: 'maskCoords', + n: MASK_POS[sticker.maskPosition.point], + x: sticker.maskPosition.x, + y: sticker.maskPosition.y, + zoom: sticker.maskPosition.scale, + } + : undefined, + }) + + i += 1 + } + + const res = await this.call({ + _: 'stickers.createStickerSet', + animated: params.animated, + masks: params.masks, + userId: owner, + title: params.title, + shortName: params.shortName, + stickers: inputStickers, + thumb: params.thumb + ? await this._normalizeFileToDocument(params.thumb, {}) + : undefined, + }) + + return new StickerSet(this, res) +} diff --git a/packages/client/src/methods/stickers/delete-sticker-from-set.ts b/packages/client/src/methods/stickers/delete-sticker-from-set.ts new file mode 100644 index 00000000..a70eb757 --- /dev/null +++ b/packages/client/src/methods/stickers/delete-sticker-from-set.ts @@ -0,0 +1,32 @@ +import { TelegramClient } from '../../client' +import { tl } from '@mtcute/tl' +import { StickerSet } from '../../types' +import { fileIdToInputDocument, tdFileId } from '../../../../file-id' + +/** + * Delete a sticker from a sticker set + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * TDLib and Bot API compatible File ID, or a + * TL object representing a sticker to be removed + * @returns Modfiied sticker set + * @internal + */ +export async function deleteStickerFromSet( + this: TelegramClient, + sticker: string | tdFileId.RawFullRemoteFileLocation | tl.TypeInputDocument +): Promise { + if (tdFileId.isFileIdLike(sticker)) { + sticker = fileIdToInputDocument(sticker) + } + + const res = await this.call({ + _: 'stickers.removeStickerFromSet', + sticker + }) + + return new StickerSet(this, res) +} diff --git a/packages/client/src/methods/stickers/move-sticker-in-set.ts b/packages/client/src/methods/stickers/move-sticker-in-set.ts new file mode 100644 index 00000000..b583e724 --- /dev/null +++ b/packages/client/src/methods/stickers/move-sticker-in-set.ts @@ -0,0 +1,37 @@ +import { TelegramClient } from '../../client' +import { fileIdToInputDocument, tdFileId } from '../../../../file-id' +import { tl } from '@mtcute/tl' +import { StickerSet } from '../../types' + +/** + * Move a sticker in a sticker set + * to another position + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * TDLib and Bot API compatible File ID, or a + * TL object representing a sticker to be removed + * @param position New sticker position (starting from 0) + * @returns Modfiied sticker set + * @internal + */ + +export async function moveStickerInSet( + this: TelegramClient, + sticker: string | tdFileId.RawFullRemoteFileLocation | tl.TypeInputDocument, + position: number +): Promise { + if (tdFileId.isFileIdLike(sticker)) { + sticker = fileIdToInputDocument(sticker) + } + + const res = await this.call({ + _: 'stickers.changeStickerPosition', + sticker, + position + }) + + return new StickerSet(this, res) +} diff --git a/packages/client/src/types/media/document.ts b/packages/client/src/types/media/document.ts index a3f5b60e..6f4cbd19 100644 --- a/packages/client/src/types/media/document.ts +++ b/packages/client/src/types/media/document.ts @@ -96,19 +96,27 @@ export class RawDocument extends FileLocation { return this.thumbnails.find((it) => it.raw.type === type) ?? null } + /** + * Input document TL object generated from this object, + * to be used with methods that use it + */ + get inputDocument(): tl.TypeInputDocument { + return { + _: 'inputDocument', + id: this.doc.id, + accessHash: this.doc.accessHash, + fileReference: this.doc.fileReference + } + } + /** * Input media TL object generated from this object, - * to be used inside {@link InputMediaLike} or {@link TelegramClient.sendPhoto} + * to be used inside {@link InputMediaLike} */ get inputMediaTl(): tl.TypeInputMedia { return { _: 'inputMediaDocument', - id: { - _: 'inputDocument', - id: this.doc.id, - accessHash: this.doc.accessHash, - fileReference: this.doc.fileReference - } + id: this.inputDocument } } diff --git a/packages/client/src/types/media/sticker.ts b/packages/client/src/types/media/sticker.ts index df65f663..7996f819 100644 --- a/packages/client/src/types/media/sticker.ts +++ b/packages/client/src/types/media/sticker.ts @@ -5,6 +5,37 @@ import { makeInspectable } from '../utils' import { StickerSet } from '../misc' import { tdFileId } from '@mtcute/file-id' +export namespace Sticker { + export interface MaskPosition { + /** + * The part of the face relative where the mask should be placed + */ + point: 'forehead' | 'eyes' | 'mouth' | 'chin' + + /** + * Shift by X-axis measured in widths of the mask scaled to + * the face size, from left to right. For example, choosing + * -1.0 will place mask just to the left of the default + * mask position. + */ + x: number + + /** + * Shift by Y-axis measured in heights of the mask scaled to + * the face size, from top to bottom. For example, 1.0 + * will place the mask just below the default mask position. + */ + y: number + + /** + * Mask scaling coefficient. For example, 2.0 means double size. + */ + scale: number + } +} + +const MASK_POS = ['forehead', 'eyes', 'mouth', 'chin'] as const + /** * A sticker */ @@ -67,6 +98,13 @@ export class Sticker extends RawDocument { return this.mimeType === 'application/x-tgsticker' } + /** + * Whether this is a mask + */ + get isMask(): boolean { + return !!this.attr.mask + } + /** * Whether this sticker has an associated public sticker set. */ @@ -74,6 +112,26 @@ export class Sticker extends RawDocument { return this.attr.stickerset._ === 'inputStickerSetID' } + private _maskPosition?: Sticker.MaskPosition + /** + * Position where this mask should be placed + */ + get maskPosition(): Sticker.MaskPosition | null { + if (!this.attr.maskCoords) return null + + const raw = this.attr.maskCoords + if (!this._maskPosition) { + this._maskPosition = { + point: MASK_POS[raw.n], + x: raw.x, + y: raw.y, + scale: raw.zoom + } + } + + return this._maskPosition + } + /** * Get the sticker set that this sticker belongs to. * diff --git a/packages/client/src/types/misc/sticker-set.ts b/packages/client/src/types/misc/sticker-set.ts index 3d8ac5bb..f7948fe6 100644 --- a/packages/client/src/types/misc/sticker-set.ts +++ b/packages/client/src/types/misc/sticker-set.ts @@ -4,6 +4,7 @@ import { makeInspectable } from '../utils' import { Sticker } from '../media' import { MtCuteEmptyError, MtCuteTypeAssertionError } from '../errors' import { parseDocument } from '../media/document-utils' +import { InputFileLike } from '../files' export namespace StickerSet { /** @@ -196,6 +197,103 @@ export class StickerSet { return this.client.getStickerSet(this.inputStickerSet) } + + /** + * Add a new sticker to this sticker set. + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * Note that this method returns a new + * {@link StickerSet} object instead of modifying current. + */ + async addSticker(sticker: InputStickerSetItem): Promise { + return this.client.addStickerToSet(this.inputStickerSet, sticker) + } + + /** + * Delete a sticker from this set. + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * Sticker File ID. In case this is a full sticker set object, + * you can also pass index (even negative), and that sticker will be removed + */ + async deleteSticker(sticker: number | Parameters[0]): Promise { + if (typeof sticker === 'number') { + if (!this.full) throw new MtCuteEmptyError() + + if (sticker < 0) sticker = this.full!.documents.length - sticker + const doc = this.full!.documents[sticker] as tl.RawDocument + + sticker = { + _: 'inputDocument', + id: doc.id, + accessHash: doc.accessHash, + fileReference: doc.fileReference + } + } + + return this.client.deleteStickerFromSet(sticker) + } + + /** + * Move a sticker in this set. + * + * Only for bots, and the sticker set must + * have been created by this bot. + * + * @param sticker + * Sticker File ID. In case this is a full sticker set object, + * you can also pass index (even negative), and that sticker will be removed + * @param position New sticker position + */ + async moveSticker(sticker: number | Parameters[0], position: number): Promise { + if (typeof sticker === 'number') { + if (!this.full) throw new MtCuteEmptyError() + + if (sticker < 0) sticker = this.full!.documents.length - sticker + const doc = this.full!.documents[sticker] as tl.RawDocument + + sticker = { + _: 'inputDocument', + id: doc.id, + accessHash: doc.accessHash, + fileReference: doc.fileReference + } + } + + return this.client.moveStickerInSet(sticker, position) + } } makeInspectable(StickerSet, ['isFull']) + +export interface InputStickerSetItem { + /** + * File containing the sticker. + * + * For normal stickers: must be a `.png` or `.webp` file + * up to 512kb, having both dimensions `<=512px`, and having + * one of the dimensions `==512px` + * + * For animated stickers: must be a `.tgs` file + * up to 64kb, having canvas dimensions exactly + * `512x512`px, duration no more than 3 seconds + * and animated at 60fps ([source](https://core.telegram.org/animated_stickers#technical-requirements)) + */ + file: InputFileLike + + /** + * One or more emojis that represent this sticker + */ + emojis: string + + /** + * In case this is a mask sticker, + * position of the mask + */ + maskPosition?: Sticker.MaskPosition +}