diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java index a86841cb..2f13a0ca 100644 --- a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java @@ -1,145 +1,43 @@ package org.telegram.abilitybots.api.bot; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableList.Builder; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ListMultimap; -import com.google.common.collect.Multimap; -import org.apache.commons.io.IOUtils; -import org.jetbrains.annotations.NotNull; import org.telegram.abilitybots.api.db.DBContext; -import org.telegram.abilitybots.api.objects.*; -import org.telegram.abilitybots.api.sender.DefaultSender; -import org.telegram.abilitybots.api.sender.MessageSender; -import org.telegram.abilitybots.api.sender.SilentSender; -import org.telegram.abilitybots.api.util.AbilityUtils; -import org.telegram.abilitybots.api.util.Pair; -import org.telegram.abilitybots.api.util.Trio; -import org.telegram.telegrambots.api.methods.GetFile; -import org.telegram.telegrambots.api.methods.groupadministration.GetChatAdministrators; -import org.telegram.telegrambots.api.methods.send.SendDocument; -import org.telegram.telegrambots.api.objects.Message; import org.telegram.telegrambots.api.objects.Update; -import org.telegram.telegrambots.api.objects.User; import org.telegram.telegrambots.bots.DefaultBotOptions; import org.telegram.telegrambots.bots.TelegramLongPollingBot; -import org.telegram.telegrambots.exceptions.TelegramApiException; -import org.telegram.telegrambots.logging.BotLogger; +import org.telegram.telegrambots.exceptions.TelegramApiRequestException; +import org.telegram.telegrambots.generics.LongPollingBot; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileReader; -import java.io.PrintStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.util.*; -import java.util.Map.Entry; -import java.util.function.BiFunction; -import java.util.function.Predicate; -import java.util.stream.Stream; +import java.util.List; -import static com.google.common.base.Strings.isNullOrEmpty; -import static com.google.common.collect.MultimapBuilder.hashKeys; -import static java.lang.String.format; -import static java.time.ZonedDateTime.now; -import static java.util.Arrays.stream; -import static java.util.Comparator.comparing; -import static java.util.Objects.nonNull; -import static java.util.Optional.ofNullable; -import static java.util.regex.Pattern.CASE_INSENSITIVE; -import static java.util.regex.Pattern.compile; -import static java.util.stream.Collectors.joining; -import static jersey.repackaged.com.google.common.base.Throwables.propagate; -import static org.apache.commons.lang3.StringUtils.isEmpty; import static org.telegram.abilitybots.api.db.MapDBContext.onlineInstance; -import static org.telegram.abilitybots.api.objects.Ability.builder; -import static org.telegram.abilitybots.api.objects.Flag.*; -import static org.telegram.abilitybots.api.objects.Locality.*; -import static org.telegram.abilitybots.api.objects.MessageContext.newContext; -import static org.telegram.abilitybots.api.objects.Privacy.*; -import static org.telegram.abilitybots.api.util.AbilityMessageCodes.*; -import static org.telegram.abilitybots.api.util.AbilityUtils.*; /** - * The father of all ability bots. Bots that need to utilize abilities need to extend this bot. - *

- * It's important to note that this bot strictly extends {@link TelegramLongPollingBot}. - *

- * All bots extending the {@link AbilityBot} get implicit abilities: - *

- *

- * Additional information of the implicit abilities are present in the methods that declare them. - *

- * The two most important handles in the AbilityBot are the {@link DBContext} db and the {@link MessageSender} sender. - * All bots extending AbilityBot can use both handles in their update consumers. + * The default AbilityBot class implements {@link LongPollingBot}. It delegates all updates to a {@link TelegramLongPollingBot} instance. * * @author Abbas Abou Daya */ -public abstract class AbilityBot extends TelegramLongPollingBot { - private static final String TAG = AbilityBot.class.getSimpleName(); - - // DB objects - public static final String ADMINS = "ADMINS"; - public static final String USERS = "USERS"; - public static final String USER_ID = "USER_ID"; - public static final String BLACKLIST = "BLACKLIST"; - - // Factory commands - protected static final String DEFAULT = "default"; - protected static final String CLAIM = "claim"; - protected static final String BAN = "ban"; - protected static final String PROMOTE = "promote"; - protected static final String DEMOTE = "demote"; - protected static final String UNBAN = "unban"; - protected static final String BACKUP = "backup"; - protected static final String RECOVER = "recover"; - protected static final String COMMANDS = "commands"; - protected static final String REPORT = "report"; - - // DB and sender - protected final DBContext db; - protected MessageSender sender; - protected SilentSender silent; - - // Bot token and username - private final String botToken; - private final String botUsername; - - // Ability registry - private Map abilities; - - // Reply registry - private List replies; - - public abstract int creatorId(); +public abstract class AbilityBot extends BaseAbilityBot implements LongPollingBot { + private final TelegramLongPollingBot bot; protected AbilityBot(String botToken, String botUsername, DBContext db, DefaultBotOptions botOptions) { - super(botOptions); + super(botToken, botUsername, db, botOptions); - this.botToken = botToken; - this.botUsername = botUsername; - this.db = db; - this.sender = new DefaultSender(this); - silent = new SilentSender(sender); + bot = new TelegramLongPollingBot() { + @Override + public void onUpdateReceived(Update update) { + AbilityBot.this.onUpdateReceived(update); + } - registerAbilities(); + @Override + public String getBotUsername() { + return botUsername; + } + + @Override + public String getBotToken() { + return botToken; + } + }; } protected AbilityBot(String botToken, String botUsername, DBContext db) { @@ -154,722 +52,18 @@ public abstract class AbilityBot extends TelegramLongPollingBot { this(botToken, botUsername, onlineInstance(botUsername)); } - /** - * @return the map of ID -> User - */ - protected Map users() { - return db.getMap(USERS); - } - - /** - * @return the map of Username -> ID - */ - protected Map userIds() { - return db.getMap(USER_ID); - } - - /** - * @return a blacklist containing all the IDs of the banned users - */ - protected Set blacklist() { - return db.getSet(BLACKLIST); - } - - /** - * @return an admin set of all the IDs of bot administrators - */ - protected Set admins() { - return db.getSet(ADMINS); - } - - /** - * @return the immutable map of String -> Ability - */ - public Map abilities() { - return abilities; - } - - /** - * @return the immutable list carrying the embedded replies - */ - public List replies() { - return replies; - } - - /** - * This method contains the stream of actions that are applied on any update. - *

- * It will correctly handle addition of users into the DB and the execution of abilities and replies. - * - * @param update the update received by Telegram's API - */ @Override - public void onUpdateReceived(Update update) { - BotLogger.info(format("New update [%s] received at %s", update.getUpdateId(), now()), format("%s - %s", TAG, botUsername)); - BotLogger.info(update.toString(), TAG); - long millisStarted = System.currentTimeMillis(); - - Stream.of(update) - .filter(this::checkGlobalFlags) - .filter(this::checkBlacklist) - .map(this::addUser) - .filter(this::filterReply) - .map(this::getAbility) - .filter(this::validateAbility) - .filter(this::checkPrivacy) - .filter(this::checkLocality) - .filter(this::checkInput) - .filter(this::checkMessageFlags) - .map(this::getContext) - .map(this::consumeUpdate) - .forEach(this::postConsumption); - - long processingTime = System.currentTimeMillis() - millisStarted; - BotLogger.info(format("Processing of update [%s] ended at %s%n---> Processing time: [%d ms] <---%n", update.getUpdateId(), now(), processingTime), format("%s - %s", TAG, botUsername)); + public void onUpdatesReceived(List updates) { + bot.onUpdatesReceived(updates); } @Override - public String getBotToken() { - return botToken; + public void clearWebhook() throws TelegramApiRequestException { + bot.clearWebhook(); } @Override - public String getBotUsername() { - return botUsername; - } - - /** - * Test the update against the provided global flags. The default implementation is a passthrough to all updates. - *

- * This method should be overridden if the user wants to restrict bot usage to only certain updates. - * - * @param update a Telegram {@link Update} - * @return true if the update satisfies the global flags - */ - protected boolean checkGlobalFlags(Update update) { - return true; - } - - /** - * Gets the user with the specified username. - * - * @param username the username of the required user - * @return the user - */ - protected User getUser(String username) { - Integer id = userIds().get(username.toLowerCase()); - if (id == null) { - throw new IllegalStateException(format("Could not find ID corresponding to username [%s]", username)); - } - - return getUser(id); - } - - /** - * Gets the user with the specified ID. - * - * @param id the id of the required user - * @return the user - */ - protected User getUser(int id) { - User user = users().get(id); - if (user == null) { - throw new IllegalStateException(format("Could not find user corresponding to id [%d]", id)); - } - - return user; - } - - /** - * Gets the user with the specified username. If user was not found, the bot will send a message on Telegram. - * - * @param username the username of the required user - * @param ctx the message context with the originating user - * @return the id of the user - */ - protected int getUserIdSendError(String username, MessageContext ctx) { - try { - return getUser(username).getId(); - } catch (IllegalStateException ex) { - silent.send(getLocalizedMessage(USER_NOT_FOUND, ctx.user().getLanguageCode(), username), ctx.chatId()); - throw propagate(ex); - } - } - - /** - *

- * Format of the report: - *

- * [command1] - [description1] - *

- * [command2] - [description2] - *

- * ... - *

- * Once you invoke it, the bot will send the available commands to the chat. This is a public ability so anyone can invoke it. - *

- * Usage: /commands - * - * @return the ability to report commands defined by the child bot. - */ - public Ability reportCommands() { - return builder() - .name(REPORT) - .locality(ALL) - .privacy(CREATOR) - .input(0) - .action(ctx -> { - String commands = abilities.entrySet().stream() - .filter(entry -> nonNull(entry.getValue().info())) - .map(entry -> { - String name = entry.getValue().name(); - String info = entry.getValue().info(); - return format("%s - %s", name, info); - }) - .sorted() - .reduce((a, b) -> format("%s%n%s", a, b)) - .orElse(getLocalizedMessage(ABILITY_COMMANDS_NOT_FOUND, ctx.user().getLanguageCode())); - - silent.send(commands, ctx.chatId()); - }) - .build(); - } - - /** - * Default format: - *

- * PUBLIC - *

- * [command1] - [description1] - *

- * [command2] - [description2] - *

- * GROUP_ADMIN - *

- * [command1] - [description1] - *

- * ... - * - * @return the ability to print commands based on the privacy of the requesting user - */ - public Ability commands() { - return builder() - .name(COMMANDS) - .locality(USER) - .privacy(PUBLIC) - .input(0) - .action(ctx -> { - Privacy privacy = getPrivacy(ctx.update(), ctx.user().getId()); - - ListMultimap abilitiesPerPrivacy = abilities.entrySet().stream() - .map(entry -> { - String name = entry.getValue().name(); - String info = entry.getValue().info(); - - if (!isEmpty(info)) - return Pair.of(entry.getValue().privacy(), format("/%s - %s", name, info)); - return Pair.of(entry.getValue().privacy(), format("/%s", name)); - }) - .sorted(comparing(Pair::b)) - .collect(() -> hashKeys().arrayListValues().build(), - (map, pair) -> map.put(pair.a(), pair.b()), - Multimap::putAll); - - String commands = abilitiesPerPrivacy.asMap().entrySet().stream() - .filter(entry -> privacy.compareTo(entry.getKey()) >= 0) - .sorted(comparing(Entry::getKey)) - .map(entry -> - entry.getValue().stream() - .reduce(entry.getKey().toString(), (a, b) -> format("%s\n%s", a, b)) - ) - .collect(joining("\n")); - - if (commands.isEmpty()) - commands = getLocalizedMessage(ABILITY_COMMANDS_NOT_FOUND, ctx.user().getLanguageCode()); - - silent.send(commands, ctx.chatId()); - }) - .build(); - } - - /** - * This backup ability returns the object defined by {@link DBContext#backup()} as a message document. - *

- * This is a high-profile ability and is restricted to the CREATOR only. - *

- * Usage: /backup - * - * @return the ability to back-up the database of the bot - */ - public Ability backupDB() { - return builder() - .name(BACKUP) - .locality(USER) - .privacy(CREATOR) - .input(0) - .action(ctx -> { - File backup = new File("backup.json"); - - try (PrintStream printStream = new PrintStream(backup)) { - printStream.print(db.backup()); - sender.sendDocument(new SendDocument() - .setNewDocument(backup) - .setChatId(ctx.chatId()) - ); - } catch (FileNotFoundException e) { - BotLogger.error("Error while fetching backup", TAG, e); - } catch (TelegramApiException e) { - BotLogger.error("Error while sending document/backup file", TAG, e); - } - }) - .build(); - } - - /** - * Recovers the bot database using {@link DBContext#recover(Object)}. - *

- * The bot recovery process hugely depends on the implementation of the recovery method of {@link DBContext}. - *

- * Usage: /recover - * - * @return the ability to recover the database of the bot - */ - public Ability recoverDB() { - return builder() - .name(RECOVER) - .locality(USER) - .privacy(CREATOR) - .input(0) - .action(ctx -> silent.forceReply( - getLocalizedMessage(ABILITY_RECOVER_MESSAGE, ctx.user().getLanguageCode()), ctx.chatId())) - .reply(update -> { - String replyToMsg = update.getMessage().getReplyToMessage().getText(); - String recoverMessage = getLocalizedMessage(ABILITY_RECOVER_MESSAGE, AbilityUtils.getUser(update).getLanguageCode()); - if (!replyToMsg.equals(recoverMessage)) - return; - - String fileId = update.getMessage().getDocument().getFileId(); - try (FileReader reader = new FileReader(downloadFileWithId(fileId))) { - String backupData = IOUtils.toString(reader); - if (db.recover(backupData)) { - send(ABILITY_RECOVER_SUCCESS, update); - } else { - send(ABILITY_RECOVER_FAIL, update); - } - } catch (Exception e) { - BotLogger.error("Could not recover DB from backup", TAG, e); - send(ABILITY_RECOVER_ERROR, update); - } - }, MESSAGE, DOCUMENT, REPLY) - .build(); - } - - /** - * Banned users are accumulated in the blacklist. Use {@link DBContext#getSet(String)} with name specified by {@link AbilityBot#BLACKLIST}. - *

- * Usage: /ban @username - *

- * Note that admins who try to ban the creator, get banned. - * - * @return the ability to ban the user from any kind of bot interaction - */ - public Ability banUser() { - return builder() - .name(BAN) - .locality(ALL) - .privacy(ADMIN) - .input(1) - .action(ctx -> { - String username = stripTag(ctx.firstArg()); - int userId = getUserIdSendError(username, ctx); - String bannedUser; - - // Protection from abuse - if (userId == creatorId()) { - userId = ctx.user().getId(); - bannedUser = isNullOrEmpty(ctx.user().getUserName()) ? addTag(ctx.user().getUserName()) : shortName(ctx.user()); - } else { - bannedUser = addTag(username); - } - - Set blacklist = blacklist(); - if (blacklist.contains(userId)) - sendMd(ABILITY_BAN_FAIL, ctx, escape(bannedUser)); - else { - blacklist.add(userId); - sendMd(ABILITY_BAN_SUCCESS, ctx, escape(bannedUser)); - } - }) - .post(commitTo(db)) - .build(); - } - - /** - * Usage: /unban @username - * - * @return the ability to unban a user - */ - public Ability unbanUser() { - return builder() - .name(UNBAN) - .locality(ALL) - .privacy(ADMIN) - .input(1) - .action(ctx -> { - String username = stripTag(ctx.firstArg()); - Integer userId = getUserIdSendError(username, ctx); - - Set blacklist = blacklist(); - - if (!blacklist.remove(userId)) - silent.sendMd(getLocalizedMessage(ABILITY_UNBAN_FAIL, ctx.user().getLanguageCode(), escape(username)), ctx.chatId()); - else { - silent.sendMd(getLocalizedMessage(ABILITY_UNBAN_SUCCESS, ctx.user().getLanguageCode(), escape(username)), ctx.chatId()); - } - }) - .post(commitTo(db)) - .build(); - } - - /** - * @return the ability to promote a user to a bot admin - */ - public Ability promoteAdmin() { - return builder() - .name(PROMOTE) - .locality(ALL) - .privacy(ADMIN) - .input(1) - .action(ctx -> { - String username = stripTag(ctx.firstArg()); - Integer userId = getUserIdSendError(username, ctx); - - Set admins = admins(); - if (admins.contains(userId)) - sendMd(ABILITY_PROMOTE_FAIL, ctx, escape(username)); - else { - admins.add(userId); - sendMd(ABILITY_PROMOTE_SUCCESS, ctx, escape(username)); - } - }).post(commitTo(db)) - .build(); - } - - /** - * @return the ability to demote an admin to a user - */ - public Ability demoteAdmin() { - return builder() - .name(DEMOTE) - .locality(ALL) - .privacy(ADMIN) - .input(1) - .action(ctx -> { - String username = stripTag(ctx.firstArg()); - Integer userId = getUserIdSendError(username, ctx); - - Set admins = admins(); - if (admins.remove(userId)) { - sendMd(ABILITY_DEMOTE_SUCCESS, ctx, escape(username)); - } else { - sendMd(ABILITY_DEMOTE_FAIL, ctx, escape(username)); - } - }) - .post(commitTo(db)) - .build(); - } - - /** - * Regular users and admins who try to claim the bot will get banned. - * - * @return the ability to claim yourself as the master and creator of the bot - */ - public Ability claimCreator() { - return builder() - .name(CLAIM) - .locality(ALL) - .privacy(CREATOR) - .input(0) - .action(ctx -> { - Set admins = admins(); - int id = creatorId(); - - if (admins.contains(id)) - send(ABILITY_CLAIM_FAIL, ctx); - else { - admins.add(id); - send(ABILITY_CLAIM_SUCCESS, ctx); - } - }) - .post(commitTo(db)) - .build(); - } - - private Optional send(String message, MessageContext ctx, String... args) { - return silent.send(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId()); - } - - private Optional sendMd(String message, MessageContext ctx, String... args) { - return silent.sendMd(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId()); - } - - private Optional send(String message, Update upd) { - Long chatId = upd.getMessage().getChatId(); - return silent.send(getLocalizedMessage(message, AbilityUtils.getUser(upd).getLanguageCode()), chatId); - } - - /** - * Registers the declared abilities using method reflection. Also, replies are accumulated using the built abilities and standalone methods that return a Reply. - *

- * Only abilities and replies with the public accessor are registered! - */ - private void registerAbilities() { - try { - abilities = stream(this.getClass().getMethods()) - .filter(method -> method.getReturnType().equals(Ability.class)) - .map(this::returnAbility) - .collect(ImmutableMap::builder, - (b, a) -> b.put(a.name(), a), - (b1, b2) -> b1.putAll(b2.build())) - .build(); - - Stream methodReplies = stream(this.getClass().getMethods()) - .filter(method -> method.getReturnType().equals(Reply.class)) - .map(this::returnReply); - - Stream abilityReplies = abilities.values().stream() - .flatMap(ability -> ability.replies().stream()); - - replies = Stream.concat(methodReplies, abilityReplies).collect( - ImmutableList::builder, - Builder::add, - (b1, b2) -> b1.addAll(b2.build())) - .build(); - } catch (IllegalStateException e) { - BotLogger.error(TAG, "Duplicate names found while registering abilities. Make sure that the abilities declared don't clash with the reserved ones.", e); - throw propagate(e); - } - - } - - /** - * Invokes the method and retrieves its return {@link Ability}. - * - * @param method a method that returns an ability - * @return the ability returned by the method - */ - private Ability returnAbility(Method method) { - try { - return (Ability) method.invoke(this); - } catch (IllegalAccessException | InvocationTargetException e) { - BotLogger.error("Could not add ability", TAG, e); - throw propagate(e); - } - } - - /** - * Invokes the method and retrieves its returned Reply. - * - * @param method a method that returns a reply - * @return the reply returned by the method - */ - private Reply returnReply(Method method) { - try { - return (Reply) method.invoke(this); - } catch (IllegalAccessException | InvocationTargetException e) { - BotLogger.error("Could not add reply", TAG, e); - throw propagate(e); - } - } - - private void postConsumption(Pair pair) { - ofNullable(pair.b().postAction()) - .ifPresent(consumer -> consumer.accept(pair.a())); - } - - Pair consumeUpdate(Pair pair) { - pair.b().action().accept(pair.a()); - return pair; - } - - Pair getContext(Trio trio) { - Update update = trio.a(); - User user = AbilityUtils.getUser(update); - - return Pair.of(newContext(update, user, getChatId(update), trio.c()), trio.b()); - } - - boolean checkBlacklist(Update update) { - Integer id = AbilityUtils.getUser(update).getId(); - - return id == creatorId() || !blacklist().contains(id); - } - - boolean checkInput(Trio trio) { - String[] tokens = trio.c(); - int abilityTokens = trio.b().tokens(); - - boolean isOk = abilityTokens == 0 || (tokens.length > 0 && tokens.length == abilityTokens); - - if (!isOk) - silent.send( - getLocalizedMessage( - CHECK_INPUT_FAIL, - AbilityUtils.getUser(trio.a()).getLanguageCode(), - abilityTokens, abilityTokens == 1 ? "input" : "inputs"), - getChatId(trio.a())); - return isOk; - } - - boolean checkLocality(Trio trio) { - Update update = trio.a(); - Locality locality = isUserMessage(update) ? USER : GROUP; - Locality abilityLocality = trio.b().locality(); - - boolean isOk = abilityLocality == ALL || locality == abilityLocality; - - if (!isOk) - silent.send( - getLocalizedMessage( - CHECK_LOCALITY_FAIL, - AbilityUtils.getUser(trio.a()).getLanguageCode(), - abilityLocality.toString().toLowerCase()), - getChatId(trio.a())); - return isOk; - } - - boolean checkPrivacy(Trio trio) { - Update update = trio.a(); - User user = AbilityUtils.getUser(update); - Privacy privacy; - int id = user.getId(); - - privacy = getPrivacy(update, id); - - boolean isOk = privacy.compareTo(trio.b().privacy()) >= 0; - - if (!isOk) - silent.send( - getLocalizedMessage( - CHECK_PRIVACY_FAIL, - AbilityUtils.getUser(trio.a()).getLanguageCode()), - getChatId(trio.a())); - return isOk; - } - - @NotNull - private Privacy getPrivacy(Update update, int id) { - return isCreator(id) ? - CREATOR : isAdmin(id) ? - ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ? - GROUP_ADMIN : PUBLIC; - } - - private boolean isGroupAdmin(Update update, int id) { - GetChatAdministrators admins = new GetChatAdministrators().setChatId(getChatId(update)); - - return silent.execute(admins) - .orElse(new ArrayList<>()).stream() - .anyMatch(member -> member.getUser().getId() == id); - } - - private boolean isCreator(int id) { - return id == creatorId(); - } - - private boolean isAdmin(Integer id) { - return admins().contains(id); - } - - boolean validateAbility(Trio trio) { - return trio.b() != null; - } - - Trio getAbility(Update update) { - // Handle updates without messages - // Passing through this function means that the global flags have passed - Message msg = update.getMessage(); - if (!update.hasMessage() || !msg.hasText()) - return Trio.of(update, abilities.get(DEFAULT), new String[]{}); - - String[] tokens = msg.getText().split(" "); - - if (tokens[0].startsWith("/")) { - String abilityToken = stripBotUsername(tokens[0].substring(1)).toLowerCase(); - Ability ability = abilities.get(abilityToken); - tokens = Arrays.copyOfRange(tokens, 1, tokens.length); - return Trio.of(update, ability, tokens); - } else { - Ability ability = abilities.get(DEFAULT); - return Trio.of(update, ability, tokens); - } - } - - private String stripBotUsername(String token) { - return compile(format("@%s", botUsername), CASE_INSENSITIVE) - .matcher(token) - .replaceAll(""); - } - - Update addUser(Update update) { - User endUser = AbilityUtils.getUser(update); - - users().compute(endUser.getId(), (id, user) -> { - if (user == null) { - updateUserId(user, endUser); - return endUser; - } - - if (!user.equals(endUser)) { - updateUserId(user, endUser); - return endUser; - } - - return user; - }); - - db.commit(); - return update; - } - - private void updateUserId(User oldUser, User newUser) { - if (oldUser != null && oldUser.getUserName() != null) { - // Remove old username -> ID - userIds().remove(oldUser.getUserName()); - } - - if (newUser.getUserName() != null) { - // Add new mapping with the new username - userIds().put(newUser.getUserName().toLowerCase(), newUser.getId()); - } - } - - boolean filterReply(Update update) { - return replies.stream() - .filter(reply -> reply.isOkFor(update)) - .map(reply -> { - reply.actOn(update); - return false; - }) - .reduce(true, Boolean::logicalAnd); - } - - boolean checkMessageFlags(Trio trio) { - Ability ability = trio.b(); - Update update = trio.a(); - - // The following variable is required to avoid bug #JDK-8044546 - BiFunction, Boolean> flagAnd = (flag, nextFlag) -> flag && nextFlag.test(update); - return ability.flags().stream() - .reduce(true, flagAnd, Boolean::logicalAnd); - } - - private File downloadFileWithId(String fileId) throws TelegramApiException { - return sender.downloadFile(sender.execute(new GetFile().setFileId(fileId))); - } - - - private String escape(String username) { - return username.replace("_", "\\_"); + public void onClosing() { + bot.onClosing(); } } \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityWebhookBot.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityWebhookBot.java new file mode 100644 index 00000000..283c2a61 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityWebhookBot.java @@ -0,0 +1,75 @@ +package org.telegram.abilitybots.api.bot; + +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.telegrambots.api.methods.BotApiMethod; +import org.telegram.telegrambots.api.objects.Update; +import org.telegram.telegrambots.bots.DefaultBotOptions; +import org.telegram.telegrambots.bots.TelegramWebhookBot; +import org.telegram.telegrambots.exceptions.TelegramApiRequestException; +import org.telegram.telegrambots.generics.WebhookBot; + +import static org.telegram.abilitybots.api.db.MapDBContext.onlineInstance; + +/** + * A {@link WebhookBot}-flavor AbilityBot. It delegates all updates to a {@link TelegramWebhookBot} instance. + * + * @author Abbas Abou Daya + */ +public abstract class AbilityWebhookBot extends BaseAbilityBot implements WebhookBot { + private final TelegramWebhookBot bot; + + protected AbilityWebhookBot(String botToken, String botUsername, String botPath, DBContext db, DefaultBotOptions botOptions) { + super(botToken, botUsername, db, botOptions); + + bot = new TelegramWebhookBot() { + + @Override + public BotApiMethod onWebhookUpdateReceived(Update update) { + AbilityWebhookBot.this.onUpdateReceived(update); + return null; + } + + @Override + public String getBotUsername() { + return botUsername; + } + + @Override + public String getBotToken() { + return botToken; + } + + @Override + public String getBotPath() { + return botPath; + } + }; + } + + protected AbilityWebhookBot(String botToken, String botUsername, String botPath, DBContext db) { + this(botToken, botUsername, botPath, db, new DefaultBotOptions()); + } + + protected AbilityWebhookBot(String botToken, String botUsername, String botPath, DefaultBotOptions botOptions) { + this(botToken, botUsername, botPath, onlineInstance(botUsername), botOptions); + } + + protected AbilityWebhookBot(String botToken, String botUsername, String botPath) { + this(botToken, botUsername, botPath, onlineInstance(botUsername)); + } + + @Override + public BotApiMethod onWebhookUpdateReceived(Update update) { + return bot.onWebhookUpdateReceived(update); + } + + @Override + public void setWebhook(String url, String publicCertificatePath) throws TelegramApiRequestException { + bot.setWebhook(url, publicCertificatePath); + } + + @Override + public String getBotPath() { + return bot.getBotPath(); + } +} \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java new file mode 100644 index 00000000..d2753f05 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java @@ -0,0 +1,863 @@ +package org.telegram.abilitybots.api.bot; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableList.Builder; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ListMultimap; +import com.google.common.collect.Multimap; +import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.abilitybots.api.objects.*; +import org.telegram.abilitybots.api.sender.DefaultSender; +import org.telegram.abilitybots.api.sender.MessageSender; +import org.telegram.abilitybots.api.sender.SilentSender; +import org.telegram.abilitybots.api.util.AbilityUtils; +import org.telegram.abilitybots.api.util.Pair; +import org.telegram.abilitybots.api.util.Trio; +import org.telegram.telegrambots.api.methods.GetFile; +import org.telegram.telegrambots.api.methods.groupadministration.GetChatAdministrators; +import org.telegram.telegrambots.api.methods.send.SendDocument; +import org.telegram.telegrambots.api.objects.Message; +import org.telegram.telegrambots.api.objects.Update; +import org.telegram.telegrambots.api.objects.User; +import org.telegram.telegrambots.bots.DefaultBotOptions; +import org.telegram.telegrambots.bots.TelegramLongPollingBot; +import org.telegram.telegrambots.exceptions.TelegramApiException; +import org.telegram.telegrambots.logging.BotLogger; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.PrintStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.*; +import java.util.Map.Entry; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.collect.MultimapBuilder.hashKeys; +import static java.lang.String.format; +import static java.time.ZonedDateTime.now; +import static java.util.Arrays.stream; +import static java.util.Comparator.comparing; +import static java.util.Objects.nonNull; +import static java.util.Optional.ofNullable; +import static java.util.regex.Pattern.CASE_INSENSITIVE; +import static java.util.regex.Pattern.compile; +import static java.util.stream.Collectors.joining; +import static jersey.repackaged.com.google.common.base.Throwables.propagate; +import static org.apache.commons.lang3.StringUtils.isEmpty; +import static org.telegram.abilitybots.api.db.MapDBContext.onlineInstance; +import static org.telegram.abilitybots.api.objects.Ability.builder; +import static org.telegram.abilitybots.api.objects.Flag.*; +import static org.telegram.abilitybots.api.objects.Locality.*; +import static org.telegram.abilitybots.api.objects.MessageContext.newContext; +import static org.telegram.abilitybots.api.objects.Privacy.*; +import static org.telegram.abilitybots.api.util.AbilityMessageCodes.*; +import static org.telegram.abilitybots.api.util.AbilityUtils.*; + +/** + * The father of all ability bots. Bots that need to utilize abilities need to extend this bot. + *

+ * It's important to note that this bot strictly extends {@link TelegramLongPollingBot}. + *

+ * All bots extending the {@link BaseAbilityBot} get implicit abilities: + *

+ *

+ * Additional information of the implicit abilities are present in the methods that declare them. + *

+ * The two most important handles in the BaseAbilityBot are the {@link DBContext} db and the {@link MessageSender} sender. + * All bots extending BaseAbilityBot can use both handles in their update consumers. + * + * @author Abbas Abou Daya + */ +public abstract class BaseAbilityBot extends TelegramLongPollingBot { + private static final String TAG = BaseAbilityBot.class.getSimpleName(); + + // DB objects + public static final String ADMINS = "ADMINS"; + public static final String USERS = "USERS"; + public static final String USER_ID = "USER_ID"; + public static final String BLACKLIST = "BLACKLIST"; + + // Factory commands + protected static final String DEFAULT = "default"; + protected static final String CLAIM = "claim"; + protected static final String BAN = "ban"; + protected static final String PROMOTE = "promote"; + protected static final String DEMOTE = "demote"; + protected static final String UNBAN = "unban"; + protected static final String BACKUP = "backup"; + protected static final String RECOVER = "recover"; + protected static final String COMMANDS = "commands"; + protected static final String REPORT = "report"; + + // DB and sender + protected final DBContext db; + protected MessageSender sender; + protected SilentSender silent; + + // Bot token and username + private final String botToken; + private final String botUsername; + + // Ability registry + private Map abilities; + + // Reply registry + private List replies; + + public abstract int creatorId(); + + protected BaseAbilityBot(String botToken, String botUsername, DBContext db, DefaultBotOptions botOptions) { + super(botOptions); + + this.botToken = botToken; + this.botUsername = botUsername; + this.db = db; + this.sender = new DefaultSender(this); + silent = new SilentSender(sender); + + registerAbilities(); + } + + /** + * @return the map of ID -> User + */ + protected Map users() { + return db.getMap(USERS); + } + + /** + * @return the map of Username -> ID + */ + protected Map userIds() { + return db.getMap(USER_ID); + } + + /** + * @return a blacklist containing all the IDs of the banned users + */ + protected Set blacklist() { + return db.getSet(BLACKLIST); + } + + /** + * @return an admin set of all the IDs of bot administrators + */ + protected Set admins() { + return db.getSet(ADMINS); + } + + /** + * @return the immutable map of String -> Ability + */ + public Map abilities() { + return abilities; + } + + /** + * @return the immutable list carrying the embedded replies + */ + public List replies() { + return replies; + } + + /** + * This method contains the stream of actions that are applied on any update. + *

+ * It will correctly handle addition of users into the DB and the execution of abilities and replies. + * + * @param update the update received by Telegram's API + */ + @Override + public void onUpdateReceived(Update update) { + BotLogger.info(format("New update [%s] received at %s", update.getUpdateId(), now()), format("%s - %s", TAG, botUsername)); + BotLogger.info(update.toString(), TAG); + long millisStarted = System.currentTimeMillis(); + + Stream.of(update) + .filter(this::checkGlobalFlags) + .filter(this::checkBlacklist) + .map(this::addUser) + .filter(this::filterReply) + .map(this::getAbility) + .filter(this::validateAbility) + .filter(this::checkPrivacy) + .filter(this::checkLocality) + .filter(this::checkInput) + .filter(this::checkMessageFlags) + .map(this::getContext) + .map(this::consumeUpdate) + .forEach(this::postConsumption); + + long processingTime = System.currentTimeMillis() - millisStarted; + BotLogger.info(format("Processing of update [%s] ended at %s%n---> Processing time: [%d ms] <---%n", update.getUpdateId(), now(), processingTime), format("%s - %s", TAG, botUsername)); + } + + @Override + public String getBotToken() { + return botToken; + } + + @Override + public String getBotUsername() { + return botUsername; + } + + /** + * Test the update against the provided global flags. The default implementation is a passthrough to all updates. + *

+ * This method should be overridden if the user wants to restrict bot usage to only certain updates. + * + * @param update a Telegram {@link Update} + * @return true if the update satisfies the global flags + */ + protected boolean checkGlobalFlags(Update update) { + return true; + } + + /** + * Gets the user with the specified username. + * + * @param username the username of the required user + * @return the user + */ + protected User getUser(String username) { + Integer id = userIds().get(username.toLowerCase()); + if (id == null) { + throw new IllegalStateException(format("Could not find ID corresponding to username [%s]", username)); + } + + return getUser(id); + } + + /** + * Gets the user with the specified ID. + * + * @param id the id of the required user + * @return the user + */ + protected User getUser(int id) { + User user = users().get(id); + if (user == null) { + throw new IllegalStateException(format("Could not find user corresponding to id [%d]", id)); + } + + return user; + } + + /** + * Gets the user with the specified username. If user was not found, the bot will send a message on Telegram. + * + * @param username the username of the required user + * @param ctx the message context with the originating user + * @return the id of the user + */ + protected int getUserIdSendError(String username, MessageContext ctx) { + try { + return getUser(username).getId(); + } catch (IllegalStateException ex) { + silent.send(getLocalizedMessage(USER_NOT_FOUND, ctx.user().getLanguageCode(), username), ctx.chatId()); + throw propagate(ex); + } + } + + /** + *

+ * Format of the report: + *

+ * [command1] - [description1] + *

+ * [command2] - [description2] + *

+ * ... + *

+ * Once you invoke it, the bot will send the available commands to the chat. This is a public ability so anyone can invoke it. + *

+ * Usage: /commands + * + * @return the ability to report commands defined by the child bot. + */ + public Ability reportCommands() { + return builder() + .name(REPORT) + .locality(ALL) + .privacy(CREATOR) + .input(0) + .action(ctx -> { + String commands = abilities.entrySet().stream() + .filter(entry -> nonNull(entry.getValue().info())) + .map(entry -> { + String name = entry.getValue().name(); + String info = entry.getValue().info(); + return format("%s - %s", name, info); + }) + .sorted() + .reduce((a, b) -> format("%s%n%s", a, b)) + .orElse(getLocalizedMessage(ABILITY_COMMANDS_NOT_FOUND, ctx.user().getLanguageCode())); + + silent.send(commands, ctx.chatId()); + }) + .build(); + } + + /** + * Default format: + *

+ * PUBLIC + *

+ * [command1] - [description1] + *

+ * [command2] - [description2] + *

+ * GROUP_ADMIN + *

+ * [command1] - [description1] + *

+ * ... + * + * @return the ability to print commands based on the privacy of the requesting user + */ + public Ability commands() { + return builder() + .name(COMMANDS) + .locality(USER) + .privacy(PUBLIC) + .input(0) + .action(ctx -> { + Privacy privacy = getPrivacy(ctx.update(), ctx.user().getId()); + + ListMultimap abilitiesPerPrivacy = abilities.entrySet().stream() + .map(entry -> { + String name = entry.getValue().name(); + String info = entry.getValue().info(); + + if (!isEmpty(info)) + return Pair.of(entry.getValue().privacy(), format("/%s - %s", name, info)); + return Pair.of(entry.getValue().privacy(), format("/%s", name)); + }) + .sorted(comparing(Pair::b)) + .collect(() -> hashKeys().arrayListValues().build(), + (map, pair) -> map.put(pair.a(), pair.b()), + Multimap::putAll); + + String commands = abilitiesPerPrivacy.asMap().entrySet().stream() + .filter(entry -> privacy.compareTo(entry.getKey()) >= 0) + .sorted(comparing(Entry::getKey)) + .map(entry -> + entry.getValue().stream() + .reduce(entry.getKey().toString(), (a, b) -> format("%s\n%s", a, b)) + ) + .collect(joining("\n")); + + if (commands.isEmpty()) + commands = getLocalizedMessage(ABILITY_COMMANDS_NOT_FOUND, ctx.user().getLanguageCode()); + + silent.send(commands, ctx.chatId()); + }) + .build(); + } + + /** + * This backup ability returns the object defined by {@link DBContext#backup()} as a message document. + *

+ * This is a high-profile ability and is restricted to the CREATOR only. + *

+ * Usage: /backup + * + * @return the ability to back-up the database of the bot + */ + public Ability backupDB() { + return builder() + .name(BACKUP) + .locality(USER) + .privacy(CREATOR) + .input(0) + .action(ctx -> { + File backup = new File("backup.json"); + + try (PrintStream printStream = new PrintStream(backup)) { + printStream.print(db.backup()); + sender.sendDocument(new SendDocument() + .setNewDocument(backup) + .setChatId(ctx.chatId()) + ); + } catch (FileNotFoundException e) { + BotLogger.error("Error while fetching backup", TAG, e); + } catch (TelegramApiException e) { + BotLogger.error("Error while sending document/backup file", TAG, e); + } + }) + .build(); + } + + /** + * Recovers the bot database using {@link DBContext#recover(Object)}. + *

+ * The bot recovery process hugely depends on the implementation of the recovery method of {@link DBContext}. + *

+ * Usage: /recover + * + * @return the ability to recover the database of the bot + */ + public Ability recoverDB() { + return builder() + .name(RECOVER) + .locality(USER) + .privacy(CREATOR) + .input(0) + .action(ctx -> silent.forceReply( + getLocalizedMessage(ABILITY_RECOVER_MESSAGE, ctx.user().getLanguageCode()), ctx.chatId())) + .reply(update -> { + String replyToMsg = update.getMessage().getReplyToMessage().getText(); + String recoverMessage = getLocalizedMessage(ABILITY_RECOVER_MESSAGE, AbilityUtils.getUser(update).getLanguageCode()); + if (!replyToMsg.equals(recoverMessage)) + return; + + String fileId = update.getMessage().getDocument().getFileId(); + try (FileReader reader = new FileReader(downloadFileWithId(fileId))) { + String backupData = IOUtils.toString(reader); + if (db.recover(backupData)) { + send(ABILITY_RECOVER_SUCCESS, update); + } else { + send(ABILITY_RECOVER_FAIL, update); + } + } catch (Exception e) { + BotLogger.error("Could not recover DB from backup", TAG, e); + send(ABILITY_RECOVER_ERROR, update); + } + }, MESSAGE, DOCUMENT, REPLY) + .build(); + } + + /** + * Banned users are accumulated in the blacklist. Use {@link DBContext#getSet(String)} with name specified by {@link BaseAbilityBot#BLACKLIST}. + *

+ * Usage: /ban @username + *

+ * Note that admins who try to ban the creator, get banned. + * + * @return the ability to ban the user from any kind of bot interaction + */ + public Ability banUser() { + return builder() + .name(BAN) + .locality(ALL) + .privacy(ADMIN) + .input(1) + .action(ctx -> { + String username = stripTag(ctx.firstArg()); + int userId = getUserIdSendError(username, ctx); + String bannedUser; + + // Protection from abuse + if (userId == creatorId()) { + userId = ctx.user().getId(); + bannedUser = isNullOrEmpty(ctx.user().getUserName()) ? addTag(ctx.user().getUserName()) : shortName(ctx.user()); + } else { + bannedUser = addTag(username); + } + + Set blacklist = blacklist(); + if (blacklist.contains(userId)) + sendMd(ABILITY_BAN_FAIL, ctx, escape(bannedUser)); + else { + blacklist.add(userId); + sendMd(ABILITY_BAN_SUCCESS, ctx, escape(bannedUser)); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * Usage: /unban @username + * + * @return the ability to unban a user + */ + public Ability unbanUser() { + return builder() + .name(UNBAN) + .locality(ALL) + .privacy(ADMIN) + .input(1) + .action(ctx -> { + String username = stripTag(ctx.firstArg()); + Integer userId = getUserIdSendError(username, ctx); + + Set blacklist = blacklist(); + + if (!blacklist.remove(userId)) + silent.sendMd(getLocalizedMessage(ABILITY_UNBAN_FAIL, ctx.user().getLanguageCode(), escape(username)), ctx.chatId()); + else { + silent.sendMd(getLocalizedMessage(ABILITY_UNBAN_SUCCESS, ctx.user().getLanguageCode(), escape(username)), ctx.chatId()); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * @return the ability to promote a user to a bot admin + */ + public Ability promoteAdmin() { + return builder() + .name(PROMOTE) + .locality(ALL) + .privacy(ADMIN) + .input(1) + .action(ctx -> { + String username = stripTag(ctx.firstArg()); + Integer userId = getUserIdSendError(username, ctx); + + Set admins = admins(); + if (admins.contains(userId)) + sendMd(ABILITY_PROMOTE_FAIL, ctx, escape(username)); + else { + admins.add(userId); + sendMd(ABILITY_PROMOTE_SUCCESS, ctx, escape(username)); + } + }).post(commitTo(db)) + .build(); + } + + /** + * @return the ability to demote an admin to a user + */ + public Ability demoteAdmin() { + return builder() + .name(DEMOTE) + .locality(ALL) + .privacy(ADMIN) + .input(1) + .action(ctx -> { + String username = stripTag(ctx.firstArg()); + Integer userId = getUserIdSendError(username, ctx); + + Set admins = admins(); + if (admins.remove(userId)) { + sendMd(ABILITY_DEMOTE_SUCCESS, ctx, escape(username)); + } else { + sendMd(ABILITY_DEMOTE_FAIL, ctx, escape(username)); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * Regular users and admins who try to claim the bot will get banned. + * + * @return the ability to claim yourself as the master and creator of the bot + */ + public Ability claimCreator() { + return builder() + .name(CLAIM) + .locality(ALL) + .privacy(CREATOR) + .input(0) + .action(ctx -> { + Set admins = admins(); + int id = creatorId(); + + if (admins.contains(id)) + send(ABILITY_CLAIM_FAIL, ctx); + else { + admins.add(id); + send(ABILITY_CLAIM_SUCCESS, ctx); + } + }) + .post(commitTo(db)) + .build(); + } + + private Optional send(String message, MessageContext ctx, String... args) { + return silent.send(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId()); + } + + private Optional sendMd(String message, MessageContext ctx, String... args) { + return silent.sendMd(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId()); + } + + private Optional send(String message, Update upd) { + Long chatId = upd.getMessage().getChatId(); + return silent.send(getLocalizedMessage(message, AbilityUtils.getUser(upd).getLanguageCode()), chatId); + } + + /** + * Registers the declared abilities using method reflection. Also, replies are accumulated using the built abilities and standalone methods that return a Reply. + *

+ * Only abilities and replies with the public accessor are registered! + */ + private void registerAbilities() { + try { + abilities = stream(this.getClass().getMethods()) + .filter(method -> method.getReturnType().equals(Ability.class)) + .map(this::returnAbility) + .collect(ImmutableMap::builder, + (b, a) -> b.put(a.name(), a), + (b1, b2) -> b1.putAll(b2.build())) + .build(); + + Stream methodReplies = stream(this.getClass().getMethods()) + .filter(method -> method.getReturnType().equals(Reply.class)) + .map(this::returnReply); + + Stream abilityReplies = abilities.values().stream() + .flatMap(ability -> ability.replies().stream()); + + replies = Stream.concat(methodReplies, abilityReplies).collect( + ImmutableList::builder, + Builder::add, + (b1, b2) -> b1.addAll(b2.build())) + .build(); + } catch (IllegalStateException e) { + BotLogger.error(TAG, "Duplicate names found while registering abilities. Make sure that the abilities declared don't clash with the reserved ones.", e); + throw propagate(e); + } + + } + + /** + * Invokes the method and retrieves its return {@link Ability}. + * + * @param method a method that returns an ability + * @return the ability returned by the method + */ + private Ability returnAbility(Method method) { + try { + return (Ability) method.invoke(this); + } catch (IllegalAccessException | InvocationTargetException e) { + BotLogger.error("Could not add ability", TAG, e); + throw propagate(e); + } + } + + /** + * Invokes the method and retrieves its returned Reply. + * + * @param method a method that returns a reply + * @return the reply returned by the method + */ + private Reply returnReply(Method method) { + try { + return (Reply) method.invoke(this); + } catch (IllegalAccessException | InvocationTargetException e) { + BotLogger.error("Could not add reply", TAG, e); + throw propagate(e); + } + } + + private void postConsumption(Pair pair) { + ofNullable(pair.b().postAction()) + .ifPresent(consumer -> consumer.accept(pair.a())); + } + + Pair consumeUpdate(Pair pair) { + pair.b().action().accept(pair.a()); + return pair; + } + + Pair getContext(Trio trio) { + Update update = trio.a(); + User user = AbilityUtils.getUser(update); + + return Pair.of(newContext(update, user, getChatId(update), trio.c()), trio.b()); + } + + boolean checkBlacklist(Update update) { + Integer id = AbilityUtils.getUser(update).getId(); + + return id == creatorId() || !blacklist().contains(id); + } + + boolean checkInput(Trio trio) { + String[] tokens = trio.c(); + int abilityTokens = trio.b().tokens(); + + boolean isOk = abilityTokens == 0 || (tokens.length > 0 && tokens.length == abilityTokens); + + if (!isOk) + silent.send( + getLocalizedMessage( + CHECK_INPUT_FAIL, + AbilityUtils.getUser(trio.a()).getLanguageCode(), + abilityTokens, abilityTokens == 1 ? "input" : "inputs"), + getChatId(trio.a())); + return isOk; + } + + boolean checkLocality(Trio trio) { + Update update = trio.a(); + Locality locality = isUserMessage(update) ? USER : GROUP; + Locality abilityLocality = trio.b().locality(); + + boolean isOk = abilityLocality == ALL || locality == abilityLocality; + + if (!isOk) + silent.send( + getLocalizedMessage( + CHECK_LOCALITY_FAIL, + AbilityUtils.getUser(trio.a()).getLanguageCode(), + abilityLocality.toString().toLowerCase()), + getChatId(trio.a())); + return isOk; + } + + boolean checkPrivacy(Trio trio) { + Update update = trio.a(); + User user = AbilityUtils.getUser(update); + Privacy privacy; + int id = user.getId(); + + privacy = getPrivacy(update, id); + + boolean isOk = privacy.compareTo(trio.b().privacy()) >= 0; + + if (!isOk) + silent.send( + getLocalizedMessage( + CHECK_PRIVACY_FAIL, + AbilityUtils.getUser(trio.a()).getLanguageCode()), + getChatId(trio.a())); + return isOk; + } + + @NotNull + private Privacy getPrivacy(Update update, int id) { + return isCreator(id) ? + CREATOR : isAdmin(id) ? + ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ? + GROUP_ADMIN : PUBLIC; + } + + private boolean isGroupAdmin(Update update, int id) { + GetChatAdministrators admins = new GetChatAdministrators().setChatId(getChatId(update)); + + return silent.execute(admins) + .orElse(new ArrayList<>()).stream() + .anyMatch(member -> member.getUser().getId() == id); + } + + private boolean isCreator(int id) { + return id == creatorId(); + } + + private boolean isAdmin(Integer id) { + return admins().contains(id); + } + + boolean validateAbility(Trio trio) { + return trio.b() != null; + } + + Trio getAbility(Update update) { + // Handle updates without messages + // Passing through this function means that the global flags have passed + Message msg = update.getMessage(); + if (!update.hasMessage() || !msg.hasText()) + return Trio.of(update, abilities.get(DEFAULT), new String[]{}); + + String[] tokens = msg.getText().split(" "); + + if (tokens[0].startsWith("/")) { + String abilityToken = stripBotUsername(tokens[0].substring(1)).toLowerCase(); + Ability ability = abilities.get(abilityToken); + tokens = Arrays.copyOfRange(tokens, 1, tokens.length); + return Trio.of(update, ability, tokens); + } else { + Ability ability = abilities.get(DEFAULT); + return Trio.of(update, ability, tokens); + } + } + + private String stripBotUsername(String token) { + return compile(format("@%s", botUsername), CASE_INSENSITIVE) + .matcher(token) + .replaceAll(""); + } + + Update addUser(Update update) { + User endUser = AbilityUtils.getUser(update); + + users().compute(endUser.getId(), (id, user) -> { + if (user == null) { + updateUserId(user, endUser); + return endUser; + } + + if (!user.equals(endUser)) { + updateUserId(user, endUser); + return endUser; + } + + return user; + }); + + db.commit(); + return update; + } + + private void updateUserId(User oldUser, User newUser) { + if (oldUser != null && oldUser.getUserName() != null) { + // Remove old username -> ID + userIds().remove(oldUser.getUserName()); + } + + if (newUser.getUserName() != null) { + // Add new mapping with the new username + userIds().put(newUser.getUserName().toLowerCase(), newUser.getId()); + } + } + + boolean filterReply(Update update) { + return replies.stream() + .filter(reply -> reply.isOkFor(update)) + .map(reply -> { + reply.actOn(update); + return false; + }) + .reduce(true, Boolean::logicalAnd); + } + + boolean checkMessageFlags(Trio trio) { + Ability ability = trio.b(); + Update update = trio.a(); + + // The following variable is required to avoid bug #JDK-8044546 + BiFunction, Boolean> flagAnd = (flag, nextFlag) -> flag && nextFlag.test(update); + return ability.flags().stream() + .reduce(true, flagAnd, Boolean::logicalAnd); + } + + private File downloadFileWithId(String fileId) throws TelegramApiException { + return sender.downloadFile(sender.execute(new GetFile().setFileId(fileId))); + } + + + private String escape(String username) { + return username.replace("_", "\\_"); + } +} \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java index eda5e9c1..1402394b 100644 --- a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java @@ -1,6 +1,6 @@ package org.telegram.abilitybots.api.db; -import org.telegram.abilitybots.api.bot.AbilityBot; +import org.telegram.abilitybots.api.bot.BaseAbilityBot; import org.telegram.telegrambots.api.objects.Update; import java.io.Closeable; @@ -12,7 +12,7 @@ import java.util.Set; * This interface represents the high-level methods exposed to the user when handling an {@link Update}. * Example usage: *

Ability.builder().action(ctx -> {db.getSet(USERS); doSomething();})*

- * {@link AbilityBot} contains a handle on the db that the user can use inside his declared abilities. + * {@link BaseAbilityBot} contains a handle on the db that the user can use inside his declared abilities. * * @author Abbas Abou Daya */ diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java index e9d2efd6..c7059eda 100644 --- a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java @@ -20,7 +20,7 @@ import static java.util.Objects.isNull; import static java.util.stream.Collectors.toMap; import static java.util.stream.StreamSupport.stream; import static org.mapdb.Serializer.JAVA; -import static org.telegram.abilitybots.api.bot.AbilityBot.USERS; +import static org.telegram.abilitybots.api.bot.BaseAbilityBot.USERS; /** * An implementation of {@link DBContext} that relies on a {@link DB}. diff --git a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java index 502f70a1..24f26118 100644 --- a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java +++ b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java @@ -13,15 +13,15 @@ import static com.google.common.collect.Maps.newHashMap; import static com.google.common.collect.Sets.newHashSet; import static java.lang.String.format; import static org.junit.Assert.*; -import static org.telegram.abilitybots.api.bot.AbilityBot.USERS; -import static org.telegram.abilitybots.api.bot.AbilityBot.USER_ID; import static org.telegram.abilitybots.api.bot.AbilityBotTest.CREATOR; import static org.telegram.abilitybots.api.bot.AbilityBotTest.USER; import static org.telegram.abilitybots.api.db.MapDBContext.offlineInstance; public class MapDBContextTest { - + private static final String USERS = "USERS"; + private static final String USER_ID = "USER_ID"; private static final String TEST = "TEST"; + private DBContext db; @Before