org.telegram.telegrambots.abilitybots.api.bot.BaseAbilityBot Maven / Gradle / Ivy
Show all versions of telegrambots-abilities Show documentation
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 super Method, AbilityExtension> 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 super Method, Ability> 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 super Method, Reply> 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 super Method, ReplyCollection> 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);
}
}