package org.telegram.abilitybots.api.bot; import com.google.common.collect.*; import com.google.common.collect.ImmutableList.Builder; import com.google.common.collect.ImmutableMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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.toggle.AbilityToggle; import org.telegram.abilitybots.api.util.AbilityExtension; 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.bots.DefaultAbsSender; import org.telegram.telegrambots.bots.DefaultBotOptions; import org.telegram.telegrambots.meta.api.methods.groupadministration.GetChatAdministrators; import org.telegram.telegrambots.meta.api.objects.Message; import org.telegram.telegrambots.meta.api.objects.Update; import org.telegram.telegrambots.meta.api.objects.User; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.*; 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.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.objects.Stats.createStats; 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 DefaultAbsSender}. *

* 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 */ @SuppressWarnings({"ConfusingArgumentToVarargsMethod", "UnusedReturnValue", "WeakerAccess", "unused", "ConstantConditions"}) public abstract class BaseAbilityBot extends DefaultAbsSender implements AbilityExtension { private static final Logger log = LoggerFactory.getLogger(BaseAbilityBot.class); 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 MessageSender sender; protected SilentSender silent; // Ability toggle private final AbilityToggle toggle; // Bot token and username private final String botToken; 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 int creatorId(); protected BaseAbilityBot(String botToken, String botUsername, DBContext db, AbilityToggle toggle, DefaultBotOptions botOptions) { super(botOptions); this.botToken = botToken; this.botUsername = botUsername; this.db = db; this.toggle = toggle; this.sender = new DefaultSender(this); silent = new SilentSender(sender); } public void onRegister() { registerAbilities(); initStats(); } /** * @return the database of this bot */ public DBContext db() { return db; } /** * @return the message sender for this bot */ public MessageSender sender() { return sender; } /** * @return the silent sender for this bot */ public SilentSender silent() { return silent; } /** * @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); } /** * @return a mapping of ability and reply names to their corresponding statistics */ public Map stats() { return stats; } /** * @return the immutable map of */ 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 */ public void onUpdateReceived(Update update) { log.info(format("[%s] New update [%s] received at %s", botUsername, update.getUpdateId(), now())); log.info(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.info(format("[%s] Processing of update [%s] ended at %s%n---> Processing time: [%d ms] <---%n", botUsername, update.getUpdateId(), now(), processingTime)); } public String getBotToken() { return botToken; } public String getBotUsername() { return botUsername; } public Privacy getPrivacy(Update update, int id) { return isCreator(id) ? CREATOR : isAdmin(id) ? ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ? GROUP_ADMIN : PUBLIC; } public boolean isGroupAdmin(Update update, int id) { return isGroupAdmin(getChatId(update), id); } public boolean isGroupAdmin(long chatId, int id) { GetChatAdministrators admins = GetChatAdministrators.builder().chatId(Long.toString(chatId)).build(); return silent.execute(admins) .orElse(new ArrayList<>()).stream() .anyMatch(member -> member.getUser().getId() == id); } public boolean isCreator(int id) { return id == creatorId(); } public boolean isAdmin(Integer 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); // 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.concat(abilityReplies, extensionReplies).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 -> 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); } }; } 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; } int 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) ? 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; } 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(update); updateReplyStats(reply); return false; }, reply.name())) .reduce(true, Boolean::logicalAnd); } boolean runSilently(Callable callable, String name) { try { return callable.call(); } catch(Exception ex) { log.error(format("Reply [%s] failed to check for conditions. " + "Make sure you're safeguarding against all possible updates.", name)); } 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); } }