Merge branch 'webhook' of https://github.com/addo37/TelegramBots into addo37-webhook
# Conflicts: # telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java # telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java # telegrambots/src/main/java/org/telegram/telegrambots/bots/DefaultAbsSender.java # telegrambots/src/main/java/org/telegram/telegrambots/bots/TelegramLongPollingBot.java # telegrambots/src/main/java/org/telegram/telegrambots/bots/TelegramWebhookBot.java
This commit is contained in:
commit
7daebe1318
@ -1,146 +1,23 @@
|
||||
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.meta.api.methods.GetFile;
|
||||
import org.telegram.telegrambots.meta.api.methods.groupadministration.GetChatAdministrators;
|
||||
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
|
||||
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.api.objects.Update;
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions;
|
||||
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
|
||||
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
|
||||
import org.telegram.telegrambots.meta.logging.BotLogger;
|
||||
import org.telegram.telegrambots.exceptions.TelegramApiRequestException;
|
||||
import org.telegram.telegrambots.generics.LongPollingBot;
|
||||
import org.telegram.telegrambots.util.WebhookUtils;
|
||||
|
||||
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 <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 TelegramLongPollingBot}.
|
||||
* <p>
|
||||
* All bots extending the {@link AbilityBot} 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 AbilityBot#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 AbilityBot are the {@link DBContext} <b><code>db</code></b> and the {@link MessageSender} <b><code>sender</code></b>.
|
||||
* 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
|
||||
*/
|
||||
@SuppressWarnings({"WeakerAccess", "UnusedReturnValue", "ConfusingArgumentToVarargsMethod", "ConstantConditions"})
|
||||
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<String, Ability> abilities;
|
||||
|
||||
// Reply registry
|
||||
private List<Reply> replies;
|
||||
|
||||
public abstract int creatorId();
|
||||
|
||||
public abstract class AbilityBot extends BaseAbilityBot implements LongPollingBot {
|
||||
protected AbilityBot(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();
|
||||
super(botToken, botUsername, db, botOptions);
|
||||
}
|
||||
|
||||
protected AbilityBot(String botToken, String botUsername, DBContext db) {
|
||||
@ -155,722 +32,13 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
|
||||
this(botToken, botUsername, onlineInstance(botUsername));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the map of ID -> User
|
||||
*/
|
||||
protected Map<Integer, User> users() {
|
||||
return db.getMap(USERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the map of Username -> ID
|
||||
*/
|
||||
protected Map<String, Integer> userIds() {
|
||||
return db.getMap(USER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a blacklist containing all the IDs of the banned users
|
||||
*/
|
||||
protected Set<Integer> blacklist() {
|
||||
return db.getSet(BLACKLIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an admin set of all the IDs of bot administrators
|
||||
*/
|
||||
protected Set<Integer> admins() {
|
||||
return db.getSet(ADMINS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
@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));
|
||||
super.onUpdateReceived(update);
|
||||
}
|
||||
|
||||
@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.
|
||||
* <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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Format of the report:
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* [command2] - [description2]
|
||||
* <p>
|
||||
* ...
|
||||
* <p>
|
||||
* Once you invoke it, the bot will send the available commands to the chat. This is a public ability so anyone can invoke it.
|
||||
* <p>
|
||||
* Usage: <code>/commands</code>
|
||||
*
|
||||
* @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:
|
||||
* <p>
|
||||
* PUBLIC
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* [command2] - [description2]
|
||||
* <p>
|
||||
* GROUP_ADMIN
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* ...
|
||||
*
|
||||
* @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<Privacy, String> 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.
|
||||
* <p>
|
||||
* This is a high-profile ability and is restricted to the CREATOR only.
|
||||
* <p>
|
||||
* Usage: <code>/backup</code>
|
||||
*
|
||||
* @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()
|
||||
.setDocument(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)}.
|
||||
* <p>
|
||||
* The bot recovery process hugely depends on the implementation of the recovery method of {@link DBContext}.
|
||||
* <p>
|
||||
* Usage: <code>/recover</code>
|
||||
*
|
||||
* @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}.
|
||||
* <p>
|
||||
* Usage: <code>/ban @username</code>
|
||||
* <p>
|
||||
* <u>Note that admins who try to ban the creator, get banned.</u>
|
||||
*
|
||||
* @return the ability to ban the user from any kind of <b>bot interaction</b>
|
||||
*/
|
||||
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<Integer> 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: <code>/unban @username</code>
|
||||
*
|
||||
* @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<Integer> 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<Integer> 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<Integer> 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 <b>banned</b>.
|
||||
*
|
||||
* @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<Integer> 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<Message> send(String message, MessageContext ctx, String... args) {
|
||||
return silent.send(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId());
|
||||
}
|
||||
|
||||
private Optional<Message> sendMd(String message, MessageContext ctx, String... args) {
|
||||
return silent.sendMd(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId());
|
||||
}
|
||||
|
||||
private Optional<Message> 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.
|
||||
* <p>
|
||||
* <b>Only abilities and replies with the <u>public</u> accessor are registered!</b>
|
||||
*/
|
||||
private void registerAbilities() {
|
||||
try {
|
||||
abilities = stream(this.getClass().getMethods())
|
||||
.filter(method -> method.getReturnType().equals(Ability.class))
|
||||
.map(this::returnAbility)
|
||||
.collect(ImmutableMap::<String, Ability>builder,
|
||||
(b, a) -> b.put(a.name(), a),
|
||||
(b1, b2) -> b1.putAll(b2.build()))
|
||||
.build();
|
||||
|
||||
Stream<Reply> methodReplies = stream(this.getClass().getMethods())
|
||||
.filter(method -> method.getReturnType().equals(Reply.class))
|
||||
.map(this::returnReply);
|
||||
|
||||
Stream<Reply> abilityReplies = abilities.values().stream()
|
||||
.flatMap(ability -> ability.replies().stream());
|
||||
|
||||
replies = Stream.concat(methodReplies, abilityReplies).collect(
|
||||
ImmutableList::<Reply>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<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> getContext(Trio<Update, Ability, String[]> 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<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;
|
||||
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<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[]{});
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
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 clearWebhook() throws TelegramApiRequestException {
|
||||
WebhookUtils.clearWebhook(this);
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
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 org.telegram.telegrambots.util.WebhookUtils;
|
||||
|
||||
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 String botPath;
|
||||
|
||||
protected AbilityWebhookBot(String botToken, String botUsername, String botPath, DBContext db, DefaultBotOptions botOptions) {
|
||||
super(botToken, botUsername, db, botOptions);
|
||||
this.botPath = 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) {
|
||||
super.onUpdateReceived(update);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWebhook(String url, String publicCertificatePath) throws TelegramApiRequestException {
|
||||
WebhookUtils.setWebhook(this, url, publicCertificatePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getBotPath() {
|
||||
return botPath;
|
||||
}
|
||||
}
|
@ -0,0 +1,861 @@
|
||||
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.DefaultAbsSender;
|
||||
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 <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 TelegramLongPollingBot}.
|
||||
* <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
|
||||
*/
|
||||
public abstract class BaseAbilityBot extends DefaultAbsSender {
|
||||
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<String, Ability> abilities;
|
||||
|
||||
// Reply registry
|
||||
private List<Reply> 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<Integer, User> users() {
|
||||
return db.getMap(USERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the map of Username -> ID
|
||||
*/
|
||||
protected Map<String, Integer> userIds() {
|
||||
return db.getMap(USER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a blacklist containing all the IDs of the banned users
|
||||
*/
|
||||
protected Set<Integer> blacklist() {
|
||||
return db.getSet(BLACKLIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return an admin set of all the IDs of bot administrators
|
||||
*/
|
||||
protected Set<Integer> admins() {
|
||||
return db.getSet(ADMINS);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
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 String getBotToken() {
|
||||
return botToken;
|
||||
}
|
||||
|
||||
public String getBotUsername() {
|
||||
return botUsername;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* Format of the report:
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* [command2] - [description2]
|
||||
* <p>
|
||||
* ...
|
||||
* <p>
|
||||
* Once you invoke it, the bot will send the available commands to the chat. This is a public ability so anyone can invoke it.
|
||||
* <p>
|
||||
* Usage: <code>/commands</code>
|
||||
*
|
||||
* @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:
|
||||
* <p>
|
||||
* PUBLIC
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* [command2] - [description2]
|
||||
* <p>
|
||||
* GROUP_ADMIN
|
||||
* <p>
|
||||
* [command1] - [description1]
|
||||
* <p>
|
||||
* ...
|
||||
*
|
||||
* @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<Privacy, String> 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.
|
||||
* <p>
|
||||
* This is a high-profile ability and is restricted to the CREATOR only.
|
||||
* <p>
|
||||
* Usage: <code>/backup</code>
|
||||
*
|
||||
* @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)}.
|
||||
* <p>
|
||||
* The bot recovery process hugely depends on the implementation of the recovery method of {@link DBContext}.
|
||||
* <p>
|
||||
* Usage: <code>/recover</code>
|
||||
*
|
||||
* @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}.
|
||||
* <p>
|
||||
* Usage: <code>/ban @username</code>
|
||||
* <p>
|
||||
* <u>Note that admins who try to ban the creator, get banned.</u>
|
||||
*
|
||||
* @return the ability to ban the user from any kind of <b>bot interaction</b>
|
||||
*/
|
||||
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<Integer> 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: <code>/unban @username</code>
|
||||
*
|
||||
* @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<Integer> 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<Integer> 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<Integer> 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 <b>banned</b>.
|
||||
*
|
||||
* @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<Integer> 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<Message> send(String message, MessageContext ctx, String... args) {
|
||||
return silent.send(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId());
|
||||
}
|
||||
|
||||
private Optional<Message> sendMd(String message, MessageContext ctx, String... args) {
|
||||
return silent.sendMd(getLocalizedMessage(message, ctx.user().getLanguageCode(), args), ctx.chatId());
|
||||
}
|
||||
|
||||
private Optional<Message> 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.
|
||||
* <p>
|
||||
* <b>Only abilities and replies with the <u>public</u> accessor are registered!</b>
|
||||
*/
|
||||
private void registerAbilities() {
|
||||
try {
|
||||
abilities = stream(this.getClass().getMethods())
|
||||
.filter(method -> method.getReturnType().equals(Ability.class))
|
||||
.map(this::returnAbility)
|
||||
.collect(ImmutableMap::<String, Ability>builder,
|
||||
(b, a) -> b.put(a.name(), a),
|
||||
(b1, b2) -> b1.putAll(b2.build()))
|
||||
.build();
|
||||
|
||||
Stream<Reply> methodReplies = stream(this.getClass().getMethods())
|
||||
.filter(method -> method.getReturnType().equals(Reply.class))
|
||||
.map(this::returnReply);
|
||||
|
||||
Stream<Reply> abilityReplies = abilities.values().stream()
|
||||
.flatMap(ability -> ability.replies().stream());
|
||||
|
||||
replies = Stream.concat(methodReplies, abilityReplies).collect(
|
||||
ImmutableList::<Reply>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<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> getContext(Trio<Update, Ability, String[]> 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<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;
|
||||
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<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[]{});
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
private File downloadFileWithId(String fileId) throws TelegramApiException {
|
||||
return sender.downloadFile(sender.execute(new GetFile().setFileId(fileId)));
|
||||
}
|
||||
|
||||
|
||||
private String escape(String username) {
|
||||
return username.replace("_", "\\_");
|
||||
}
|
||||
}
|
@ -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.meta.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:
|
||||
* <p><code>Ability.builder().action(ctx -> {db.getSet(USERS); doSomething();})* </code></p>
|
||||
* {@link AbilityBot} contains a handle on the <code>db</code> that the user can use inside his declared abilities.
|
||||
* {@link BaseAbilityBot} contains a handle on the <code>db</code> that the user can use inside his declared abilities.
|
||||
*
|
||||
* @author Abbas Abou Daya
|
||||
*/
|
||||
|
@ -21,7 +21,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}.
|
||||
|
@ -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
|
||||
|
@ -777,7 +777,7 @@ public abstract class DefaultAbsSender extends AbsSender {
|
||||
}
|
||||
}
|
||||
|
||||
protected String getBaseUrl() {
|
||||
public String getBaseUrl() {
|
||||
return options.getBaseUrl() + getBotToken() + "/";
|
||||
}
|
||||
|
||||
|
@ -23,19 +23,11 @@ public abstract class TelegramLongPollingBot extends DefaultAbsSender implements
|
||||
|
||||
@Override
|
||||
public void clearWebhook() throws TelegramApiRequestException {
|
||||
try {
|
||||
boolean result = execute(new DeleteWebhook());
|
||||
if (!result) {
|
||||
throw new TelegramApiRequestException("Error removing old webhook");
|
||||
}
|
||||
} catch (TelegramApiException e) {
|
||||
throw new TelegramApiRequestException("Error removing old webhook", e);
|
||||
}
|
||||
WebhookUtils.clearWebhook(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClosing() {
|
||||
exe.shutdown();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
@ -30,54 +30,16 @@ import java.nio.charset.StandardCharsets;
|
||||
* @date 14 of January of 2016
|
||||
*/
|
||||
public abstract class TelegramWebhookBot extends DefaultAbsSender implements WebhookBot {
|
||||
private final DefaultBotOptions botOptions;
|
||||
public TelegramWebhookBot() {
|
||||
this(ApiContext.getInstance(DefaultBotOptions.class));
|
||||
}
|
||||
|
||||
public TelegramWebhookBot() {
|
||||
this(ApiContext.getInstance(DefaultBotOptions.class));
|
||||
}
|
||||
public TelegramWebhookBot(DefaultBotOptions options) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
public TelegramWebhookBot(DefaultBotOptions options) {
|
||||
super(options);
|
||||
this.botOptions = options;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setWebhook(String url, String publicCertificatePath) throws TelegramApiRequestException {
|
||||
try (CloseableHttpClient httpclient = TelegramHttpClientBuilder.build(getOptions())) {
|
||||
String requestUrl = getBaseUrl() + SetWebhook.PATH;
|
||||
|
||||
HttpPost httppost = new HttpPost(requestUrl);
|
||||
httppost.setConfig(botOptions.getRequestConfig());
|
||||
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
|
||||
builder.addTextBody(SetWebhook.URL_FIELD, url);
|
||||
if (botOptions.getMaxWebhookConnections() != null) {
|
||||
builder.addTextBody(SetWebhook.MAXCONNECTIONS_FIELD, botOptions.getMaxWebhookConnections().toString());
|
||||
}
|
||||
if (botOptions.getAllowedUpdates() != null) {
|
||||
builder.addTextBody(SetWebhook.ALLOWEDUPDATES_FIELD, new JSONArray(botOptions.getMaxWebhookConnections()).toString());
|
||||
}
|
||||
if (publicCertificatePath != null) {
|
||||
File certificate = new File(publicCertificatePath);
|
||||
if (certificate.exists()) {
|
||||
builder.addBinaryBody(SetWebhook.CERTIFICATE_FIELD, certificate, ContentType.TEXT_PLAIN, certificate.getName());
|
||||
}
|
||||
}
|
||||
HttpEntity multipart = builder.build();
|
||||
httppost.setEntity(multipart);
|
||||
try (CloseableHttpResponse response = httpclient.execute(httppost)) {
|
||||
HttpEntity ht = response.getEntity();
|
||||
BufferedHttpEntity buf = new BufferedHttpEntity(ht);
|
||||
String responseContent = EntityUtils.toString(buf, StandardCharsets.UTF_8);
|
||||
JSONObject jsonObject = new JSONObject(responseContent);
|
||||
if (!jsonObject.getBoolean(ApiConstants.RESPONSE_FIELD_OK)) {
|
||||
throw new TelegramApiRequestException("Error setting webhook", jsonObject);
|
||||
}
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
throw new TelegramApiRequestException("Error deserializing setWebhook method response", e);
|
||||
} catch (IOException e) {
|
||||
throw new TelegramApiRequestException("Error executing setWebook method", e);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@Override
|
||||
public void setWebhook(String url, String publicCertificatePath) throws TelegramApiRequestException {
|
||||
WebhookUtils.setWebhook(this, url, publicCertificatePath);
|
||||
}
|
||||
}
|
@ -0,0 +1,82 @@
|
||||
package org.telegram.telegrambots.util;
|
||||
|
||||
import org.apache.http.HttpEntity;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpPost;
|
||||
import org.apache.http.entity.BufferedHttpEntity;
|
||||
import org.apache.http.entity.ContentType;
|
||||
import org.apache.http.entity.mime.MultipartEntityBuilder;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.util.EntityUtils;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.telegram.telegrambots.ApiConstants;
|
||||
import org.telegram.telegrambots.api.methods.updates.DeleteWebhook;
|
||||
import org.telegram.telegrambots.api.methods.updates.SetWebhook;
|
||||
import org.telegram.telegrambots.bots.DefaultAbsSender;
|
||||
import org.telegram.telegrambots.bots.DefaultBotOptions;
|
||||
import org.telegram.telegrambots.exceptions.TelegramApiException;
|
||||
import org.telegram.telegrambots.exceptions.TelegramApiRequestException;
|
||||
import org.telegram.telegrambots.facilities.TelegramHttpClientBuilder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public final class WebhookUtils {
|
||||
private WebhookUtils() {
|
||||
|
||||
}
|
||||
|
||||
public static void setWebhook(DefaultAbsSender bot, String url, String publicCertificatePath) throws TelegramApiRequestException {
|
||||
DefaultBotOptions botOptions = bot.getOptions();
|
||||
|
||||
try (CloseableHttpClient httpclient = TelegramHttpClientBuilder.build(botOptions)) {
|
||||
String requestUrl = bot.getBaseUrl() + SetWebhook.PATH;
|
||||
|
||||
HttpPost httppost = new HttpPost(requestUrl);
|
||||
httppost.setConfig(botOptions.getRequestConfig());
|
||||
MultipartEntityBuilder builder = MultipartEntityBuilder.create();
|
||||
builder.addTextBody(SetWebhook.URL_FIELD, url);
|
||||
if (botOptions.getMaxWebhookConnections() != null) {
|
||||
builder.addTextBody(SetWebhook.MAXCONNECTIONS_FIELD, botOptions.getMaxWebhookConnections().toString());
|
||||
}
|
||||
if (botOptions.getAllowedUpdates() != null) {
|
||||
builder.addTextBody(SetWebhook.ALLOWEDUPDATES_FIELD, new JSONArray(botOptions.getMaxWebhookConnections()).toString());
|
||||
}
|
||||
if (publicCertificatePath != null) {
|
||||
File certificate = new File(publicCertificatePath);
|
||||
if (certificate.exists()) {
|
||||
builder.addBinaryBody(SetWebhook.CERTIFICATE_FIELD, certificate, ContentType.TEXT_PLAIN, certificate.getName());
|
||||
}
|
||||
}
|
||||
HttpEntity multipart = builder.build();
|
||||
httppost.setEntity(multipart);
|
||||
try (CloseableHttpResponse response = httpclient.execute(httppost)) {
|
||||
HttpEntity ht = response.getEntity();
|
||||
BufferedHttpEntity buf = new BufferedHttpEntity(ht);
|
||||
String responseContent = EntityUtils.toString(buf, StandardCharsets.UTF_8);
|
||||
JSONObject jsonObject = new JSONObject(responseContent);
|
||||
if (!jsonObject.getBoolean(ApiConstants.RESPONSE_FIELD_OK)) {
|
||||
throw new TelegramApiRequestException("Error setting webhook", jsonObject);
|
||||
}
|
||||
}
|
||||
} catch (JSONException e) {
|
||||
throw new TelegramApiRequestException("Error deserializing setWebhook method response", e);
|
||||
} catch (IOException e) {
|
||||
throw new TelegramApiRequestException("Error executing setWebook method", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void clearWebhook(DefaultAbsSender bot) throws TelegramApiRequestException {
|
||||
try {
|
||||
boolean result = bot.execute(new DeleteWebhook());
|
||||
if (!result) {
|
||||
throw new TelegramApiRequestException("Error removing old webhook");
|
||||
}
|
||||
} catch (TelegramApiException e) {
|
||||
throw new TelegramApiRequestException("Error removing old webhook", e);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user