All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot Maven / Gradle / Ivy

There is a newer version: 7.10.0
Show newest version
package org.telegram.telegrambots.abilitybots.api.bot;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import com.google.common.collect.ImmutableMap;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.telegram.telegrambots.abilitybots.api.db.DBContext;
import org.telegram.telegrambots.abilitybots.api.objects.Ability;
import org.telegram.telegrambots.abilitybots.api.objects.Locality;
import org.telegram.telegrambots.abilitybots.api.objects.MessageContext;
import org.telegram.telegrambots.abilitybots.api.objects.Privacy;
import org.telegram.telegrambots.abilitybots.api.objects.Reply;
import org.telegram.telegrambots.abilitybots.api.objects.ReplyCollection;
import org.telegram.telegrambots.abilitybots.api.objects.Stats;
import org.telegram.telegrambots.abilitybots.api.sender.SilentSender;
import org.telegram.telegrambots.abilitybots.api.toggle.AbilityToggle;
import org.telegram.telegrambots.abilitybots.api.util.AbilityExtension;
import org.telegram.telegrambots.abilitybots.api.util.AbilityUtils;
import org.telegram.telegrambots.abilitybots.api.util.Pair;
import org.telegram.telegrambots.abilitybots.api.util.Trio;
import org.telegram.telegrambots.longpolling.util.LongPollingSingleThreadUpdateConsumer;
import org.telegram.telegrambots.meta.api.methods.groupadministration.GetChatAdministrators;
import org.telegram.telegrambots.meta.api.objects.message.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberAdministrator;
import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberOwner;
import org.telegram.telegrambots.meta.generics.TelegramClient;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.google.common.collect.Sets.difference;
import static java.lang.String.format;
import static java.time.ZonedDateTime.now;
import static java.util.Arrays.stream;
import static java.util.Comparator.comparingInt;
import static java.util.Objects.isNull;
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.toSet;
import static org.telegram.telegrambots.abilitybots.api.objects.MessageContext.newContext;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityMessageCodes.CHECK_INPUT_FAIL;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityMessageCodes.CHECK_LOCALITY_FAIL;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityMessageCodes.CHECK_PRIVACY_FAIL;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.EMPTY_USER;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.getChatId;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.getLocalizedMessage;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.getUser;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.isGroupUpdate;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.isSuperGroupUpdate;
import static org.telegram.telegrambots.abilitybots.api.util.AbilityUtils.isUserMessage;

/**
 * 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 implements {@link LongPollingSingleThreadUpdateConsumer}. *

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

    *
  • /claim - Claims this bot
  • *
      *
    • Sets the user as the {@link Privacy#CREATOR} of the bot
    • *
    • Only the user with the ID returned by {@link BaseAbilityBot#creatorId()} can genuinely claim the bot
    • *
    *
  • /report - reports all user-defined commands (abilities)
  • *
      *
    • The same format acceptable by BotFather
    • *
    *
  • /commands - returns a list of all possible bot commands based on the privacy of the requesting user
  • *
  • /backup - returns a backup of the bot database
  • *
  • /recover - recovers the database
  • *
  • /promote @username - promotes user to bot admin
  • *
  • /demote @username - demotes bot admin to user
  • *
  • /ban @username - bans the user from accessing your bot commands and features
  • *
  • /unban @username - lifts the ban from the user
  • *
*

* 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 TelegramClient} sender. * All bots extending BaseAbilityBot can use both handles in their update consumers. * * @author Abbas Abou Daya */ @Getter @Slf4j public abstract class BaseAbilityBot implements AbilityExtension, LongPollingSingleThreadUpdateConsumer { protected static final String DEFAULT = "default"; // 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"; public static final String STATS = "ABILITYBOT_STATS"; // DB and sender protected final DBContext db; protected TelegramClient telegramClient; protected SilentSender silent; // Ability toggle private final AbilityToggle toggle; // Bot username private final String botUsername; // Ability registry private final List extensions = new ArrayList<>(); private Map abilities; private Map stats; // Reply registry private List replies; public abstract long creatorId(); protected BaseAbilityBot(TelegramClient telegramClient, String botUsername, DBContext db, AbilityToggle toggle) { this.telegramClient = telegramClient; this.botUsername = botUsername; this.db = db; this.toggle = toggle; this.silent = new SilentSender(telegramClient); } public void onRegister() { registerAbilities(); initStats(); } /** * @return the map of */ public Map users() { return db.getMap(USERS); } /** * @return the map of */ public Map userIds() { return db.getMap(USER_ID); } /** * @return a blacklist containing all the IDs of the banned users */ public Set blacklist() { return db.getSet(BLACKLIST); } /** * @return an admin set of all the IDs of bot administrators */ public Set admins() { return db.getSet(ADMINS); } /** * 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 consume(Update update) { log.debug(format("[%s] New update [%s] received at %s", botUsername, update.getUpdateId(), now())); log.debug(update.toString()); long millisStarted = System.currentTimeMillis(); Stream.of(update) .filter(this::checkGlobalFlags) .filter(this::checkBlacklist) .map(this::addUser) .filter(this::filterReply) .filter(this::hasUser) .map(this::getAbility) .filter(this::validateAbility) .filter(this::checkPrivacy) .filter(this::checkLocality) .filter(this::checkInput) .filter(this::checkMessageFlags) .map(this::getContext) .map(this::consumeUpdate) .map(this::updateStats) .forEach(this::postConsumption); // Commit to DB now after all the actions have been dealt db.commit(); long processingTime = System.currentTimeMillis() - millisStarted; log.debug(format("[%s] Processing of update [%s] ended at %s%n---> Processing time: [%d ms] <---%n", botUsername, update.getUpdateId(), now(), processingTime)); } public Privacy getPrivacy(Update update, long id) { return isCreator(id) ? Privacy.CREATOR : isAdmin(id) ? Privacy.ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ? Privacy.GROUP_ADMIN : Privacy.PUBLIC; } public boolean isGroupAdmin(Update update, long id) { return isGroupAdmin(getChatId(update), id); } public boolean isGroupAdmin(long chatId, long id) { GetChatAdministrators admins = GetChatAdministrators.builder().chatId(chatId).build(); return silent.execute(admins) .orElse(new ArrayList<>()) .stream() .map(member -> { final String status = member.getStatus(); if (status.equals(ChatMemberOwner.STATUS) || status.equals(ChatMemberAdministrator.STATUS)) { return member.getUser().getId(); } return 0L; }) .anyMatch(member -> member == id); } public boolean isCreator(long id) { return id == creatorId(); } public boolean isAdmin(long id) { return admins().contains(id); } /** * 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; } protected String getCommandPrefix() { return "/"; } protected String getCommandRegexSplit() { return " "; } protected boolean allowContinuousText() { return false; } protected void addExtension(AbilityExtension extension) { this.extensions.add(extension); } protected void addExtensions(AbilityExtension... extensions) { this.extensions.addAll(Arrays.asList(extensions)); } protected void addExtensions(Collection extensions) { this.extensions.addAll(extensions); } /** * 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 { // Collect all classes that implement AbilityExtension declared in the bot extensions.addAll(stream(getClass().getMethods()) .filter(checkReturnType(AbilityExtension.class)) .map(returnExtension(this)) .collect(Collectors.toList())); // Add the bot itself as it is an AbilityExtension extensions.add(this); DefaultAbilities defaultAbs = new DefaultAbilities(this); Stream defaultAbsStream = stream(DefaultAbilities.class.getMethods()) .filter(checkReturnType(Ability.class)) .map(returnAbility(defaultAbs)) .filter(ab -> !toggle.isOff(ab)) .map(toggle::processAbility); // Extract all abilities from every single extension instance abilities = Stream.concat(defaultAbsStream, extensions.stream() .flatMap(ext -> stream(ext.getClass().getMethods()) .filter(checkReturnType(Ability.class)) .map(returnAbility(ext)))) // Abilities are immutable, build it respectively .collect(ImmutableMap::builder, (b, a) -> b.put(a.name(), a), (b1, b2) -> b1.putAll(b2.build())) .build(); // Extract all replies from every single extension instance Stream extensionReplies = extensions.stream() .flatMap(ext -> stream(ext.getClass().getMethods()) .filter(checkReturnType(Reply.class)) .map(returnReply(ext))) .flatMap(Reply::stream); // Extract all replies from extension instances methods, returning ReplyCollection Stream extensionCollectionReplies = extensions.stream() .flatMap(extension -> stream(extension.getClass().getMethods()) .filter(checkReturnType(ReplyCollection.class)) .map(returnReplyCollection(extension)) .flatMap(ReplyCollection::stream)); // Replies can be standalone or attached to abilities, fetch those too Stream abilityReplies = abilities.values().stream() .flatMap(ability -> ability.replies().stream()) .flatMap(Reply::stream); // Now create the replies registry (list) replies = Stream.of(abilityReplies, extensionReplies, extensionCollectionReplies) .flatMap(replyStream -> replyStream) .collect( ImmutableList::builder, Builder::add, (b1, b2) -> b1.addAll(b2.build())) .build(); } catch (IllegalStateException e) { log.error("Duplicate names found while registering abilities. Make sure that the abilities declared don't clash with the reserved ones.", e); throw new RuntimeException(e); } } private void initStats() { Set enabledStats = Stream.concat( replies.stream().filter(Reply::statsEnabled).map(Reply::name), abilities.entrySet().stream() .filter(entry -> entry.getValue().statsEnabled()) .map(Map.Entry::getKey)).collect(toSet()); stats = db.getMap(STATS); Set toBeRemoved = difference(stats.keySet(), enabledStats); toBeRemoved.forEach(stats::remove); enabledStats.forEach(abName -> stats.computeIfAbsent(abName, name -> Stats.createStats(abName, 0))); } /** * @param clazz the type to be tested * @return a predicate testing the return type of the method corresponding to the class parameter */ private static Predicate checkReturnType(Class clazz) { return method -> clazz.isAssignableFrom(method.getReturnType()); } /** * Invokes the method and retrieves its return {@link Reply}. * * @param obj a bot or extension that this method is invoked with * @return a {@link Function} which returns the {@link Reply} returned by the given method */ private Function returnExtension(Object obj) { return method -> { try { return (AbilityExtension) method.invoke(obj); } catch (IllegalAccessException | InvocationTargetException e) { log.error("Could not add ability extension", e); throw new RuntimeException(e); } }; } /** * Invokes the method and retrieves its return {@link Ability}. * * @param obj a bot or extension that this method is invoked with * @return a {@link Function} which returns the {@link Ability} returned by the given method */ private static Function returnAbility(Object obj) { return method -> { try { return (Ability) method.invoke(obj); } catch (IllegalAccessException | InvocationTargetException e) { log.error("Could not add ability", e); throw new RuntimeException(e); } }; } /** * Invokes the method and retrieves its return {@link Reply}. * * @param obj a bot or extension that this method is invoked with * @return a {@link Function} which returns the {@link Reply} returned by the given method */ private static Function returnReply(Object obj) { return method -> { try { return (Reply) method.invoke(obj); } catch (IllegalAccessException | InvocationTargetException e) { log.error("Could not add reply", e); throw new RuntimeException(e); } }; } /** * Invokes the method and retrieves its return {@link ReplyCollection}. * * @param obj a bot or extension that this method is invoked with * @return a {@link Function} which returns the {@link ReplyCollection} returned by the given method */ private static Function returnReplyCollection(Object obj) { return method -> { try { return (ReplyCollection) method.invoke(obj); } catch (IllegalAccessException | InvocationTargetException e) { log.error("Could not add Reply Collection", e); throw new RuntimeException(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 updateStats(Pair pair) { Ability ab = pair.b(); if (ab.statsEnabled()) { updateStats(pair.b().name()); } return pair; } private void updateReplyStats(Reply reply) { if (reply.statsEnabled()) { updateStats(reply.name()); } } void updateStats(String name) { Stats statsObj = stats.get(name); statsObj.hit(); stats.put(name, statsObj); } Pair getContext(Trio trio) { Update update = trio.a(); User user = AbilityUtils.getUser(update); return Pair.of(newContext(update, user, getChatId(update), this, trio.c()), trio.b()); } boolean checkBlacklist(Update update) { User user = getUser(update); if (isNull(user)) { return true; } long id = user.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) ? Locality.USER : Locality.GROUP; Locality abilityLocality = trio.b().locality(); boolean isOk = abilityLocality == Locality.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; long 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; } 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[]{}); Ability ability; String[] tokens; if (allowContinuousText()) { String abName = abilities.keySet().stream() .filter(name -> msg.getText().startsWith(format("%s%s", getCommandPrefix(), name))) .max(comparingInt(String::length)) .orElse(DEFAULT); tokens = msg.getText() .replaceFirst(getCommandPrefix() + abName, "") .split(getCommandRegexSplit()); ability = abilities.get(abName); } else { tokens = msg.getText().split(getCommandRegexSplit()); if (tokens[0].startsWith(getCommandPrefix())) { String abilityToken = stripBotUsername(tokens[0].substring(1)).toLowerCase(); ability = abilities.get(abilityToken); tokens = Arrays.copyOfRange(tokens, 1, tokens.length); } else { 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); if (endUser.equals(EMPTY_USER)) { // Can't add an empty user, return the update as is return 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; }); return update; } private boolean hasUser(Update update) { // Valid updates without users should return an empty user // Updates that are not recognized by the getUser method will throw an exception return !AbilityUtils.getUser(update).equals(EMPTY_USER); } 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 -> runSilently(() -> reply.isOkFor(update), reply.name())) .map(reply -> runSilently(() -> { reply.actOn(this, update); updateReplyStats(reply); return false; }, reply.name())) .reduce(true, Boolean::logicalAnd); } boolean runSilently(Callable callable, String name) { try { return callable.call(); } catch(Exception ex) { String msg = format("Reply [%s] failed to check for conditions. " + "Make sure you're safeguarding against all possible updates.", name); if (log.isDebugEnabled()) { log.error(msg, ex); } else { log.error(msg); } } return false; } 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); } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy