From fb5a7cfb5acb8340690e6009d7f912b5567a26b4 Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:15:58 +0200 Subject: [PATCH] Composer: Support guest bot mentions (#6961) --- src/api/gramjs/apiBuilders/misc.ts | 3 +- src/api/gramjs/methods/bots.ts | 64 --------- src/api/gramjs/methods/index.ts | 2 + src/api/gramjs/methods/topPeers.ts | 103 ++++++++++++++ src/api/gramjs/methods/users.ts | 19 --- src/api/types/misc.ts | 18 +++ src/components/common/Composer.tsx | 14 +- src/components/left/search/BotAppResults.tsx | 2 +- src/components/left/search/RecentContacts.tsx | 59 ++++---- src/components/main/Main.tsx | 8 +- .../middle/composer/AttachmentModal.tsx | 4 + .../composer/hooks/useMentionTooltip.ts | 6 +- .../modals/webApp/MoreAppsTabContent.tsx | 2 +- src/global/actions/all.ts | 1 + src/global/actions/api/bots.ts | 70 +++------- src/global/actions/api/topPeers.ts | 127 ++++++++++++++++++ src/global/actions/api/users.ts | 28 ---- src/global/actions/apiUpdaters/messages.ts | 45 +++++++ src/global/cache.ts | 17 ++- src/global/initialState.ts | 5 +- src/global/types/actions.ts | 19 ++- src/global/types/globalState.ts | 19 +-- src/lib/gramjs/client/MockClient.ts | 8 +- 23 files changed, 410 insertions(+), 233 deletions(-) create mode 100644 src/api/gramjs/methods/topPeers.ts create mode 100644 src/global/actions/api/topPeers.ts diff --git a/src/api/gramjs/apiBuilders/misc.ts b/src/api/gramjs/apiBuilders/misc.ts index 5eb377898..c6e9a11ab 100644 --- a/src/api/gramjs/apiBuilders/misc.ts +++ b/src/api/gramjs/apiBuilders/misc.ts @@ -211,7 +211,7 @@ export function buildApiUrlAuthResult(result: GramJs.TypeUrlAuthResult): ApiUrlA export function buildApiConfig(config: GramJs.Config): ApiConfig { const { testMode, expires, gifSearchUsername, chatSizeMax, autologinToken, reactionsDefault, - messageLengthMax, editTimeLimit, forwardedCountMax, + messageLengthMax, editTimeLimit, forwardedCountMax, ratingEDecay, } = config; const defaultReaction = reactionsDefault && buildApiReaction(reactionsDefault); return { @@ -224,6 +224,7 @@ export function buildApiConfig(config: GramJs.Config): ApiConfig { maxMessageLength: messageLengthMax, editTimeLimit, maxForwardedCount: forwardedCountMax, + ratingEDecay, }; } diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 2697bd634..d5440aef4 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -23,7 +23,6 @@ import { buildBotSwitchPm, buildBotSwitchWebview, } from '../apiBuilders/bots'; -import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildMessageMediaContent } from '../apiBuilders/messageContent'; import { buildApiUrlAuthResult } from '../apiBuilders/misc'; @@ -39,7 +38,6 @@ import { import { addDocumentToLocalDb, addPhotoToLocalDb, - addUserToLocalDb, addWebDocumentToLocalDb, } from '../helpers/localDb'; import { deserializeBytes } from '../helpers/misc'; @@ -61,68 +59,6 @@ export async function answerCallbackButton({ return result ? omitVirtualClassFields(result) : undefined; } -export async function fetchTopInlineBots() { - const topPeers = await invokeRequest(new GramJs.contacts.GetTopPeers({ - botsInline: true, - limit: DEFAULT_PRIMITIVES.INT, - offset: DEFAULT_PRIMITIVES.INT, - hash: DEFAULT_PRIMITIVES.BIGINT, - })); - - if (!(topPeers instanceof GramJs.contacts.TopPeers)) { - return undefined; - } - - const users = topPeers.users.map(buildApiUser).filter(Boolean); - const ids = users.map(({ id }) => id); - - return { - ids, - }; -} - -export async function fetchTopBotApps() { - const topPeers = await invokeRequest(new GramJs.contacts.GetTopPeers({ - botsApp: true, - limit: DEFAULT_PRIMITIVES.INT, - offset: DEFAULT_PRIMITIVES.INT, - hash: DEFAULT_PRIMITIVES.BIGINT, - })); - - if (!(topPeers instanceof GramJs.contacts.TopPeers)) { - return undefined; - } - - const users = topPeers.users.map(buildApiUser).filter(Boolean); - const ids = users.map(({ id }) => id); - - return { - ids, - }; -} - -export async function fetchInlineBot({ username }: { username: string }) { - const resolvedPeer = await invokeRequest(new GramJs.contacts.ResolveUsername({ username })); - - if ( - !resolvedPeer - || !( - resolvedPeer.users[0] instanceof GramJs.User - && resolvedPeer.users[0].bot - && resolvedPeer.users[0].botInlinePlaceholder - ) - ) { - return undefined; - } - - addUserToLocalDb(resolvedPeer.users[0]); - - return { - user: buildApiUser(resolvedPeer.users[0]), - chat: buildApiChatFromPreview(resolvedPeer.users[0]), - }; -} - export async function fetchInlineBotResults({ bot, chat, query, offset = DEFAULT_PRIMITIVES.STRING, }: { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index ccc59a325..34b447dbf 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -21,6 +21,8 @@ export * from './messages'; export * from './users'; +export * from './topPeers'; + export * from './symbols'; export * from './management'; diff --git a/src/api/gramjs/methods/topPeers.ts b/src/api/gramjs/methods/topPeers.ts new file mode 100644 index 000000000..314a7227c --- /dev/null +++ b/src/api/gramjs/methods/topPeers.ts @@ -0,0 +1,103 @@ +import { Api as GramJs } from '../../../lib/gramjs'; + +import type { + ApiPeer, + ApiTopPeer, + ApiTopPeerCategory, + ApiTopPeersResult, +} from '../../types'; + +import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; +import { buildInputPeer, DEFAULT_PRIMITIVES } from '../gramjsBuilders'; +import { addChatToLocalDb, addUserToLocalDb } from '../helpers/localDb'; +import { invokeRequest } from './client'; + +const TOP_PEER_LIMIT = 50; + +export async function fetchTopPeers({ + category, +}: { + category: ApiTopPeerCategory; +}): Promise { + const result = await invokeRequest(new GramJs.contacts.GetTopPeers({ + correspondents: category === 'correspondents' || undefined, + botsInline: category === 'botsInline' || undefined, + botsApp: category === 'botsApp' || undefined, + botsGuestchat: category === 'botsGuestChat' || undefined, + offset: DEFAULT_PRIMITIVES.INT, + limit: TOP_PEER_LIMIT, + hash: DEFAULT_PRIMITIVES.BIGINT, + })); + + if (result instanceof GramJs.contacts.TopPeersNotModified) { + return { type: 'unchanged' }; + } + + if (result instanceof GramJs.contacts.TopPeersDisabled) { + return { type: 'disabled' }; + } + + if (!(result instanceof GramJs.contacts.TopPeers)) { + return undefined; + } + + result.users.forEach(addUserToLocalDb); + result.chats.forEach((chat) => { + if (chat instanceof GramJs.Chat || chat instanceof GramJs.Channel) { + addChatToLocalDb(chat); + } + }); + + const topPeerCategory = result.categories.find(({ category: mtpCategory }) => { + return getTopPeerCategory(mtpCategory) === category; + }); + + const topPeers: ApiTopPeer[] = topPeerCategory + ? topPeerCategory.peers.map(({ peer, rating }) => ({ + peerId: getApiChatIdFromMtpPeer(peer), + rating, + })) : []; + + return { + type: 'topPeers', + category, + topPeers, + }; +} + +export function resetTopPeerRating({ category, peer }: { category: ApiTopPeerCategory; peer: ApiPeer }) { + return invokeRequest(new GramJs.contacts.ResetTopPeerRating({ + category: buildTopPeerCategory(category), + peer: buildInputPeer(peer.id, peer.accessHash), + })); +} + +function getTopPeerCategory(category: GramJs.TypeTopPeerCategory): ApiTopPeerCategory | undefined { + if (category instanceof GramJs.TopPeerCategoryCorrespondents) { + return 'correspondents'; + } + if (category instanceof GramJs.TopPeerCategoryBotsInline) { + return 'botsInline'; + } + if (category instanceof GramJs.TopPeerCategoryBotsApp) { + return 'botsApp'; + } + if (category instanceof GramJs.TopPeerCategoryBotsGuestChat) { + return 'botsGuestChat'; + } + + return undefined; +} + +function buildTopPeerCategory(category: ApiTopPeerCategory): GramJs.TypeTopPeerCategory { + switch (category) { + case 'correspondents': + return new GramJs.TopPeerCategoryCorrespondents(); + case 'botsInline': + return new GramJs.TopPeerCategoryBotsInline(); + case 'botsApp': + return new GramJs.TopPeerCategoryBotsApp(); + case 'botsGuestChat': + return new GramJs.TopPeerCategoryBotsGuestChat(); + } +} diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 1b5f3094c..8e18d1b3c 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -132,25 +132,6 @@ export async function fetchNearestCountry() { return dcInfo?.country; } -export async function fetchTopUsers() { - const topPeers = await invokeRequest(new GramJs.contacts.GetTopPeers({ - correspondents: true, - offset: DEFAULT_PRIMITIVES.INT, - limit: DEFAULT_PRIMITIVES.INT, - hash: DEFAULT_PRIMITIVES.BIGINT, - })); - if (!(topPeers instanceof GramJs.contacts.TopPeers)) { - return undefined; - } - - const users = topPeers.users.map(buildApiUser).filter((user): user is ApiUser => Boolean(user) && !user.isSelf); - const ids = users.map(({ id }) => id); - - return { - ids, - }; -} - export async function fetchContactList() { const result = await invokeRequest(new GramJs.contacts.GetContacts({ hash: DEFAULT_PRIMITIVES.BIGINT })); if (!result || result instanceof GramJs.contacts.ContactsNotModified) { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 77e48db6c..47ac03d65 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -351,8 +351,26 @@ export interface ApiConfig { maxMessageLength: number; editTimeLimit: number; maxForwardedCount: number; + ratingEDecay: number; } +export type ApiTopPeerCategory = 'correspondents' | 'botsInline' | 'botsApp' | 'botsGuestChat'; + +export type ApiTopPeer = { + peerId: string; + rating: number; +}; + +export type ApiTopPeersResult = { + type: 'topPeers'; + category: ApiTopPeerCategory; + topPeers: ApiTopPeer[]; +} | { + type: 'unchanged'; +} | { + type: 'disabled'; +}; + export interface ApiPromoData { expires: number; pendingSuggestions: string[]; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 288abce4e..643d45cd9 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -67,6 +67,7 @@ import { isChatSuperGroup, isSameReaction, isSystemBot, + isUserRightBanned, } from '../../global/helpers'; import { getChatNotifySettings } from '../../global/helpers/notifications'; import { getPeerTitle } from '../../global/helpers/peers'; @@ -269,6 +270,7 @@ type StateProps = { baseEmojiKeywords?: Record; emojiKeywords?: Record; topInlineBotIds?: string[]; + topGuestBotIds?: string[]; isInlineBotLoading: boolean; inlineBots?: Record; botCommands?: ApiBotCommand[] | false; @@ -387,6 +389,7 @@ const Composer = ({ stickersForEmoji, customEmojiForEmoji, topInlineBotIds, + topGuestBotIds, currentUserId, currentUser, captionLimit, @@ -591,6 +594,7 @@ const Composer = ({ ), [chat, chatFullInfo, isChatWithBot, isChatWithSelf, isInStoryViewer, paidMessagesStars, isInScheduledList], ); + const canUseInlineBots = !chat || isChatAdmin(chat) || !isUserRightBanned(chat, 'sendInline', chatFullInfo); const isNeedPremium = isContactRequirePremium && isInStoryViewer; const isSendTextBlocked = isNeedPremium || !canSendPlainText; @@ -844,7 +848,8 @@ const Composer = ({ getSelectionRange, inputRef, groupChatMembers, - topInlineBotIds, + canUseInlineBots ? topInlineBotIds : undefined, + topGuestBotIds, currentUserId, ); @@ -887,7 +892,7 @@ const Composer = ({ help: inlineBotHelp, loadMore: loadMoreForInlineBot, } = useInlineBotTooltip( - Boolean(isInMessageList && isReady && isForCurrentMessageList && !hasAttachments), + Boolean(canUseInlineBots && isInMessageList && isReady && isForCurrentMessageList && !hasAttachments), chatId, getHtml, inlineBots, @@ -1595,7 +1600,7 @@ const Composer = ({ const handleInlineBotSelect = useLastCallback(( inlineResult: ApiBotInlineResult | ApiBotInlineMediaResult, isSilent?: boolean, isScheduleRequested?: boolean, ) => { - if (!currentMessageList && !storyId) { + if ((!currentMessageList && !storyId) || !inlineBotId) { return; } @@ -2786,7 +2791,8 @@ export default memo(withGlobal( stickersForEmoji: global.stickers.forEmoji.stickers, customEmojiForEmoji: global.customEmojis.forEmoji.stickers, chatFullInfo, - topInlineBotIds: global.topInlineBots?.userIds, + topInlineBotIds: global.topPeerCategories.botsInline?.peerIds, + topGuestBotIds: global.topPeerCategories.botsGuestChat?.peerIds, currentUserId, currentUser, contentToBeScheduled: tabState.contentToBeScheduled, diff --git a/src/components/left/search/BotAppResults.tsx b/src/components/left/search/BotAppResults.tsx index 3f2092023..f558a53b3 100644 --- a/src/components/left/search/BotAppResults.tsx +++ b/src/components/left/search/BotAppResults.tsx @@ -152,6 +152,6 @@ export default memo(withGlobal((global): Complete => { return { isLoading: !foundIds && globalSearch.fetchingStatus?.botApps, foundIds, - recentBotIds: global.topBotApps.userIds, + recentBotIds: global.topPeerCategories.botsApp?.peerIds, }; })(BotAppResults)); diff --git a/src/components/left/search/RecentContacts.tsx b/src/components/left/search/RecentContacts.tsx index 4e7ca7731..f5eac2184 100644 --- a/src/components/left/search/RecentContacts.tsx +++ b/src/components/left/search/RecentContacts.tsx @@ -1,19 +1,20 @@ -import type { FC } from '../../../lib/teact/teact'; import { memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; -import type { ApiUser } from '../../../api/types'; +import type { GlobalState } from '../../../global/types'; -import { getUserFirstOrLastName } from '../../../global/helpers'; +import { getPeerTitle } from '../../../global/helpers/peers'; +import { selectPeer } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import { throttle } from '../../../util/schedulers'; import renderText from '../../common/helpers/renderText'; +import { useShallowSelector } from '../../../hooks/data/useSelector'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; -import useOldLang from '../../../hooks/useOldLang'; +import useLang from '../../../hooks/useLang'; import Avatar from '../../common/Avatar'; import Button from '../../ui/Button'; @@ -26,8 +27,7 @@ type OwnProps = { }; type StateProps = { - topUserIds?: string[]; - usersById: Record; + topPeerIds?: string[]; recentlyFoundChatIds?: string[]; }; @@ -36,28 +36,33 @@ const NBSP = '\u00A0'; const runThrottled = throttle((cb) => cb(), 60000, true); -const RecentContacts: FC = ({ - topUserIds, - usersById, +const RecentContacts = ({ + topPeerIds, recentlyFoundChatIds, onReset, -}) => { +}: OwnProps & StateProps) => { const { - loadTopUsers, openChat, + loadTopPeers, openChat, addRecentlyFoundChatId, clearRecentlyFoundChats, } = getActions(); - const topUsersRef = useRef(); + const topPeersRef = useRef(); // Due to the parent Transition, this component never gets unmounted, // that's why we use throttled API call on every update. useEffect(() => { runThrottled(() => { - loadTopUsers(); + loadTopPeers({ category: 'correspondents' }); }); - }, [loadTopUsers]); + }, [loadTopPeers]); - useHorizontalScroll(topUsersRef, !topUserIds); + const topPeersSelector = useCallback((global: GlobalState) => { + return topPeerIds?.map((peerId) => selectPeer(global, peerId)).filter(Boolean); + }, [topPeerIds]); + const topPeers = useShallowSelector(topPeersSelector); + const shouldRenderTopPeers = Boolean(topPeers?.length); + + useHorizontalScroll(topPeersRef, !shouldRenderTopPeers); const handleClick = useCallback((id: string) => { openChat({ id, shouldReplaceHistory: true }); @@ -71,22 +76,22 @@ const RecentContacts: FC = ({ clearRecentlyFoundChats(); }, [clearRecentlyFoundChats]); - const lang = useOldLang(); + const lang = useLang(); return (
- {topUserIds && ( + {shouldRenderTopPeers && (
-
- {topUserIds.map((userId) => ( +
+ {topPeers?.map((peer) => (
handleClick(userId)} + onClick={() => handleClick(peer.id)} dir={lang.isRtl ? 'rtl' : undefined} > - -
{renderText(getUserFirstOrLastName(usersById[userId]) || NBSP)}
+ +
{renderText(getPeerTitle(lang, peer) || NBSP)}
))}
@@ -97,7 +102,7 @@ const RecentContacts: FC = ({

@@ -129,13 +134,11 @@ const RecentContacts: FC = ({ export default memo(withGlobal( (global): Complete => { - const { userIds: topUserIds } = global.topPeers; - const usersById = global.users.byId; + const topPeerIds = global.topPeerCategories.correspondents?.peerIds; const { recentlyFoundChatIds } = global; return { - topUserIds, - usersById, + topPeerIds, recentlyFoundChatIds, }; }, diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 97d60ef27..8de4bb34d 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -213,7 +213,7 @@ const Main = ({ loadNotificationExceptions, updateIsOnline, onTabFocusChange, - loadTopInlineBots, + loadTopPeers, loadEmojiKeywords, loadCountryList, loadAvailableReactions, @@ -257,7 +257,6 @@ const Main = ({ loadQuickReplies, loadStarStatus, loadAvailableEffects, - loadTopBotApps, loadPaidReactionPrivacy, loadPasswordInfo, loadBotFreezeAppeal, @@ -327,13 +326,14 @@ const Main = ({ loadAttachBots(); loadNotificationSettings(); loadNotificationExceptions(); - loadTopInlineBots(); + loadTopPeers({ category: 'botsInline' }); loadTopReactions(); loadStarStatus(); loadEmojiKeywords({ language: BASE_EMOJI_KEYWORD_LANG }); loadFeaturedEmojiStickers(); loadSavedReactionTags(); - loadTopBotApps(); + loadTopPeers({ category: 'botsApp' }); + loadTopPeers({ category: 'botsGuestChat' }); loadPaidReactionPrivacy(); loadDefaultTopicIcons(); loadAnimatedEmojis(); diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 9a07029ba..a941f8164 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -103,6 +103,7 @@ type StateProps = { emojiKeywords?: Record; shouldSuggestCustomEmoji?: boolean; customEmojiForEmoji?: ApiSticker[]; + topGuestBotIds?: string[]; captionLimit: number; attachmentSettings: GlobalState['attachmentSettings']; shouldSaveAttachmentsCompression?: boolean; @@ -127,6 +128,7 @@ const AttachmentModal = ({ isChatWithSelf, currentUserId, groupChatMembers, + topGuestBotIds, recentEmojis, baseEmojiKeywords, emojiKeywords, @@ -283,6 +285,7 @@ const AttachmentModal = ({ inputRef, groupChatMembers, undefined, + topGuestBotIds, currentUserId, ); @@ -944,6 +947,7 @@ export default memo(withGlobal( isChatWithSelf, currentUserId, groupChatMembers: chatFullInfo?.members, + topGuestBotIds: global.topPeerCategories.botsGuestChat?.peerIds, recentEmojis, baseEmojiKeywords: baseEmojiKeywords?.keywords, emojiKeywords: emojiKeywords?.keywords, diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index 423ae1920..854f19160 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -39,6 +39,7 @@ export default function useMentionTooltip( inputRef: ElementRef, groupChatMembers?: ApiChatMember[], topInlineBotIds?: string[], + topGuestBotIds?: string[], currentUserId?: string, ) { const lang = useLang(); @@ -65,7 +66,7 @@ export default function useMentionTooltip( useEffect(() => { const usernameTag = getUsernameTag(); - if (!usernameTag || !(groupChatMembers || topInlineBotIds)) { + if (!usernameTag || !(groupChatMembers || topInlineBotIds || topGuestBotIds)) { setFilteredUsers(undefined); return; } @@ -90,13 +91,14 @@ export default function useMentionTooltip( ids: unique([ ...((getWithInlineBots() && topInlineBotIds) || []), ...(memberIds || []), + ...(topGuestBotIds || []), ]), query: filter, type: 'user', }); setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds))); - }, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]); + }, [currentUserId, groupChatMembers, topInlineBotIds, topGuestBotIds, getUsernameTag, getWithInlineBots]); const insertMention = useLastCallback(( peer: ApiPeer, diff --git a/src/components/modals/webApp/MoreAppsTabContent.tsx b/src/components/modals/webApp/MoreAppsTabContent.tsx index 3d1c794cb..e42818aa8 100644 --- a/src/components/modals/webApp/MoreAppsTabContent.tsx +++ b/src/components/modals/webApp/MoreAppsTabContent.tsx @@ -133,6 +133,6 @@ export default memo(withGlobal((global): Complete => { return { isLoading: !foundIds && globalSearch.fetchingStatus?.botApps, foundIds, - recentBotIds: global.topBotApps.userIds, + recentBotIds: global.topPeerCategories.botsApp?.peerIds, }; })(MoreAppsTabContent)); diff --git a/src/global/actions/all.ts b/src/global/actions/all.ts index 1c395c723..643734c39 100644 --- a/src/global/actions/all.ts +++ b/src/global/actions/all.ts @@ -9,6 +9,7 @@ import './api/sync'; import './api/accounts'; import './api/ai'; import './api/users'; +import './api/topPeers'; import './api/bots'; import './api/settings'; import './api/twoFaSettings'; diff --git a/src/global/actions/api/bots.ts b/src/global/actions/api/bots.ts index 682ef7e25..7e866bac4 100644 --- a/src/global/actions/api/bots.ts +++ b/src/global/actions/api/bots.ts @@ -23,12 +23,14 @@ import { formatStarsAsText } from '../../../util/localization/format'; import { oldTranslate } from '../../../util/oldLangProvider'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { debounce } from '../../../util/schedulers'; -import { getServerTime } from '../../../util/serverTime'; import { extractCurrentThemeParams } from '../../../util/themeStyle'; import { callApi } from '../../../api/gramjs'; import { getMainUsername, getWebAppKey, + isChatAdmin, + isUserBot, + isUserRightBanned, prepareMessageReplyInfo, } from '../../helpers'; import { @@ -52,6 +54,7 @@ import { updateTabState } from '../../reducers/tabs'; import { selectBot, selectChat, + selectChatFullInfo, selectChatLastMessageId, selectChatMessage, selectCurrentChat, @@ -73,10 +76,13 @@ import { getPeerStarsForMessage } from './messages'; import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout'; -const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const runDebouncedForSearch = debounce((cb) => cb(), 500, false); let botFatherId: string | null; +function canUseInlineBots(global: T, chat: ApiChat) { + return isChatAdmin(chat) || !isUserRightBanned(chat, 'sendInline', selectChatFullInfo(global, chat.id)); +} + addActionHandler('clickSuggestedMessageButton', (global, actions, payload): ActionReturnType => { const { chatId, messageId, button, tabId = getCurrentTabId(), @@ -285,71 +291,26 @@ addActionHandler('restartBot', async (global, actions, payload): Promise = void sendBotCommand(chat, MAIN_THREAD_ID, '/start', undefined, selectSendAs(global, chatId), lastMessageId); }); -addActionHandler('loadTopInlineBots', async (global): Promise => { - const { lastRequestedAt } = global.topInlineBots; - if (lastRequestedAt && getServerTime() - lastRequestedAt < TOP_PEERS_REQUEST_COOLDOWN) { - return; - } - - const result = await callApi('fetchTopInlineBots'); - if (!result) { - return; - } - - const { ids } = result; - - global = getGlobal(); - global = { - ...global, - topInlineBots: { - ...global.topInlineBots, - userIds: ids, - lastRequestedAt: getServerTime(), - }, - }; - setGlobal(global); -}); - -addActionHandler('loadTopBotApps', async (global): Promise => { - const { lastRequestedAt } = global.topBotApps; - if (lastRequestedAt && getServerTime() - lastRequestedAt < TOP_PEERS_REQUEST_COOLDOWN) { - return; - } - - const result = await callApi('fetchTopBotApps'); - if (!result) { - return; - } - - const { ids } = result; - - global = getGlobal(); - global = { - ...global, - topBotApps: { - ...global.topBotApps, - userIds: ids, - lastRequestedAt: getServerTime(), - }, - }; - setGlobal(global); -}); - addActionHandler('queryInlineBot', async (global, actions, payload): Promise => { const { chatId, username, query, offset, tabId = getCurrentTabId(), } = payload; + const chat = selectChat(global, chatId); + if (!chat || !canUseInlineBots(global, chat)) { + return; + } + let inlineBotData = selectTabState(global, tabId).inlineBots.byUsername[username]; if (inlineBotData === false) { return; } if (inlineBotData === undefined) { - const { user: inlineBot, chat } = await callApi('fetchInlineBot', { username }) || {}; + const { user: inlineBot } = await callApi('getChatByUsername', username) || {}; global = getGlobal(); - if (!inlineBot || !chat) { + if (!inlineBot || !isUserBot(inlineBot) || !inlineBot.botPlaceholder) { global = replaceInlineBotSettings(global, username, false, tabId); setGlobal(global); return; @@ -818,6 +779,7 @@ addActionHandler('requestMainWebView', async (global, actions, payload): Promise }; global = addWebAppToOpenList(global, newActiveApp, true, true, tabId); setGlobal(global); + actions.bumpTopPeerRating({ category: 'botsApp', peerId: botId }); if (isFullscreen && getIsWebAppsFullscreenSupported()) { actions.changeWebAppModalState({ state: 'fullScreen', tabId }); diff --git a/src/global/actions/api/topPeers.ts b/src/global/actions/api/topPeers.ts new file mode 100644 index 000000000..3b714239f --- /dev/null +++ b/src/global/actions/api/topPeers.ts @@ -0,0 +1,127 @@ +import type { ApiTopPeerCategory } from '../../../api/types'; +import type { ActionReturnType, GlobalState } from '../../types'; + +import { unique } from '../../../util/iteratees'; +import { getServerTime } from '../../../util/serverTime'; +import { callApi } from '../../../api/gramjs'; +import { addActionHandler, getGlobal, setGlobal } from '../../index'; +import { selectPeer } from '../../selectors'; + +const TOP_PEERS_CACHE_TTL = 24 * 60 * 60; // 24 hours + +addActionHandler('loadTopPeers', async (global, actions, payload): Promise => { + const { category, force } = payload; + const current = global.topPeerCategories[category]; + const now = getServerTime(); + + if (!force && current?.lastRequestedAt && now - current.lastRequestedAt < TOP_PEERS_CACHE_TTL) { + return; + } + + const result = await callApi('fetchTopPeers', { category }); + if (!result) { + return; + } + + global = getGlobal(); + const nextNow = getServerTime(); + const nextCurrent = global.topPeerCategories[category]; + + if (result.type === 'unchanged') { + global = updateTopPeerCategory(global, category, { + ...nextCurrent, + peerIds: nextCurrent?.peerIds || [], + ratingsByPeerId: nextCurrent?.ratingsByPeerId || {}, + lastRequestedAt: nextNow, + }); + setGlobal(global); + return; + } + + if (result.type === 'disabled') { + global = updateTopPeerCategory(global, category, { + peerIds: [], + ratingsByPeerId: {}, + lastRequestedAt: nextNow, + isDisabled: true, + }); + setGlobal(global); + return; + } + + const ratingsByPeerId = result.topPeers.reduce((acc, { peerId, rating }) => { + acc[peerId] = rating; + return acc; + }, {} as Record); + const peerIds = result.topPeers.map(({ peerId }) => peerId); + + global = updateTopPeerCategory(global, category, { + peerIds, + ratingsByPeerId, + lastRequestedAt: nextNow, + isDisabled: undefined, + }); + setGlobal(global); +}); + +addActionHandler('removeTopPeer', async (global, actions, payload): Promise => { + const { category, peerId } = payload; + const current = global.topPeerCategories[category]; + if (!current) { + return; + } + + const peerIds = current.peerIds.filter((id) => id !== peerId); + const { [peerId]: removedRating, ...ratingsByPeerId } = current.ratingsByPeerId; + + global = updateTopPeerCategory(global, category, { + ...current, + peerIds, + ratingsByPeerId, + }); + setGlobal(global); + + const peer = selectPeer(global, peerId); + if (peer) { + await callApi('resetTopPeerRating', { category, peer }); + } +}); + +addActionHandler('bumpTopPeerRating', (global, actions, payload): ActionReturnType => { + const { category, peerId, date } = payload; + const current = global.topPeerCategories[category]; + const ratingEDecay = global.config?.ratingEDecay; + if (!ratingEDecay || current?.isDisabled) { + return; + } + + const ratingDate = date || getServerTime(); + const basePeerIds = current?.peerIds || []; + const peerIds = unique([...basePeerIds, peerId]); + const ratingsByPeerId = { ...current?.ratingsByPeerId }; + const normalizeRate = current?.lastRequestedAt || ratingDate; + + ratingsByPeerId[peerId] = (ratingsByPeerId[peerId] || 0) + Math.exp((ratingDate - normalizeRate) / ratingEDecay); + peerIds.sort((firstId, secondId) => (ratingsByPeerId[secondId] || 0) - (ratingsByPeerId[firstId] || 0)); + + return updateTopPeerCategory(global, category, { + peerIds, + ratingsByPeerId, + lastRequestedAt: normalizeRate, + isDisabled: undefined, + }); +}); + +function updateTopPeerCategory( + global: T, + category: ApiTopPeerCategory, + categoryState: NonNullable, +): T { + return { + ...global, + topPeerCategories: { + ...global.topPeerCategories, + [category]: categoryState, + }, + }; +} diff --git a/src/global/actions/api/users.ts b/src/global/actions/api/users.ts index 08a69df9a..fca2d2ece 100644 --- a/src/global/actions/api/users.ts +++ b/src/global/actions/api/users.ts @@ -8,7 +8,6 @@ import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey, unique } from '../../../util/iteratees'; import * as langProvider from '../../../util/oldLangProvider'; import { throttle } from '../../../util/schedulers'; -import { getServerTime } from '../../../util/serverTime'; import { callApi } from '../../../api/gramjs'; import { isUserBot } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal } from '../../index'; @@ -43,7 +42,6 @@ import { } from '../../selectors'; const PROFILE_PHOTOS_FIRST_LOAD_LIMIT = 10; -const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min const runThrottledForSearch = throttle((cb) => cb(), 500, false); addActionHandler('loadFullUser', async (global, actions, payload): Promise => { @@ -105,32 +103,6 @@ addActionHandler('loadUser', async (global, actions, payload): Promise => setGlobal(global); }); -addActionHandler('loadTopUsers', async (global): Promise => { - const { topPeers: { lastRequestedAt } } = global; - - if (!(!lastRequestedAt || getServerTime() - lastRequestedAt > TOP_PEERS_REQUEST_COOLDOWN)) { - return; - } - - const result = await callApi('fetchTopUsers'); - if (!result) { - return; - } - - const { ids } = result; - - global = getGlobal(); - global = { - ...global, - topPeers: { - ...global.topPeers, - userIds: ids, - lastRequestedAt: getServerTime(), - }, - }; - setGlobal(global); -}); - addActionHandler('loadContactList', async (global): Promise => { const contactList = await callApi('fetchContactList'); if (!contactList) { diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 22bc9a820..c88814b49 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -30,7 +30,9 @@ import { getMessageText, groupMessageIdsByThreadId, isActionMessage, + isDeletedUser, isMessageLocal, + isUserBot, pickMatchingTypingDraftMessage, } from '../../helpers'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../helpers/replies'; @@ -99,6 +101,7 @@ import { selectTabState, selectTopic, selectTopicFromMessage, + selectUser, selectViewportIds, } from '../../selectors'; import { @@ -179,6 +182,25 @@ function removeTypingDraftEntries( return global; } +function shouldBumpGuestBotTopPeer(global: T, message: ApiMessage) { + const { guestChatViaId, senderId } = message; + if (message.isOutgoing || message.content.action || guestChatViaId !== global.currentUserId || !senderId) { + return false; + } + + const sender = selectUser(global, senderId); + return Boolean(sender?.isGuestChatBot); +} + +function shouldBumpInlineBotTopPeer(message: ApiMessage) { + return Boolean(message.isOutgoing && !message.content.action && message.viaBotId); +} + +function shouldBumpCorrespondentTopPeer(global: T, chatId: string) { + const user = selectUser(global, chatId); + return Boolean(user && !user.isSelf && !isUserBot(user) && !isDeletedUser(user)); +} + addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { switch (update['@type']) { case 'newMessage': { @@ -345,6 +367,22 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { setGlobal(global); + if (shouldBumpGuestBotTopPeer(global, newMessage)) { + actions.bumpTopPeerRating({ + category: 'botsGuestChat', + peerId: newMessage.senderId!, + date: newMessage.date, + }); + } + + if (shouldBumpInlineBotTopPeer(newMessage)) { + actions.bumpTopPeerRating({ + category: 'botsInline', + peerId: newMessage.viaBotId!, + date: newMessage.date, + }); + } + // Reload dialogs if chat is not present in the list if (!isLocal && !chat?.isNotJoined && !selectIsChatListed(global, chatId)) { actions.loadTopChats(); @@ -690,6 +728,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { setGlobal(global); + if (shouldBumpCorrespondentTopPeer(global, chatId)) { + actions.bumpTopPeerRating({ category: 'correspondents', peerId: chatId }); + } + break; } @@ -725,6 +767,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } setGlobal(global); + if (shouldBumpCorrespondentTopPeer(global, chatId)) { + actions.bumpTopPeerRating({ category: 'correspondents', peerId: chatId }); + } break; } diff --git a/src/global/cache.ts b/src/global/cache.ts index 1fd8d6a32..4ae6bdd09 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -252,8 +252,8 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { if (!cached.chats.loadingParameters) { cached.chats.loadingParameters = initialState.chats.loadingParameters; } - if (!cached.topBotApps) { - cached.topBotApps = initialState.topBotApps; + if (!cached.topPeerCategories) { + cached.topPeerCategories = initialState.topPeerCategories; } if (!cached.reactions.defaultTags?.[0]?.type) { @@ -443,9 +443,7 @@ function reduceGlobal(global: T) { 'attachMenu', 'currentUserId', 'contactList', - 'topPeers', - 'topInlineBots', - 'topBotApps', + 'topPeerCategories', 'recentEmojis', 'recentCustomEmojis', 'push', @@ -560,6 +558,7 @@ function reduceUsers(global: T): GlobalState['users'] { .filter((id): id is string => Boolean(id) && isUserId(id)); const attachBotIds = Object.keys(global.attachMenu?.bots || {}); + const topPeerIds = getTopPeerIds(global); const idsToSave = unique([ ...currentUserId ? [currentUserId] : [], @@ -567,7 +566,7 @@ function reduceUsers(global: T): GlobalState['users'] { ...chatStoriesUserIds, ...visibleUserIds || [], ...attachBotIds, - ...global.topPeers.userIds || [], + ...topPeerIds.filter(isUserId), ...global.recentlyFoundChatIds?.filter(isUserId) || [], ...getOrderedIds(ARCHIVED_FOLDER_ID)?.slice(0, GLOBAL_STATE_CACHE_ARCHIVED_CHAT_LIST_LIMIT).filter(isUserId) || [], ...getOrderedIds(ALL_FOLDER_ID)?.filter(isUserId) || [], @@ -608,11 +607,13 @@ function reduceChats(global: T): GlobalState['chats'] { return content.storyData?.peerId || webPage?.story?.peerId || replyPeer; }); })); + const topPeerIds = getTopPeerIds(global); const unlinkedIdsToSave = [ ...currentUserId ? [currentUserId] : [], ...currentChatIds, ...messagesChatIds, + ...topPeerIds, ...global.recentlyFoundChatIds || [], ...getOrderedIds(ARCHIVED_FOLDER_ID)?.slice(0, GLOBAL_STATE_CACHE_ARCHIVED_CHAT_LIST_LIMIT) || [], ...getOrderedIds(ALL_FOLDER_ID) || [], @@ -652,6 +653,10 @@ function reduceChats(global: T): GlobalState['chats'] { }; } +function getTopPeerIds(global: T) { + return unique(Object.values(global.topPeerCategories).flatMap((category) => category?.peerIds || [])); +} + function reduceMessages(global: T): GlobalState['messages'] { const { currentUserId } = global; const byChatId: GlobalState['messages']['byChatId'] = {}; diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 7227a1bd2..bf7a2d073 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -268,10 +268,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { saved: {}, }, - topPeers: {}, - - topInlineBots: {}, - topBotApps: {}, + topPeerCategories: {}, activeSessions: { byHash: {}, diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index ebe4b1de4..0c22efe75 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -58,6 +58,7 @@ import type { ApiStickerSetInfo, ApiThemeParameters, ApiTodoItem, + ApiTopPeerCategory, ApiTypeCurrencyAmount, ApiTypePrepaidGiveaway, ApiUpdate, @@ -1914,7 +1915,6 @@ export interface ActionPayloads { // Users loadNearestCountry: undefined; - loadTopUsers: undefined; loadContactList: undefined; loadCurrentUser: undefined; @@ -2164,8 +2164,19 @@ export interface ActionPayloads { command: string; chatId?: string; } & WithTabId; - loadTopInlineBots: undefined; - loadTopBotApps: undefined; + loadTopPeers: { + category: ApiTopPeerCategory; + force?: boolean; + }; + removeTopPeer: { + category: ApiTopPeerCategory; + peerId: string; + }; + bumpTopPeerRating: { + category: ApiTopPeerCategory; + peerId: string; + date?: number; + }; queryInlineBot: { chatId: string; username: string; @@ -2175,6 +2186,7 @@ export interface ActionPayloads { sendInlineBotResult: { id: string; queryId: string; + botId?: string; chatId: string; threadId: ThreadId; isSilent?: boolean; @@ -2185,6 +2197,7 @@ export interface ActionPayloads { chat: ApiChat; id: string; queryId: string; + botId?: string; replyInfo?: ApiInputMessageReplyInfo; sendAs?: ApiPeer; isSilent?: boolean; diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index 738a9cd5e..358848c7d 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -46,6 +46,7 @@ import type { ApiStoryAlbum, ApiTimezone, ApiTonAmount, + ApiTopPeerCategory, ApiTranscription, ApiUpdateAuthorizationStateType, ApiUpdateConnectionStateType, @@ -418,20 +419,12 @@ export type GlobalState = { }; }; - topPeers: { - userIds?: string[]; + topPeerCategories: Partial; lastRequestedAt?: number; - }; - - topInlineBots: { - userIds?: string[]; - lastRequestedAt?: number; - }; - - topBotApps: { - userIds?: string[]; - lastRequestedAt?: number; - }; + isDisabled?: boolean; + }>>; activeSessions: { byHash: Record; diff --git a/src/lib/gramjs/client/MockClient.ts b/src/lib/gramjs/client/MockClient.ts index 90be5f75c..9cb58f6a8 100644 --- a/src/lib/gramjs/client/MockClient.ts +++ b/src/lib/gramjs/client/MockClient.ts @@ -145,7 +145,13 @@ class TelegramClient { if (request instanceof Api.contacts.GetTopPeers) { return new Api.contacts.TopPeers({ categories: [new Api.TopPeerCategoryPeers({ - category: new Api.TopPeerCategoryCorrespondents(), + category: request.botsInline + ? new Api.TopPeerCategoryBotsInline() + : request.botsApp + ? new Api.TopPeerCategoryBotsApp() + : request.botsGuestchat + ? new Api.TopPeerCategoryBotsGuestChat() + : new Api.TopPeerCategoryCorrespondents(), count: this.mockData.topPeers.length, peers: this.mockData.topPeers.map((id) => { return new Api.TopPeer({