Management: Allow to edit bot info (#4178)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2024-01-18 18:18:28 +01:00
parent 13846c0d42
commit 21aefeffef
19 changed files with 445 additions and 9 deletions

View File

@ -71,6 +71,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
type: userType,
firstName,
lastName,
canEditBot: Boolean(mtpUser.botCanEdit),
...(userType === 'userTypeBot' && { canBeInvitedToGroup: !mtpUser.botNochats }),
...(usernames && { usernames }),
phoneNumber: mtpUser.phone || '',

View File

@ -564,3 +564,27 @@ function addPhotoToLocalDb(photo: GramJs.Photo) {
function addWebDocumentToLocalDb(webDocument: GramJs.TypeWebDocument) {
localDb.webDocuments[webDocument.url] = webDocument;
}
export function setBotInfo({
bot,
langCode,
name,
about,
description,
}: {
bot: ApiUser;
langCode: string;
name?: string;
about?: string;
description?: string;
}) {
return invokeRequest(new GramJs.bots.SetBotInfo({
bot: buildInputPeer(bot.id, bot.accessHash),
langCode,
name: name || '',
about: about || '',
description: description || '',
}), {
shouldReturnTrue: true,
});
}

View File

@ -75,7 +75,8 @@ export {
} from './twoFaSettings';
export {
answerCallbackButton, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults, sendInlineBotResult, startBot,
answerCallbackButton, setBotInfo, fetchTopInlineBots, fetchInlineBot, fetchInlineBotResults,
sendInlineBotResult, startBot,
requestWebView, requestSimpleWebView, sendWebViewData, prolongWebView, loadAttachBots, toggleAttachBot, fetchBotApp,
requestBotUrlAuth, requestLinkUrlAuth, acceptBotUrlAuth, acceptLinkUrlAuth, loadAttachBot, requestAppWebView,
allowBotSendMessages, fetchBotCanSendMessage, invokeWebViewCustomMethod,

View File

@ -109,9 +109,12 @@ export async function updateProfilePhoto(photo?: ApiPhoto, isFallback?: boolean)
return undefined;
}
export async function uploadProfilePhoto(file: File, isFallback?: boolean, isVideo = false, videoTs = 0) {
export async function uploadProfilePhoto(
file: File, isFallback?: boolean, isVideo = false, videoTs = 0, bot?: ApiUser,
) {
const inputFile = await uploadFile(file);
const result = await invokeRequest(new GramJs.photos.UploadProfilePhoto({
...(bot ? { bot: buildInputPeer(bot.id, bot.accessHash) } : undefined),
...(isVideo ? { video: inputFile, videoStartTs: videoTs } : { file: inputFile }),
...(isFallback ? { fallback: true } : undefined),
}));

View File

@ -37,6 +37,7 @@ export interface ApiUser {
hasUnreadStories?: boolean;
maxStoryId?: number;
color?: ApiPeerColor;
canEditBot?: boolean;
}
export interface ApiUserFullInfo {

View File

@ -31,6 +31,7 @@ import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import Icon from '../common/Icon';
import Button from '../ui/Button';
import ConfirmDialog from '../ui/ConfirmDialog';
import SearchInput from '../ui/SearchInput';
@ -75,6 +76,7 @@ type StateProps = {
currentInviteInfo?: ApiExportedInvite;
shouldSkipHistoryAnimations?: boolean;
isBot?: boolean;
canEditBot?: boolean;
isInsideTopic?: boolean;
canEditTopic?: boolean;
};
@ -157,6 +159,7 @@ const RightHeader: FC<OwnProps & StateProps> = ({
canEditTopic,
onClose,
onScreenSelect,
canEditBot,
}) => {
const {
setLocalTextSearchQuery,
@ -494,6 +497,17 @@ const RightHeader: FC<OwnProps & StateProps> = ({
<i className="icon icon-edit" />
</Button>
)}
{canEditBot && (
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Edit')}
onClick={handleToggleManagement}
>
<Icon name="edit" />
</Button>
)}
{canEditTopic && (
<Button
round
@ -580,6 +594,7 @@ export default withGlobal<OwnProps>(
const topic = isInsideTopic ? chat.topics?.[threadId!] : undefined;
const canEditTopic = isInsideTopic && topic && getCanManageTopic(chat, topic);
const isBot = user && isUserBot(user);
const canEditBot = isBot && user?.canEditBot;
const canAddContact = user && getCanAddContact(user);
const canManage = Boolean(!isManagement && isProfile && chatId && selectCanManage(global, chatId));
@ -607,6 +622,7 @@ export default withGlobal<OwnProps>(
isEditingInvite,
currentInviteInfo,
shouldSkipHistoryAnimations: tabState.shouldSkipHistoryAnimations,
canEditBot,
};
},
)(RightHeader);

View File

@ -0,0 +1,267 @@
import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiBotInfo, ApiUser } from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { ManagementProgress } from '../../../types';
import {
getChatAvatarHash, getMainUsername, getUserFirstOrLastName,
} from '../../../global/helpers';
import {
selectBot,
selectTabState,
selectUserFullInfo,
} from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import Icon from '../../common/Icon';
import AvatarEditable from '../../ui/AvatarEditable';
import FloatingActionButton from '../../ui/FloatingActionButton';
import InputText from '../../ui/InputText';
import ListItem from '../../ui/ListItem';
import SelectAvatar from '../../ui/SelectAvatar';
import Spinner from '../../ui/Spinner';
import TextArea from '../../ui/TextArea';
import './Management.scss';
type OwnProps = {
userId: string;
onClose: NoneToVoidFunction;
isActive: boolean;
};
type StateProps = {
userId?: string;
user?: ApiUser;
chatBot?: ApiBotInfo;
currentBio?: string;
progress?: ManagementProgress;
isMuted?: boolean;
maxBioLength: number;
};
const ERROR_NAME_MISSING = 'Please provide name';
const ManageBot: FC<OwnProps & StateProps> = ({
userId,
user,
progress,
onClose,
currentBio,
isActive,
maxBioLength,
}) => {
const {
setBotInfo,
uploadProfilePhoto,
uploadContactProfilePhoto,
startBotFatherConversation,
} = getActions();
const [isFieldTouched, markFieldTouched, unmarkProfileTouched] = useFlag(false);
const [isAvatarTouched, markAvatarTouched, unmarkAvatarTouched] = useFlag(false);
const [error, setError] = useState<string | undefined>();
const lang = useLang();
const username = useMemo(() => (user ? getMainUsername(user) : undefined), [user]);
useHistoryBack({
isActive,
onBack: onClose,
});
const currentName = user ? getUserFirstOrLastName(user) : '';
const [photo, setPhoto] = useState<File | undefined>();
const [name, setName] = useState(currentName || '');
const [bio, setBio] = useState(currentBio || '');
const currentAvatarHash = user && getChatAvatarHash(user);
const currentAvatarBlobUrl = useMedia(currentAvatarHash, false, ApiMediaFormat.BlobUrl);
useEffect(() => {
unmarkProfileTouched();
unmarkAvatarTouched();
}, [userId]);
useEffect(() => {
setName(currentName || '');
setBio(currentBio || '');
}, [currentName, currentBio, user]);
useEffect(() => {
setPhoto(undefined);
}, [currentAvatarBlobUrl]);
useEffect(() => {
if (progress === ManagementProgress.Complete) {
unmarkProfileTouched();
unmarkAvatarTouched();
setError(undefined);
}
}, [progress]);
const handleNameChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
markFieldTouched();
if (error === ERROR_NAME_MISSING) {
setError(undefined);
}
});
const handleBioChange = useLastCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setBio(e.target.value);
markFieldTouched();
});
const handlePhotoChange = useLastCallback((newPhoto: File) => {
setPhoto(newPhoto);
markAvatarTouched();
});
const handleProfileSave = useLastCallback(() => {
const trimmedName = name.trim();
const trimmedBio = bio.trim();
if (!trimmedName.length) {
setError(ERROR_NAME_MISSING);
return;
}
setBotInfo({
...(isFieldTouched && {
bot: user,
name: trimmedName,
description: trimmedBio,
}),
});
if (photo) {
uploadProfilePhoto({
file: photo,
...(isAvatarTouched && { bot: user }),
});
}
});
const handleChangeEditIntro = useLastCallback(() => {
startBotFatherConversation({ param: `${username}-intro` });
});
const handleChangeEditCommands = useLastCallback(() => {
startBotFatherConversation({ param: `${username}-commands` });
});
const handleChangeSettings = useLastCallback(() => {
startBotFatherConversation({ param: `${username}` });
});
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLInputElement>(null);
const isSuggestRef = useRef(false);
const handleSelectAvatar = useLastCallback((file: File) => {
markAvatarTouched();
uploadContactProfilePhoto({ userId, file, isSuggest: isSuggestRef.current });
});
if (!user) {
return undefined;
}
const isLoading = progress === ManagementProgress.InProgress;
return (
<div className="Management">
<div className="custom-scroll">
<div className="section">
<AvatarEditable
currentAvatarBlobUrl={currentAvatarBlobUrl}
onChange={handlePhotoChange}
title={lang('ChatSetPhotoOrVideo')}
disabled={isLoading}
/>
<InputText
id="user-name"
label={lang('PaymentCheckoutName')}
onChange={handleNameChange}
value={name}
error={error === ERROR_NAME_MISSING ? error : undefined}
teactExperimentControlled
/>
<TextArea
value={bio}
onChange={handleBioChange}
label={lang('DescriptionPlaceholder')}
disabled={isLoading}
maxLength={maxBioLength}
maxLengthIndicator={maxBioLength ? (maxBioLength - bio.length).toString() : undefined}
/>
</div>
<div className="section">
<div className="dialog-buttons">
<ListItem icon="bot-commands-filled" ripple onClick={handleChangeEditIntro}>
<span>{lang('BotEditIntro')}</span>
</ListItem>
<ListItem icon="bot-command" ripple onClick={handleChangeEditCommands}>
<span>{lang('BotEditCommands')}</span>
</ListItem>
<ListItem icon="bots" ripple onClick={handleChangeSettings}>
<span>{lang('BotChangeSettings')}</span>
</ListItem>
<div className="section-info section-info_push">
{renderText(lang('BotManageInfo'), ['links'])}
</div>
</div>
</div>
</div>
<FloatingActionButton
isShown={isFieldTouched || isAvatarTouched}
onClick={handleProfileSave}
disabled={isLoading}
ariaLabel={lang('Save')}
>
{isLoading ? (
<Spinner color="white" />
) : (
<Icon name="check" />
)}
</FloatingActionButton>
<SelectAvatar
onChange={handleSelectAvatar}
inputRef={inputRef}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => {
const user = selectBot(global, userId);
const userFullInfo = selectUserFullInfo(global, userId);
const { progress } = selectTabState(global).management;
const maxBioLength = selectCurrentLimit(global, 'aboutLength');
return {
userId,
user,
progress,
currentBio: userFullInfo?.bio,
maxBioLength,
};
},
)(ManageBot));

View File

@ -161,6 +161,10 @@
}
}
.button-position {
justify-content: initial;
}
&__filter {
padding: 0 1rem 0.25rem 0.75rem;
margin-bottom: 0.625rem;

View File

@ -7,6 +7,7 @@ import { ManagementScreens } from '../../../types';
import { selectCurrentManagementType } from '../../../global/selectors';
import ManageBot from './ManageBot';
import ManageChannel from './ManageChannel';
import ManageChatAdministrators from './ManageChatAdministrators';
import ManageChatPrivacyType from './ManageChatPrivacyType';
@ -54,6 +55,15 @@ const Management: FC<OwnProps & StateProps> = ({
switch (currentScreen) {
case ManagementScreens.Initial: {
switch (managementType) {
case 'bot':
return (
<ManageBot
key={chatId}
userId={chatId}
onClose={onClose}
isActive={isActive}
/>
);
case 'user':
return (
<ManageUser

View File

@ -272,6 +272,7 @@ export const RE_TG_LINK = /^tg:(\/\/)?/i;
export const RE_TME_LINK = /^(https?:\/\/)?([-a-zA-Z0-9@:%_+~#=]{1,32}\.)?t\.me/i;
export const RE_TELEGRAM_LINK = /^(https?:\/\/)?telegram\.org\//i;
export const TME_LINK_PREFIX = 'https://t.me/';
export const BOT_FATHER_USERNAME = 'botfather';
export const USERNAME_PURCHASE_ERROR = 'USERNAME_PURCHASE_AVAILABLE';
export const PURCHASE_USERNAME = 'auction';
export const TME_WEB_DOMAINS = new Set(['t.me', 'web.t.me', 'a.t.me', 'k.t.me', 'z.t.me']);

View File

@ -5,8 +5,9 @@ import {
type ApiChat, type ApiChatType, type ApiContact, type ApiInputMessageReplyInfo, type ApiPeer, type ApiUrlAuthResult,
MAIN_THREAD_ID,
} from '../../../api/types';
import { ManagementProgress } from '../../../types';
import { GENERAL_REFETCH_INTERVAL } from '../../../config';
import { BOT_FATHER_USERNAME, GENERAL_REFETCH_INTERVAL } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey } from '../../../util/iteratees';
import { translate } from '../../../util/langProvider';
@ -19,17 +20,21 @@ import { callApi } from '../../../api/gramjs';
import {
addActionHandler, getGlobal, setGlobal,
} from '../../index';
import { addChats, addUsers, removeBlockedUser } from '../../reducers';
import {
addChats, addUsers, removeBlockedUser, updateManagementProgress, updateUser, updateUserFullInfo,
} from '../../reducers';
import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots';
import { updateTabState } from '../../reducers/tabs';
import {
selectBot, selectChat, selectChatMessage, selectCurrentChat, selectCurrentMessageList, selectDraft,
selectIsTrustedBot, selectMessageReplyInfo, selectSendAs, selectTabState, selectUser, selectUserFullInfo,
} from '../../selectors';
import { fetchChatByUsername } from './chats';
const GAMEE_URL = 'https://prizes.gamee.com/';
const TOP_PEERS_REQUEST_COOLDOWN = 60; // 1 min
const runDebouncedForSearch = debounce((cb) => cb(), 500, false);
let botFatherId: string | null;
addActionHandler('clickBotInlineButton', (global, actions, payload): ActionReturnType => {
const { messageId, button, tabId = getCurrentTabId() } = payload;
@ -1128,3 +1133,66 @@ async function answerCallbackButton<T extends GlobalState>(
}
}
}
addActionHandler('setBotInfo', async (global, actions, payload): Promise<void> => {
const {
bot, name, description: about,
tabId = getCurrentTabId(),
} = payload;
let { langCode } = payload;
if (!langCode) langCode = global.settings.byKey.language;
const { currentUserId } = global;
if (!currentUserId || !bot) {
return;
}
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.InProgress, tabId);
setGlobal(global);
if (name || about) {
const result = await callApi('setBotInfo', {
bot, langCode, name, about,
});
if (result) {
global = getGlobal();
global = updateUser(
global,
bot.id,
{
firstName: name,
},
);
global = updateUserFullInfo(global, bot.id, { bio: about });
setGlobal(global);
}
}
global = getGlobal();
global = updateManagementProgress(global, ManagementProgress.Complete, tabId);
setGlobal(global);
});
addActionHandler('startBotFatherConversation', async (global, actions, payload): Promise<void> => {
const {
param,
tabId = getCurrentTabId(),
} = payload;
if (!botFatherId) {
const chat = await fetchChatByUsername(global, BOT_FATHER_USERNAME);
if (!chat) {
return;
}
botFatherId = chat.id;
}
if (param) {
actions.startBot({ botId: botFatherId, param });
}
actions.openChat({ id: botFatherId, tabId });
});

View File

@ -1,4 +1,5 @@
import type { ActionReturnType } from '../../types';
import { ManagementProgress } from '../../../types';
import {
CUSTOM_BG_CACHE_NAME,
@ -34,7 +35,9 @@ import { serializeGlobal } from '../../cache';
import {
addActionHandler, getGlobal, setGlobal,
} from '../../index';
import { addUsers, clearGlobalForLockScreen, updatePasscodeSettings } from '../../reducers';
import {
addUsers, clearGlobalForLockScreen, updateManagementProgress, updatePasscodeSettings,
} from '../../reducers';
addActionHandler('initApi', async (global, actions): Promise<void> => {
if (!IS_TEST) {
@ -100,14 +103,19 @@ addActionHandler('setAuthPassword', (global, actions, payload): ActionReturnType
addActionHandler('uploadProfilePhoto', async (global, actions, payload): Promise<void> => {
const {
file, isFallback, isVideo, videoTs,
file, isFallback, isVideo, videoTs, bot,
tabId = getCurrentTabId(),
} = payload!;
const result = await callApi('uploadProfilePhoto', file, isFallback, isVideo, videoTs);
global = updateManagementProgress(global, ManagementProgress.InProgress, tabId);
setGlobal(global);
const result = await callApi('uploadProfilePhoto', file, isFallback, isVideo, videoTs, bot);
if (!result) return;
global = getGlobal();
global = addUsers(global, buildCollectionByKey(result.users, 'id'));
global = updateManagementProgress(global, ManagementProgress.Complete, tabId);
setGlobal(global);
actions.loadFullUser({ userId: global.currentUserId! });

View File

@ -15,6 +15,7 @@ export function getUserFirstOrLastName(user?: ApiUser) {
switch (user.type) {
case 'userTypeBot':
return user.firstName;
case 'userTypeRegular': {
return user.firstName || user.lastName;
}

View File

@ -8,7 +8,7 @@ import {
import { selectChat, selectIsChatWithSelf } from './chats';
import { selectCurrentMessageList } from './messages';
import { selectTabState } from './tabs';
import { selectUser } from './users';
import { selectBot, selectUser } from './users';
export function selectManagement<T extends GlobalState>(
global: T, chatId: string,
@ -39,10 +39,16 @@ export function selectCurrentManagementType<T extends GlobalState>(
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {};
if (!chatId || !threadId) {
return undefined;
}
const chatBot = selectBot(global, chatId);
if (chatBot) {
return 'bot';
}
if (isUserId(chatId)) {
return 'user';
}

View File

@ -1020,6 +1020,8 @@ export interface ActionPayloads {
isFallback?: boolean;
videoTs?: number;
isVideo?: boolean;
bot?: ApiUser;
tabId?: number;
};
goToAuthQrCode: undefined;
@ -2274,6 +2276,22 @@ export interface ActionPayloads {
bio?: string;
username?: string;
} & WithTabId;
updateBotProfile: {
photo?: File;
firstName?: string;
bio?: string;
} & WithTabId;
setBotInfo: {
bot?: ApiUser | undefined;
langCode?: string;
name?: string | undefined;
about?: string | undefined;
description?: string | undefined;
isMuted?: boolean;
} & WithTabId;
startBotFatherConversation: {
param: string;
} & WithTabId;
checkUsername: {
username: string;
} & WithTabId;

View File

@ -1464,6 +1464,7 @@ channels.toggleParticipantsHidden#6a6e7854 channel:InputChannel enabled:Bool = U
channels.clickSponsoredMessage#18afbc93 channel:InputChannel random_id:bytes = Bool;
channels.toggleViewForumAsMessages#9738bb15 channel:InputChannel enabled:Bool = Updates;
channels.getChannelRecommendations#83b70d97 channel:InputChannel = messages.Chats;
bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool;
bots.canSendMessage#1359f4e6 bot:InputUser = Bool;
bots.allowSendMessage#f132e3ef bot:InputUser = Updates;
bots.invokeWebViewCustomMethod#87fc5e7 bot:InputUser custom_method:string params:DataJSON = DataJSON;

View File

@ -218,6 +218,7 @@
"bots.canSendMessage",
"bots.allowSendMessage",
"bots.invokeWebViewCustomMethod",
"bots.setBotInfo",
"payments.getPaymentForm",
"payments.getPaymentReceipt",
"payments.validateRequestedInfo",

View File

@ -414,7 +414,7 @@ export enum ManagementScreens {
JoinRequests,
}
export type ManagementType = 'user' | 'group' | 'channel';
export type ManagementType = 'user' | 'group' | 'channel' | 'bot';
export type NotifyException = {
isMuted: boolean;

View File

@ -139,6 +139,8 @@ export default {
PrivacySettings: 'Privacy and Security',
Language: 'Language',
FirstName: 'First name (required)',
PaymentCheckoutName: 'Name',
ChatSetPhotoOrVideo: 'Set Photo',
LastName: 'Last name (optional)',
UserBio: 'Bio',
lng_settings_about_bio: 'Any details such as age, occupation or city.\nExample: 23 y.o. designer from San Francisco',
@ -176,6 +178,9 @@ export default {
AccDescrChannel: 'Channel',
AccDescrGroup: 'Group',
Bot: 'bot',
BotChangeSettings: 'Change Bot Settings',
BotEditCommands: 'Edit Commands',
BotEditIntro: 'Edit Intro',
ServiceNotifications: 'Service notifications',
'LastSeen.TodayAt': 'last seen today at %@',
ALongTimeAgo: 'last seen a long time ago',