diff --git a/packages/core/scripts/generate-app-config.cjs b/packages/core/scripts/generate-app-config.cjs new file mode 100644 index 00000000..807c3bc3 --- /dev/null +++ b/packages/core/scripts/generate-app-config.cjs @@ -0,0 +1,25 @@ +const fs = require('fs') +const path = require('path') + +const spec = require('@mtcute/tl/app-config.json') + +const OUT_FILE = path.join(__dirname, '../src/highlevel/types/misc/app-config.ts') + +const out = fs.createWriteStream(OUT_FILE) +out.write(`// This file is generated automatically, do not modify! +/* eslint-disable */ + +export interface AppConfigSchema { +`) + +const indent = (str) => str.split('\n').map((x) => ' ' + x).join('\n') + +for (const [key, { type, description }] of Object.entries(spec)) { + out.write(indent(description) + '\n') + out.write(indent(`${key}?: ${type}`) + '\n') +} + +out.write(' [key: string]: unknown\n') +out.write('}\n') + +out.close() diff --git a/packages/core/src/highlevel/base.ts b/packages/core/src/highlevel/base.ts index 62e0e17c..7ea50a87 100644 --- a/packages/core/src/highlevel/base.ts +++ b/packages/core/src/highlevel/base.ts @@ -16,6 +16,7 @@ import { } from '../utils/index.js' import { LogManager } from '../utils/logger.js' import { ITelegramClient } from './client.types.js' +import { AppConfigManager } from './managers/app-config-manager.js' import { ITelegramStorageProvider } from './storage/provider.js' import { TelegramStorageManager, TelegramStorageManagerExtraOptions } from './storage/storage.js' import { UpdatesManager } from './updates/manager.js' @@ -52,6 +53,7 @@ export class BaseTelegramClient implements ITelegramClient { provider: this.params.storage, ...this.params.storageOptions, }) + readonly appConfig = new AppConfigManager(this) private _prepare = asyncResettable(async () => { await this.mt.prepare() diff --git a/packages/core/src/highlevel/client.types.ts b/packages/core/src/highlevel/client.types.ts index 7a671388..a1ec6ded 100644 --- a/packages/core/src/highlevel/client.types.ts +++ b/packages/core/src/highlevel/client.types.ts @@ -4,6 +4,7 @@ import type { ConnectionKind, RpcCallOptions } from '../network/index.js' import type { MustEqual, PublicPart } from '../types/utils.js' import type { Logger } from '../utils/logger.js' import type { StringSessionData } from '../utils/string-session.js' +import type { AppConfigManager } from './managers/app-config-manager.js' import type { TelegramStorageManager } from './storage/storage.js' import type { RawUpdateHandler } from './updates/types.js' @@ -14,6 +15,7 @@ import type { RawUpdateHandler } from './updates/types.js' export interface ITelegramClient { readonly log: Logger readonly storage: PublicPart + readonly appConfig: PublicPart prepare(): Promise connect(): Promise diff --git a/packages/core/src/highlevel/managers/app-config-manager.ts b/packages/core/src/highlevel/managers/app-config-manager.ts new file mode 100644 index 00000000..6c81764e --- /dev/null +++ b/packages/core/src/highlevel/managers/app-config-manager.ts @@ -0,0 +1,57 @@ +import { tl } from '@mtcute/tl' + +import { MtTypeAssertionError } from '../../types/errors.js' +import { Reloadable } from '../../utils/reloadable.js' +import { tlJsonToJson } from '../../utils/tl-json.js' +import { BaseTelegramClient } from '../base.js' +import { AppConfigSchema } from '../types/misc/app-config.js' + +export class AppConfigManager { + constructor(private client: BaseTelegramClient) {} + + private _reloadable = new Reloadable({ + reload: this._reload.bind(this), + getExpiresAt: () => 3_600_000, + disableAutoReload: true, + }) + + private async _reload(old?: tl.help.RawAppConfig) { + const res = await this.client.call({ + _: 'help.getAppConfig', + hash: old?.hash ?? 0, + }) + + if (res._ === 'help.appConfigNotModified') return old! + + return res + } + + private _object?: AppConfigSchema + async get(): Promise { + if (!this._reloadable.isStale && this._object) return this._object + + const obj = tlJsonToJson((await this._reloadable.get()).config) + + if (!obj || typeof obj !== 'object') { + throw new MtTypeAssertionError('appConfig', 'object', typeof obj) + } + + this._object = obj as AppConfigSchema + + return this._object + } + + async getField(field: K): Promise + async getField( + field: K, + fallback: NonNullable, + ): Promise> + async getField( + field: K, + fallback?: NonNullable, + ): Promise { + const obj = await this.get() + + return obj[field] ?? fallback + } +} diff --git a/packages/core/src/highlevel/types/misc/app-config.ts b/packages/core/src/highlevel/types/misc/app-config.ts new file mode 100644 index 00000000..8dfbe9f8 --- /dev/null +++ b/packages/core/src/highlevel/types/misc/app-config.ts @@ -0,0 +1,896 @@ +// This file is generated automatically, do not modify! +/* eslint-disable */ + +export interface AppConfigSchema { + /** + * Animated + * emojis and + * animated + * dice should be scaled by this factor before being shown + * to the user (float) + */ + emojies_animated_zoom?: number + /** + * Whether app clients should start a keepalive service to keep + * the app running and fetch updates even when the app is + * closed (boolean) + */ + keep_alive_service?: boolean + /** + * Whether app clients should start a background TCP connection + * for MTProto update fetching (boolean) + */ + background_connection?: boolean + /** + * A list of supported + * animated + * dice stickers (array of strings). + */ + emojies_send_dice?: string[] + /** + * For + * animated + * dice emojis other than the basic ๐ŸŽฒ, indicates the winning dice value and + * the final frame of the animated sticker, at which to show + * the fireworks ๐ŸŽ† (object with emoji keys and object + * values, containing value and + * frame_start float values) + */ + emojies_send_dice_success?: Record< + string, + { + value: number + frame_start: number + } + > + /** + * A map of soundbites to be played when the user clicks on the + * specified + * animated + * emoji; the + * file + * reference field should be base64-decoded before + * downloading + * the file (map of + * file + * IDs ({@link RawInputDocument}.id), with emoji string + * keys) + */ + emojies_sounds?: Record< + string, + { + id: string + access_hash: string + file_reference_base64: string + } + > + /** + * Specifies the name of the service providing GIF search + * through + * gif_search_username + * (string) + */ + gif_search_branding?: string + /** + * Specifies a list of emojis that should be suggested as + * search term in a bar above the GIF search box (array of + * string emojis) + */ + gif_search_emojies?: string[] + /** + * Specifies that the app should not display + * local + * sticker suggestions ยป for emojis at all and just use the + * result of {@link messages.RawGetStickersRequest} (bool) + */ + stickers_emoji_suggest_only_api?: boolean + /** + * Specifies the validity period of the local cache of + * {@link messages.RawGetStickersRequest}, also relevant when + * generating the + * pagination + * hash when invoking the method. (integer) + */ + stickers_emoji_cache_time?: number + /** + * Whether the Settings->Devices menu should show an option + * to scan a + * QR + * login code (boolean) + */ + qr_login_camera?: boolean + /** + * Whether the login screen should show a + * QR code + * login option, possibly as default login method (string, + * "disabled", "primary" or "secondary") + */ + qr_login_code?: 'disabled' | 'primary' | 'secondary' + /** + * Whether clients should show an option for managing + * dialog + * filters AKA folders (boolean) + */ + dialog_filters_enabled?: boolean + /** + * Whether clients should actively show a tooltip, inviting the + * user to configure + * dialog + * filters AKA folders; typically this happens when the + * chat list is long enough to start getting cluttered. + * (boolean) + */ + dialog_filters_tooltip?: boolean + /** + * Whether clients can invoke + * {@link account.RawSetGlobalPrivacySettingsRequest} with + * {@link RawGlobalPrivacySettings}, to automatically archive + * and mute new incoming chats from non-contacts. (boolean) + */ + autoarchive_setting_available?: boolean + /** + * Contains a list of suggestions that should be actively shown + * as a tooltip to the user. (Array of strings, possible values + * shown in the suggestions section + * ยป. + */ + pending_suggestions?: string[] + /** + * Maximum number of + * topics + * that can be pinned in a single + * forum. + * (integer) + */ + topics_pinned_limit?: number + /** + * The ID of the official + * native + * antispam bot, that will automatically delete spam + * messages if enabled as specified in the + * native + * antispam documentation ยป. + * + * + * When fetching the admin list of a supergroup using + * {@link channels.RawGetParticipantsRequest}, if native + * antispam functionality in the specified supergroup, the bot + * should be manually added to the admin list displayed to the + * user. (numeric string that represents a Telegram user/bot + * ID, should be casted to an int64) + */ + telegram_antispam_user_id?: string + /** + * Minimum number of group members required to enable + * native + * antispam functionality. (integer) + */ + telegram_antispam_group_size_min?: number + /** + * List of phone number prefixes for anonymous + * Fragment phone numbers. + * (array of strings). + */ + fragment_prefixes?: string[] + /** + * Minimum number of participants required to hide the + * participants list of a supergroup using + * {@link channels.RawToggleParticipantsHiddenRequest}. + * (integer) + */ + hidden_members_group_size_min?: number + /** + * A list of domains that support automatic login with manual + * user confirmation, + * click + * here for more info on URL authorization ยป. (array of + * strings) + */ + url_auth_domains?: string[] + /** + * A list of Telegram domains that support automatic login with + * no user confirmation, + * click + * here for more info on URL authorization ยป. (array of + * strings) + */ + autologin_domains?: string[] + /** + * A list of Telegram domains that can always be opened without + * additional user confirmation, when clicking on in-app links + * where the URL is not fully displayed (i.e. + * {@link RawMessageEntityTextUrl} entities). (array of + * strings)Note that when opening + * named + * Mini App links for the first time, confirmation should + * still be requested from the user, even if the domain of the + * containing deep link is whitelisted (i.e. + * t.me/<bot_username>/<short_name>?startapp=<start_parameter>, + * where t.me is whitelisted). Confirmation + * should always be asked, even if we already + * opened the + * named + * Mini App before, if the link is not visible (i.e. + * {@link RawMessageEntityTextUrl} text links, inline buttons + * etc.). + */ + whitelisted_domains?: string[] + /** + * Contains a set of recommended codec parameters for round + * videos. (object, as described in the example) + */ + round_video_encoding?: { + diameter: number + video_bitrate: number + audio_bitrate: number + max_size: number + } + /** + * Per-user read receipts, fetchable using + * {@link messages.RawGetMessageReadParticipantsRequest}, will + * be available in groups with an amount of participants less + * or equal to chat_read_mark_size_threshold. + * (integer) + */ + chat_read_mark_size_threshold?: number + /** + * To protect user privacy, read receipts for chats are only + * stored for chat_read_mark_expire_period seconds + * after the message was sent. (integer) + */ + chat_read_mark_expire_period?: number + /** + * To protect user privacy, read receipts for private chats are + * only stored for pm_read_date_expire_period + * seconds after the message was sent. (integer) + */ + pm_read_date_expire_period?: number + /** + * Maximum number of participants in a group call (livestreams + * allow โˆž participants) (integer) + */ + groupcall_video_participants_max?: number + /** + * Maximum number of unique reactions for any given message: + * for example, if there are 2000 ๐Ÿ‘ and 1000 custom emoji ๐Ÿ˜ reactions and + * reactions_uniq_max = 2, you can't add a ๐Ÿ‘Ž reaction, because that would raise the + * number of unique reactions to 3 > 2. (integer) + */ + reactions_uniq_max?: number + /** + * Maximum number of reactions that can be marked as allowed in + * a chat using {@link RawChatReactionsSome}. (integer) + */ + reactions_in_chat_max?: number + /** + * Maximum number of reactions that can be added to a single + * message by a non-Premium user. (integer) + */ + reactions_user_max_default?: number + /** + * Maximum number of reactions that can be added to a single + * message by a Premium user. (integer) + */ + reactions_user_max_premium?: number + /** + * Default emoji status stickerset ID. (integer) + * + * + * Note that the stickerset can be fetched using + * {@link RawInputStickerSetEmojiDefaultStatuses}. + */ + default_emoji_statuses_stickerset_id?: number + /** + * The maximum duration in seconds of + * uploadable + * notification sounds ยป (integer) + */ + ringtone_duration_max?: number + /** + * The maximum post-conversion size in bytes of + * uploadable + * notification sounds ยป + */ + ringtone_size_max?: number + /** + * The maximum number of + * saveable + * notification sounds ยป + */ + ringtone_saved_count_max?: number + /** + * The maximum number of + * custom + * emojis that may be present in a message. (integer) + */ + message_animated_emoji_max?: number + /** + * Defines how many + * Premium + * stickers to show in the sticker suggestion popup when + * entering an emoji into the text field, see the + * sticker + * docs for more info (integer, defaults to 0) + */ + stickers_premium_by_emoji_num?: number + /** + * For + * Premium + * users, used to define the suggested sticker list, see + * the + * sticker + * docs for more info (integer, defaults to 2) + */ + stickers_normal_by_emoji_per_premium_num?: number + /** + * The user can't purchase + * Telegram + * Premium. The app must also hide all Premium features, + * including stars for other users, et cetera. (boolean) + */ + premium_purchase_blocked?: boolean + /** + * The maximum number of + * channels + * and supergroups a + * non-Premium + * user may join (integer) + */ + channels_limit_default?: number + /** + * The maximum number of + * channels + * and supergroups a + * Premium + * user may join (integer) + */ + channels_limit_premium?: number + /** + * The maximum number of GIFs a + * non-Premium + * user may save (integer) + */ + saved_gifs_limit_default?: number + /** + * The maximum number of GIFs a + * Premium + * user may save (integer) + */ + saved_gifs_limit_premium?: number + /** + * The maximum number of stickers a + * non-Premium + * user may + * add + * to Favorites ยป (integer) + */ + stickers_faved_limit_default?: number + /** + * The maximum number of stickers a + * Premium + * user may + * add + * to Favorites ยป (integer) + */ + stickers_faved_limit_premium?: number + /** + * The maximum number of + * folders + * a + * non-Premium + * user may create (integer) + */ + dialog_filters_limit_default?: number + /** + * The maximum number of + * folders + * a + * Premium + * user may create (integer) + */ + dialog_filters_limit_premium?: number + /** + * The maximum number of chats a + * non-Premium + * user may add to a + * folder + * (integer) + */ + dialog_filters_chats_limit_default?: number + /** + * The maximum number of chats a + * Premium + * user may add to a + * folder + * (integer) + */ + dialog_filters_chats_limit_premium?: number + /** + * The maximum number of chats a + * non-Premium + * user may pin (integer) + */ + dialogs_pinned_limit_default?: number + /** + * The maximum number of chats a + * Premium + * user may pin (integer) + */ + dialogs_pinned_limit_premium?: number + /** + * The maximum number of chats a + * non-Premium + * user may pin in a folder (integer) + */ + dialogs_folder_pinned_limit_default?: number + /** + * The maximum number of chats a + * Premium + * user may pin in a folder (integer) + */ + dialogs_folder_pinned_limit_premium?: number + /** + * The maximum number of public + * channels + * or supergroups a + * non-Premium + * user may create (integer) + */ + channels_public_limit_default?: number + /** + * The maximum number of public + * channels + * or supergroups a + * Premium + * user may create (integer) + */ + channels_public_limit_premium?: number + /** + * The maximum UTF-8 length of media captions sendable by + * non-Premium + * users (integer) + */ + caption_length_limit_default?: number + /** + * The maximum UTF-8 length of media captions sendable by + * Premium + * users (integer) + */ + caption_length_limit_premium?: number + /** + * The maximum number of file parts uploadable by + * non-Premium + * users (integer, the maximum file size can be extrapolated by + * multiplying this value by 524288, the biggest + * possible chunk size) + */ + upload_max_fileparts_default?: number + /** + * The maximum number of file parts uploadable by + * Premium + * users (integer, the maximum file size can be extrapolated by + * multiplying this value by 524288, the biggest + * possible chunk size) + */ + upload_max_fileparts_premium?: number + /** + * The maximum UTF-8 length of bios of + * non-Premium + * users (integer) + */ + about_length_limit_default?: number + /** + * The maximum UTF-8 length of bios of + * Premium + * users (integer) + */ + about_length_limit_premium?: number + /** + * Array of string identifiers, indicating the order of + * Telegram + * Premium features in the Telegram Premium promotion + * popup, + * see + * here for the possible values ยป + */ + premium_promo_order?: string[] + /** + * Contains the username of the official + * Telegram + * Premium bot that may be used to buy a + * Telegram + * Premium subscription, see + * here for + * detailed instructions ยป (string) + */ + premium_bot_username?: string + /** + * Contains an + * invoice + * slug that may be used to buy a + * Telegram + * Premium subscription, see + * here for + * detailed instructions ยป (string) + */ + premium_invoice_slug?: string + /** + * Whether a gift icon should be shown in the attachment menu + * in private chats with users, offering the current user to + * gift a + * Telegram + * Premium subscription to the other user in the chat. + * (boolean) + */ + premium_gift_attach_menu_icon?: boolean + /** + * Whether a gift icon should be shown in the text bar in + * private chats with users (ie like the / icon in + * chats with bots), offering the current user to gift a + * Telegram + * Premium subscription to the other user in the chat. Can + * only be true if premium_gift_attach_menu_icon + * is also true. (boolean) + */ + premium_gift_text_field_icon?: boolean + /** + * Users that import a folder using a + * chat + * folder deep link ยป should retrieve additions made to the + * folder by invoking + * {@link chatlists.RawGetChatlistUpdatesRequest} at most every + * chatlist_update_period seconds. (integer) + */ + chatlist_update_period?: number + /** + * Maximum number of per-folder + * chat + * folder deep links ยป that can be created by + * non-Premium + * users. (integer) + */ + chatlist_invites_limit_default?: number + /** + * Maximum number of per-folder + * chat + * folder deep links ยป that can be created by + * Premium + * users. (integer) + */ + chatlist_invites_limit_premium?: number + /** + * Maximum number of + * shareable + * folders + * non-Premium + * users may have. (integer) + */ + chatlists_joined_limit_default?: number + /** + * Maximum number of + * shareable + * folders + * Premium + * users may have. (integer) + */ + chatlists_joined_limit_premium?: number + /** + * A soft limit, specifying the maximum number of files that + * should be downloaded in parallel from the same DC, for files + * smaller than 20MB. (integer) + */ + small_queue_max_active_operations_count?: number + /** + * A soft limit, specifying the maximum number of files that + * should be downloaded in parallel from the same DC, for files + * bigger than 20MB. (integer) + */ + large_queue_max_active_operations_count?: number + /** + * An + * unconfirmed + * session ยป will be autoconfirmed this many seconds after + * login. (integer) + */ + authorization_autoconfirm_period?: number + /** + * The exact list of users that viewed the story will be hidden + * from the poster this many seconds after the story expires. + * (integer)This limit applies only to + * non-Premium + * users, + * Premium + * users can always access the viewer list. + */ + story_viewers_expire_period?: number + /** + * The maximum number of active + * stories + * for + * non-Premium + * users (integer). + */ + story_expiring_limit_default?: number + /** + * The maximum number of active + * stories + * for + * Premium + * users (integer). + */ + story_expiring_limit_premium?: number + /** + * The maximum UTF-8 length of story captions for + * Premium + * users. (integer) + */ + story_caption_length_limit_premium?: number + /** + * The maximum UTF-8 length of story captions for + * non-Premium + * users. (integer) + */ + story_caption_length_limit_default?: number + /** + * Indicates whether users can post stories. (string)One of: + *
  • enabled - Any user can post stories.
  • + *
  • premium - Only users with a + * Premium + * subscription can post stories.
  • + *
  • disabled - Users can't post stories.
  • + * + */ + stories_posting?: string + /** + * Enabling + * stories + * stealth mode with the past flag will erase + * views of any story opened in the past + * stories_stealth_past_period seconds. (integer) + */ + stories_stealth_past_period?: number + /** + * Enabling + * stories + * stealth mode with the future flag will hide + * views of any story opened in the next + * stories_stealth_future_period seconds. + * (integer) + */ + stories_stealth_future_period?: number + /** + * After enabling + * stories + * stealth mode, this many seconds must elapse before the + * user is allowed to enable it again. (integer) + */ + stories_stealth_cooldown_period?: number + /** + * Maximum number of stories that can be sent in a week by + * non-Premium + * users. (integer) + */ + stories_sent_weekly_limit_default?: number + /** + * Maximum number of stories that can be sent in a week by + * Premium + * users. (integer) + */ + stories_sent_weekly_limit_premium?: number + /** + * Maximum number of stories that can be sent in a month by + * non-Premium + * users. (integer) + */ + stories_sent_monthly_limit_default?: number + /** + * Maximum number of stories that can be sent in a month by + * Premium + * users. (integer) + */ + stories_sent_monthly_limit_premium?: number + /** + * Maximum number of + * story + * reaction media areas ยป that can be added to a story by + * non-Premium + * users. (integer) + */ + stories_suggested_reactions_limit_default?: number + /** + * Maximum number of + * story + * reaction media areas ยป that can be added to a story by + * Premium + * users. (integer) + */ + stories_suggested_reactions_limit_premium?: number + /** + * Username of the inline bot to use to generate venue location + * tags for stories, see + * here + * ยป for more info. (string) + */ + stories_venue_search_username?: string + /** + * ID of the official Telegram user that will post stories + * about new Telegram features: stories posted by this user + * should be shown on the + * active + * or active and hidden stories bar just like for contacts, + * even if the user was removed from the contact list. + * (integer, defaults to 777000) + */ + stories_changelog_user_id?: number + /** + * Whether + * styled + * text entities and links in story text captions can be + * used by all users (enabled), only + * [Premium](/api/premium users) (premium), or no + * one (disabled). (string)This field is used both + * when posting stories, to indicate to the user whether they + * can use entities, and when viewing stories, to hide entities + * (client-side) on stories posted by users whose + * Premium + * subscription has expired (if stories_entities == + * "premium" and {@link RawUser}.premium is + * not set, or if stories_entities == "disabled"). + * + */ + stories_entities?: string + /** + * Whether + * giveaways + * can be started by the current user. (boolean) + */ + giveaway_gifts_purchase_available?: boolean + /** + * The maximum number of users that can be specified when + * making a + * direct + * giveaway. (integer) + */ + giveaway_add_peers_max?: number + /** + * The maximum number of countries that can be specified when + * restricting the set of participating countries in a + * giveaway. + * (itneger) + */ + giveaway_countries_max?: number + /** + * The number of + * boosts + * that will be gained by a channel for each winner of a + * giveaway. + * (integer) + */ + giveaway_boosts_per_premium?: number + /** + * The maximum duration in seconds of a + * giveaway. + * (integer) + */ + giveaway_period_max?: number + /** + * Maximum + * boost + * level for channels. (integer) + */ + boosts_channel_level_max?: number + /** + * The number of additional + * boost + * slots that the current user will receive when + * gifting + * a Telegram Premium subscription. + */ + boosts_per_sent_gift?: number + /** + * The maximum number of + * speech + * recognition ยป calls per week for + * non-Premium + * users. (integer) + */ + transcribe_audio_trial_weekly_number?: number + /** + * The maximum allowed duration of media in seconds for + * speech + * recognition ยป for + * non-Premium + * users. (integer) + */ + transcribe_audio_trial_duration_max?: number + /** + * The maximum number of similar channels that can be + * recommended by + * {@link channels.RawGetChannelRecommendationsRequest} to + * non-Premium + * users. (integer) + */ + recommended_channels_limit_default?: number + /** + * The maximum number of similar channels that can be + * recommended by + * {@link channels.RawGetChannelRecommendationsRequest} to + * Premium + * users. (integer) + */ + recommended_channels_limit_premium?: number + /** + * Maximum UTF-8 length of {@link RawInputReplyToMessage}. + * (integer) + */ + quote_length_max?: number + /** + * After reaching at least this + * boost + * level ยป, channels gain the ability to change their + * message + * accent palette emoji ยป. (integer) + */ + channel_bg_icon_level_min?: number + /** + * After reaching at least this + * boost + * level ยป, channels gain the ability to change their + * profile + * accent palette emoji ยป. (integer) + */ + channel_profile_bg_icon_level_min?: number + /** + * After reaching at least this + * boost + * level ยป, channels gain the ability to change their + * status + * emoji ยป. (integer) + */ + channel_emoji_status_level_min?: number + /** + * After reaching at least this + * boost + * level ยป, channels gain the ability to set a + * fill + * channel wallpaper, see here ยป for more info. (integer) + */ + channel_wallpaper_level_min?: number + /** + * After reaching at least this + * boost + * level ยป, channels gain the ability to set any custom + * wallpaper, + * not just + * fill + * channel wallpapers, see here ยป for more info. (integer) + */ + channel_custom_wallpaper_level_min?: number + /** + * Maximum number of pinned dialogs in + * saved + * messages for + * non-Premium + * users. (integer) + */ + saved_dialogs_pinned_limit_default?: number + /** + * Maximum number of pinned dialogs in + * saved + * messages for + * Premium + * users. (integer) + */ + saved_dialogs_pinned_limit_premium?: number + [key: string]: unknown +} diff --git a/packages/core/src/highlevel/types/misc/index.ts b/packages/core/src/highlevel/types/misc/index.ts index 85728bfc..04c676ae 100644 --- a/packages/core/src/highlevel/types/misc/index.ts +++ b/packages/core/src/highlevel/types/misc/index.ts @@ -1,3 +1,4 @@ +export * from './app-config.js' export * from './entities.js' export * from './input-privacy-rule.js' export * from './sticker-set.js' diff --git a/packages/core/src/highlevel/updates/manager.ts b/packages/core/src/highlevel/updates/manager.ts index 5f2771a2..fad0eb63 100644 --- a/packages/core/src/highlevel/updates/manager.ts +++ b/packages/core/src/highlevel/updates/manager.ts @@ -1167,7 +1167,7 @@ export class UpdatesManager { const config = client.mt.network.config.getNow() if (config) { - client.mt.network.config.setConfig({ + client.mt.network.config.setData({ ...config, dcOptions: upd.dcOptions, }) diff --git a/packages/core/src/highlevel/worker/app-config.ts b/packages/core/src/highlevel/worker/app-config.ts new file mode 100644 index 00000000..6f5dcbbc --- /dev/null +++ b/packages/core/src/highlevel/worker/app-config.ts @@ -0,0 +1,12 @@ +import { PublicPart } from '../../types/utils.js' +import { AppConfigManager } from '../managers/app-config-manager.js' +import { WorkerInvoker } from './invoker.js' + +export class AppConfigManagerProxy implements PublicPart { + constructor(readonly invoker: WorkerInvoker) {} + + private _bind = this.invoker.makeBinder('app-config') + + readonly get = this._bind('get') + readonly getField = this._bind('getField') +} diff --git a/packages/core/src/highlevel/worker/port.ts b/packages/core/src/highlevel/worker/port.ts index 22986bf2..4e3278e6 100644 --- a/packages/core/src/highlevel/worker/port.ts +++ b/packages/core/src/highlevel/worker/port.ts @@ -4,6 +4,7 @@ import { LogManager } from '../../utils/logger.js' import { ITelegramClient } from '../client.types.js' import { PeersIndex } from '../types/peers/peers-index.js' import { RawUpdateHandler } from '../updates/types.js' +import { AppConfigManagerProxy } from './app-config.js' import { WorkerInvoker } from './invoker.js' import { connectToWorker } from './platform/connect.js' import { ClientMessageHandler, SomeWorker, WorkerCustomMethods } from './protocol.js' @@ -64,6 +65,7 @@ export class TelegramWorkerPort implements I private _bind = this._invoker.makeBinder('client') readonly storage = new TelegramStorageProxy(this._invoker) + readonly appConfig = new AppConfigManagerProxy(this._invoker) private _destroyed = false destroy(terminate = false): void { diff --git a/packages/core/src/highlevel/worker/protocol.ts b/packages/core/src/highlevel/worker/protocol.ts index b607f0d7..859aeb26 100644 --- a/packages/core/src/highlevel/worker/protocol.ts +++ b/packages/core/src/highlevel/worker/protocol.ts @@ -4,45 +4,39 @@ import { tl } from '@mtcute/tl' import { SerializedError } from './errors.js' -export type WorkerInboundMessage = - | { - type: 'invoke' - id: number - target: - | 'custom' - | 'client' - | 'storage' - | 'storage-self' - | 'storage-peers' - method: string - args: unknown[] - void: boolean - } +export type WorkerInboundMessage = { + type: 'invoke' + id: number + target: 'custom' | 'client' | 'storage' | 'storage-self' | 'storage-peers' | 'app-config' + method: string + args: unknown[] + void: boolean +} export type WorkerOutboundMessage = | { type: 'server_update'; update: tl.TypeUpdates } | { - type: 'update' - update: tl.TypeUpdate - users: Map - chats: Map - hasMin: boolean - } + type: 'update' + update: tl.TypeUpdate + users: Map + chats: Map + hasMin: boolean + } | { type: 'error'; error: unknown } | { - type: 'log' - color: number - level: number - tag: string - fmt: string - args: unknown[] - } + type: 'log' + color: number + level: number + tag: string + fmt: string + args: unknown[] + } | { - type: 'result' - id: number - result?: unknown - error?: SerializedError - } + type: 'result' + id: number + result?: unknown + error?: SerializedError + } export type SomeWorker = NodeWorker | Worker | SharedWorker diff --git a/packages/core/src/highlevel/worker/worker.ts b/packages/core/src/highlevel/worker/worker.ts index 33513946..14610b7e 100644 --- a/packages/core/src/highlevel/worker/worker.ts +++ b/packages/core/src/highlevel/worker/worker.ts @@ -33,6 +33,9 @@ export function makeTelegramWorker(params: Telegr case 'storage-peers': target = client.storage.peers break + case 'app-config': + target = client.appConfig + break default: { respond({ diff --git a/packages/core/src/network/config-manager.test.ts b/packages/core/src/network/config-manager.test.ts index 43e0442f..ee766963 100644 --- a/packages/core/src/network/config-manager.test.ts +++ b/packages/core/src/network/config-manager.test.ts @@ -48,7 +48,7 @@ describe('ConfigManager', () => { const cm = new ConfigManager(getConfig) expect(cm.isStale).toBe(true) - cm.setConfig(config) + cm.setData(config) expect(cm.isStale).toBe(false) vi.setSystemTime(300_000) @@ -69,8 +69,14 @@ describe('ConfigManager', () => { const cm = new ConfigManager(getConfig) await cm.update() - vi.setSystemTime(300_000) + getConfig.mockImplementation(() => + Promise.resolve({ + ...config, + expires: 600, + }), + ) getConfig.mockClear() + await vi.advanceTimersByTimeAsync(301_000) await Promise.all([cm.update(), cm.update()]) expect(getConfig).toHaveBeenCalledOnce() @@ -79,11 +85,11 @@ describe('ConfigManager', () => { it('should call listeners on config update', async () => { const cm = new ConfigManager(getConfig) const listener = vi.fn() - cm.onConfigUpdate(listener) + cm.onReload(listener) await cm.update() vi.setSystemTime(300_000) - cm.offConfigUpdate(listener) + cm.onReload(listener) await cm.update() expect(listener).toHaveBeenCalledOnce() diff --git a/packages/core/src/network/config-manager.ts b/packages/core/src/network/config-manager.ts index 8123258a..fbe03963 100644 --- a/packages/core/src/network/config-manager.ts +++ b/packages/core/src/network/config-manager.ts @@ -1,74 +1,19 @@ import { tl } from '@mtcute/tl' +import { Reloadable } from '../utils/reloadable.js' + /** * Config manager is responsible for keeping * the current server configuration up-to-date * and providing methods to find the best DC * option for the current session. */ -export class ConfigManager { - constructor(private _update: () => Promise) {} - - private _destroyed = false - private _config?: tl.RawConfig - private _cdnConfig?: tl.RawCdnConfig - - private _updateTimeout?: NodeJS.Timeout - private _updatingPromise?: Promise - - private _listeners: ((config: tl.RawConfig) => void)[] = [] - - get isStale(): boolean { - return !this._config || this._config.expires <= Date.now() / 1000 - } - - update(force = false): Promise { - if (!force && !this.isStale) return Promise.resolve() - if (this._updatingPromise) return this._updatingPromise - - return (this._updatingPromise = this._update().then((config) => { - if (this._destroyed) return - this._updatingPromise = undefined - - this.setConfig(config) - })) - } - - setConfig(config: tl.RawConfig): void { - this._config = config - - if (this._updateTimeout) clearTimeout(this._updateTimeout) - this._updateTimeout = setTimeout( - () => void this.update().catch(() => {}), - (config.expires - Date.now() / 1000) * 1000, - ) - - for (const cb of this._listeners) cb(config) - } - - onConfigUpdate(cb: (config: tl.RawConfig) => void): void { - this._listeners.push(cb) - } - - offConfigUpdate(cb: (config: tl.RawConfig) => void): void { - const idx = this._listeners.indexOf(cb) - if (idx >= 0) this._listeners.splice(idx, 1) - } - - getNow(): tl.RawConfig | undefined { - return this._config - } - - async get(): Promise { - if (this.isStale) await this.update() - - return this._config! - } - - destroy(): void { - if (this._updateTimeout) clearTimeout(this._updateTimeout) - this._listeners.length = 0 - this._destroyed = true +export class ConfigManager extends Reloadable { + constructor(update: () => Promise) { + super({ + reload: update, + getExpiresAt: (data) => data.expires * 1000, + }) } async findOption(params: { @@ -81,7 +26,7 @@ export class ConfigManager { }): Promise { if (this.isStale) await this.update() - const options = this._config!.dcOptions.filter((opt) => { + const options = this._data!.dcOptions.filter((opt) => { if (opt.tcpoOnly) return false // unsupported if (opt.ipv6 && !params.allowIpv6) return false if (opt.mediaOnly && !params.allowMedia) return false diff --git a/packages/core/src/network/network-manager.ts b/packages/core/src/network/network-manager.ts index ad68e6d1..31f9c0dc 100644 --- a/packages/core/src/network/network-manager.ts +++ b/packages/core/src/network/network-manager.ts @@ -3,7 +3,14 @@ import { TlReaderMap, TlWriterMap } from '@mtcute/tl-runtime' import { StorageManager } from '../storage/storage.js' import { MtArgumentError, MtcuteError, MtTimeoutError, MtUnsupportedError } from '../types/index.js' -import { ControllablePromise, createControllablePromise, DcOptions, ICryptoProvider, Logger, sleep } from '../utils/index.js' +import { + ControllablePromise, + createControllablePromise, + DcOptions, + ICryptoProvider, + Logger, + sleep, +} from '../utils/index.js' import { assertTypeIs } from '../utils/type-assertions.js' import { ConfigManager } from './config-manager.js' import { MultiSessionConnection } from './multi-session-connection.js' @@ -462,7 +469,7 @@ export class NetworkManager { this._updateHandler = params.onUpdate this._onConfigChanged = this._onConfigChanged.bind(this) - config.onConfigUpdate(this._onConfigChanged) + config.onReload(this._onConfigChanged) } private async _findDcOptions(dcId: number): Promise { @@ -627,8 +634,7 @@ export class NetworkManager { } else { if (auth.bot) { // bots may receive tmpSessions, which we should respect - this.config.update(true) - .catch((e: Error) => this.params.emitError(e)) + this.config.update(true).catch((e: Error) => this.params.emitError(e)) } user = auth @@ -837,6 +843,6 @@ export class NetworkManager { for (const dc of this._dcConnections.values()) { dc.destroy() } - this.config.offConfigUpdate(this._onConfigChanged) + this.config.offReload(this._onConfigChanged) } } diff --git a/packages/core/src/utils/reloadable.ts b/packages/core/src/utils/reloadable.ts new file mode 100644 index 00000000..952238e9 --- /dev/null +++ b/packages/core/src/utils/reloadable.ts @@ -0,0 +1,77 @@ +import { asyncResettable } from './function-utils.js' + +export interface ReloadableParams { + reload: (old?: Data) => Promise + getExpiresAt: (data: Data) => number + onError?: (err: unknown) => void + disableAutoReload?: boolean +} + +export class Reloadable { + constructor(readonly params: ReloadableParams) {} + + protected _data?: Data + protected _expiresAt = 0 + protected _listeners: ((data: Data) => void)[] = [] + protected _timeout?: NodeJS.Timeout + + private _reload = asyncResettable(async () => { + const data = await this.params.reload(this._data) + this.setData(data) + + this._listeners.forEach((cb) => cb(data)) + }) + + get isStale(): boolean { + return !this._data || this._expiresAt <= Date.now() + } + + setData(data: Data): void { + const expiresAt = this.params.getExpiresAt(data) + + this._data = data + this._expiresAt = expiresAt + + if (this._timeout) clearTimeout(this._timeout) + + if (!this.params.disableAutoReload) { + this._timeout = setTimeout(() => { + this._reload.reset() + this.update().catch((err: unknown) => { + this.params.onError?.(err) + }) + }, expiresAt - Date.now()) + } + } + + update(force = false): Promise { + if (!force && !this.isStale) return Promise.resolve() + + return this._reload.run() + } + + onReload(cb: (data: Data) => void): void { + this._listeners.push(cb) + } + + offReload(cb: (data: Data) => void): void { + const idx = this._listeners.indexOf(cb) + if (idx >= 0) this._listeners.splice(idx, 1) + } + + getNow(): Data | undefined { + return this._data + } + + async get(): Promise { + await this.update() + + return this._data! + } + + destroy(): void { + if (this._timeout) clearTimeout(this._timeout) + this._listeners.length = 0 + this._reload.reset() + } +} diff --git a/packages/tl/app-config.json b/packages/tl/app-config.json new file mode 100644 index 00000000..2a408625 --- /dev/null +++ b/packages/tl/app-config.json @@ -0,0 +1 @@ +{"emojies_animated_zoom":{"type":"number","description":"/**\n * Animated\n * emojis and\n * animated\n * dice should be scaled by this factor before being shown\n * to the user (float)\n */"},"keep_alive_service":{"type":"boolean","description":"/**\n * Whether app clients should start a keepalive service to keep\n * the app running and fetch updates even when the app is\n * closed (boolean)\n */"},"background_connection":{"type":"boolean","description":"/**\n * Whether app clients should start a background TCP connection\n * for MTProto update fetching (boolean)\n */"},"emojies_send_dice":{"type":"string[]","description":"/**\n * A list of supported\n * animated\n * dice stickers (array of strings).\n */"},"emojies_send_dice_success":{"type":"Record","description":"/**\n * For\n * animated\n * dice emojis other than the basic \"๐ŸŽฒ\", indicates the winning dice value and\n * the final frame of the animated sticker, at which to show\n * the fireworks \"๐ŸŽ†\" (object with emoji keys and object\n * values, containing value and\n * frame_start float values)\n */"},"emojies_sounds":{"type":"Record","description":"/**\n * A map of soundbites to be played when the user clicks on the\n * specified\n * animated\n * emoji; the\n * file\n * reference field should be base64-decoded before\n * downloading\n * the file (map of\n * file\n * IDs ({@link RawInputDocument}.id), with emoji string\n * keys)\n */"},"gif_search_branding":{"type":"string","description":"/**\n * Specifies the name of the service providing GIF search\n * through\n * gif_search_username\n * (string)\n */"},"gif_search_emojies":{"type":"string[]","description":"/**\n * Specifies a list of emojis that should be suggested as\n * search term in a bar above the GIF search box (array of\n * string emojis)\n */"},"stickers_emoji_suggest_only_api":{"type":"boolean","description":"/**\n * Specifies that the app should not display\n * local\n * sticker suggestions ยป for emojis at all and just use the\n * result of {@link messages.RawGetStickersRequest} (bool)\n */"},"stickers_emoji_cache_time":{"type":"number","description":"/**\n * Specifies the validity period of the local cache of\n * {@link messages.RawGetStickersRequest}, also relevant when\n * generating the\n * pagination\n * hash when invoking the method. (integer)\n */"},"qr_login_camera":{"type":"boolean","description":"/**\n * Whether the Settings->Devices menu should show an option\n * to scan a\n * QR\n * login code (boolean)\n */"},"qr_login_code":{"type":"\"disabled\" | \"primary\" | \"secondary\"","description":"/**\n * Whether the login screen should show a\n * QR code\n * login option, possibly as default login method (string,\n * \"disabled\", \"primary\" or \"secondary\")\n */"},"dialog_filters_enabled":{"type":"boolean","description":"/**\n * Whether clients should show an option for managing\n * dialog\n * filters AKA folders (boolean)\n */"},"dialog_filters_tooltip":{"type":"boolean","description":"/**\n * Whether clients should actively show a tooltip, inviting the\n * user to configure\n * dialog\n * filters AKA folders; typically this happens when the\n * chat list is long enough to start getting cluttered.\n * (boolean)\n */"},"autoarchive_setting_available":{"type":"boolean","description":"/**\n * Whether clients can invoke\n * {@link account.RawSetGlobalPrivacySettingsRequest} with\n * {@link RawGlobalPrivacySettings}, to automatically archive\n * and mute new incoming chats from non-contacts. (boolean)\n */"},"pending_suggestions":{"type":"string[]","description":"/**\n * Contains a list of suggestions that should be actively shown\n * as a tooltip to the user. (Array of strings, possible values\n * shown in the suggestions section\n * ยป. \n */"},"topics_pinned_limit":{"type":"number","description":"/**\n * Maximum number of\n * topics\n * that can be pinned in a single\n * forum.\n * (integer)\n */"},"telegram_antispam_user_id":{"type":"string","description":"/**\n * The ID of the official\n * native\n * antispam bot, that will automatically delete spam\n * messages if enabled as specified in the\n * native\n * antispam documentation ยป.\n * \n * \n * When fetching the admin list of a supergroup using\n * {@link channels.RawGetParticipantsRequest}, if native\n * antispam functionality in the specified supergroup, the bot\n * should be manually added to the admin list displayed to the\n * user. (numeric string that represents a Telegram user/bot\n * ID, should be casted to an int64)\n */"},"telegram_antispam_group_size_min":{"type":"number","description":"/**\n * Minimum number of group members required to enable\n * native\n * antispam functionality. (integer)\n */"},"fragment_prefixes":{"type":"string[]","description":"/**\n * List of phone number prefixes for anonymous\n * Fragment phone numbers.\n * (array of strings).\n */"},"hidden_members_group_size_min":{"type":"number","description":"/**\n * Minimum number of participants required to hide the\n * participants list of a supergroup using\n * {@link channels.RawToggleParticipantsHiddenRequest}.\n * (integer)\n */"},"url_auth_domains":{"type":"string[]","description":"/**\n * A list of domains that support automatic login with manual\n * user confirmation,\n * click\n * here for more info on URL authorization ยป. (array of\n * strings)\n */"},"autologin_domains":{"type":"string[]","description":"/**\n * A list of Telegram domains that support automatic login with\n * no user confirmation,\n * click\n * here for more info on URL authorization ยป. (array of\n * strings)\n */"},"whitelisted_domains":{"type":"string[]","description":"/**\n * A list of Telegram domains that can always be opened without\n * additional user confirmation, when clicking on in-app links\n * where the URL is not fully displayed (i.e.\n * {@link RawMessageEntityTextUrl} entities). (array of\n * strings)Note that when opening\n * named\n * Mini App links for the first time, confirmation should\n * still be requested from the user, even if the domain of the\n * containing deep link is whitelisted (i.e.\n * t.me/<bot_username>/<short_name>?startapp=<start_parameter>,\n * where t.me is whitelisted). Confirmation\n * should always be asked, even if we already\n * opened the\n * named\n * Mini App before, if the link is not visible (i.e.\n * {@link RawMessageEntityTextUrl} text links, inline buttons\n * etc.). \n */"},"round_video_encoding":{"type":"{\n diameter: number\n video_bitrate: number\n audio_bitrate: number\n max_size: number\n}","description":"/**\n * Contains a set of recommended codec parameters for round\n * videos. (object, as described in the example)\n */"},"chat_read_mark_size_threshold":{"type":"number","description":"/**\n * Per-user read receipts, fetchable using\n * {@link messages.RawGetMessageReadParticipantsRequest}, will\n * be available in groups with an amount of participants less\n * or equal to chat_read_mark_size_threshold.\n * (integer)\n */"},"chat_read_mark_expire_period":{"type":"number","description":"/**\n * To protect user privacy, read receipts for chats are only\n * stored for chat_read_mark_expire_period seconds\n * after the message was sent. (integer)\n */"},"pm_read_date_expire_period":{"type":"number","description":"/**\n * To protect user privacy, read receipts for private chats are\n * only stored for pm_read_date_expire_period\n * seconds after the message was sent. (integer)\n */"},"groupcall_video_participants_max":{"type":"number","description":"/**\n * Maximum number of participants in a group call (livestreams\n * allow โˆž participants) (integer)\n */"},"reactions_uniq_max":{"type":"number","description":"/**\n * Maximum number of unique reactions for any given message:\n * for example, if there are 2000 \"๐Ÿ‘\" and 1000 custom emoji reactions and\n * reactions_uniq_max = 2, you can't add a \"๐Ÿ‘Ž\" reaction, because that would raise the\n * number of unique reactions to 3 > 2. (integer)\n */"},"reactions_in_chat_max":{"type":"number","description":"/**\n * Maximum number of reactions that can be marked as allowed in\n * a chat using {@link RawChatReactionsSome}. (integer)\n */"},"reactions_user_max_default":{"type":"number","description":"/**\n * Maximum number of reactions that can be added to a single\n * message by a non-Premium user. (integer)\n */"},"reactions_user_max_premium":{"type":"number","description":"/**\n * Maximum number of reactions that can be added to a single\n * message by a Premium user. (integer)\n */"},"default_emoji_statuses_stickerset_id":{"type":"number","description":"/**\n * Default emoji status stickerset ID. (integer)\n * \n * \n * Note that the stickerset can be fetched using\n * {@link RawInputStickerSetEmojiDefaultStatuses}. \n */"},"ringtone_duration_max":{"type":"number","description":"/**\n * The maximum duration in seconds of\n * uploadable\n * notification sounds ยป (integer)\n */"},"ringtone_size_max":{"type":"number","description":"/**\n * The maximum post-conversion size in bytes of\n * uploadable\n * notification sounds ยป\n */"},"ringtone_saved_count_max":{"type":"number","description":"/**\n * The maximum number of\n * saveable\n * notification sounds ยป\n */"},"message_animated_emoji_max":{"type":"number","description":"/**\n * The maximum number of\n * custom\n * emojis that may be present in a message. (integer)\n */"},"stickers_premium_by_emoji_num":{"type":"number","description":"/**\n * Defines how many\n * Premium\n * stickers to show in the sticker suggestion popup when\n * entering an emoji into the text field, see the\n * sticker\n * docs for more info (integer, defaults to 0)\n */"},"stickers_normal_by_emoji_per_premium_num":{"type":"number","description":"/**\n * For\n * Premium\n * users, used to define the suggested sticker list, see\n * the\n * sticker\n * docs for more info (integer, defaults to 2)\n */"},"premium_purchase_blocked":{"type":"boolean","description":"/**\n * The user can't purchase\n * Telegram\n * Premium. The app must also hide all Premium features,\n * including stars for other users, et cetera. (boolean)\n */"},"channels_limit_default":{"type":"number","description":"/**\n * The maximum number of\n * channels\n * and supergroups a\n * non-Premium\n * user may join (integer)\n */"},"channels_limit_premium":{"type":"number","description":"/**\n * The maximum number of\n * channels\n * and supergroups a\n * Premium\n * user may join (integer)\n */"},"saved_gifs_limit_default":{"type":"number","description":"/**\n * The maximum number of GIFs a\n * non-Premium\n * user may save (integer)\n */"},"saved_gifs_limit_premium":{"type":"number","description":"/**\n * The maximum number of GIFs a\n * Premium\n * user may save (integer)\n */"},"stickers_faved_limit_default":{"type":"number","description":"/**\n * The maximum number of stickers a\n * non-Premium\n * user may\n * add\n * to Favorites ยป (integer)\n */"},"stickers_faved_limit_premium":{"type":"number","description":"/**\n * The maximum number of stickers a\n * Premium\n * user may\n * add\n * to Favorites ยป (integer)\n */"},"dialog_filters_limit_default":{"type":"number","description":"/**\n * The maximum number of\n * folders\n * a\n * non-Premium\n * user may create (integer)\n */"},"dialog_filters_limit_premium":{"type":"number","description":"/**\n * The maximum number of\n * folders\n * a\n * Premium\n * user may create (integer)\n */"},"dialog_filters_chats_limit_default":{"type":"number","description":"/**\n * The maximum number of chats a\n * non-Premium\n * user may add to a\n * folder\n * (integer)\n */"},"dialog_filters_chats_limit_premium":{"type":"number","description":"/**\n * The maximum number of chats a\n * Premium\n * user may add to a\n * folder\n * (integer)\n */"},"dialogs_pinned_limit_default":{"type":"number","description":"/**\n * The maximum number of chats a\n * non-Premium\n * user may pin (integer)\n */"},"dialogs_pinned_limit_premium":{"type":"number","description":"/**\n * The maximum number of chats a\n * Premium\n * user may pin (integer)\n */"},"dialogs_folder_pinned_limit_default":{"type":"number","description":"/**\n * The maximum number of chats a\n * non-Premium\n * user may pin in a folder (integer)\n */"},"dialogs_folder_pinned_limit_premium":{"type":"number","description":"/**\n * The maximum number of chats a\n * Premium\n * user may pin in a folder (integer)\n */"},"channels_public_limit_default":{"type":"number","description":"/**\n * The maximum number of public\n * channels\n * or supergroups a\n * non-Premium\n * user may create (integer)\n */"},"channels_public_limit_premium":{"type":"number","description":"/**\n * The maximum number of public\n * channels\n * or supergroups a\n * Premium\n * user may create (integer)\n */"},"caption_length_limit_default":{"type":"number","description":"/**\n * The maximum UTF-8 length of media captions sendable by\n * non-Premium\n * users (integer)\n */"},"caption_length_limit_premium":{"type":"number","description":"/**\n * The maximum UTF-8 length of media captions sendable by\n * Premium\n * users (integer)\n */"},"upload_max_fileparts_default":{"type":"number","description":"/**\n * The maximum number of file parts uploadable by\n * non-Premium\n * users (integer, the maximum file size can be extrapolated by\n * multiplying this value by 524288, the biggest\n * possible chunk size)\n */"},"upload_max_fileparts_premium":{"type":"number","description":"/**\n * The maximum number of file parts uploadable by\n * Premium\n * users (integer, the maximum file size can be extrapolated by\n * multiplying this value by 524288, the biggest\n * possible chunk size)\n */"},"about_length_limit_default":{"type":"number","description":"/**\n * The maximum UTF-8 length of bios of\n * non-Premium\n * users (integer)\n */"},"about_length_limit_premium":{"type":"number","description":"/**\n * The maximum UTF-8 length of bios of\n * Premium\n * users (integer)\n */"},"premium_promo_order":{"type":"string[]","description":"/**\n * Array of string identifiers, indicating the order of\n * Telegram\n * Premium features in the Telegram Premium promotion\n * popup,\n * see\n * here for the possible values ยป\n */"},"premium_bot_username":{"type":"string","description":"/**\n * Contains the username of the official\n * Telegram\n * Premium bot that may be used to buy a\n * Telegram\n * Premium subscription, see\n * here for\n * detailed instructions ยป (string)\n */"},"premium_invoice_slug":{"type":"string","description":"/**\n * Contains an\n * invoice\n * slug that may be used to buy a\n * Telegram\n * Premium subscription, see\n * here for\n * detailed instructions ยป (string)\n */"},"premium_gift_attach_menu_icon":{"type":"boolean","description":"/**\n * Whether a gift icon should be shown in the attachment menu\n * in private chats with users, offering the current user to\n * gift a\n * Telegram\n * Premium subscription to the other user in the chat.\n * (boolean)\n */"},"premium_gift_text_field_icon":{"type":"boolean","description":"/**\n * Whether a gift icon should be shown in the text bar in\n * private chats with users (ie like the / icon in\n * chats with bots), offering the current user to gift a\n * Telegram\n * Premium subscription to the other user in the chat. Can\n * only be true if premium_gift_attach_menu_icon\n * is also true. (boolean)\n */"},"chatlist_update_period":{"type":"number","description":"/**\n * Users that import a folder using a\n * chat\n * folder deep link ยป should retrieve additions made to the\n * folder by invoking\n * {@link chatlists.RawGetChatlistUpdatesRequest} at most every\n * chatlist_update_period seconds. (integer)\n */"},"chatlist_invites_limit_default":{"type":"number","description":"/**\n * Maximum number of per-folder\n * chat\n * folder deep links ยป that can be created by\n * non-Premium\n * users. (integer)\n */"},"chatlist_invites_limit_premium":{"type":"number","description":"/**\n * Maximum number of per-folder\n * chat\n * folder deep links ยป that can be created by\n * Premium\n * users. (integer)\n */"},"chatlists_joined_limit_default":{"type":"number","description":"/**\n * Maximum number of\n * shareable\n * folders\n * non-Premium\n * users may have. (integer)\n */"},"chatlists_joined_limit_premium":{"type":"number","description":"/**\n * Maximum number of\n * shareable\n * folders\n * Premium\n * users may have. (integer)\n */"},"small_queue_max_active_operations_count":{"type":"number","description":"/**\n * A soft limit, specifying the maximum number of files that\n * should be downloaded in parallel from the same DC, for files\n * smaller than 20MB. (integer)\n */"},"large_queue_max_active_operations_count":{"type":"number","description":"/**\n * A soft limit, specifying the maximum number of files that\n * should be downloaded in parallel from the same DC, for files\n * bigger than 20MB. (integer)\n */"},"authorization_autoconfirm_period":{"type":"number","description":"/**\n * An\n * unconfirmed\n * session ยป will be autoconfirmed this many seconds after\n * login. (integer)\n */"},"story_viewers_expire_period":{"type":"number","description":"/**\n * The exact list of users that viewed the story will be hidden\n * from the poster this many seconds after the story expires.\n * (integer)This limit applies only to\n * non-Premium\n * users,\n * Premium\n * users can always access the viewer list.\n */"},"story_expiring_limit_default":{"type":"number","description":"/**\n * The maximum number of active\n * stories\n * for\n * non-Premium\n * users (integer).\n */"},"story_expiring_limit_premium":{"type":"number","description":"/**\n * The maximum number of active\n * stories\n * for\n * Premium\n * users (integer).\n */"},"story_caption_length_limit_premium":{"type":"number","description":"/**\n * The maximum UTF-8 length of story captions for\n * Premium\n * users. (integer)\n */"},"story_caption_length_limit_default":{"type":"number","description":"/**\n * The maximum UTF-8 length of story captions for\n * non-Premium\n * users. (integer)\n */"},"stories_posting":{"type":"string","description":"/**\n * Indicates whether users can post stories. (string)One of:\n *
  • enabled - Any user can post stories.
  • \n *
  • premium - Only users with a\n * Premium\n * subscription can post stories.
  • \n *
  • disabled - Users can't post stories.
  • \n * \n */"},"stories_stealth_past_period":{"type":"number","description":"/**\n * Enabling\n * stories\n * stealth mode with the past flag will erase\n * views of any story opened in the past\n * stories_stealth_past_period seconds. (integer)\n */"},"stories_stealth_future_period":{"type":"number","description":"/**\n * Enabling\n * stories\n * stealth mode with the future flag will hide\n * views of any story opened in the next\n * stories_stealth_future_period seconds.\n * (integer)\n */"},"stories_stealth_cooldown_period":{"type":"number","description":"/**\n * After enabling\n * stories\n * stealth mode, this many seconds must elapse before the\n * user is allowed to enable it again. (integer)\n */"},"stories_sent_weekly_limit_default":{"type":"number","description":"/**\n * Maximum number of stories that can be sent in a week by\n * non-Premium\n * users. (integer)\n */"},"stories_sent_weekly_limit_premium":{"type":"number","description":"/**\n * Maximum number of stories that can be sent in a week by\n * Premium\n * users. (integer)\n */"},"stories_sent_monthly_limit_default":{"type":"number","description":"/**\n * Maximum number of stories that can be sent in a month by\n * non-Premium\n * users. (integer)\n */"},"stories_sent_monthly_limit_premium":{"type":"number","description":"/**\n * Maximum number of stories that can be sent in a month by\n * Premium\n * users. (integer)\n */"},"stories_suggested_reactions_limit_default":{"type":"number","description":"/**\n * Maximum number of\n * story\n * reaction media areas ยป that can be added to a story by\n * non-Premium\n * users. (integer)\n */"},"stories_suggested_reactions_limit_premium":{"type":"number","description":"/**\n * Maximum number of\n * story\n * reaction media areas ยป that can be added to a story by\n * Premium\n * users. (integer)\n */"},"stories_venue_search_username":{"type":"string","description":"/**\n * Username of the inline bot to use to generate venue location\n * tags for stories, see\n * here\n * ยป for more info. (string)\n */"},"stories_changelog_user_id":{"type":"number","description":"/**\n * ID of the official Telegram user that will post stories\n * about new Telegram features: stories posted by this user\n * should be shown on the\n * active\n * or active and hidden stories bar just like for contacts,\n * even if the user was removed from the contact list.\n * (integer, defaults to 777000)\n */"},"stories_entities":{"type":"string","description":"/**\n * Whether\n * styled\n * text entities and links in story text captions can be\n * used by all users (enabled), only\n * [Premium](/api/premium users) (premium), or no\n * one (disabled). (string)This field is used both\n * when posting stories, to indicate to the user whether they\n * can use entities, and when viewing stories, to hide entities\n * (client-side) on stories posted by users whose\n * Premium\n * subscription has expired (if stories_entities ==\n * \"premium\" and {@link RawUser}.premium is\n * not set, or if stories_entities == \"disabled\").\n * \n */"},"giveaway_gifts_purchase_available":{"type":"boolean","description":"/**\n * Whether\n * giveaways\n * can be started by the current user. (boolean)\n */"},"giveaway_add_peers_max":{"type":"number","description":"/**\n * The maximum number of users that can be specified when\n * making a\n * direct\n * giveaway. (integer)\n */"},"giveaway_countries_max":{"type":"number","description":"/**\n * The maximum number of countries that can be specified when\n * restricting the set of participating countries in a\n * giveaway.\n * (itneger)\n */"},"giveaway_boosts_per_premium":{"type":"number","description":"/**\n * The number of\n * boosts\n * that will be gained by a channel for each winner of a\n * giveaway.\n * (integer)\n */"},"giveaway_period_max":{"type":"number","description":"/**\n * The maximum duration in seconds of a\n * giveaway.\n * (integer)\n */"},"boosts_channel_level_max":{"type":"number","description":"/**\n * Maximum\n * boost\n * level for channels. (integer)\n */"},"boosts_per_sent_gift":{"type":"number","description":"/**\n * The number of additional\n * boost\n * slots that the current user will receive when\n * gifting\n * a Telegram Premium subscription. \n */"},"transcribe_audio_trial_weekly_number":{"type":"number","description":"/**\n * The maximum number of\n * speech\n * recognition ยป calls per week for\n * non-Premium\n * users. (integer)\n */"},"transcribe_audio_trial_duration_max":{"type":"number","description":"/**\n * The maximum allowed duration of media in seconds for\n * speech\n * recognition ยป for\n * non-Premium\n * users. (integer)\n */"},"recommended_channels_limit_default":{"type":"number","description":"/**\n * The maximum number of similar channels that can be\n * recommended by\n * {@link channels.RawGetChannelRecommendationsRequest} to\n * non-Premium\n * users. (integer)\n */"},"recommended_channels_limit_premium":{"type":"number","description":"/**\n * The maximum number of similar channels that can be\n * recommended by\n * {@link channels.RawGetChannelRecommendationsRequest} to\n * Premium\n * users. (integer)\n */"},"quote_length_max":{"type":"number","description":"/**\n * Maximum UTF-8 length of {@link RawInputReplyToMessage}.\n * (integer)\n */"},"channel_bg_icon_level_min":{"type":"number","description":"/**\n * After reaching at least this\n * boost\n * level ยป, channels gain the ability to change their\n * message\n * accent palette emoji ยป. (integer)\n */"},"channel_profile_bg_icon_level_min":{"type":"number","description":"/**\n * After reaching at least this\n * boost\n * level ยป, channels gain the ability to change their\n * profile\n * accent palette emoji ยป. (integer)\n */"},"channel_emoji_status_level_min":{"type":"number","description":"/**\n * After reaching at least this\n * boost\n * level ยป, channels gain the ability to change their\n * status\n * emoji ยป. (integer)\n */"},"channel_wallpaper_level_min":{"type":"number","description":"/**\n * After reaching at least this\n * boost\n * level ยป, channels gain the ability to set a\n * fill\n * channel wallpaper, see here ยป for more info. (integer)\n */"},"channel_custom_wallpaper_level_min":{"type":"number","description":"/**\n * After reaching at least this\n * boost\n * level ยป, channels gain the ability to set any custom\n * wallpaper,\n * not just\n * fill\n * channel wallpapers, see here ยป for more info. (integer)\n */"},"saved_dialogs_pinned_limit_default":{"type":"number","description":"/**\n * Maximum number of pinned dialogs in\n * saved\n * messages for\n * non-Premium\n * users. (integer)\n */"},"saved_dialogs_pinned_limit_premium":{"type":"number","description":"/**\n * Maximum number of pinned dialogs in\n * saved\n * messages for\n * Premium\n * users. (integer)\n */"}} \ No newline at end of file diff --git a/packages/tl/scripts/constants.ts b/packages/tl/scripts/constants.ts index a3a479f0..60aab4eb 100644 --- a/packages/tl/scripts/constants.ts +++ b/packages/tl/scripts/constants.ts @@ -9,6 +9,7 @@ export const API_SCHEMA_JSON_FILE = join(__dirname, '../api-schema.json') export const API_SCHEMA_DIFF_JSON_FILE = join(__dirname, '../diff.json') export const MTP_SCHEMA_JSON_FILE = join(__dirname, '../mtp-schema.json') export const ERRORS_JSON_FILE = join(__dirname, '../raw-errors.json') +export const APP_CONFIG_JSON_FILE = join(__dirname, '../app-config.json') export const CORE_DOMAIN = 'https://core.telegram.org' export const COREFORK_DOMAIN = 'https://corefork.telegram.org' diff --git a/packages/tl/scripts/documentation.ts b/packages/tl/scripts/documentation.ts index 212af470..8c123baf 100644 --- a/packages/tl/scripts/documentation.ts +++ b/packages/tl/scripts/documentation.ts @@ -7,6 +7,7 @@ import { createInterface } from 'readline' import { camelToPascal, + jsComment, PRIMITIVE_TO_TS, snakeToCamel, splitNameToNamespace, @@ -16,6 +17,7 @@ import { import { API_SCHEMA_JSON_FILE, + APP_CONFIG_JSON_FILE, BLOGFORK_DOMAIN, CORE_DOMAIN, COREFORK_DOMAIN, @@ -98,6 +100,13 @@ function extractDescription($: cheerio.CheerioAPI) { .trim() } +function htmlAll($: cheerio.CheerioAPI, search: cheerio.Cheerio) { + return search + .get() + .map((el) => $(el).html() ?? '') + .join('') +} + // from https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json const PROGRESS_CHARS = ['โ ‹', 'โ ™', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ‡', 'โ '] @@ -127,6 +136,168 @@ async function chooseDomainForDocs(headers: Record): Promise<[nu return [maxLayer, maxDomain] } +function lastParensGroup(text: string): string | undefined { + const groups = [] + let depth = 0 + let current = '' + + for (let i = 0; i < text.length; i++) { + if (text[i] === ')') depth-- + + if (depth > 0) { + current += text[i] + } + + if (text[i] === '(') depth++ + + if (current && depth === 0) { + groups.push(current) + current = '' + } + } + + return groups[groups.length - 1] +} + +async function fetchAppConfigDocumentation() { + const headers = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/87.0.4280.88 Safari/537.36', + } + + const [, domain] = await chooseDomainForDocs(headers) + + const page = await fetchRetry(`${domain}/api/config`, { headers }) + const $ = cheerio.load(page) + + const fields = $('p:icontains(typical fields included)').nextUntil('h3') + normalizeLinks(`${domain}/api/config`, fields) + const fieldNames = fields.filter('h4') + + const _example = $('p:icontains(example value)').next('pre').find('code') + const example = JSON.parse(_example.text().trim()) as Record + + const result: Record = {} + + function valueToTypescript(value: unknown, record = false): string { + if (value === undefined) return 'unknown' + if (value === null) return 'null' + + if (Array.isArray(value)) { + const types = new Set(value.map((v) => typeof v)) + + if (types.size === 1) { + return valueToTypescript(value[0]) + '[]' + } + + return `(${value.map((v) => valueToTypescript(v)).join(' | ')})[]` + } + + if (typeof value === 'object') { + if (record) { + const inner = Object.values(value)[0] as unknown + + return `Record` + } + + return ( + '{\n' + + Object.entries(value) + .map(([k, v]) => ` ${k}: ${valueToTypescript(v)}`) + .join('\n') + + '\n}' + ) + } + + return typeof value + } + + function docsTypeToTypescript(field: string, type: string): string { + let m + + if ((m = type.match(/(.*), defaults to .+$/i))) { + return docsTypeToTypescript(field, m[1]) + } + + if ((m = type.match(/^(?:array of )(.+?)s?$/i))) { + return docsTypeToTypescript(field, m[1]) + '[]' + } + + switch (type) { + case 'integer': + return 'number' + case 'itneger': + return 'number' + case 'float': + return 'number' + case 'string': + return 'string' + case 'string emoji': + return 'string' + case 'boolean': + return 'boolean' + case 'bool': + return 'boolean' + } + + if (type.match(/^object with .+? keys|^map of/i)) { + return valueToTypescript(example[field], true) + } + + if (type.match(/^strings?, /)) { + if (type.includes('or')) { + const options = type.slice(8).split(/, | or /) + + return options.map((o) => (o[0] === '"' ? o : JSON.stringify(o))).join(' | ') + } + + return 'string' + } + + if (type.includes(',')) { + return docsTypeToTypescript(field, type.split(',')[0]) + } + + if (type.match(/^numeric string/)) { + return 'string' + } + + if (type.includes('as described')) { + return valueToTypescript(example[field]) + } + + console.log(`Failed to parse type at ${field}: ${type}`) + + return valueToTypescript(example[field]) + } + + for (const fieldName of fieldNames.toArray()) { + const name = $(fieldName).text().trim() + const description = htmlAll($, $(fieldName).nextUntil('h3, h4')) + let type = 'unknown' + + let typeStr = lastParensGroup(description) + + if (!typeStr) { + typeStr = description.match(/\s+\((.+?)(?:\)|\.|\)\.)$/)?.[1] + } + + if (typeStr) { + type = docsTypeToTypescript(name, typeStr) + } else if (name in example) { + type = valueToTypescript(example[name]) + } + + result[name] = { + type, + description: jsComment(description), + } + } + + return result +} + export async function fetchDocumentation( schema: TlFullSchema, layer: number, @@ -366,10 +537,11 @@ async function main() { console.log('1. Update documentation') console.log('2. Apply descriptions.yaml') console.log('3. Apply documentation to schema') + console.log('4. Fetch app config documentation') - const act = parseInt(await input('[0-3] > ')) + const act = parseInt(await input('[0-4] > ')) - if (isNaN(act) || act < 0 || act > 3) { + if (isNaN(act) || act < 0 || act > 4) { console.log('Invalid action') continue } @@ -412,15 +584,20 @@ async function main() { applyDocumentation(schema, cached) await writeFile(API_SCHEMA_JSON_FILE, JSON.stringify(packTlSchema(schema, layer))) } + + if (act === 4) { + const appConfig = await fetchAppConfigDocumentation() + + console.log('Fetched app config documentation') + await writeFile(APP_CONFIG_JSON_FILE, JSON.stringify(appConfig)) + } } } if (import.meta.url.startsWith('file:')) { - // (A) const modulePath = fileURLToPath(import.meta.url) if (process.argv[1] === modulePath) { - // (B) main().catch((err) => { console.error(err) process.exit(1)