Support stopping and restarting bots (#1316)

This commit is contained in:
Alexander Zinchuk 2021-07-23 23:28:24 +03:00
parent 7bd89f47d8
commit 85ed696721
13 changed files with 149 additions and 19 deletions

View File

@ -3,7 +3,7 @@ import { ApiUser, ApiUserStatus, ApiUserType } from '../../types';
export function buildApiUserFromFull(mtpUserFull: GramJs.UserFull): ApiUser {
const {
about, commonChatsCount, pinnedMsgId, botInfo,
about, commonChatsCount, pinnedMsgId, botInfo, blocked,
} = mtpUserFull;
return {
@ -12,6 +12,7 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.UserFull): ApiUser {
bio: about,
commonChatsCount,
pinnedMessageId: pinnedMsgId,
isBlocked: Boolean(blocked),
...(botInfo && { botDescription: botInfo.description }),
},
};

View File

@ -13,7 +13,7 @@ import {
import { buildApiUser } from '../apiBuilders/users';
import { buildApiChatFromPreview, getApiChatIdFromMtpPeer } from '../apiBuilders/chats';
import { buildInputPrivacyKey, buildInputPeer, buildPeer } from '../gramjsBuilders';
import { buildInputPrivacyKey, buildInputPeer } from '../gramjsBuilders';
import { invokeRequest, uploadFile, getClient } from './client';
import { omitVirtualClassFields } from '../apiBuilders/helpers';
import { buildCollectionByKey } from '../../../util/iteratees';
@ -129,9 +129,9 @@ export async function fetchBlockedContacts() {
};
}
export function blockContact(chatOrUserId: number) {
export function blockContact(chatOrUserId: number, accessHash?: string) {
return invokeRequest(new GramJs.contacts.Block({
id: buildPeer(chatOrUserId),
id: buildInputPeer(chatOrUserId, accessHash),
}));
}

View File

@ -23,6 +23,7 @@ export interface ApiUser {
}
export interface ApiUserFullInfo {
isBlocked?: boolean;
bio?: string;
commonChatsCount?: number;
botDescription?: string;

View File

@ -7,6 +7,7 @@ import { GlobalActions } from '../../global/types';
import { selectIsChatWithSelf, selectUser } from '../../modules/selectors';
import {
isChatPrivate,
isUserBot,
getUserFirstOrLastName,
getPrivateChatUserId,
isChatBasicGroup,
@ -34,6 +35,7 @@ export type OwnProps = {
type StateProps = {
isChannel: boolean;
isChatWithSelf?: boolean;
isBot?: boolean;
isPrivateChat: boolean;
isBasicGroup: boolean;
isSuperGroup: boolean;
@ -42,7 +44,9 @@ type StateProps = {
contactName?: string;
};
type DispatchProps = Pick<GlobalActions, 'leaveChannel' | 'deleteHistory' | 'deleteChannel' | 'deleteChatUser'>;
type DispatchProps = Pick<GlobalActions, (
'leaveChannel' | 'deleteHistory' | 'deleteChannel' | 'deleteChatUser' | 'blockContact'
)>;
const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen,
@ -50,6 +54,7 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
isChannel,
isPrivateChat,
isChatWithSelf,
isBot,
isBasicGroup,
isSuperGroup,
currentUserId,
@ -61,6 +66,7 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
deleteHistory,
deleteChannel,
deleteChatUser,
blockContact,
}) => {
const lang = useLang();
const chatTitle = getChatTitle(lang, chat);
@ -71,6 +77,13 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
onClose();
}, [deleteHistory, chat.id, onClose]);
const handleDeleteAndStop = useCallback(() => {
deleteHistory({ chatId: chat.id, shouldDeleteForAll: true });
blockContact({ contactId: chat.id, accessHash: chat.accessHash });
onClose();
}, [deleteHistory, chat.id, chat.accessHash, blockContact, onClose]);
const handleDeleteChat = useCallback(() => {
if (isPrivateChat) {
deleteHistory({ chatId: chat.id, shouldDeleteForAll: false });
@ -163,6 +176,11 @@ const DeleteChatModal: FC<OwnProps & StateProps & DispatchProps> = ({
onCloseAnimationEnd={onCloseAnimationEnd}
>
{renderMessage()}
{isBot && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteAndStop}>
{lang('DeleteAndStop')}
</Button>
)}
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName ? renderText(lang('ChatList.DeleteForEveryone', contactName)) : lang('DeleteForAll')}
@ -180,14 +198,17 @@ export default memo(withGlobal<OwnProps>(
(global, { chat }): StateProps => {
const isPrivateChat = isChatPrivate(chat.id);
const isChatWithSelf = selectIsChatWithSelf(global, chat.id);
const canDeleteForAll = (isPrivateChat && !isChatWithSelf);
const contactName = chat && isChatPrivate(chat.id)
const user = isPrivateChat && selectUser(global, getPrivateChatUserId(chat)!);
const isBot = user && isUserBot(user) && !chat.isSupport;
const canDeleteForAll = (isPrivateChat && !isChatWithSelf && !isBot);
const contactName = isPrivateChat
? getUserFirstOrLastName(selectUser(global, getPrivateChatUserId(chat)!))
: undefined;
return {
isPrivateChat,
isChatWithSelf,
isBot,
isChannel: isChatChannel(chat),
isBasicGroup: isChatBasicGroup(chat),
isSuperGroup: isChatSuperGroup(chat),
@ -197,5 +218,5 @@ export default memo(withGlobal<OwnProps>(
};
},
(setGlobal, actions): DispatchProps => pick(actions,
['leaveChannel', 'deleteHistory', 'deleteChannel', 'deleteChatUser']),
['leaveChannel', 'deleteHistory', 'deleteChannel', 'deleteChatUser', 'blockContact']),
)(DeleteChatModal));

View File

@ -16,7 +16,10 @@ import { pick } from '../../util/iteratees';
import { isChatChannel, isChatSuperGroup } from '../../modules/helpers';
import {
selectChat,
selectIsChatBotNotStarted, selectIsChatWithSelf,
selectChatBot,
selectIsUserBlocked,
selectIsChatBotNotStarted,
selectIsChatWithSelf,
selectIsInSelectMode,
selectIsRightColumnShown,
} from '../../modules/selectors';
@ -36,6 +39,7 @@ interface StateProps {
isChannel?: boolean;
isRightColumnShown?: boolean;
canStartBot?: boolean;
canRestartBot?: boolean;
canSubscribe?: boolean;
canSearch?: boolean;
canMute?: boolean;
@ -43,7 +47,7 @@ interface StateProps {
canLeave?: boolean;
}
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch'>;
type DispatchProps = Pick<GlobalActions, 'joinChannel' | 'sendBotCommand' | 'openLocalTextSearch' | 'restartBot'>;
// Chrome breaks layout when focusing input during transition
const SEARCH_FOCUS_DELAY_MS = 400;
@ -54,6 +58,7 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
noMenu,
isChannel,
canStartBot,
canRestartBot,
canSubscribe,
canSearch,
canMute,
@ -63,6 +68,7 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
joinChannel,
sendBotCommand,
openLocalTextSearch,
restartBot,
}) => {
// eslint-disable-next-line no-null/no-null
const menuButtonRef = useRef<HTMLButtonElement>(null);
@ -91,6 +97,10 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
sendBotCommand({ command: '/start' });
}, [sendBotCommand]);
const handleRestartBot = useCallback(() => {
restartBot({ chatId });
}, [chatId, restartBot]);
const handleSearchClick = useCallback(() => {
openLocalTextSearch();
@ -132,6 +142,16 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
{lang('BotStart')}
</Button>
)}
{!IS_SINGLE_COLUMN_LAYOUT && canRestartBot && (
<Button
size="tiny"
ripple
fluid
onClick={handleRestartBot}
>
{lang('BotRestart')}
</Button>
)}
{!IS_SINGLE_COLUMN_LAYOUT && canSearch && (
<Button
round
@ -166,6 +186,8 @@ const HeaderActions: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen={isMenuOpen}
anchor={menuPosition}
isChannel={isChannel}
canStartBot={canStartBot}
canRestartBot={canRestartBot}
canSubscribe={canSubscribe}
canSearch={canSearch}
canMute={canMute}
@ -192,12 +214,14 @@ export default memo(withGlobal<OwnProps>(
};
}
const bot = selectChatBot(global, chatId);
const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID;
const isDiscussionThread = messageListType === 'thread' && threadId !== MAIN_THREAD_ID;
const isRightColumnShown = selectIsRightColumnShown(global);
const canStartBot = Boolean(selectIsChatBotNotStarted(global, chatId));
const canRestartBot = Boolean(bot && selectIsUserBlocked(global, bot.id));
const canStartBot = !canRestartBot && Boolean(selectIsChatBotNotStarted(global, chatId));
const canSubscribe = Boolean(
isMainThread && chat && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined,
);
@ -219,6 +243,7 @@ export default memo(withGlobal<OwnProps>(
isChannel,
isRightColumnShown,
canStartBot,
canRestartBot,
canSubscribe,
canSearch,
canMute,
@ -227,6 +252,6 @@ export default memo(withGlobal<OwnProps>(
};
},
(setGlobal, actions): DispatchProps => pick(actions, [
'joinChannel', 'sendBotCommand', 'openLocalTextSearch',
'joinChannel', 'sendBotCommand', 'openLocalTextSearch', 'restartBot',
]),
)(HeaderActions));

View File

@ -22,7 +22,9 @@ import DeleteChatModal from '../common/DeleteChatModal';
import './HeaderMenuContainer.scss';
type DispatchProps = Pick<GlobalActions, 'updateChatMutedState' | 'enterMessageSelectMode'>;
type DispatchProps = Pick<GlobalActions, (
'updateChatMutedState' | 'enterMessageSelectMode' | 'sendBotCommand' | 'restartBot'
)>;
export type OwnProps = {
chatId: number;
@ -30,6 +32,8 @@ export type OwnProps = {
isOpen: boolean;
anchor: IAnchorPosition;
isChannel?: boolean;
canStartBot?: boolean;
canRestartBot?: boolean;
canSubscribe?: boolean;
canSearch?: boolean;
canMute?: boolean;
@ -53,6 +57,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen,
anchor,
isChannel,
canStartBot,
canRestartBot,
canSubscribe,
canSearch,
canMute,
@ -68,6 +74,8 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onCloseAnimationEnd,
updateChatMutedState,
enterMessageSelectMode,
sendBotCommand,
restartBot,
}) => {
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -90,6 +98,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onClose();
}, [onClose]);
const handleStartBot = useCallback(() => {
sendBotCommand({ command: '/start' });
}, [sendBotCommand]);
const handleRestartBot = useCallback(() => {
restartBot({ chatId });
}, [chatId, restartBot]);
const handleToggleMuteClick = useCallback(() => {
updateChatMutedState({ chatId, isMuted: !isMuted });
closeMenu();
@ -127,6 +143,22 @@ const HeaderMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
style={`left: ${x}px;top: ${y}px;`}
onClose={closeMenu}
>
{IS_SINGLE_COLUMN_LAYOUT && canStartBot && (
<MenuItem
icon="bots"
onClick={handleStartBot}
>
{lang('BotStart')}
</MenuItem>
)}
{IS_SINGLE_COLUMN_LAYOUT && canRestartBot && (
<MenuItem
icon="bots"
onClick={handleRestartBot}
>
{lang('BotRestart')}
</MenuItem>
)}
{IS_SINGLE_COLUMN_LAYOUT && canSubscribe && (
<MenuItem
icon={isChannel ? 'channel' : 'group'}
@ -200,5 +232,7 @@ export default memo(withGlobal<OwnProps>(
(setGlobal, actions): DispatchProps => pick(actions, [
'updateChatMutedState',
'enterMessageSelectMode',
'sendBotCommand',
'restartBot',
]),
)(HeaderMenuContainer));

View File

@ -446,7 +446,7 @@ export default memo(withGlobal(
messageListType,
originChatId: originChat ? originChat.id : chatId,
isPrivate: isChatPrivate(chatId),
canPost: !isPinnedMessageList && (!chat || canPost) && (!isBotNotStarted || IS_SINGLE_COLUMN_LAYOUT),
canPost: !isPinnedMessageList && (!chat || canPost) && !isBotNotStarted,
isPinnedMessageList,
isScheduledMessageList,
currentUserBannedRights: chat && chat.currentUserBannedRights,

View File

@ -487,7 +487,7 @@ export type ActionTypes = (
'loadStickersForEmoji' | 'clearStickersForEmoji' | 'loadEmojiKeywords' |
// bots
'clickInlineButton' | 'sendBotCommand' | 'loadTopInlineBots' | 'queryInlineBot' | 'sendInlineBotResult' |
'resetInlineBot' |
'resetInlineBot' | 'restartBot' |
// misc
'openMediaViewer' | 'closeMediaViewer' | 'openAudioPlayer' | 'closeAudioPlayer' | 'openPollModal' | 'closePollModal' |
'loadWebPagePreview' | 'clearWebPagePreview' | 'loadWallpapers' | 'uploadWallpaper' | 'setDeviceToken' |

View File

@ -8,9 +8,10 @@ import { InlineBotSettings } from '../../../types';
import { RE_TME_INVITE_LINK, RE_TME_LINK } from '../../../config';
import { callApi } from '../../../api/gramjs';
import {
selectChat, selectChatMessage, selectCurrentChat, selectCurrentMessageList, selectReplyingToId, selectUser,
selectChat, selectChatBot, selectChatMessage, selectCurrentChat, selectCurrentMessageList,
selectReplyingToId, selectUser,
} from '../../selectors';
import { addChats, addUsers } from '../../reducers';
import { addChats, addUsers, removeBlockedContact } from '../../reducers';
import { buildCollectionByKey } from '../../../util/iteratees';
import { debounce } from '../../../util/schedulers';
import { replaceInlineBotSettings, replaceInlineBotsIsLoading } from '../../reducers/bots';
@ -74,6 +75,26 @@ addReducer('sendBotCommand', (global, actions, payload) => {
void sendBotCommand(chat, currentUserId, command);
});
addReducer('restartBot', (global, actions, payload) => {
const { chatId } = payload;
const { currentUserId } = global;
const chat = selectCurrentChat(global);
const bot = currentUserId && selectChatBot(global, chatId);
if (!currentUserId || !chat || !bot) {
return;
}
(async () => {
const result = await callApi('unblockContact', bot.id, bot.accessHash);
if (!result) {
return;
}
setGlobal(removeBlockedContact(getGlobal(), bot.id));
void sendBotCommand(chat, currentUserId, '/start');
})();
});
addReducer('loadTopInlineBots', (global) => {
const { serverTimeOffset } = global;
const { hash, lastRequestedAt } = global.topInlineBots;

View File

@ -217,10 +217,10 @@ addReducer('loadBlockedContacts', () => {
});
addReducer('blockContact', (global, actions, payload) => {
const { contactId } = payload!;
const { contactId, accessHash } = payload!;
(async () => {
const result = await callApi('blockContact', contactId);
const result = await callApi('blockContact', contactId, accessHash);
if (!result) {
return;
}

View File

@ -3,6 +3,7 @@ import {
ISettings, IThemeSettings, ThemeKey, NotifyException,
} from '../../types';
import { ApiNotifyException } from '../../api/types';
import { updateUserBlockedState } from './users';
export function replaceSettings(global: GlobalState, newSettings?: Partial<ISettings>): GlobalState {
return {
@ -87,6 +88,8 @@ export function updateNotifySettings(
}
export function addBlockedContact(global: GlobalState, contactId: number): GlobalState {
global = updateUserBlockedState(global, contactId, true);
return {
...global,
blocked: {
@ -98,6 +101,8 @@ export function addBlockedContact(global: GlobalState, contactId: number): Globa
}
export function removeBlockedContact(global: GlobalState, contactId: number): GlobalState {
global = updateUserBlockedState(global, contactId, false);
return {
...global,
blocked: {

View File

@ -169,3 +169,19 @@ export function updateUserSearchFetchingStatus(
fetchingStatus: newState,
});
}
export function updateUserBlockedState(global: GlobalState, userId: number, isBlocked: boolean) {
const { byId } = global.users;
const user = byId[userId];
if (!user || !user.fullInfo) {
return global;
}
return updateUser(global, userId, {
...user,
fullInfo: {
...user.fullInfo,
isBlocked,
},
});
}

View File

@ -5,6 +5,12 @@ export function selectUser(global: GlobalState, userId: number): ApiUser | undef
return global.users.byId[userId];
}
export function selectIsUserBlocked(global: GlobalState, userId: number) {
const user = selectUser(global, userId);
return user && user.fullInfo && user.fullInfo.isBlocked;
}
// Slow, not to be used in `withGlobal`
export function selectUserByUsername(global: GlobalState, username: string) {
const usernameLowered = username.toLowerCase();