TDLightTelegramBots/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java

715 lines
27 KiB
Java

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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.telegram.abilitybots.api.db.DBContext;
import org.telegram.abilitybots.api.objects.Ability;
import org.telegram.abilitybots.api.objects.Locality;
import org.telegram.abilitybots.api.objects.MessageContext;
import org.telegram.abilitybots.api.objects.Privacy;
import org.telegram.abilitybots.api.objects.Reply;
import org.telegram.abilitybots.api.objects.ReplyCollection;
import org.telegram.abilitybots.api.objects.Stats;
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 org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberAdministrator;
import org.telegram.telegrambots.meta.api.objects.chatmember.ChatMemberOwner;
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.abilitybots.api.objects.Locality.ALL;
import static org.telegram.abilitybots.api.objects.Locality.GROUP;
import static org.telegram.abilitybots.api.objects.Locality.USER;
import static org.telegram.abilitybots.api.objects.MessageContext.newContext;
import static org.telegram.abilitybots.api.objects.Privacy.ADMIN;
import static org.telegram.abilitybots.api.objects.Privacy.CREATOR;
import static org.telegram.abilitybots.api.objects.Privacy.GROUP_ADMIN;
import static org.telegram.abilitybots.api.objects.Privacy.PUBLIC;
import static org.telegram.abilitybots.api.objects.Stats.createStats;
import static org.telegram.abilitybots.api.util.AbilityMessageCodes.CHECK_INPUT_FAIL;
import static org.telegram.abilitybots.api.util.AbilityMessageCodes.CHECK_LOCALITY_FAIL;
import static org.telegram.abilitybots.api.util.AbilityMessageCodes.CHECK_PRIVACY_FAIL;
import static org.telegram.abilitybots.api.util.AbilityUtils.EMPTY_USER;
import static org.telegram.abilitybots.api.util.AbilityUtils.getChatId;
import static org.telegram.abilitybots.api.util.AbilityUtils.getLocalizedMessage;
import static org.telegram.abilitybots.api.util.AbilityUtils.getUser;
import static org.telegram.abilitybots.api.util.AbilityUtils.isGroupUpdate;
import static org.telegram.abilitybots.api.util.AbilityUtils.isSuperGroupUpdate;
import static org.telegram.abilitybots.api.util.AbilityUtils.isUserMessage;
/**
* The <b>father</b> of all ability bots. Bots that need to utilize abilities need to extend this bot.
* <p>
* It's important to note that this bot strictly extends {@link DefaultAbsSender}.
* <p>
* All bots extending the {@link BaseAbilityBot} get implicit abilities:
* <ul>
* <li>/claim - Claims this bot</li>
* <ul>
* <li>Sets the user as the {@link Privacy#CREATOR} of the bot</li>
* <li>Only the user with the ID returned by {@link BaseAbilityBot#creatorId()} can genuinely claim the bot</li>
* </ul>
* <li>/report - reports all user-defined commands (abilities)</li>
* <ul>
* <li>The same format acceptable by BotFather</li>
* </ul>
* <li>/commands - returns a list of all possible bot commands based on the privacy of the requesting user</li>
* <li>/backup - returns a backup of the bot database</li>
* <li>/recover - recovers the database</li>
* <li>/promote <code>@username</code> - promotes user to bot admin</li>
* <li>/demote <code>@username</code> - demotes bot admin to user</li>
* <li>/ban <code>@username</code> - bans the user from accessing your bot commands and features</li>
* <li>/unban <code>@username</code> - lifts the ban from the user</li>
* </ul>
* <p>
* Additional information of the implicit abilities are present in the methods that declare them.
* <p>
* The two most important handles in the BaseAbilityBot are the {@link DBContext} <b><code>db</code></b> and the {@link MessageSender} <b><code>sender</code></b>.
* All bots extending BaseAbilityBot can use both handles in their update consumers.
*
* @author Abbas Abou Daya
*/
@SuppressWarnings({"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 username
private final String botUsername;
// Ability registry
private final List<AbilityExtension> extensions = new ArrayList<>();
private Map<String, Ability> abilities;
private Map<String, Stats> stats;
// Reply registry
private List<Reply> replies;
public abstract long creatorId();
protected BaseAbilityBot(String botToken, String botUsername, DBContext db, AbilityToggle toggle, DefaultBotOptions botOptions) {
super(botOptions, 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 <ID,User>
*/
public Map<Long, User> users() {
return db.getMap(USERS);
}
/**
* @return the map of <Username,ID>
*/
public Map<String, Long> userIds() {
return db.getMap(USER_ID);
}
/**
* @return a blacklist containing all the IDs of the banned users
*/
public Set<Long> blacklist() {
return db.getSet(BLACKLIST);
}
/**
* @return an admin set of all the IDs of bot administrators
*/
public Set<Long> admins() {
return db.getSet(ADMINS);
}
/**
* @return a mapping of ability and reply names to their corresponding statistics
*/
public Map<String, Stats> stats() {
return stats;
}
/**
* @return the immutable map of <String,Ability>
*/
public Map<String, Ability> abilities() {
return abilities;
}
/**
* @return the immutable list carrying the embedded replies
*/
public List<Reply> replies() {
return replies;
}
/**
* This method contains the stream of actions that are applied on any update.
* <p>
* 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 getBotUsername() {
return botUsername;
}
public Privacy getPrivacy(Update update, long id) {
return isCreator(id) ?
CREATOR : isAdmin(id) ?
ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ?
GROUP_ADMIN : 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.
* <p>
* This method should be <b>overridden</b> if the user wants to restrict bot usage to only certain updates.
*
* @param update a Telegram {@link Update}
* @return <tt>true</tt> 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<AbilityExtension> 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.
* <p>
* <b>Only abilities and replies with the <u>public</u> accessor are registered!</b>
*/
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<Ability> 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::<String, Ability>builder,
(b, a) -> b.put(a.name(), a),
(b1, b2) -> b1.putAll(b2.build()))
.build();
// Extract all replies from every single extension instance
Stream<Reply> 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<Reply> 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<Reply> 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::<Reply>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<String> 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<String> 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<Method> 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<MessageContext, Ability> pair) {
ofNullable(pair.b().postAction())
.ifPresent(consumer -> consumer.accept(pair.a()));
}
Pair<MessageContext, Ability> consumeUpdate(Pair<MessageContext, Ability> pair) {
pair.b().action().accept(pair.a());
return pair;
}
Pair<MessageContext, Ability> updateStats(Pair<MessageContext, Ability> 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<MessageContext, Ability> getContext(Trio<Update, Ability, String[]> 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<Update, Ability, String[]> 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<Update, Ability, String[]> 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<Update, Ability, String[]> 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<Update, Ability, String[]> trio) {
return trio.b() != null;
}
Trio<Update, Ability, String[]> 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<Boolean> 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<Update, Ability, String[]> trio) {
Ability ability = trio.b();
Update update = trio.a();
// The following variable is required to avoid bug #JDK-8044546
BiFunction<Boolean, Predicate<Update>, Boolean> flagAnd = (flag, nextFlag) -> flag && nextFlag.test(update);
return ability.flags().stream()
.reduce(true, flagAnd, Boolean::logicalAnd);
}
}