diff --git a/packages/core/src/highlevel/client.ts b/packages/core/src/highlevel/client.ts index 9fbc9e61..4f8950d0 100644 --- a/packages/core/src/highlevel/client.ts +++ b/packages/core/src/highlevel/client.ts @@ -13,7 +13,7 @@ import { MtUnsupportedError } from '../types/index.js' import type { BaseTelegramClientOptions } from './base.js' import { BaseTelegramClient } from './base.js' import type { ITelegramClient } from './client.types.js' -import type { AllStories, ArrayPaginated, ArrayWithTotal, Boost, BoostSlot, BoostStats, BotChatJoinRequestUpdate, BotCommands, BotReactionCountUpdate, BotReactionUpdate, BotStoppedUpdate, BusinessCallbackQuery, BusinessChatLink, BusinessConnection, BusinessMessage, BusinessWorkHoursDay, CallbackQuery, Chat, ChatEvent, ChatInviteLink, ChatInviteLinkMember, ChatJoinRequestUpdate, ChatMember, ChatMemberUpdate, ChatPreview, ChatlistPreview, ChosenInlineResult, CollectibleInfo, DeleteBusinessMessageUpdate, DeleteMessageUpdate, DeleteStoryUpdate, Dialog, FactCheck, FileDownloadLocation, FileDownloadParameters, ForumTopic, FullChat, GameHighScore, HistoryReadUpdate, InlineCallbackQuery, InlineQuery, InputChatEventFilters, InputDialogFolder, InputFileLike, InputInlineResult, InputMediaLike, InputMediaSticker, InputMessageId, InputPeerLike, InputPrivacyRule, InputReaction, InputStickerSet, InputStickerSetItem, InputText, MaybeDynamic, Message, MessageEffect, MessageMedia, MessageReactions, ParametersSkip2, ParsedUpdate, PeerReaction, PeerStories, PeersIndex, Photo, Poll, PollUpdate, PollVoteUpdate, PreCheckoutQuery, RawDocument, ReplyMarkup, SentCode, Sticker, StickerSet, StickerSourceType, StickerType, StoriesStealthMode, Story, StoryInteractions, StoryUpdate, StoryViewer, StoryViewersList, TakeoutSession, TextWithEntities, TypingStatus, UploadFileLike, UploadedFile, User, UserStatusUpdate, UserTypingUpdate } from './types/index.js' +import type { AllStories, ArrayPaginated, ArrayWithTotal, Boost, BoostSlot, BoostStats, BotChatJoinRequestUpdate, BotCommands, BotReactionCountUpdate, BotReactionUpdate, BotStoppedUpdate, BusinessCallbackQuery, BusinessChatLink, BusinessConnection, BusinessMessage, BusinessWorkHoursDay, CallbackQuery, Chat, ChatEvent, ChatInviteLink, ChatInviteLinkMember, ChatJoinRequestUpdate, ChatMember, ChatMemberUpdate, ChatPreview, ChatlistPreview, ChosenInlineResult, CollectibleInfo, DeleteBusinessMessageUpdate, DeleteMessageUpdate, DeleteStoryUpdate, Dialog, FactCheck, FileDownloadLocation, FileDownloadParameters, ForumTopic, FullChat, GameHighScore, HistoryReadUpdate, InlineCallbackQuery, InlineQuery, InputChatEventFilters, InputDialogFolder, InputFileLike, InputInlineResult, InputMediaLike, InputMediaSticker, InputMessageId, InputPeerLike, InputPrivacyRule, InputReaction, InputStickerSet, InputStickerSetItem, InputText, MaybeDynamic, Message, MessageEffect, MessageMedia, MessageReactions, ParametersSkip2, ParsedUpdate, PeerReaction, PeerStories, PeersIndex, Photo, Poll, PollUpdate, PollVoteUpdate, PreCheckoutQuery, RawDocument, ReplyMarkup, SentCode, StarsStatus, StarsTransaction, Sticker, StickerSet, StickerSourceType, StickerType, StoriesStealthMode, Story, StoryInteractions, StoryUpdate, StoryViewer, StoryViewersList, TakeoutSession, TextWithEntities, TypingStatus, UploadFileLike, UploadedFile, User, UserStatusUpdate, UserTypingUpdate } from './types/index.js' import type { StringSessionData } from './utils/string-session.js' import type { ITelegramStorageProvider } from './storage/provider.js' import { Conversation } from './types/conversation.js' @@ -216,7 +216,9 @@ import { getBoosts } from './methods/premium/get-boosts.js' import { getBusinessChatLinks } from './methods/premium/get-business-chat-links.js' import { getBusinessConnection } from './methods/premium/get-business-connection.js' import { getMyBoostSlots } from './methods/premium/get-my-boost-slots.js' +import { getStarsTransactions } from './methods/premium/get-stars-transactions.js' import { iterBoosters } from './methods/premium/iter-boosters.js' +import { iterStarsTransactions } from './methods/premium/iter-stars-transactions.js' import { setBusinessIntro } from './methods/premium/set-business-intro.js' import { setBusinessWorkHours } from './methods/premium/set-business-work-hours.js' import { addStickerToSet } from './methods/stickers/add-sticker-to-set.js' @@ -4593,13 +4595,46 @@ export interface TelegramClient extends ITelegramClient { */ getMyBoostSlots(): Promise /** - * Iterate over boosters of a channel. + * Get Telegram Stars transactions for a given peer. * - * Wrapper over {@link getBoosters} + * You can either pass `self` to get your own transactions, + * or a chat/bot ID to get transactions of that peer. * * **Available**: ✅ both users and bots * - * @returns IDs of stories that were removed + * @param peerId Peer ID + * @param params Additional parameters + */ + getStarsTransactions( + peerId: InputPeerLike, + params?: { + /** + * If passed, only transactions of this direction will be returned + */ + direction?: 'incoming' | 'outgoing' + /** + * Direction to sort transactions date by (default: desc) + */ + sort?: 'asc' | 'desc' + /** + * If passed, will only return transactions related to this subscription ID + */ + subscriptionId?: string + /** Pagination offset */ + offset?: string + /** + * Pagination limit + * + * @default 100 + */ + limit?: number + }): Promise + /** + * Iterate over boosters of a channel. + * + * Wrapper over {@link getBoosters} + * **Available**: ✅ both users and bots + * */ iterBoosters( peerId: InputPeerLike, @@ -4619,6 +4654,37 @@ export interface TelegramClient extends ITelegramClient { */ chunkSize?: number }): AsyncIterableIterator + /** + * Iterate over Telegram Stars transactions for a given peer. + * + * You can either pass `self` to get your own transactions, + * or a chat/bot ID to get transactions of that peer. + * + * Wrapper over {@link getStarsTransactions} + * + * **Available**: ✅ both users and bots + * + * @param peerId Peer ID + * @param params Additional parameters + */ + iterStarsTransactions( + peerId: InputPeerLike, + params?: Parameters[2] & { + /** + * Total number of boosters to fetch + * + * @default Infinity, i.e. fetch all boosters + */ + limit?: number + + /** + * Number of boosters to fetch per request + * Usually you don't need to change this + * + * @default 100 + */ + chunkSize?: number + }): AsyncIterableIterator /** * Set current user's business introduction. @@ -6314,9 +6380,15 @@ TelegramClient.prototype.getBusinessConnection = function (...args) { TelegramClient.prototype.getMyBoostSlots = function (...args) { return getMyBoostSlots(this._client, ...args) } +TelegramClient.prototype.getStarsTransactions = function (...args) { + return getStarsTransactions(this._client, ...args) +} TelegramClient.prototype.iterBoosters = function (...args) { return iterBoosters(this._client, ...args) } +TelegramClient.prototype.iterStarsTransactions = function (...args) { + return iterStarsTransactions(this._client, ...args) +} TelegramClient.prototype.setBusinessIntro = function (...args) { return setBusinessIntro(this._client, ...args) } diff --git a/packages/core/src/highlevel/methods.ts b/packages/core/src/highlevel/methods.ts index ca8ed930..22f3f6b2 100644 --- a/packages/core/src/highlevel/methods.ts +++ b/packages/core/src/highlevel/methods.ts @@ -213,7 +213,9 @@ export { getBoosts } from './methods/premium/get-boosts.js' export { getBusinessChatLinks } from './methods/premium/get-business-chat-links.js' export { getBusinessConnection } from './methods/premium/get-business-connection.js' export { getMyBoostSlots } from './methods/premium/get-my-boost-slots.js' +export { getStarsTransactions } from './methods/premium/get-stars-transactions.js' export { iterBoosters } from './methods/premium/iter-boosters.js' +export { iterStarsTransactions } from './methods/premium/iter-stars-transactions.js' export { setBusinessIntro } from './methods/premium/set-business-intro.js' export { setBusinessWorkHours } from './methods/premium/set-business-work-hours.js' export { addStickerToSet } from './methods/stickers/add-sticker-to-set.js' diff --git a/packages/core/src/highlevel/methods/_imports.ts b/packages/core/src/highlevel/methods/_imports.ts index 8ca440f9..c5ef2ec9 100644 --- a/packages/core/src/highlevel/methods/_imports.ts +++ b/packages/core/src/highlevel/methods/_imports.ts @@ -87,6 +87,8 @@ import { RawDocument, ReplyMarkup, SentCode, + StarsStatus, + StarsTransaction, Sticker, StickerSet, StickerSourceType, diff --git a/packages/core/src/highlevel/methods/premium/get-stars-transactions.ts b/packages/core/src/highlevel/methods/premium/get-stars-transactions.ts new file mode 100644 index 00000000..5749428a --- /dev/null +++ b/packages/core/src/highlevel/methods/premium/get-stars-transactions.ts @@ -0,0 +1,55 @@ +import type { ITelegramClient } from '../../client.types.js' +import type { InputPeerLike } from '../../types/index.js' +import { StarsStatus } from '../../types/premium/stars-status.js' +import { resolvePeer } from '../users/resolve-peer.js' + +/** + * Get Telegram Stars transactions for a given peer. + * + * You can either pass `self` to get your own transactions, + * or a chat/bot ID to get transactions of that peer. + * + * @param peerId Peer ID + * @param params Additional parameters + */ +export async function getStarsTransactions( + client: ITelegramClient, + peerId: InputPeerLike, + params?: { + /** + * If passed, only transactions of this direction will be returned + */ + direction?: 'incoming' | 'outgoing' + /** + * Direction to sort transactions date by (default: desc) + */ + sort?: 'asc' | 'desc' + /** + * If passed, will only return transactions related to this subscription ID + */ + subscriptionId?: string + /** Pagination offset */ + offset?: string + /** + * Pagination limit + * + * @default 100 + */ + limit?: number + }, +): Promise { + const { direction, sort, subscriptionId, offset = '', limit = 100 } = params ?? {} + + const res = await client.call({ + _: 'payments.getStarsTransactions', + peer: await resolvePeer(client, peerId), + offset, + limit, + outbound: direction === 'outgoing', + inbound: direction === 'incoming', + ascending: sort === 'asc', + subscriptionId, + }) + + return new StarsStatus(res) +} diff --git a/packages/core/src/highlevel/methods/premium/iter-boosters.ts b/packages/core/src/highlevel/methods/premium/iter-boosters.ts index 30e9fe64..e4e9e172 100644 --- a/packages/core/src/highlevel/methods/premium/iter-boosters.ts +++ b/packages/core/src/highlevel/methods/premium/iter-boosters.ts @@ -9,8 +9,6 @@ import { getBoosts } from './get-boosts.js' * Iterate over boosters of a channel. * * Wrapper over {@link getBoosters} - * - * @returns IDs of stories that were removed */ export async function* iterBoosters( client: ITelegramClient, diff --git a/packages/core/src/highlevel/methods/premium/iter-stars-transactions.ts b/packages/core/src/highlevel/methods/premium/iter-stars-transactions.ts new file mode 100644 index 00000000..afea0583 --- /dev/null +++ b/packages/core/src/highlevel/methods/premium/iter-stars-transactions.ts @@ -0,0 +1,61 @@ +import type { ITelegramClient } from '../../client.types.js' +import type { InputPeerLike, StarsTransaction } from '../../types/index.js' +import { resolvePeer } from '../users/resolve-peer.js' + +import { getStarsTransactions } from './get-stars-transactions.js' + +/** + * Iterate over Telegram Stars transactions for a given peer. + * + * You can either pass `self` to get your own transactions, + * or a chat/bot ID to get transactions of that peer. + * + * Wrapper over {@link getStarsTransactions} + * + * @param peerId Peer ID + * @param params Additional parameters + */ +export async function* iterStarsTransactions( + client: ITelegramClient, + peerId: InputPeerLike, + params?: Parameters[2] & { + /** + * Total number of boosters to fetch + * + * @default Infinity, i.e. fetch all boosters + */ + limit?: number + + /** + * Number of boosters to fetch per request + * Usually you don't need to change this + * + * @default 100 + */ + chunkSize?: number + }, +): AsyncIterableIterator { + if (!params) params = {} + const { limit = Infinity, chunkSize = 100 } = params + + let { offset } = params + let current = 0 + + const peer = await resolvePeer(client, peerId) + + for (;;) { + const res = await getStarsTransactions(client, peer, { + offset, + limit: Math.min(limit - current, chunkSize), + }) + + for (const transaction of res.transactions) { + yield transaction + + if (++current >= limit) return + } + + if (!res.transactionsNextOffset) return + offset = res.transactionsNextOffset + } +} diff --git a/packages/core/src/highlevel/types/premium/index.ts b/packages/core/src/highlevel/types/premium/index.ts index 2e41e8c8..d9df24bb 100644 --- a/packages/core/src/highlevel/types/premium/index.ts +++ b/packages/core/src/highlevel/types/premium/index.ts @@ -6,3 +6,5 @@ export * from './business-chat-link.js' export * from './business-connection.js' export * from './business-intro.js' export * from './business-work-hours.js' +export * from './stars-transaction.js' +export * from './stars-status.js' diff --git a/packages/core/src/highlevel/types/premium/stars-status.ts b/packages/core/src/highlevel/types/premium/stars-status.ts new file mode 100644 index 00000000..4d72e6d8 --- /dev/null +++ b/packages/core/src/highlevel/types/premium/stars-status.ts @@ -0,0 +1,36 @@ +import type { tl } from '@mtcute/tl' + +import { makeInspectable } from '../../utils/inspectable.js' +import { memoizeGetters } from '../../utils/memoize.js' +import { PeersIndex } from '../peers/peers-index.js' + +import { StarsTransaction } from './stars-transaction.js' + +export class StarsStatus { + readonly peers: PeersIndex + constructor( + readonly raw: tl.payments.RawStarsStatus, + ) { + this.peers = PeersIndex.from(raw) + } + + /** Current Telegram Stars balance */ + get balance(): tl.Long { + return this.raw.balance + } + + /** + * History of Telegram Stars transactions + */ + get transactions(): StarsTransaction[] { + return this.raw.history?.map(it => new StarsTransaction(it, this.peers)) ?? [] + } + + /** Next offset of {@link transactions} for pagination */ + get transactionsNextOffset(): string | null { + return this.raw.nextOffset ?? null + } +} + +makeInspectable(StarsStatus) +memoizeGetters(StarsStatus, ['transactions']) diff --git a/packages/core/src/highlevel/types/premium/stars-transaction.ts b/packages/core/src/highlevel/types/premium/stars-transaction.ts new file mode 100644 index 00000000..5c501976 --- /dev/null +++ b/packages/core/src/highlevel/types/premium/stars-transaction.ts @@ -0,0 +1,250 @@ +import type { tl } from '@mtcute/tl' + +import type { PeersIndex } from '../peers/peers-index.js' +import { makeInspectable } from '../../utils/inspectable.js' +import { memoizeGetters } from '../../utils/memoize.js' +import type { Peer } from '../peers/peer.js' +import { parsePeer } from '../peers/peer.js' +import type { User } from '../peers/user.js' +import { type MessageMedia, _messageMediaFromTl } from '../messages/message-media.js' +import { WebDocument } from '../files/web-document.js' + +// ref: https://github.com/tdlib/td/blob/master/td/telegram/StarManager.cpp#L223 + +/** + * Type of the transaction. + * + * - `unsupported`: This transaction is not supported by the current version of client + * - `app_store`, `play_market`, `premium_bot`, `fragment`: This transaction is a purchase + * through App Store, Play Market, Premium Bot or Fragment respectively + * - `fragment_withdraw`: This transaction is a withdrawal via Fragment + * - `ads`: This transaction is with the Telegram Ads platform + * - `reaction`: This transaction is a paid reaction in a chat + * - `gift`: This transaction is a gift from a user + * - `bot_purchase`: This transaction is a purchase at a bot-operated store + * - `channel_subscription`: This transaction is a subscription to a channel + */ +export type StarsTransactionType = + | { type: 'unsupported' } + | { type: 'app_store' } + | { type: 'play_market' } + | { type: 'premium_bot' } + | { type: 'fragment' } + | { + type: 'fragment_withdraw' + status: 'pending' | 'success' | 'failed' + /** If successful, date of the withdrawal */ + date?: Date + /** If successful, URL of the withdrawal transaction */ + url?: string + } + | { type: 'ads' } + | { + type: 'reaction' + /** + * Related peer + * + * - For incoming transactions - user who sent the reaction + * - For outgoing transactions - channel which received the reaction + */ + peer: Peer + /** ID of the message containing the reaction */ + messageId: number + } + | { + type: 'gift' + /** User who sent the gift */ + user: User + } + | { + type: 'media_purchase' + /** + * Related peer + * + * - For incoming transactions - user who bought the media + * - For outgoing transactions - seller of the media + */ + peer: Peer + /** ID of the message containing the media */ + messageId: number + /** The bought media (available if not refunded) */ + media?: MessageMedia[] + } + | { + type: 'bot_purchase' + + /** + * Related user + * + * - For incoming transactions - user who bought the item + * - For outgoing transactions - the seller bot + */ + user: User + + /** Photo of the item, if available */ + photo?: WebDocument + + /** Title of the item */ + title: string + + /** Description of the item */ + description?: string + + /** Custom payload of the item */ + payload?: Uint8Array + } + | { + type: 'channel_subscription' + /** + * Related peer + * + * - For incoming transactions - user who subscribed to the channel + * - For outgoing transactions - channel which was subscribed to + */ + peer: Peer + + /** Period of the subscription, in seconds */ + period: number + } + +export class StarsTransaction { + constructor( + readonly raw: tl.RawStarsTransaction, + readonly peers: PeersIndex, + ) {} + + /** ID of the transaction */ + get id(): string { + return this.raw.id + } + + /** Whether this transaction is a refund */ + get isRefund(): boolean { + return this.raw.refund! + } + + /** + * Whether this transaction is outgoing or incoming + */ + get direction(): 'incoming' | 'outgoing' { + let isNegative = this.raw.stars.isNegative() + if (this.raw.refund) isNegative = !isNegative + + return isNegative ? 'outgoing' : 'incoming' + } + + /** Absolute amount of stars in the transaction */ + get amount(): tl.Long { + let res = this.raw.stars + + if (res.isNegative()) { + res = res.negate() + } + + return res + } + + /** Date of the transaction */ + get date(): Date { + return new Date(this.raw.date * 1000) + } + + /** Type of this transaction */ + get type(): StarsTransactionType { + switch (this.raw.peer._) { + case 'starsTransactionPeerAppStore': + return { type: 'app_store' } + case 'starsTransactionPeerPlayMarket': + return { type: 'play_market' } + case 'starsTransactionPeerPremiumBot': + return { type: 'premium_bot' } + case 'starsTransactionPeerFragment': { + if (this.raw.gift) { + return { type: 'fragment' } + } + + let status + if (this.raw.pending) { + status = 'pending' as const + } else if (this.raw.failed) { + status = 'failed' as const + } else { + status = 'success' as const + } + + return { + type: 'fragment_withdraw', + status, + date: this.raw.transactionDate + ? new Date(this.raw.transactionDate * 1000) + : undefined, + url: this.raw.transactionUrl, + } + } + case 'starsTransactionPeerAds': + return { type: 'ads' } + case 'starsTransactionPeer': { + const peer = parsePeer(this.raw.peer.peer, this.peers) + + if (this.raw.msgId) { + if (this.raw.reaction) { + return { + type: 'reaction', + peer, + messageId: this.raw.msgId, + } + } + + return { + type: 'media_purchase', + peer, + messageId: this.raw.msgId, + media: this.raw.extendedMedia + ? this.raw.extendedMedia.map(it => _messageMediaFromTl(this.peers, it)) + : undefined, + } + } + + if (this.raw.subscriptionPeriod) { + return { + type: 'channel_subscription', + peer, + period: this.raw.subscriptionPeriod, + } + } + + if (peer.type === 'user') { + if (this.raw.gift && !peer.isBot) { + return { type: 'gift', user: peer } + } + + if (this.raw.title + || this.raw.description + || this.raw.photo + || this.raw.botPayload) { + return { + type: 'bot_purchase', + user: peer, + title: this.raw.title ?? '', + description: this.raw.description, + payload: this.raw.botPayload, + photo: this.raw.photo + ? new WebDocument(this.raw.photo) + : undefined, + } + } + + return { type: 'unsupported' } + } + + // todo + return { type: 'unsupported' } + } + default: + return { type: 'unsupported' } + } + } +} + +makeInspectable(StarsTransaction) +memoizeGetters(StarsTransaction, ['amount', 'type'])