From 6df6627821041e4a0e6eec61f39ba0fbf428bba0 Mon Sep 17 00:00:00 2001 From: Abbas Abou Daya Date: Fri, 30 Jun 2017 19:29:10 +0300 Subject: [PATCH] Added AbilityBot module --- pom.xml | 1 + telegrambots-abilities/README.md | 170 ++++ telegrambots-abilities/pom.xml | 253 ++++++ .../abilitybots/api/bot/AbilityBot.java | 739 ++++++++++++++++++ .../abilitybots/api/db/DBContext.java | 85 ++ .../abilitybots/api/db/MapDBContext.java | 224 ++++++ .../abilitybots/api/objects/Ability.java | 207 +++++ .../abilitybots/api/objects/EndUser.java | 138 ++++ .../abilitybots/api/objects/Flag.java | 46 ++ .../abilitybots/api/objects/Locality.java | 23 + .../api/objects/MessageContext.java | 123 +++ .../abilitybots/api/objects/Privacy.java | 21 + .../abilitybots/api/objects/Reply.java | 65 ++ .../api/sender/DefaultMessageSender.java | 493 ++++++++++++ .../abilitybots/api/sender/MessageSender.java | 200 +++++ .../abilitybots/api/util/AbilityUtils.java | 131 ++++ .../telegram/abilitybots/api/util/Pair.java | 57 ++ .../telegram/abilitybots/api/util/Trio.java | 66 ++ .../abilitybots/api/bot/AbilityBotTest.java | 589 ++++++++++++++ .../abilitybots/api/bot/DefaultBot.java | 78 ++ .../abilitybots/api/db/MapDBContextTest.java | 109 +++ .../abilitybots/api/objects/AbilityTest.java | 58 ++ 22 files changed, 3876 insertions(+) create mode 100644 telegrambots-abilities/README.md create mode 100644 telegrambots-abilities/pom.xml create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Ability.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/EndUser.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Flag.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Locality.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/MessageContext.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Privacy.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Reply.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/DefaultMessageSender.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/MessageSender.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/AbilityUtils.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Pair.java create mode 100644 telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Trio.java create mode 100644 telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/AbilityBotTest.java create mode 100644 telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/DefaultBot.java create mode 100644 telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java create mode 100644 telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/objects/AbilityTest.java diff --git a/pom.xml b/pom.xml index 5162cc08..124187fc 100644 --- a/pom.xml +++ b/pom.xml @@ -13,6 +13,7 @@ telegrambots telegrambots-meta telegrambots-extensions + telegrambots-abilities diff --git a/telegrambots-abilities/README.md b/telegrambots-abilities/README.md new file mode 100644 index 00000000..8837ed7d --- /dev/null +++ b/telegrambots-abilities/README.md @@ -0,0 +1,170 @@ +
+ abilitybots + +[![Build Status](https://travis-ci.org/rubenlagus/TelegramBots.svg?branch=master)](https://travis-ci.org/rubenlagus/TelegramBots) +[![Jitpack](https://jitpack.io/v/rubenlagus/TelegramBots.svg)](https://jitpack.io/#rubenlagus/TelegramBots) +[![JavaDoc](http://svgur.com/i/1Ex.svg)](https://addo37.github.io/AbilityBots/) +[![Telegram](http://trellobot.doomdns.org/telegrambadge.svg)](https://telegram.me/JavaBotsApi) +[![ghit.me](https://ghit.me/badge.svg?repo=rubenlagus/TelegramBots)](https://ghit.me/repo/rubenlagus/TelegramBots) + +
+ +Usage +----- + +**Maven** + +```xml + + org.telegram + telegrambots-abilities + 3.1.1 + +``` + +**Gradle** + +```gradle + compile "org.telegram:telegrambots-abilities:3.1.1" +``` + +**JitPack** - [JitPack](https://jitpack.io/#rubenlagus/TelegramBots/v3.1.1) + +**Plain imports** - [Jar](https://github.com/addo37/AbilityBots/releases/download/v1.2.5/AbilityBots-1.2.5.jar) | [fatJar](https://github.com/addo37/AbilityBots/releases/download/v1.2.5/AbilityBots-with-dependencies-1.2.5.jar) + +Motivation +---------- +Ever since I've started programming bots for Telegram, I've been using the Telegram Bot Java API. It's a basic and nicely done API that is a 1-to-1 translation of the HTTP API exposed by Telegram. + +Dealing with a basic API has its advantages and disadvantages. Obviously, there's nothing hidden. If it's there on Telegram, it's here in the Java API. +When you want to implement a feature in your bot, you start asking these questions: + +* The **WHO**? + * Who is going to use this feature? Should they be allowed to use all the features? +* The **WHAT**? + * Under what conditions should I allow this feature? + * Should the message have a photo? A document? Oh, maybe a callback query? +* The **HOW**? + * If my bot crashes, how can I resume my operation? + * Should I utilize a DB? + * How can I separate logic execution of different features? + * How can I unit-test my feature outside of Telegram? + +Every time you write a command or a feature, you will need to answer these questions and ensure that your feature logic works. + +Ability Bot Abstraction +----------------------- +After implementing my fifth bot using that API, I had had it with the amount of **boilerplate code** that was needed for every added feature. Methods were getting overly-complex and readability became subpar. +That is where the notion of another layer of abstraction (AbilityBot) began taking shape. + +The AbilityBot abstraction defines a new object, named **Ability**. An ability combines conditions, flags, action, post-action and replies. +As an example, here is a code-snippet of an ability that creates a ***/hello*** command: + +```java +public Ability sayHelloWorld() { + return Ability + .builder() + .name("hello") + .info("says hello world!") + .input(0) + .locality(USER) + .privacy(ADMIN) + .action(ctx -> sender.send("Hello world!", ctx.chatId())) + .post(ctx -> sender.send("Bye world!", ctx.chatId())) + .build(); +} +``` +Here is a breakdown of the above code snippet: +* *.name()* - the name of the ability (essentially, this is the command) +* *.info()* - provides information for the command + * More on this later, but it basically centralizes command information in-code. +* *.input()* - the number of input arguments needed, 0 is for do-not-care +* *.locality()* - this answers where you want the ability to be available + * In GROUP, USER private chats or ALL (both) +* *.privacy()* - this answers who you want to access your ability + * CREATOR, ADMIN, or everyone as PUBLIC +* *.action()* - the feature logic resides here (a lambda function that takes a *MessageContext*) + * *MessageContext* provides fast accessors for the **chatId**, **user** and the underlying **update**. It also conforms to the specifications of the basic API. +* *.post()* - the logic executed **after** your main action finishes execution + +The following is a snippet of how this would look like with the plain basic API. + +```java + @Override + public void onUpdateReceived(Update update) { + // Global checks... + // Switch, if, logic to route to hello world method + // Execute method + } + + public void sayHelloWorld(Update update) { + if (!update.hasMessage() || !update.getMessage().isUserMessage() || !update.getMessage().hasText() || update.getMessage.getText().isEmpty()) + return; + User maybeAdmin = update.getMessage().getFrom(); + /* Query DB for if the user is an admin, can be SQL, Reddis, Ignite, etc... + If user is not an admin, then return here. + */ + + SendMessage snd = new SendMessage(); + snd.setChatId(update.getMessage().getChatId()); + snd.setText("Hello world!"); + + try { + sendMessage(snd); + } catch (TelegramApiException e) { + BotLogger.error("Could not send message", TAG, e); + } + } +``` + +I will leave you the choice to decide between the two snippets as to which is more **readable**, **writable** and **testable**. + +***You can do so much more with abilities, besides plain commands. Head over to our [examples](#examples) to check out all of its features!*** + +Objective +--------- +The AbilityBot abstraction intends to provide the following: +* New feature is a new **Ability**, a new method - no fuss, zero overhead, no cross-code with other features +* Argument length on a command is as easy as changing a single integer +* Privacy settings per Ability - access levels to Abilities! User | Admin | Creator +* Embedded database - available for every declared ability +* Proxy sender interface - enhances testability; accurate results pre-release + +Alongside these exciting core features of the AbilityBot, the following have been introduced: +* The bot automatically maintains an up-to-date set of all the users who have contacted the bot + * up-to-date: if a user changes their Username, First Name or Last Name, the bot updates the respective field in the embedded-DB +* Backup and recovery for the DB + * Default implementation relies on JSON/Jackson +* Ban and unban users from accessing your bots + * The bot will execute the shortest path to discard the update the next time they try to spam +* Promote and demote users as bot administrators + * Allows admins to execute admin abilities + +What's next? +------------ +I am looking forward to: +* Provide a trigger to record metrics per ability +* Implement AsyncAbility +* Maintain integration with the latest updates on the basic API +* Enrich the bot with features requested by the community + +Examples +------------------- +* [Example Bots](https://github.com/addo37/ExampleBots) + +Do you have a project that uses **AbilityBots**? Let us know! + +Support +------- +For issues and features, please use GitHub's [issues](https://github.com/rubenlagus/TelegramBots/issues) tab. + +For quick feedback, chatting or just having fun, please come and join us in our Telegram Supergroup. + +[![Telegram](http://trellobot.doomdns.org/telegrambadge.svg)](https://telegram.me/JavaBotsApi) + +Credits +------- +This project would not have been made possible had it not been for [Ruben](https://github.com/rubenlagus)'s work with the [Telegram Bot Java API](https://github.com/rubenlagus/TelegramBots). +I strongly urge you to check out that project and implement a bot to have a sense of how the basic API feels like. +Ruben has done a great job in supplying a clear and straightforward API that conforms to Telegram's HTTP API. +There is also a chat for that API. \ No newline at end of file diff --git a/telegrambots-abilities/pom.xml b/telegrambots-abilities/pom.xml new file mode 100644 index 00000000..291cacec --- /dev/null +++ b/telegrambots-abilities/pom.xml @@ -0,0 +1,253 @@ + + + 4.0.0 + org.telegram + telegrambots-abilities + 3.1.0 + jar + + Telegram Ability Bot + https://github.com/rubenlagus/TelegramBots + AbilityBot Extension and Abstraction + + + https://github.com/rubenlagus/TelegramBots/issues + GitHub Issues + + + + https://github.com/rubenlagus/TelegramBots + scm:git:git://github.com/rubenlagus/TelegramBots.git + scm:git:git@github.com:rubenlagus/TelegramBots.git + + + + https://travis-ci.org/rubenlagus/TelegramBots + Travis + + + + + abbas.aboudayya@gmail.com + Abbas Abou Daya + https://github.com/addo37 + addo37 + + + + + + MIT License + http://www.opensource.org/licenses/mit-license.php + repo + + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + UTF-8 + UTF-8 + 3.1.0 + + + + + org.telegram + telegrambots + ${bots.version} + + + org.apache.commons + commons-lang3 + 3.5 + + + org.mapdb + mapdb + 3.0.4 + + + com.google.guava + guava + 19.0 + + + org.mockito + mockito-all + 2.0.2-beta + test + + + junit + junit + 4.11 + test + + + + + ${project.basedir}/target + ${project.build.directory}/classes + ${project.artifactId}-${project.version} + ${project.build.directory}/test-classes + ${project.basedir}/src/main/java + + + org.apache.maven.plugins + maven-gpg-plugin + 1.5 + + + sign-artifacts + verify + + sign + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.6.3 + true + + ossrh + https://oss.sonatype.org/ + true + + + + maven-clean-plugin + 3.0.0 + + + clean-project + clean + + clean + + + + + + maven-assembly-plugin + 2.6 + + + jar-with-dependencies + + + + + make-assembly + package + + single + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.0.0 + + + + jar + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 2.10.3 + + + + jar + + + -Xdoclint:none + + + + + + org.jacoco + jacoco-maven-plugin + 0.7.7.201606060606 + + + + prepare-agent + + + + report + test + + report + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + 1.4.1 + + + enforce-versions + + enforce + + + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 2.4 + + + copy + package + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + UTF-8 + + + + + + \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java new file mode 100644 index 00000000..f54d6acb --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/AbilityBot.java @@ -0,0 +1,739 @@ +package org.telegram.abilitybots.api.bot; + +import org.apache.commons.io.IOUtils; +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.abilitybots.api.objects.*; +import org.telegram.abilitybots.api.sender.DefaultMessageSender; +import org.telegram.abilitybots.api.sender.MessageSender; +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.send.SendDocument; +import org.telegram.telegrambots.api.objects.Message; +import org.telegram.telegrambots.api.objects.Update; +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.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.String.format; +import static java.time.ZonedDateTime.now; +import static java.util.Arrays.stream; +import static java.util.Objects.nonNull; +import static java.util.Optional.ofNullable; +import static java.util.function.Function.identity; +import static java.util.regex.Pattern.CASE_INSENSITIVE; +import static java.util.regex.Pattern.compile; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; +import static jersey.repackaged.com.google.common.base.Throwables.propagate; +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.EndUser.fromUser; +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.AbilityUtils.*; + +/** + * The father of all ability bots. Bots that need to utilize abilities need to extend this bot. + *

+ * It's important to note that this bot strictly extends {@link TelegramLongPollingBot}. + *

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

+ *

+ * Additional information of the implicit abilities are present in the methods that declare them. + *

+ * The two most important handles in the AbilityBot are the {@link DBContext} db and the {@link MessageSender} sender. + * All bots extending AbilityBot can use both handles in their update consumers. + * + * @author Abbas Abou Daya + */ +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"; + + // Messages + protected static final String RECOVERY_MESSAGE = "I am ready to receive the backup file. Please reply to this message with the backup file attached."; + protected static final String RECOVER_SUCCESS = "I have successfully recovered."; + + // DB and sender + protected final DBContext db; + protected MessageSender sender; + + // Bot token and username + private final String botToken; + private final String botUsername; + + // Ability registry + private Map abilities; + + // Reply registry + private List replies; + + protected AbilityBot(String botToken, String botUsername, DBContext db, DefaultBotOptions botOptions) { + super(botOptions); + + this.botToken = botToken; + this.botUsername = botUsername; + this.db = db; + this.sender = new DefaultMessageSender(this); + + registerAbilities(); + } + + protected AbilityBot(String botToken, String botUsername, DBContext db) { + this(botToken, botUsername, db, new DefaultBotOptions()); + } + + protected AbilityBot(String botToken, String botUsername, DefaultBotOptions botOptions) { + this(botToken, botUsername, onlineInstance(botUsername), botOptions); + } + + protected AbilityBot(String botToken, String botUsername) { + this(botToken, botUsername, onlineInstance(botUsername)); + } + + public abstract int creatorId(); + + /** + * @return the map of ID -> EndUser + */ + protected Map users() { + return db.getMap(USERS); + } + + /** + * @return the map of Username -> ID + */ + protected Map userIds() { + return db.getMap(USER_ID); + } + + /** + * @return a blacklist containing all the IDs of the banned users + */ + protected Set blacklist() { + return db.getSet(BLACKLIST); + } + + /** + * @return an admin set of all the IDs of bot administrators + */ + protected Set admins() { + return db.getSet(ADMINS); + } + + /** + * This method contains the stream of actions that are applied on any update. + *

+ * It will correctly handle addition of users into the DB and the execution of abilities and replies. + * + * @param update the update received by Telegram's API + */ + @Override + public void 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)); + } + + @Override + public String getBotToken() { + return botToken; + } + + @Override + public String getBotUsername() { + return botUsername; + } + + /** + * Test the update against the provided global flags. The default implementation requires a {@link Flag#MESSAGE}. + *

+ * This method should be overridden if the user wants updates that don't require a MESSAGE to pass through. + * + * @param update a Telegram {@link Update} + * @return true if the update satisfies the global flags + */ + protected boolean checkGlobalFlags(Update update) { + return MESSAGE.test(update); + } + + /** + * Gets the user with the specified username. + * + * @param username the username of the required user + * @return the user + */ + protected EndUser 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 EndUser getUser(int id) { + EndUser endUser = users().get(id); + if (endUser == null) { + throw new IllegalStateException(format("Could not find user corresponding to id [%d]", id)); + } + + return endUser; + } + + /** + * 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 + * @return the id of the user + */ + protected int getUserIdSendError(String username, long chatId) { + try { + return getUser(username).id(); + } catch (IllegalStateException ex) { + sender.send(format("Sorry, I could not find the user [%s].", username), chatId); + throw propagate(ex); + } + } + + /** + *

+ * Format of the report: + *

+ * [command1] - [description1] + *

+ * [command2] - [description2] + *

+ * ... + *

+ * Once you invoke it, the bot will send the available commands to the chat. This is a public ability so anyone can invoke it. + *

+ * Usage: /commands + * + * @return the ability to report commands defined by the child bot. + */ + public Ability reportCommands() { + return builder() + .name(COMMANDS) + .locality(ALL) + .privacy(PUBLIC) + .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("No public commands found."); + + sender.send(commands, ctx.chatId()); + }) + .build(); + } + + /** + * This backup ability returns the object defined by {@link DBContext#backup()} as a message document. + *

+ * This is a high-profile ability and is restricted to the CREATOR only. + *

+ * Usage: /backup + * + * @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)}. + *

+ * The bot recovery process hugely depends on the implementation of the recovery method of {@link DBContext}. + *

+ * Usage: /recover + * + * @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 -> sender.forceReply(RECOVERY_MESSAGE, ctx.chatId())) + .reply(update -> { + Long chatId = update.getMessage().getChatId(); + String fileId = update.getMessage().getDocument().getFileId(); + + try (FileReader reader = new FileReader(downloadFileWithId(fileId))) { + String backupData = IOUtils.toString(reader); + if (db.recover(backupData)) { + sender.send(RECOVER_SUCCESS, chatId); + } else { + sender.send("Oops, something went wrong during recovery.", chatId); + } + } catch (Exception e) { + BotLogger.error("Could not recover DB from backup", TAG, e); + sender.send("I have failed to recover.", chatId); + } + }, MESSAGE, DOCUMENT, REPLY, isReplyTo(RECOVERY_MESSAGE)) + .build(); + } + + /** + * Banned users are accumulated in the blacklist. Use {@link DBContext#getSet(String)} with name specified by {@link AbilityBot#BLACKLIST}. + *

+ * Usage: /ban @username + *

+ * Note that admins who try to ban the creator, get banned. + * + * @return the ability to ban the user from any kind of bot interaction + */ + 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.chatId()); + String bannedUser; + + // Protection from abuse + if (userId == creatorId()) { + userId = ctx.user().id(); + bannedUser = isNullOrEmpty(ctx.user().username()) ? addTag(ctx.user().username()) : ctx.user().shortName(); + } else { + bannedUser = addTag(username); + } + + Set blacklist = blacklist(); + if (blacklist.contains(userId)) + sender.sendMd(format("%s is already *banned*.", bannedUser), ctx.chatId()); + else { + blacklist.add(userId); + sender.sendMd(format("%s is now *banned*.", bannedUser), ctx.chatId()); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * Usage: /unban @username + * + * @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.chatId()); + + Set blacklist = blacklist(); + + if (!blacklist.remove(userId)) + sender.sendMd(format("@%s is *not* on the *blacklist*.", username), ctx.chatId()); + else { + sender.sendMd(format("@%s, your ban has been *lifted*.", 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.chatId()); + + Set admins = admins(); + if (admins.contains(userId)) + sender.sendMd(format("@%s is already an *admin*.", username), ctx.chatId()); + else { + admins.add(userId); + sender.sendMd(format("@%s has been *promoted*.", username), ctx.chatId()); + } + }).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.chatId()); + + Set admins = admins(); + if (admins.remove(userId)) { + sender.sendMd(format("@%s has been *demoted*.", username), ctx.chatId()); + } else { + sender.sendMd(format("@%s is *not* an *admin*.", username), ctx.chatId()); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * Regular users and admins who try to claim the bot will get banned. + * + * @return the ability to claim yourself as the master and creator of the bot + */ + public Ability claimCreator() { + return builder() + .name(CLAIM) + .locality(ALL) + .privacy(PUBLIC) + .input(0) + .action(ctx -> { + if (ctx.user().id() == creatorId()) { + Set admins = admins(); + int id = creatorId(); + long chatId = ctx.chatId(); + + if (admins.contains(id)) + sender.send("You're already my master.", chatId); + else { + admins.add(id); + sender.send("You're now my master.", chatId); + } + } else { + // This is not a joke + abilities.get(BAN).action().accept(newContext(ctx.update(), ctx.user(), ctx.chatId(), ctx.user().username())); + } + }) + .post(commitTo(db)) + .build(); + } + + /** + * Registers the declared abilities using method reflection. Also, replies are accumulated using the built abilities and standalone methods that return a Reply. + *

+ * Only abilities and replies with the public accessor are registered! + */ + private void registerAbilities() { + try { + abilities = stream(this.getClass().getMethods()) + .filter(method -> method.getReturnType().equals(Ability.class)) + .map(this::returnAbility) + .collect(toMap(Ability::name, identity())); + + Stream methodReplies = stream(this.getClass().getMethods()) + .filter(method -> method.getReturnType().equals(Reply.class)) + .map(this::returnReply); + + Stream abilityReplies = abilities.values().stream() + .flatMap(ability -> ability.replies().stream()); + + replies = Stream.concat(methodReplies, abilityReplies).collect(toList()); + } 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 pair) { + ofNullable(pair.b().postAction()) + .ifPresent(consumer -> consumer.accept(pair.a())); + } + + Pair consumeUpdate(Pair pair) { + pair.b().action().accept(pair.a()); + return pair; + } + + Pair getContext(Trio trio) { + Update update = trio.a(); + EndUser user = fromUser(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 trio) { + String[] tokens = trio.c(); + int abilityTokens = trio.b().tokens(); + + boolean isOk = abilityTokens == 0 || (tokens.length > 0 && tokens.length == abilityTokens); + + if (!isOk) + sender.send(String.format("Sorry, this feature requires %d additional %s.", abilityTokens, abilityTokens == 1 ? "input" : "inputs"), getChatId(trio.a())); + return isOk; + } + + boolean checkLocality(Trio trio) { + Update update = trio.a(); + Locality locality = isUserMessage(update) ? USER : GROUP; + Locality abilityLocality = trio.b().locality(); + + boolean isOk = abilityLocality == ALL || locality == abilityLocality; + + if (!isOk) + sender.send(String.format("Sorry, %s-only feature.", abilityLocality.toString().toLowerCase()), getChatId(trio.a())); + return isOk; + } + + boolean checkPrivacy(Trio trio) { + Update update = trio.a(); + EndUser user = fromUser(AbilityUtils.getUser(update)); + Privacy privacy; + int id = user.id(); + + privacy = isCreator(id) ? CREATOR : isAdmin(id) ? ADMIN : PUBLIC; + + boolean isOk = privacy.compareTo(trio.b().privacy()) >= 0; + + if (!isOk) + sender.send(String.format("Sorry, %s-only feature.", trio.b().privacy().toString().toLowerCase()), getChatId(trio.a())); + return isOk; + } + + private boolean isCreator(int id) { + return id == creatorId(); + } + + private boolean isAdmin(Integer id) { + return admins().contains(id); + } + + boolean validateAbility(Trio trio) { + return trio.b() != null; + } + + Trio getAbility(Update update) { + // Handle updates without messages + // Passing through this function means that the global flags have passed + Message msg = update.getMessage(); + if (!update.hasMessage() || !msg.hasText()) + return Trio.of(update, abilities.get(DEFAULT), new String[]{}); + + // Priority goes to text before captions + String[] tokens = msg.getText().split(" "); + + if (tokens[0].startsWith("/")) { + String abilityToken = stripBotUsername(tokens[0].substring(1)); + 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) { + EndUser endUser = fromUser(AbilityUtils.getUser(update)); + + users().compute(endUser.id(), (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(EndUser oldUser, EndUser newUser) { + if (oldUser != null && oldUser.username() != null) { + // Remove old username -> ID + userIds().remove(oldUser.username()); + } + + if (newUser.username() != null) { + // Add new mapping with the new username + userIds().put(newUser.username().toLowerCase(), newUser.id()); + } + } + + 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 trio) { + Ability ability = trio.b(); + Update update = trio.a(); + + return ability.flags().stream() + .reduce(true, (flag, nextFlag) -> flag && nextFlag.test(update), Boolean::logicalAnd); + } + + private File downloadFileWithId(String fileId) throws TelegramApiException { + return sender.downloadFile(sender.getFile(new GetFile().setFileId(fileId))); + } +} \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java new file mode 100644 index 00000000..eda5e9c1 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/DBContext.java @@ -0,0 +1,85 @@ +package org.telegram.abilitybots.api.db; + +import org.telegram.abilitybots.api.bot.AbilityBot; +import org.telegram.telegrambots.api.objects.Update; + +import java.io.Closeable; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * This interface represents the high-level methods exposed to the user when handling an {@link Update}. + * Example usage: + *

Ability.builder().action(ctx -> {db.getSet(USERS); doSomething();})*

+ * {@link AbilityBot} contains a handle on the db that the user can use inside his declared abilities. + * + * @author Abbas Abou Daya + */ +public interface DBContext extends Closeable { + /** + * @param name the unique name of the {@link List} + * @param the type that the List holds + * @return the List with the specified name + */ + List getList(String name); + + /** + * @param name the unique name of the {@link Map} + * @param the type of the Map keys + * @param the type of the Map values + * @return the Map with the specified name + */ + Map getMap(String name); + + /** + * @param name the unique name of the {@link Set} + * @param the type that the Set holds + * @return the Set with the specified name + */ + Set getSet(String name); + + /** + * @return a high-level summary of the database structures (Sets, Lists, Maps, ...) present. + */ + String summary(); + + /** + * Implementations of this method are free to return any object such as XML, JSON, etc... + * + * @return a backup of the DB + */ + Object backup(); + + /** + * The object passed to this method need to conform to the implementation of the {@link DBContext#backup()} method. + * + * @param backup the backup of the database containing all the structures + * @return true if the database successfully recovered + */ + boolean recover(Object backup); + + /** + * @param name the name of the data structure + * @return the high-level information of the structure + */ + String info(String name); + + /** + * Commits the database to its persistent layer. Implementations are free to not implement this method as it is not compulsory. + */ + void commit(); + + /** + * Clears the data structures present in the database. + *

+ * This method does not delete the data-structure themselves, but leaves them empty. + */ + void clear(); + + /** + * @param name the name of the data structure + * @return true if this database contains the specified structure name + */ + boolean contains(String name); +} \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java new file mode 100644 index 00000000..8eeba590 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/db/MapDBContext.java @@ -0,0 +1,224 @@ +package org.telegram.abilitybots.api.db; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import org.mapdb.DB; +import org.mapdb.DBMaker; +import org.mapdb.Serializer; +import org.telegram.abilitybots.api.util.Pair; +import org.telegram.telegrambots.logging.BotLogger; + +import java.io.IOException; +import java.util.*; + +import static com.google.common.collect.Lists.newArrayList; +import static com.google.common.collect.Maps.newHashMap; +import static com.google.common.collect.Sets.newHashSet; +import static java.lang.String.format; +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; + +/** + * An implementation of {@link DBContext} that relies on a {@link DB}. + * + * @author Abbas Abou Daya + * @see MapDB project + */ +public class MapDBContext implements DBContext { + private static final String TAG = DBContext.class.getSimpleName(); + + private final DB db; + private final ObjectMapper objectMapper; + + public MapDBContext(DB db) { + this.db = db; + + objectMapper = new ObjectMapper(); + objectMapper.enableDefaultTyping(); + } + + /** + * This DB returned by this method does not trigger deletion on JVM shutdown. + * + * @param name name of the DB file + * @return an online instance of {@link MapDBContext} + */ + public static DBContext onlineInstance(String name) { + DB db = DBMaker + .fileDB(name) + .fileMmapEnableIfSupported() + .closeOnJvmShutdown() + .transactionEnable() + .make(); + + return new MapDBContext(db); + } + + /** + * This DB returned by this method gets deleted on JVM shutdown. + * + * @param name name of the DB file + * @return an offline instance of {@link MapDBContext} + */ + public static DBContext offlineInstance(String name) { + DB db = DBMaker + .fileDB(name) + .fileMmapEnableIfSupported() + .closeOnJvmShutdown() + .cleanerHackEnable() + .transactionEnable() + .fileDeleteAfterClose() + .make(); + + return new MapDBContext(db); + } + + @Override + public List getList(String name) { + return (List) db.indexTreeList(name, Serializer.JAVA).createOrOpen(); + } + + @Override + public Map getMap(String name) { + return db.hashMap(name, JAVA, JAVA).createOrOpen(); + } + + @Override + public Set getSet(String name) { + return (Set) db.hashSet(name, JAVA).createOrOpen(); + } + + @Override + public String summary() { + return stream(db.getAllNames().spliterator(), false) + .map(this::info) + .reduce(new StringJoiner("\n"), StringJoiner::add, StringJoiner::merge) + .toString(); + } + + @Override + public Object backup() { + Map collectedMap = localCopy(); + return writeAsString(collectedMap); + } + + @Override + public boolean recover(Object backup) { + Map snapshot = localCopy(); + + try { + Map backupData = objectMapper.readValue(backup.toString(), new TypeReference>() { + }); + doRecover(backupData); + return true; + } catch (IOException e) { + BotLogger.error(format("Could not recover DB data from file with String representation %s", backup), TAG, e); + // Attempt to fallback to data snapshot before recovery + doRecover(snapshot); + return false; + } + } + + @Override + public String info(String name) { + Object struct = db.get(name); + if (isNull(struct)) + throw new IllegalStateException(format("DB structure with name [%s] does not exist", name)); + + if (struct instanceof Set) + return format("%s - Set - %d", name, ((Set) struct).size()); + else if (struct instanceof List) + return format("%s - List - %d", name, ((List) struct).size()); + else if (struct instanceof Map) + return format("%s - Map - %d", name, ((Map) struct).size()); + else + return format("%s - %s", name, struct.getClass().getSimpleName()); + } + + @Override + public void commit() { + db.commit(); + } + + @Override + public void clear() { + db.getAllNames().forEach(name -> { + Object struct = db.get(name); + if (struct instanceof Collection) + ((Collection) struct).clear(); + else if (struct instanceof Map) + ((Map) struct).clear(); + }); + commit(); + } + + @Override + public boolean contains(String name) { + return db.exists(name); + } + + @Override + public void close() throws IOException { + db.close(); + } + + /** + * @return a local non-thread safe copy of the database + */ + private Map localCopy() { + return db.getAll().entrySet().stream().map(entry -> { + Object struct = entry.getValue(); + if (struct instanceof Set) + return Pair.of(entry.getKey(), newHashSet((Set) struct)); + else if (struct instanceof List) + return Pair.of(entry.getKey(), newArrayList((List) struct)); + else if (struct instanceof Map) + return Pair.of(entry.getKey(), newHashMap((Map) struct)); + else + return Pair.of(entry.getKey(), struct); + }).collect(toMap(pair -> (String) pair.a(), Pair::b)); + } + + private void doRecover(Map backupData) { + clear(); + backupData.forEach((name, value) -> { + + if (value instanceof Set) { + Set entrySet = (Set) value; + getSet(name).addAll(entrySet); + } else if (value instanceof Map) { + Map entryMap = (Map) value; + + // TODO: This is ugly + // Special handling of USERS since the key is an integer. JSON by default considers a map a JSONObject. + // Keys are serialized and deserialized as String + if (name.equals(USERS)) + entryMap = entryMap.entrySet().stream() + .map(entry -> Pair.of(Integer.parseInt(entry.getKey().toString()), entry.getValue())) + .collect(toMap(Pair::a, Pair::b)); + + getMap(name).putAll(entryMap); + } else if (value instanceof List) { + List entryList = (List) value; + getList(name).addAll(entryList); + } else { + BotLogger.error(TAG, format("Unable to identify object type during DB recovery, entry name: %s", name)); + } + }); + commit(); + } + + private String writeAsString(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + BotLogger.info(format("Failed to read the JSON representation of object: %s", obj), TAG, e); + return "Error reading required data..."; + } + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Ability.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Ability.java new file mode 100644 index 00000000..46a9e326 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Ability.java @@ -0,0 +1,207 @@ +package org.telegram.abilitybots.api.objects; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import org.telegram.telegrambots.api.objects.Update; +import org.telegram.telegrambots.logging.BotLogger; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.Lists.newArrayList; +import static java.lang.String.format; +import static java.util.Objects.hash; +import static java.util.Optional.ofNullable; +import static org.apache.commons.lang3.StringUtils.*; + +/** + * An ability is a fully-fledged bot action that contains all the necessary information to process: + *

    + *
  1. A response to a command
  2. + *
  3. A post-response to a command
  4. + *
  5. A reply to a sequence of actions
  6. + *
+ *

+ * In-order to instantiate an ability, you can call {@link Ability#builder()} to get the {@link AbilityBuilder}. + * Once you're done setting your ability, you'll call {@link AbilityBuilder#build()} to get your constructed ability. + *

+ * The only optional fields in an ability are {@link Ability#info}, {@link Ability#postAction}, {@link Ability#flags} and {@link Ability#replies}. + * + * @author Abbas Abou Daya + */ +public final class Ability { + private static final String TAG = Ability.class.getSimpleName(); + + private final String name; + private final String info; + private final Locality locality; + private final Privacy privacy; + private final int argNum; + private final Consumer action; + private final Consumer postAction; + private final List replies; + private final List> flags; + + private Ability(String name, String info, Locality locality, Privacy privacy, int argNum, Consumer action, Consumer postAction, List replies, Predicate... flags) { + checkArgument(!isEmpty(name), "Method name cannot be empty"); + checkArgument(!containsWhitespace(name), "Method name cannot contain spaces"); + checkArgument(isAlphanumeric(name), "Method name can only be alpha-numeric", name); + this.name = name; + this.info = info; + + this.locality = checkNotNull(locality, "Please specify a valid locality setting. Use the Locality enum class"); + this.privacy = checkNotNull(privacy, "Please specify a valid privacy setting. Use the Privacy enum class"); + + checkArgument(argNum >= 0, "The number of arguments the method can handle CANNOT be negative. " + + "Use the number 0 if the method ignores the arguments OR uses as many as appended"); + this.argNum = argNum; + + this.action = checkNotNull(action, "Method action can't be empty. Please assign a function by using .action() method"); + if (postAction == null) + BotLogger.info(TAG, format("No post action was detected for method with name [%s]", name)); + + this.flags = ofNullable(flags).map(Arrays::asList).orElse(newArrayList()); + + this.postAction = postAction; + this.replies = replies; + } + + public static AbilityBuilder builder() { + return new AbilityBuilder(); + } + + public String name() { + return name; + } + + public String info() { + return info; + } + + public Locality locality() { + return locality; + } + + public Privacy privacy() { + return privacy; + } + + public int tokens() { + return argNum; + } + + public Consumer action() { + return action; + } + + public Consumer postAction() { + return postAction; + } + + public List replies() { + return replies; + } + + public List> flags() { + return flags; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("name", name) + .add("locality", locality) + .add("privacy", privacy) + .add("argNum", argNum) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Ability ability = (Ability) o; + return argNum == ability.argNum && + Objects.equal(name, ability.name) && + locality == ability.locality && + privacy == ability.privacy; + } + + @Override + public int hashCode() { + return hash(name, info, locality, privacy, argNum, action, postAction, replies, flags); + } + + public static class AbilityBuilder { + private String name; + private String info; + private Privacy privacy; + private Locality locality; + private int argNum; + private Consumer consumer; + private Consumer postConsumer; + private List replies; + private Flag[] flags; + + private AbilityBuilder() { + replies = newArrayList(); + } + + public AbilityBuilder action(Consumer consumer) { + this.consumer = consumer; + return this; + } + + public AbilityBuilder name(String name) { + this.name = name; + return this; + } + + public AbilityBuilder info(String info) { + this.info = info; + return this; + } + + public AbilityBuilder flag(Flag... flags) { + this.flags = flags; + return this; + } + + public AbilityBuilder locality(Locality type) { + this.locality = type; + return this; + } + + public AbilityBuilder input(int argNum) { + this.argNum = argNum; + return this; + } + + public AbilityBuilder privacy(Privacy privacy) { + this.privacy = privacy; + return this; + } + + public AbilityBuilder post(Consumer postConsumer) { + this.postConsumer = postConsumer; + return this; + } + + @SafeVarargs + public final AbilityBuilder reply(Consumer action, Predicate... conditions) { + replies.add(Reply.of(action, conditions)); + return this; + } + + public Ability build() { + return new Ability(name, info, locality, privacy, argNum, consumer, postConsumer, replies, flags); + } + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/EndUser.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/EndUser.java new file mode 100644 index 00000000..7cc08145 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/EndUser.java @@ -0,0 +1,138 @@ +package org.telegram.abilitybots.api.objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; +import org.telegram.telegrambots.api.objects.User; + +import java.io.Serializable; +import java.util.Objects; +import java.util.StringJoiner; + +import static org.apache.commons.lang3.StringUtils.isEmpty; + +/** + * This class serves the purpose of separating the basic Telegram {@link User} and the augmented {@link EndUser}. + *

+ * It adds proper hashCode, equals, toString as well as useful utility methods such as {@link EndUser#shortName} and {@link EndUser#fullName}. + * + * @author Abbas Abou Daya + */ +public final class EndUser implements Serializable { + @JsonProperty("id") + private final Integer id; + @JsonProperty("firstName") + private final String firstName; + @JsonProperty("lastName") + private final String lastName; + @JsonProperty("username") + private final String username; + + private EndUser(Integer id, String firstName, String lastName, String username) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.username = username; + } + + @JsonCreator + public static EndUser endUser(@JsonProperty("id") Integer id, + @JsonProperty("firstName") String firstName, + @JsonProperty("lastName") String lastName, + @JsonProperty("username") String username) { + return new EndUser(id, firstName, lastName, username); + } + + /** + * Constructs an {@link EndUser} from a {@link User}. + * + * @param user the Telegram user + * @return an augmented end-user + */ + public static EndUser fromUser(User user) { + return new EndUser(user.getId(), user.getFirstName(), user.getLastName(), user.getUserName()); + } + + public int id() { + return id; + } + + public String firstName() { + return firstName; + } + + public String lastName() { + return lastName; + } + + public String username() { + return username; + } + + /** + * The full name is identified as the concatenation of the first and last name, separated by a space. + * This method can return an empty name if both first and last name are empty. + * + * @return the full name of the user + */ + public String fullName() { + StringJoiner name = new StringJoiner(" "); + + if (!isEmpty(firstName)) + name.add(firstName); + if (!isEmpty(lastName)) + name.add(lastName); + + return name.toString(); + } + + /** + * The short name is one of the following: + *

    + *
  1. First name
  2. + *
  3. Last name
  4. + *
  5. Username
  6. + *
+ * The method will try to return the first valid name in the specified order. + * + * @return the short name of the user + */ + public String shortName() { + if (!isEmpty(firstName)) + return firstName; + + if (!isEmpty(lastName)) + return lastName; + + return username; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + EndUser endUser = (EndUser) o; + return Objects.equals(id, endUser.id) && + Objects.equals(firstName, endUser.firstName) && + Objects.equals(lastName, endUser.lastName) && + Objects.equals(username, endUser.username); + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, username); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("firstName", firstName) + .add("lastName", lastName) + .add("username", username) + .toString(); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Flag.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Flag.java new file mode 100644 index 00000000..8b03175e --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Flag.java @@ -0,0 +1,46 @@ +package org.telegram.abilitybots.api.objects; + +import org.telegram.abilitybots.api.objects.Ability.AbilityBuilder; +import org.telegram.telegrambots.api.objects.Update; + +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static java.util.Objects.nonNull; + +/** + * Flags are an conditions that are applied on an {@link Update}. + *

+ * They can be used on {@link AbilityBuilder#flag(Flag...)} and on the post conditions in {@link AbilityBuilder#reply(Consumer, Predicate[])}. + * + * @author Abbas Abou Daya + */ +public enum Flag implements Predicate { + // Update Flags + NONE(update -> true), + MESSAGE(Update::hasMessage), + CALLBACK_QUERY(Update::hasCallbackQuery), + CHANNEL_POST(Update::hasChannelPost), + EDITED_CHANNEL_POST(Update::hasEditedChannelPost), + EDITED_MESSAGE(Update::hasEditedMessage), + INLINE_QUERY(Update::hasInlineQuery), + CHOSEN_INLINE_QUERY(Update::hasChosenInlineQuery), + + // Message Flags + REPLY(update -> update.getMessage().isReply()), + DOCUMENT(upd -> upd.getMessage().hasDocument()), + TEXT(upd -> upd.getMessage().hasText()), + PHOTO(upd -> upd.getMessage().hasPhoto()), + LOCATION(upd -> upd.getMessage().hasLocation()), + CAPTION(upd -> nonNull(upd.getMessage().getCaption())); + + private final Predicate predicate; + + Flag(Predicate predicate) { + this.predicate = predicate; + } + + public boolean test(Update update) { + return nonNull(update) && predicate.test(update); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Locality.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Locality.java new file mode 100644 index 00000000..c4770900 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Locality.java @@ -0,0 +1,23 @@ +package org.telegram.abilitybots.api.objects; + +/** + * Locality identifies the location in which you want your message to be accessed. + *

+ * If locality of your message is set to USER, then the ability will only be executed if its being called in a user private chat. + * + * @author Abbas Abou Daya + */ +public enum Locality { + /** + * Ability would be valid for groups and private user chats + */ + ALL, + /** + * Only user chats + */ + USER, + /** + * Only group chats + */ + GROUP +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/MessageContext.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/MessageContext.java new file mode 100644 index 00000000..c3335af9 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/MessageContext.java @@ -0,0 +1,123 @@ +package org.telegram.abilitybots.api.objects; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import org.telegram.telegrambots.api.objects.Update; + +import java.util.Arrays; + +/** + * MessageContext is a wrapper class to the {@link Update}, originating end-user and the arguments present in its message (if any). + *

+ * A user is not bound to the usage of this higher level context as it's possible to fetch the underlying {@link Update} using {@link MessageContext#update()}. + * + * @author Abbas Abou Daya + */ +public class MessageContext { + private final EndUser user; + private final Long chatId; + private final String[] arguments; + private final Update update; + + private MessageContext(Update update, EndUser user, Long chatId, String[] arguments) { + this.user = user; + this.chatId = chatId; + this.update = update; + this.arguments = arguments; + } + + public static MessageContext newContext(Update update, EndUser user, Long chatId, String... arguments) { + return new MessageContext(update, user, chatId, arguments); + } + + /** + * @return the originating Telegram user of this update + */ + public EndUser user() { + return user; + } + + /** + * @return the originating chatId, maps correctly to both group and user-private chats + */ + public Long chatId() { + return chatId; + } + + /** + * If there's no message in the update, then this will an empty array. + * + * @return the text sent by the user message. + */ + public String[] arguments() { + return arguments; + } + + /** + * @return the first argument directly after the command + * @throws IllegalStateException if message has no arguments + */ + public String firstArg() { + checkLength(); + return arguments[0]; + } + + /** + * @return the second argument directly after the command + * @throws IllegalStateException if message has no arguments + */ + public String secondArg() { + checkLength(); + return arguments[1 % arguments.length]; + } + + /** + * @return the third argument directly after the command + * @throws IllegalStateException if message has no arguments + */ + public String thirdArg() { + checkLength(); + return arguments[2 % arguments.length]; + } + + /** + * @return the actual update behind this context + */ + public Update update() { + return update; + } + + private void checkLength() { + if (arguments.length == 0) + throw new IllegalStateException("This message has no arguments"); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("user", user) + .add("chatId", chatId) + .add("arguments", arguments) + .add("update", update) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + MessageContext that = (MessageContext) o; + return Objects.equal(user, that.user) && + Objects.equal(chatId, that.chatId) && + Arrays.equals(arguments, that.arguments) && + Objects.equal(update, that.update); + } + + @Override + public int hashCode() { + return Objects.hashCode(user, chatId, Arrays.hashCode(arguments), update); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Privacy.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Privacy.java new file mode 100644 index 00000000..90d13cef --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Privacy.java @@ -0,0 +1,21 @@ +package org.telegram.abilitybots.api.objects; + +/** + * Privacy represents a restriction on who can use the ability. + * + * @author Abbas Abou Daya + */ +public enum Privacy { + /** + * Anybody who is not a bot admin or its creator will be considered as a public user. + */ + PUBLIC, + /** + * A global admin of the bot, regardless of the group the bot is in. + */ + ADMIN, + /** + * The creator of the bot. + */ + CREATOR +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Reply.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Reply.java new file mode 100644 index 00000000..5a1f2324 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Reply.java @@ -0,0 +1,65 @@ +package org.telegram.abilitybots.api.objects; + +import com.google.common.base.MoreObjects; +import org.telegram.telegrambots.api.objects.Update; + +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static java.util.Arrays.asList; + +/** + * A reply consists of update conditionals and an action to be applied on the update. + *

+ * If an update satisfies the {@link Reply#conditions}set by the reply, then it's safe to {@link Reply#actOn(Update)}. + * + * @author Abbas Abou Daya + */ +public final class Reply { + public final List> conditions; + public final Consumer action; + + private Reply(List> conditions, Consumer action) { + this.conditions = conditions; + this.action = action; + } + + public static Reply of(Consumer action, Predicate... conditions) { + return new Reply(asList(conditions), action); + } + + public boolean isOkFor(Update update) { + return conditions.stream().reduce(true, (state, cond) -> state && cond.test(update), Boolean::logicalAnd); + } + + public void actOn(Update update) { + action.accept(update); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Reply reply = (Reply) o; + return Objects.equals(conditions, reply.conditions) && + Objects.equals(action, reply.action); + } + + @Override + public int hashCode() { + return Objects.hash(conditions, action); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("conditions", conditions) + .add("action", action) + .toString(); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/DefaultMessageSender.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/DefaultMessageSender.java new file mode 100644 index 00000000..6e49d764 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/DefaultMessageSender.java @@ -0,0 +1,493 @@ +package org.telegram.abilitybots.api.sender; + +import org.telegram.telegrambots.api.methods.*; +import org.telegram.telegrambots.api.methods.games.GetGameHighScores; +import org.telegram.telegrambots.api.methods.games.SetGameScore; +import org.telegram.telegrambots.api.methods.groupadministration.*; +import org.telegram.telegrambots.api.methods.pinnedmessages.PinChatMessage; +import org.telegram.telegrambots.api.methods.pinnedmessages.UnpinChatMessage; +import org.telegram.telegrambots.api.methods.send.*; +import org.telegram.telegrambots.api.methods.updates.DeleteWebhook; +import org.telegram.telegrambots.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageCaption; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageReplyMarkup; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageText; +import org.telegram.telegrambots.api.objects.*; +import org.telegram.telegrambots.api.objects.games.GameHighScore; +import org.telegram.telegrambots.api.objects.replykeyboard.ForceReplyKeyboard; +import org.telegram.telegrambots.bots.DefaultAbsSender; +import org.telegram.telegrambots.exceptions.TelegramApiException; +import org.telegram.telegrambots.logging.BotLogger; +import org.telegram.telegrambots.updateshandlers.DownloadFileCallback; +import org.telegram.telegrambots.updateshandlers.SentCallback; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static java.util.Optional.empty; +import static java.util.Optional.ofNullable; + +/** + * The default implementation of the {@link MessageSender}. This serves as a proxy to the {@link DefaultAbsSender} methods. + *

Most of the methods below will be directly calling the bot's similar functions. However, there are some methods introduced to ease sending messages such as:

+ *
    + *
  1. {@link DefaultMessageSender#sendMd(String, long)} - with markdown
  2. + *
  3. {@link DefaultMessageSender#send(String, long)} - without markdown
  4. + *
+ * + * @author Abbas Abou Daya + */ +public class DefaultMessageSender implements MessageSender { + private static final String TAG = MessageSender.class.getName(); + + private DefaultAbsSender bot; + + public DefaultMessageSender(DefaultAbsSender bot) { + this.bot = bot; + } + + @Override + public Optional send(String message, long id) { + return doSendMessage(message, id, false); + } + + @Override + public Optional sendMd(String message, long id) { + return doSendMessage(message, id, true); + } + + @Override + public Optional forceReply(String message, long id) { + SendMessage msg = new SendMessage(); + msg.setText(message); + msg.setChatId(id); + msg.setReplyMarkup(new ForceReplyKeyboard()); + + return optionalSendMessage(msg); + } + + @Override + public Boolean answerInlineQuery(AnswerInlineQuery answerInlineQuery) throws TelegramApiException { + return bot.answerInlineQuery(answerInlineQuery); + } + + @Override + public Boolean sendChatAction(SendChatAction sendChatAction) throws TelegramApiException { + return bot.sendChatAction(sendChatAction); + } + + @Override + public Message forwardMessage(ForwardMessage forwardMessage) throws TelegramApiException { + return bot.forwardMessage(forwardMessage); + } + + @Override + public Message sendLocation(SendLocation sendLocation) throws TelegramApiException { + return bot.sendLocation(sendLocation); + } + + @Override + public Message sendVenue(SendVenue sendVenue) throws TelegramApiException { + return bot.sendVenue(sendVenue); + } + + @Override + public Message sendContact(SendContact sendContact) throws TelegramApiException { + return bot.sendContact(sendContact); + } + + @Override + public Boolean kickMember(KickChatMember kickChatMember) throws TelegramApiException { + return bot.kickMember(kickChatMember); + } + + @Override + public Boolean unbanMember(UnbanChatMember unbanChatMember) throws TelegramApiException { + return bot.unbanMember(unbanChatMember); + } + + @Override + public Boolean leaveChat(LeaveChat leaveChat) throws TelegramApiException { + return bot.leaveChat(leaveChat); + } + + @Override + public Chat getChat(GetChat getChat) throws TelegramApiException { + return bot.getChat(getChat); + } + + @Override + public List getChatAdministrators(GetChatAdministrators getChatAdministrators) throws TelegramApiException { + return bot.getChatAdministrators(getChatAdministrators); + } + + @Override + public ChatMember getChatMember(GetChatMember getChatMember) throws TelegramApiException { + return bot.getChatMember(getChatMember); + } + + @Override + public Integer getChatMemberCount(GetChatMemberCount getChatMemberCount) throws TelegramApiException { + return bot.getChatMemberCount(getChatMemberCount); + } + + @Override + public Boolean setChatPhoto(SetChatPhoto setChatPhoto) throws TelegramApiException { + return bot.setChatPhoto(setChatPhoto); + } + + @Override + public Boolean deleteChatPhoto(DeleteChatPhoto deleteChatPhoto) throws TelegramApiException { + return bot.deleteChatPhoto(deleteChatPhoto); + } + + @Override + public void deleteChatPhoto(DeleteChatPhoto deleteChatPhoto, SentCallback sentCallback) throws TelegramApiException { + bot.deleteChatPhoto(deleteChatPhoto, sentCallback); + } + + @Override + public Boolean pinChatMessage(PinChatMessage pinChatMessage) throws TelegramApiException { + return bot.pinChatMessage(pinChatMessage); + } + + @Override + public void pinChatMessage(PinChatMessage pinChatMessage, SentCallback sentCallback) throws TelegramApiException { + bot.pinChatMessage(pinChatMessage, sentCallback); + } + + @Override + public Boolean unpinChatMessage(UnpinChatMessage unpinChatMessage) throws TelegramApiException { + return bot.unpinChatMessage(unpinChatMessage); + } + + @Override + public void unpinChatMessage(UnpinChatMessage unpinChatMessage, SentCallback sentCallback) throws TelegramApiException { + bot.unpinChatMessage(unpinChatMessage, sentCallback); + } + + @Override + public Boolean promoteChatMember(PromoteChatMember promoteChatMember) throws TelegramApiException { + return bot.promoteChatMember(promoteChatMember); + } + + @Override + public void promoteChatMember(PromoteChatMember promoteChatMember, SentCallback sentCallback) throws TelegramApiException { + bot.promoteChatMember(promoteChatMember, sentCallback); + } + + @Override + public Boolean restrictChatMember(RestrictChatMember restrictChatMember) throws TelegramApiException { + return bot.restrictChatMember(restrictChatMember); + } + + @Override + public void restrictChatMember(RestrictChatMember restrictChatMember, SentCallback sentCallback) throws TelegramApiException { + bot.restrictChatMember(restrictChatMember, sentCallback); + } + + @Override + public Boolean setChatDescription(SetChatDescription setChatDescription) throws TelegramApiException { + return bot.setChatDescription(setChatDescription); + } + + @Override + public void setChatDescription(SetChatDescription setChatDescription, SentCallback sentCallback) throws TelegramApiException { + bot.setChatDescription(setChatDescription, sentCallback); + } + + @Override + public Boolean setChatTite(SetChatTitle setChatTitle) throws TelegramApiException { + return bot.setChatTitle(setChatTitle); + } + + @Override + public void setChatTite(SetChatTitle setChatTitle, SentCallback sentCallback) throws TelegramApiException { + bot.setChatTitle(setChatTitle, sentCallback); + } + + @Override + public String exportChatInviteLink(ExportChatInviteLink exportChatInviteLink) throws TelegramApiException { + return bot.exportChatInviteLink(exportChatInviteLink); + } + + @Override + public void exportChatInviteLinkAsync(ExportChatInviteLink exportChatInviteLink, SentCallback sentCallback) throws TelegramApiException { + bot.exportChatInviteLinkAsync(exportChatInviteLink, sentCallback); + } + + @Override + public Boolean deleteMessage(DeleteMessage deleteMessage) throws TelegramApiException { + return bot.deleteMessage(deleteMessage); + } + + @Override + public void deleteMessageAsync(DeleteMessage deleteMessage, SentCallback sentCallback) throws TelegramApiException { + bot.deleteMessage(deleteMessage, sentCallback); + } + + @Override + public Serializable editMessageText(EditMessageText editMessageText) throws TelegramApiException { + return bot.editMessageText(editMessageText); + } + + @Override + public Serializable editMessageCaption(EditMessageCaption editMessageCaption) throws TelegramApiException { + return bot.editMessageCaption(editMessageCaption); + } + + @Override + public Serializable editMessageReplyMarkup(EditMessageReplyMarkup editMessageReplyMarkup) throws TelegramApiException { + return bot.editMessageReplyMarkup(editMessageReplyMarkup); + } + + @Override + public Boolean answerCallbackQuery(AnswerCallbackQuery answerCallbackQuery) throws TelegramApiException { + return bot.answerCallbackQuery(answerCallbackQuery); + } + + @Override + public UserProfilePhotos getUserProfilePhotos(GetUserProfilePhotos getUserProfilePhotos) throws TelegramApiException { + return bot.getUserProfilePhotos(getUserProfilePhotos); + } + + @Override + public java.io.File downloadFile(String path) throws TelegramApiException { + return bot.downloadFile(path); + } + + @Override + public void downloadFileAsync(String path, DownloadFileCallback callback) throws TelegramApiException { + bot.downloadFileAsync(path, callback); + } + + @Override + public java.io.File downloadFile(File file) throws TelegramApiException { + return bot.downloadFile(file); + } + + @Override + public void downloadFileAsync(File file, DownloadFileCallback callback) throws TelegramApiException { + bot.downloadFileAsync(file, callback); + } + + @Override + public File getFile(GetFile getFile) throws TelegramApiException { + return bot.getFile(getFile); + } + + @Override + public User getMe() throws TelegramApiException { + return bot.getMe(); + } + + @Override + public WebhookInfo getWebhookInfo() throws TelegramApiException { + return bot.getWebhookInfo(); + } + + @Override + public Serializable setGameScore(SetGameScore setGameScore) throws TelegramApiException { + return bot.setGameScore(setGameScore); + } + + @Override + public Serializable getGameHighScores(GetGameHighScores getGameHighScores) throws TelegramApiException { + return bot.getGameHighScores(getGameHighScores); + } + + @Override + public Message sendGame(SendGame sendGame) throws TelegramApiException { + return bot.sendGame(sendGame); + } + + @Override + public Boolean deleteWebhook(DeleteWebhook deleteWebhook) throws TelegramApiException { + return bot.deleteWebhook(deleteWebhook); + } + + @Override + public Message sendMessage(SendMessage sendMessage) throws TelegramApiException { + return bot.sendMessage(sendMessage); + } + + @Override + public void sendMessageAsync(SendMessage sendMessage, SentCallback sentCallback) throws TelegramApiException { + bot.sendMessageAsync(sendMessage, sentCallback); + } + + @Override + public void answerInlineQueryAsync(AnswerInlineQuery answerInlineQuery, SentCallback sentCallback) throws TelegramApiException { + bot.answerInlineQueryAsync(answerInlineQuery, sentCallback); + } + + @Override + public void sendChatActionAsync(SendChatAction sendChatAction, SentCallback sentCallback) throws TelegramApiException { + bot.sendChatActionAsync(sendChatAction, sentCallback); + } + + @Override + public void forwardMessageAsync(ForwardMessage forwardMessage, SentCallback sentCallback) throws TelegramApiException { + bot.forwardMessageAsync(forwardMessage, sentCallback); + } + + @Override + public void sendLocationAsync(SendLocation sendLocation, SentCallback sentCallback) throws TelegramApiException { + bot.sendLocationAsync(sendLocation, sentCallback); + } + + @Override + public void sendVenueAsync(SendVenue sendVenue, SentCallback sentCallback) throws TelegramApiException { + bot.sendVenueAsync(sendVenue, sentCallback); + } + + @Override + public void sendContactAsync(SendContact sendContact, SentCallback sentCallback) throws TelegramApiException { + bot.sendContactAsync(sendContact, sentCallback); + } + + @Override + public void kickMemberAsync(KickChatMember kickChatMember, SentCallback sentCallback) throws TelegramApiException { + bot.kickMemberAsync(kickChatMember, sentCallback); + } + + @Override + public void unbanMemberAsync(UnbanChatMember unbanChatMember, SentCallback sentCallback) throws TelegramApiException { + bot.unbanMemberAsync(unbanChatMember, sentCallback); + } + + @Override + public void leaveChatAsync(LeaveChat leaveChat, SentCallback sentCallback) throws TelegramApiException { + bot.leaveChatAsync(leaveChat, sentCallback); + } + + @Override + public void getChatAsync(GetChat getChat, SentCallback sentCallback) throws TelegramApiException { + bot.getChatAsync(getChat, sentCallback); + } + + @Override + public void getChatAdministratorsAsync(GetChatAdministrators getChatAdministrators, SentCallback> sentCallback) throws TelegramApiException { + bot.getChatAdministratorsAsync(getChatAdministrators, sentCallback); + } + + @Override + public void getChatMemberAsync(GetChatMember getChatMember, SentCallback sentCallback) throws TelegramApiException { + bot.getChatMemberAsync(getChatMember, sentCallback); + } + + @Override + public void getChatMemberCountAsync(GetChatMemberCount getChatMemberCount, SentCallback sentCallback) throws TelegramApiException { + bot.getChatMemberCountAsync(getChatMemberCount, sentCallback); + } + + @Override + public void editMessageTextAsync(EditMessageText editMessageText, SentCallback sentCallback) throws TelegramApiException { + bot.editMessageTextAsync(editMessageText, sentCallback); + } + + @Override + public void editMessageCaptionAsync(EditMessageCaption editMessageCaption, SentCallback sentCallback) throws TelegramApiException { + bot.editMessageCaptionAsync(editMessageCaption, sentCallback); + } + + @Override + public void editMessageReplyMarkup(EditMessageReplyMarkup editMessageReplyMarkup, SentCallback sentCallback) throws TelegramApiException { + bot.editMessageReplyMarkup(editMessageReplyMarkup, sentCallback); + } + + @Override + public void answerCallbackQueryAsync(AnswerCallbackQuery answerCallbackQuery, SentCallback sentCallback) throws TelegramApiException { + bot.answerCallbackQueryAsync(answerCallbackQuery, sentCallback); + } + + @Override + public void getUserProfilePhotosAsync(GetUserProfilePhotos getUserProfilePhotos, SentCallback sentCallback) throws TelegramApiException { + bot.getUserProfilePhotosAsync(getUserProfilePhotos, sentCallback); + } + + @Override + public void getFileAsync(GetFile getFile, SentCallback sentCallback) throws TelegramApiException { + bot.getFileAsync(getFile, sentCallback); + } + + @Override + public void getMeAsync(SentCallback sentCallback) throws TelegramApiException { + bot.getMeAsync(sentCallback); + } + + @Override + public void getWebhookInfoAsync(SentCallback sentCallback) throws TelegramApiException { + bot.getWebhookInfoAsync(sentCallback); + } + + @Override + public void setGameScoreAsync(SetGameScore setGameScore, SentCallback sentCallback) throws TelegramApiException { + bot.setGameScoreAsync(setGameScore, sentCallback); + } + + @Override + public void getGameHighScoresAsync(GetGameHighScores getGameHighScores, SentCallback> sentCallback) throws TelegramApiException { + bot.getGameHighScoresAsync(getGameHighScores, sentCallback); + } + + @Override + public void sendGameAsync(SendGame sendGame, SentCallback sentCallback) throws TelegramApiException { + bot.sendGameAsync(sendGame, sentCallback); + } + + @Override + public void deleteWebhook(DeleteWebhook deleteWebhook, SentCallback sentCallback) throws TelegramApiException { + bot.deleteWebhook(deleteWebhook, sentCallback); + } + + @Override + public Message sendDocument(SendDocument sendDocument) throws TelegramApiException { + return bot.sendDocument(sendDocument); + } + + @Override + public Message sendPhoto(SendPhoto sendPhoto) throws TelegramApiException { + return bot.sendPhoto(sendPhoto); + } + + @Override + public Message sendVideo(SendVideo sendVideo) throws TelegramApiException { + return bot.sendVideo(sendVideo); + } + + @Override + public Message sendSticker(SendSticker sendSticker) throws TelegramApiException { + return bot.sendSticker(sendSticker); + } + + @Override + public Message sendAudio(SendAudio sendAudio) throws TelegramApiException { + return bot.sendAudio(sendAudio); + } + + @Override + public Message sendVoice(SendVoice sendVoice) throws TelegramApiException { + return bot.sendVoice(sendVoice); + } + + private Optional doSendMessage(String txt, long groupId, boolean format) { + SendMessage smsg = new SendMessage(); + smsg.setChatId(groupId); + smsg.setText(txt); + smsg.enableMarkdown(format); + + return optionalSendMessage(smsg); + } + + private Optional optionalSendMessage(SendMessage smsg) { + try { + return ofNullable(sendMessage(smsg)); + } catch (TelegramApiException e) { + BotLogger.error("Could not send message", TAG, e); + return empty(); + } + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/MessageSender.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/MessageSender.java new file mode 100644 index 00000000..942bd8d8 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/sender/MessageSender.java @@ -0,0 +1,200 @@ +package org.telegram.abilitybots.api.sender; + +import org.telegram.telegrambots.api.methods.*; +import org.telegram.telegrambots.api.methods.games.GetGameHighScores; +import org.telegram.telegrambots.api.methods.games.SetGameScore; +import org.telegram.telegrambots.api.methods.groupadministration.*; +import org.telegram.telegrambots.api.methods.pinnedmessages.PinChatMessage; +import org.telegram.telegrambots.api.methods.pinnedmessages.UnpinChatMessage; +import org.telegram.telegrambots.api.methods.send.*; +import org.telegram.telegrambots.api.methods.updates.DeleteWebhook; +import org.telegram.telegrambots.api.methods.updatingmessages.DeleteMessage; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageCaption; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageReplyMarkup; +import org.telegram.telegrambots.api.methods.updatingmessages.EditMessageText; +import org.telegram.telegrambots.api.objects.*; +import org.telegram.telegrambots.api.objects.games.GameHighScore; +import org.telegram.telegrambots.bots.DefaultAbsSender; +import org.telegram.telegrambots.exceptions.TelegramApiException; +import org.telegram.telegrambots.updateshandlers.DownloadFileCallback; +import org.telegram.telegrambots.updateshandlers.SentCallback; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * A sender interface that replicates {@link DefaultAbsSender} methods. + * + * @author Abbas Abou Daya + */ +public interface MessageSender { + Optional send(String message, long id); + + Optional sendMd(String message, long id); + + Optional forceReply(String message, long id); + + Boolean answerInlineQuery(AnswerInlineQuery answerInlineQuery) throws TelegramApiException; + + Boolean sendChatAction(SendChatAction sendChatAction) throws TelegramApiException; + + Message forwardMessage(ForwardMessage forwardMessage) throws TelegramApiException; + + Message sendLocation(SendLocation sendLocation) throws TelegramApiException; + + Message sendVenue(SendVenue sendVenue) throws TelegramApiException; + + Message sendContact(SendContact sendContact) throws TelegramApiException; + + Boolean kickMember(KickChatMember kickChatMember) throws TelegramApiException; + + Boolean unbanMember(UnbanChatMember unbanChatMember) throws TelegramApiException; + + Boolean leaveChat(LeaveChat leaveChat) throws TelegramApiException; + + Chat getChat(GetChat getChat) throws TelegramApiException; + + List getChatAdministrators(GetChatAdministrators getChatAdministrators) throws TelegramApiException; + + ChatMember getChatMember(GetChatMember getChatMember) throws TelegramApiException; + + Integer getChatMemberCount(GetChatMemberCount getChatMemberCount) throws TelegramApiException; + + Boolean setChatPhoto(SetChatPhoto setChatPhoto) throws TelegramApiException; + + Boolean deleteChatPhoto(DeleteChatPhoto deleteChatPhoto) throws TelegramApiException; + + void deleteChatPhoto(DeleteChatPhoto deleteChatPhoto, SentCallback sentCallback) throws TelegramApiException; + + Boolean pinChatMessage(PinChatMessage pinChatMessage) throws TelegramApiException; + + void pinChatMessage(PinChatMessage pinChatMessage, SentCallback sentCallback) throws TelegramApiException; + + Boolean unpinChatMessage(UnpinChatMessage unpinChatMessage) throws TelegramApiException; + + void unpinChatMessage(UnpinChatMessage unpinChatMessage, SentCallback sentCallback) throws TelegramApiException; + + Boolean promoteChatMember(PromoteChatMember promoteChatMember) throws TelegramApiException; + + void promoteChatMember(PromoteChatMember promoteChatMember, SentCallback sentCallback) throws TelegramApiException; + + Boolean restrictChatMember(RestrictChatMember restrictChatMember) throws TelegramApiException; + + void restrictChatMember(RestrictChatMember restrictChatMember, SentCallback sentCallback) throws TelegramApiException; + + Boolean setChatDescription(SetChatDescription setChatDescription) throws TelegramApiException; + + void setChatDescription(SetChatDescription setChatDescription, SentCallback sentCallback) throws TelegramApiException; + + Boolean setChatTite(SetChatTitle setChatTitle) throws TelegramApiException; + + void setChatTite(SetChatTitle setChatTitle, SentCallback sentCallback) throws TelegramApiException; + + String exportChatInviteLink(ExportChatInviteLink exportChatInviteLink) throws TelegramApiException; + + void exportChatInviteLinkAsync(ExportChatInviteLink exportChatInviteLink, SentCallback sentCallback) throws TelegramApiException; + + Boolean deleteMessage(DeleteMessage deleteMessage) throws TelegramApiException; + + void deleteMessageAsync(DeleteMessage deleteMessage, SentCallback sentCallback) throws TelegramApiException; + + Serializable editMessageText(EditMessageText editMessageText) throws TelegramApiException; + + Serializable editMessageCaption(EditMessageCaption editMessageCaption) throws TelegramApiException; + + Serializable editMessageReplyMarkup(EditMessageReplyMarkup editMessageReplyMarkup) throws TelegramApiException; + + Boolean answerCallbackQuery(AnswerCallbackQuery answerCallbackQuery) throws TelegramApiException; + + UserProfilePhotos getUserProfilePhotos(GetUserProfilePhotos getUserProfilePhotos) throws TelegramApiException; + + java.io.File downloadFile(String path) throws TelegramApiException; + + void downloadFileAsync(String path, DownloadFileCallback callback) throws TelegramApiException; + + java.io.File downloadFile(File file) throws TelegramApiException; + + void downloadFileAsync(File file, DownloadFileCallback callback) throws TelegramApiException; + + File getFile(GetFile getFile) throws TelegramApiException; + + User getMe() throws TelegramApiException; + + WebhookInfo getWebhookInfo() throws TelegramApiException; + + Serializable setGameScore(SetGameScore setGameScore) throws TelegramApiException; + + Serializable getGameHighScores(GetGameHighScores getGameHighScores) throws TelegramApiException; + + Message sendGame(SendGame sendGame) throws TelegramApiException; + + Boolean deleteWebhook(DeleteWebhook deleteWebhook) throws TelegramApiException; + + Message sendMessage(SendMessage sendMessage) throws TelegramApiException; + + void sendMessageAsync(SendMessage sendMessage, SentCallback sentCallback) throws TelegramApiException; + + void answerInlineQueryAsync(AnswerInlineQuery answerInlineQuery, SentCallback sentCallback) throws TelegramApiException; + + void sendChatActionAsync(SendChatAction sendChatAction, SentCallback sentCallback) throws TelegramApiException; + + void forwardMessageAsync(ForwardMessage forwardMessage, SentCallback sentCallback) throws TelegramApiException; + + void sendLocationAsync(SendLocation sendLocation, SentCallback sentCallback) throws TelegramApiException; + + void sendVenueAsync(SendVenue sendVenue, SentCallback sentCallback) throws TelegramApiException; + + void sendContactAsync(SendContact sendContact, SentCallback sentCallback) throws TelegramApiException; + + void kickMemberAsync(KickChatMember kickChatMember, SentCallback sentCallback) throws TelegramApiException; + + void unbanMemberAsync(UnbanChatMember unbanChatMember, SentCallback sentCallback) throws TelegramApiException; + + void leaveChatAsync(LeaveChat leaveChat, SentCallback sentCallback) throws TelegramApiException; + + void getChatAsync(GetChat getChat, SentCallback sentCallback) throws TelegramApiException; + + void getChatAdministratorsAsync(GetChatAdministrators getChatAdministrators, SentCallback> sentCallback) throws TelegramApiException; + + void getChatMemberAsync(GetChatMember getChatMember, SentCallback sentCallback) throws TelegramApiException; + + void getChatMemberCountAsync(GetChatMemberCount getChatMemberCount, SentCallback sentCallback) throws TelegramApiException; + + void editMessageTextAsync(EditMessageText editMessageText, SentCallback sentCallback) throws TelegramApiException; + + void editMessageCaptionAsync(EditMessageCaption editMessageCaption, SentCallback sentCallback) throws TelegramApiException; + + void editMessageReplyMarkup(EditMessageReplyMarkup editMessageReplyMarkup, SentCallback sentCallback) throws TelegramApiException; + + void answerCallbackQueryAsync(AnswerCallbackQuery answerCallbackQuery, SentCallback sentCallback) throws TelegramApiException; + + void getUserProfilePhotosAsync(GetUserProfilePhotos getUserProfilePhotos, SentCallback sentCallback) throws TelegramApiException; + + void getFileAsync(GetFile getFile, SentCallback sentCallback) throws TelegramApiException; + + void getMeAsync(SentCallback sentCallback) throws TelegramApiException; + + void getWebhookInfoAsync(SentCallback sentCallback) throws TelegramApiException; + + void setGameScoreAsync(SetGameScore setGameScore, SentCallback sentCallback) throws TelegramApiException; + + void getGameHighScoresAsync(GetGameHighScores getGameHighScores, SentCallback> sentCallback) throws TelegramApiException; + + void sendGameAsync(SendGame sendGame, SentCallback sentCallback) throws TelegramApiException; + + void deleteWebhook(DeleteWebhook deleteWebhook, SentCallback sentCallback) throws TelegramApiException; + + Message sendDocument(SendDocument sendDocument) throws TelegramApiException; + + Message sendPhoto(SendPhoto sendPhoto) throws TelegramApiException; + + Message sendVideo(SendVideo sendVideo) throws TelegramApiException; + + Message sendSticker(SendSticker sendSticker) throws TelegramApiException; + + Message sendAudio(SendAudio sendAudio) throws TelegramApiException; + + Message sendVoice(SendVoice sendVoice) throws TelegramApiException; +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/AbilityUtils.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/AbilityUtils.java new file mode 100644 index 00000000..d0586039 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/AbilityUtils.java @@ -0,0 +1,131 @@ +package org.telegram.abilitybots.api.util; + +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.abilitybots.api.objects.MessageContext; +import org.telegram.telegrambots.api.objects.Update; +import org.telegram.telegrambots.api.objects.User; + +import java.util.function.Consumer; +import java.util.function.Predicate; + +import static org.telegram.abilitybots.api.objects.Flag.*; + +/** + * Helper and utility methods + */ +public final class AbilityUtils { + private AbilityUtils() { + + } + + /** + * @param username any username + * @return the username with the preceding "@" stripped off + */ + public static String stripTag(String username) { + String lowerCase = username.toLowerCase(); + return lowerCase.startsWith("@") ? lowerCase.substring(1, lowerCase.length()) : lowerCase; + } + + /** + * Commits to DB. + * + * @param db the database to commit on + * @return a lambda consumer that takes in a {@link MessageContext}, used in post actions for abilities + */ + public static Consumer commitTo(DBContext db) { + return ctx -> db.commit(); + } + + /** + * Fetches the user who caused the update. + * + * @param update a Telegram {@link Update} + * @return the originating user + * @throws IllegalStateException if the user could not be found + */ + public static User getUser(Update update) { + if (MESSAGE.test(update)) { + return update.getMessage().getFrom(); + } else if (CALLBACK_QUERY.test(update)) { + return update.getCallbackQuery().getFrom(); + } else if (INLINE_QUERY.test(update)) { + return update.getInlineQuery().getFrom(); + } else if (CHANNEL_POST.test(update)) { + return update.getChannelPost().getFrom(); + } else if (EDITED_CHANNEL_POST.test(update)) { + return update.getEditedChannelPost().getFrom(); + } else if (EDITED_MESSAGE.test(update)) { + return update.getEditedMessage().getFrom(); + } else if (CHOSEN_INLINE_QUERY.test(update)) { + return update.getChosenInlineQuery().getFrom(); + } else { + throw new IllegalStateException("Could not retrieve originating user from update"); + } + } + + /** + * Fetches the direct chat ID of the specified update. + * + * @param update a Telegram {@link Update} + * @return the originating chat ID + * @throws IllegalStateException if the chat ID could not be found + */ + public static Long getChatId(Update update) { + if (MESSAGE.test(update)) { + return update.getMessage().getChatId(); + } else if (CALLBACK_QUERY.test(update)) { + return update.getCallbackQuery().getMessage().getChatId(); + } else if (INLINE_QUERY.test(update)) { + return (long) update.getInlineQuery().getFrom().getId(); + } else if (CHANNEL_POST.test(update)) { + return update.getChannelPost().getChatId(); + } else if (EDITED_CHANNEL_POST.test(update)) { + return update.getEditedChannelPost().getChatId(); + } else if (EDITED_MESSAGE.test(update)) { + return update.getEditedMessage().getChatId(); + } else if (CHOSEN_INLINE_QUERY.test(update)) { + return (long) update.getChosenInlineQuery().getFrom().getId(); + } else { + throw new IllegalStateException("Could not retrieve originating chat ID from update"); + } + } + + /** + * @param update a Telegram {@link Update} + * @return true if the update contains contains a private user message + */ + public static boolean isUserMessage(Update update) { + if (MESSAGE.test(update)) { + return update.getMessage().isUserMessage(); + } else if (CALLBACK_QUERY.test(update)) { + return update.getCallbackQuery().getMessage().isUserMessage(); + } else if (CHANNEL_POST.test(update)) { + return update.getChannelPost().isUserMessage(); + } else if (EDITED_CHANNEL_POST.test(update)) { + return update.getEditedChannelPost().isUserMessage(); + } else if (EDITED_MESSAGE.test(update)) { + return update.getEditedMessage().isUserMessage(); + } else if (CHOSEN_INLINE_QUERY.test(update) || INLINE_QUERY.test(update)) { + return true; + } else { + throw new IllegalStateException("Could not retrieve update context origin (user/group)"); + } + } + + /** + * @param username the username to add the tag to + * @return the username prefixed with the "@" tag. + */ + public static String addTag(String username) { + return "@" + username; + } + + /** + * @param msg the message to be replied to + * @return a predicate that asserts that the update is a reply to the specified message. + */ + public static Predicate isReplyTo(String msg) { + return update -> update.getMessage().getReplyToMessage().getText().equals(msg); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Pair.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Pair.java new file mode 100644 index 00000000..dd227281 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Pair.java @@ -0,0 +1,57 @@ +package org.telegram.abilitybots.api.util; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import java.util.Objects; + +public final class Pair { + @JsonProperty("a") + private final A a; + @JsonProperty("b") + private final B b; + + private Pair(A a, B b) { + this.a = a; + this.b = b; + } + + @JsonCreator + public static Pair of(@JsonProperty("a") A a, @JsonProperty("b") B b) { + return new Pair<>(a, b); + } + + public A a() { + return a; + } + + public B b() { + return b; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Pair pair = (Pair) o; + return Objects.equals(a, pair.a) && + Objects.equals(b, pair.b); + } + + @Override + public int hashCode() { + return Objects.hash(a, b); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("a", a) + .add("b", b) + .toString(); + } +} diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Trio.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Trio.java new file mode 100644 index 00000000..e8cb7db7 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/util/Trio.java @@ -0,0 +1,66 @@ +package org.telegram.abilitybots.api.util; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import java.util.Objects; + +public class Trio { + @JsonProperty("a") + private final A a; + @JsonProperty("b") + private final B b; + @JsonProperty("c") + private final C c; + + private Trio(A a, B b, C c) { + this.a = a; + this.b = b; + this.c = c; + } + + @JsonCreator + public static Trio of(@JsonProperty("a") A a, @JsonProperty("b") B b, @JsonProperty("c") C c) { + return new Trio<>(a, b, c); + } + + public A a() { + return a; + } + + public B b() { + return b; + } + + public C c() { + return c; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + Trio trio = (Trio) o; + return Objects.equals(a, trio.a) && + Objects.equals(b, trio.b) && + Objects.equals(c, trio.c); + } + + @Override + public int hashCode() { + return Objects.hash(a, b, c); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("a", a) + .add("b", b) + .add("c", c) + .toString(); + } +} diff --git a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/AbilityBotTest.java b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/AbilityBotTest.java new file mode 100644 index 00000000..275b83f3 --- /dev/null +++ b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/AbilityBotTest.java @@ -0,0 +1,589 @@ +package org.telegram.abilitybots.api.bot; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Files; +import org.jetbrains.annotations.NotNull; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Matchers; +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.abilitybots.api.objects.*; +import org.telegram.abilitybots.api.sender.MessageSender; +import org.telegram.abilitybots.api.util.Pair; +import org.telegram.abilitybots.api.util.Trio; +import org.telegram.telegrambots.api.objects.*; +import org.telegram.telegrambots.exceptions.TelegramApiException; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; + +import static com.google.common.collect.Sets.newHashSet; +import static java.lang.String.format; +import static java.util.Collections.emptySet; +import static org.apache.commons.lang3.ArrayUtils.addAll; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import static org.junit.Assert.*; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.internal.verification.VerificationModeFactory.times; +import static org.telegram.abilitybots.api.bot.AbilityBot.RECOVERY_MESSAGE; +import static org.telegram.abilitybots.api.bot.AbilityBot.RECOVER_SUCCESS; +import static org.telegram.abilitybots.api.bot.DefaultBot.getDefaultBuilder; +import static org.telegram.abilitybots.api.db.MapDBContext.offlineInstance; +import static org.telegram.abilitybots.api.objects.EndUser.endUser; +import static org.telegram.abilitybots.api.objects.Flag.DOCUMENT; +import static org.telegram.abilitybots.api.objects.Flag.MESSAGE; +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.MessageContext.newContext; +import static org.telegram.abilitybots.api.objects.Privacy.ADMIN; +import static org.telegram.abilitybots.api.objects.Privacy.PUBLIC; + +public class AbilityBotTest { + public static final String[] EMPTY_ARRAY = {}; + public static final long GROUP_ID = 10L; + public static final String TEST = "test"; + public static final String[] TEXT = {TEST}; + public static final EndUser MUSER = endUser(1, "first", "last", "username"); + public static final EndUser CREATOR = endUser(1337, "creatorFirst", "creatorLast", "creatorUsername"); + + private DefaultBot bot; + private DBContext db; + private MessageSender sender; + + @Before + public void setUp() { + db = offlineInstance("db"); + bot = new DefaultBot(EMPTY, EMPTY, db); + sender = mock(MessageSender.class); + bot.setSender(sender); + } + + @Test + public void sendsPrivacyViolation() { + Update update = mockFullUpdate(MUSER, "/admin"); + + bot.onUpdateReceived(update); + + verify(sender, times(1)).send(format("Sorry, %s-only feature.", "admin"), MUSER.id()); + } + + @Test + public void sendsLocalityViolation() { + Update update = mockFullUpdate(MUSER, "/group"); + + bot.onUpdateReceived(update); + + verify(sender, times(1)).send(format("Sorry, %s-only feature.", "group"), MUSER.id()); + + } + + @Test + public void sendsInputArgsViolation() { + Update update = mockFullUpdate(MUSER, "/count 1 2 3"); + + bot.onUpdateReceived(update); + + verify(sender, times(1)).send(format("Sorry, this feature requires %d additional inputs.", 4), MUSER.id()); + } + + @Test + public void canProcessRepliesIfSatisfyRequirements() { + Update update = mockFullUpdate(MUSER, "must reply"); + + // False means the update was not pushed down the stream since it has been consumed by the reply + assertFalse(bot.filterReply(update)); + verify(sender, times(1)).send("reply", MUSER.id()); + } + + @Test + public void canBackupDB() throws TelegramApiException { + MessageContext context = defaultContext(); + + bot.backupDB().action().accept(context); + + verify(sender, times(1)).sendDocument(any()); + } + + @Test + public void canRecoverDB() throws TelegramApiException, IOException { + Update update = mockBackupUpdate(); + Object backup = getDbBackup(); + java.io.File backupFile = createBackupFile(backup); + + when(sender.downloadFile(Matchers.any(File.class))).thenReturn(backupFile); + bot.recoverDB().replies().get(0).actOn(update); + + verify(sender, times(1)).send(RECOVER_SUCCESS, GROUP_ID); + assertEquals("Bot recovered but the DB is still not in sync", db.getSet(TEST), newHashSet(TEST)); + assertTrue("Could not delete backup file", backupFile.delete()); + } + + @Test + public void canFilterOutReplies() { + Update update = mock(Update.class); + when(update.hasMessage()).thenReturn(false); + + assertTrue(bot.filterReply(update)); + } + + @Test + public void canDemote() { + addUsers(MUSER); + bot.admins().add(MUSER.id()); + + MessageContext context = defaultContext(); + + bot.demoteAdmin().action().accept(context); + + Set actual = bot.admins(); + Set expected = emptySet(); + assertEquals("Could not sudont super-admin", expected, actual); + } + + @Test + public void canPromote() { + addUsers(MUSER); + + MessageContext context = defaultContext(); + + bot.promoteAdmin().action().accept(context); + + Set actual = bot.admins(); + Set expected = newHashSet(MUSER.id()); + assertEquals("Could not sudo user", expected, actual); + } + + @Test + public void canBanUser() { + addUsers(MUSER); + MessageContext context = defaultContext(); + + bot.banUser().action().accept(context); + + Set actual = bot.blacklist(); + Set expected = newHashSet(MUSER.id()); + assertEquals("The ban was not emplaced", expected, actual); + } + + @Test + public void canUnbanUser() { + addUsers(MUSER); + bot.blacklist().add(MUSER.id()); + + MessageContext context = defaultContext(); + + bot.unbanUser().action().accept(context); + + Set actual = bot.blacklist(); + Set expected = newHashSet(); + assertEquals("The ban was not lifted", expected, actual); + } + + @NotNull + private MessageContext defaultContext() { + MessageContext context = mock(MessageContext.class); + when(context.user()).thenReturn(CREATOR); + when(context.firstArg()).thenReturn(MUSER.username()); + return context; + } + + @Test + public void cannotBanCreator() { + addUsers(MUSER, CREATOR); + MessageContext context = mock(MessageContext.class); + when(context.user()).thenReturn(MUSER); + when(context.firstArg()).thenReturn(CREATOR.username()); + + bot.banUser().action().accept(context); + + Set actual = bot.blacklist(); + Set expected = newHashSet(MUSER.id()); + assertEquals("Impostor was not added to the blacklist", expected, actual); + } + + private void addUsers(EndUser... users) { + Arrays.stream(users).forEach(user -> { + bot.users().put(user.id(), user); + bot.userIds().put(user.username().toLowerCase(), user.id()); + }); + } + + @Test + public void creatorCanClaimBot() { + MessageContext context = mock(MessageContext.class); + when(context.user()).thenReturn(CREATOR); + + bot.claimCreator().action().accept(context); + + Set actual = bot.admins(); + Set expected = newHashSet(CREATOR.id()); + assertEquals("Creator was not properly added to the super admins set", expected, actual); + } + + @Test + public void userGetsBannedIfClaimsBot() { + addUsers(MUSER); + MessageContext context = mock(MessageContext.class); + when(context.user()).thenReturn(MUSER); + + bot.claimCreator().action().accept(context); + + Set actual = bot.blacklist(); + Set expected = newHashSet(MUSER.id()); + assertEquals("Could not find user on the blacklist", expected, actual); + + actual = bot.admins(); + expected = emptySet(); + assertEquals("Admins set is not empty", expected, actual); + } + + @Test + public void bannedCreatorPassesBlacklistCheck() { + bot.blacklist().add(CREATOR.id()); + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + + mockUser(update, message, user); + + boolean notBanned = bot.checkBlacklist(update); + assertTrue("Creator is banned", notBanned); + } + + @Test + public void canAddUser() { + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + + mockAlternateUser(update, message, user, MUSER); + + bot.addUser(update); + + Map expectedUserIds = ImmutableMap.of(MUSER.username(), MUSER.id()); + Map expectedUsers = ImmutableMap.of(MUSER.id(), MUSER); + assertEquals("User was not added", expectedUserIds, bot.userIds()); + assertEquals("User was not added", expectedUsers, bot.users()); + } + + @Test + public void canEditUser() { + addUsers(MUSER); + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + + String newUsername = MUSER.username() + "-test"; + String newFirstName = MUSER.firstName() + "-test"; + String newLastName = MUSER.lastName() + "-test"; + int sameId = MUSER.id(); + EndUser changedUser = endUser(sameId, newFirstName, newLastName, newUsername); + + mockAlternateUser(update, message, user, changedUser); + + bot.addUser(update); + + Map expectedUserIds = ImmutableMap.of(changedUser.username(), changedUser.id()); + Map expectedUsers = ImmutableMap.of(changedUser.id(), changedUser); + assertEquals("User was not properly edited", bot.userIds(), expectedUserIds); + assertEquals("User was not properly edited", expectedUsers, expectedUsers); + } + + @Test + public void canValidateAbility() { + Trio invalidPair = Trio.of(null, null, null); + Ability validAbility = getDefaultBuilder().build(); + Trio validPair = Trio.of(null, validAbility, null); + + assertEquals("Bot can't validate ability properly", false, bot.validateAbility(invalidPair)); + assertEquals("Bot can't validate ability properly", true, bot.validateAbility(validPair)); + } + + @Test + public void canCheckInput() { + Update update = mockFullUpdate(MUSER, "/something"); + Ability abilityWithOneInput = getDefaultBuilder() + .build(); + Ability abilityWithZeroInput = getDefaultBuilder() + .input(0) + .build(); + + Trio trioOneArg = Trio.of(update, abilityWithOneInput, TEXT); + Trio trioZeroArg = Trio.of(update, abilityWithZeroInput, TEXT); + + assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioOneArg)); + + trioOneArg = Trio.of(update, abilityWithOneInput, addAll(TEXT, TEXT)); + assertEquals("Unexpected result when applying token filter", false, bot.checkInput(trioOneArg)); + + assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioZeroArg)); + + trioZeroArg = Trio.of(update, abilityWithZeroInput, EMPTY_ARRAY); + assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioZeroArg)); + } + + @Test + public void canCheckPrivacy() { + Update update = mock(Update.class); + Message message = mock(Message.class); + org.telegram.telegrambots.api.objects.User user = mock(User.class); + Ability publicAbility = getDefaultBuilder().privacy(PUBLIC).build(); + Ability adminAbility = getDefaultBuilder().privacy(ADMIN).build(); + Ability creatorAbility = getDefaultBuilder().privacy(Privacy.CREATOR).build(); + + Trio publicTrio = Trio.of(update, publicAbility, TEXT); + Trio adminTrio = Trio.of(update, adminAbility, TEXT); + Trio creatorTrio = Trio.of(update, creatorAbility, TEXT); + + mockUser(update, message, user); + + assertEquals("Unexpected result when checking for privacy", true, bot.checkPrivacy(publicTrio)); + assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(adminTrio)); + assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(creatorTrio)); + } + + @Test + public void canBlockAdminsFromCreatorAbilities() { + Update update = mock(Update.class); + Message message = mock(Message.class); + org.telegram.telegrambots.api.objects.User user = mock(User.class); + Ability creatorAbility = getDefaultBuilder().privacy(Privacy.CREATOR).build(); + + Trio creatorTrio = Trio.of(update, creatorAbility, TEXT); + + bot.admins().add(MUSER.id()); + mockUser(update, message, user); + + assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(creatorTrio)); + } + + @Test + public void canCheckLocality() { + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + Ability allAbility = getDefaultBuilder().locality(ALL).build(); + Ability userAbility = getDefaultBuilder().locality(Locality.USER).build(); + Ability groupAbility = getDefaultBuilder().locality(GROUP).build(); + + Trio publicTrio = Trio.of(update, allAbility, TEXT); + Trio userTrio = Trio.of(update, userAbility, TEXT); + Trio groupTrio = Trio.of(update, groupAbility, TEXT); + + mockUser(update, message, user); + when(message.isUserMessage()).thenReturn(true); + + assertEquals("Unexpected result when checking for locality", true, bot.checkLocality(publicTrio)); + assertEquals("Unexpected result when checking for locality", true, bot.checkLocality(userTrio)); + assertEquals("Unexpected result when checking for locality", false, bot.checkLocality(groupTrio)); + } + + @Test + public void canRetrieveContext() { + Update update = mock(Update.class); + Message message = mock(Message.class); + User user = mock(User.class); + Ability ability = getDefaultBuilder().build(); + Trio trio = Trio.of(update, ability, TEXT); + + when(message.getChatId()).thenReturn(GROUP_ID); + mockUser(update, message, user); + + Pair actualPair = bot.getContext(trio); + Pair expectedPair = Pair.of(newContext(update, MUSER, GROUP_ID, TEXT), ability); + + assertEquals("Unexpected result when fetching for context", expectedPair, actualPair); + } + + @Test + public void canCheckGlobalFlags() { + Update update = mock(Update.class); + Message message = mock(Message.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + assertEquals("Unexpected result when checking for locality", true, bot.checkGlobalFlags(update)); + } + + @Test(expected = ArithmeticException.class) + public void canConsumeUpdate() { + Ability ability = getDefaultBuilder() + .action((context) -> { + int x = 1 / 0; + }).build(); + MessageContext context = mock(MessageContext.class); + + Pair pair = Pair.of(context, ability); + + bot.consumeUpdate(pair); + } + + @Test + public void canFetchAbility() { + Update update = mock(Update.class); + Message message = mock(Message.class); + + String text = "/test"; + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(update.getMessage().hasText()).thenReturn(true); + when(message.getText()).thenReturn(text); + + Trio trio = bot.getAbility(update); + + Ability expected = bot.testAbility(); + Ability actual = trio.b(); + + assertEquals("Wrong ability was fetched", expected, actual); + } + + @Test + public void canFetchDefaultAbility() { + Update update = mock(Update.class); + Message message = mock(Message.class); + + String text = "test tags"; + when(update.getMessage()).thenReturn(message); + when(message.getText()).thenReturn(text); + + Trio trio = bot.getAbility(update); + + Ability expected = bot.defaultAbility(); + Ability actual = trio.b(); + + assertEquals("Wrong ability was fetched", expected, actual); + } + + @Test + public void canCheckAbilityFlags() { + Update update = mock(Update.class); + Message message = mock(Message.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.hasDocument()).thenReturn(false); + when(message.hasText()).thenReturn(true); + + Ability documentAbility = getDefaultBuilder().flag(DOCUMENT, MESSAGE).build(); + Ability textAbility = getDefaultBuilder().flag(Flag.TEXT, MESSAGE).build(); + + Trio docTrio = Trio.of(update, documentAbility, TEXT); + Trio textTrio = Trio.of(update, textAbility, TEXT); + + assertEquals("Unexpected result when checking for message flags", false, bot.checkMessageFlags(docTrio)); + assertEquals("Unexpected result when checking for message flags", true, bot.checkMessageFlags(textTrio)); + } + + @Test + public void canReportCommands() { + Update update = mock(Update.class); + Message message = mock(Message.class); + + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.hasText()).thenReturn(true); + MessageContext context = mock(MessageContext.class); + when(context.chatId()).thenReturn(GROUP_ID); + + bot.reportCommands().action().accept(context); + + verify(sender, times(1)).send("default - dis iz default command", GROUP_ID); + } + + @After + public void tearDown() throws IOException { + db.clear(); + db.close(); + } + + private User mockUser(EndUser fromUser) { + User user = mock(User.class); + when(user.getId()).thenReturn(fromUser.id()); + when(user.getUserName()).thenReturn(fromUser.username()); + when(user.getFirstName()).thenReturn(fromUser.firstName()); + when(user.getLastName()).thenReturn(fromUser.lastName()); + + return user; + } + + @NotNull + private Update mockFullUpdate(EndUser fromUser, String args) { + bot.users().put(MUSER.id(), MUSER); + bot.users().put(CREATOR.id(), CREATOR); + bot.userIds().put(CREATOR.username(), CREATOR.id()); + bot.userIds().put(MUSER.username(), MUSER.id()); + + bot.admins().add(CREATOR.id()); + + User user = mockUser(fromUser); + + Update update = mock(Update.class); + when(update.hasMessage()).thenReturn(true); + Message message = mock(Message.class); + when(message.getFrom()).thenReturn(user); + when(message.getText()).thenReturn(args); + when(message.hasText()).thenReturn(true); + when(message.isUserMessage()).thenReturn(true); + when(message.getChatId()).thenReturn((long) fromUser.id()); + when(update.getMessage()).thenReturn(message); + return update; + } + + private void mockUser(Update update, Message message, User user) { + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + when(message.getFrom()).thenReturn(user); + when(user.getFirstName()).thenReturn(MUSER.firstName()); + when(user.getLastName()).thenReturn(MUSER.lastName()); + when(user.getId()).thenReturn(MUSER.id()); + when(user.getUserName()).thenReturn(MUSER.username()); + } + + private void mockAlternateUser(Update update, Message message, User user, EndUser changedUser) { + when(user.getId()).thenReturn(changedUser.id()); + when(user.getFirstName()).thenReturn(changedUser.firstName()); + when(user.getLastName()).thenReturn(changedUser.lastName()); + when(user.getUserName()).thenReturn(changedUser.username()); + when(message.getFrom()).thenReturn(user); + when(update.hasMessage()).thenReturn(true); + when(update.getMessage()).thenReturn(message); + } + + private Update mockBackupUpdate() { + Update update = mock(Update.class); + Message message = mock(Message.class); + Message botMessage = mock(Message.class); + Document document = mock(Document.class); + + when(update.getMessage()).thenReturn(message); + when(message.getDocument()).thenReturn(document); + when(botMessage.getText()).thenReturn(RECOVERY_MESSAGE); + when(message.isReply()).thenReturn(true); + when(message.hasDocument()).thenReturn(true); + when(message.getReplyToMessage()).thenReturn(botMessage); + when(message.getChatId()).thenReturn(GROUP_ID); + return update; + } + + private Object getDbBackup() { + db.getSet(TEST).add(TEST); + Object backup = db.backup(); + db.clear(); + return backup; + } + + private java.io.File createBackupFile(Object backup) throws IOException { + java.io.File backupFile = new java.io.File(TEST); + BufferedWriter writer = Files.newWriter(backupFile, Charset.defaultCharset()); + writer.write(backup.toString()); + writer.flush(); + writer.close(); + return backupFile; + } +} diff --git a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/DefaultBot.java b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/DefaultBot.java new file mode 100644 index 00000000..4b01a5d0 --- /dev/null +++ b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/bot/DefaultBot.java @@ -0,0 +1,78 @@ +package org.telegram.abilitybots.api.bot; + +import com.google.common.annotations.VisibleForTesting; +import org.telegram.abilitybots.api.db.DBContext; +import org.telegram.abilitybots.api.objects.Ability; +import org.telegram.abilitybots.api.objects.Ability.AbilityBuilder; +import org.telegram.abilitybots.api.sender.MessageSender; + +import static org.telegram.abilitybots.api.objects.Ability.builder; +import static org.telegram.abilitybots.api.objects.Flag.CALLBACK_QUERY; +import static org.telegram.abilitybots.api.objects.Flag.MESSAGE; +import static org.telegram.abilitybots.api.objects.Locality.*; +import static org.telegram.abilitybots.api.objects.Privacy.ADMIN; +import static org.telegram.abilitybots.api.objects.Privacy.PUBLIC; + +public class DefaultBot extends AbilityBot { + + public DefaultBot(String token, String username, DBContext db) { + super(token, username, db); + } + + public static AbilityBuilder getDefaultBuilder() { + return builder() + .name("test") + .privacy(PUBLIC) + .locality(ALL) + .input(1) + .action(ctx -> { + }); + } + + @Override + public int creatorId() { + return 1337; + } + + public Ability defaultAbility() { + return getDefaultBuilder() + .name(DEFAULT) + .info("dis iz default command") + .reply(upd -> sender.send("reply", upd.getMessage().getChatId()), MESSAGE, update -> update.getMessage().getText().equals("must reply")) + .reply(upd -> sender.send("reply", upd.getCallbackQuery().getMessage().getChatId()), CALLBACK_QUERY) + .build(); + } + + public Ability adminAbility() { + return getDefaultBuilder() + .name("admin") + .privacy(ADMIN) + .build(); + } + + public Ability groupAbility() { + return getDefaultBuilder() + .name("group") + .privacy(PUBLIC) + .locality(GROUP) + .build(); + } + + public Ability multipleInputAbility() { + return getDefaultBuilder() + .name("count") + .privacy(PUBLIC) + .locality(USER) + .input(4) + .build(); + } + + public Ability testAbility() { + return getDefaultBuilder().build(); + } + + @VisibleForTesting + void setSender(MessageSender sender) { + this.sender = sender; + } +} \ No newline at end of file diff --git a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java new file mode 100644 index 00000000..2d9a6f16 --- /dev/null +++ b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/db/MapDBContextTest.java @@ -0,0 +1,109 @@ +package org.telegram.abilitybots.api.db; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.telegram.abilitybots.api.objects.EndUser; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +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.assertEquals; +import static org.junit.Assert.assertTrue; +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.MUSER; +import static org.telegram.abilitybots.api.db.MapDBContext.offlineInstance; + +public class MapDBContextTest { + + private static final String TEST = "TEST"; + private DBContext db; + + @Before + public void setUp() { + db = offlineInstance("db"); + } + + @Test + public void canRecoverDB() throws IOException { + Map users = db.getMap(USERS); + Map userIds = db.getMap(USER_ID); + users.put(CREATOR.id(), CREATOR); + users.put(MUSER.id(), MUSER); + userIds.put(CREATOR.username(), CREATOR.id()); + userIds.put(MUSER.username(), MUSER.id()); + + db.getSet("AYRE").add(123123); + Map originalUsers = newHashMap(users); + String beforeBackupInfo = db.info(USERS); + + Object jsonBackup = db.backup(); + db.clear(); + boolean recovered = db.recover(jsonBackup); + + Map recoveredUsers = db.getMap(USERS); + String afterRecoveryInfo = db.info(USERS); + + assertTrue("Could not recover database successfully", recovered); + assertEquals("Map info before and after recovery is different", beforeBackupInfo, afterRecoveryInfo); + assertEquals("Map before and after recovery are not equal", originalUsers, recoveredUsers); + } + + @Test + public void canFallbackDBIfRecoveryFails() throws IOException { + Set users = db.getSet(USERS); + users.add(CREATOR); + users.add(MUSER); + + Set originalSet = newHashSet(users); + Object jsonBackup = db.backup(); + String corruptBackup = "!@#$" + String.valueOf(jsonBackup); + boolean recovered = db.recover(corruptBackup); + + Set recoveredSet = db.getSet(USERS); + + assertEquals("Recovery was successful from a CORRUPT backup", false, recovered); + assertEquals("Set before and after corrupt recovery are not equal", originalSet, recoveredSet); + } + + @Test + public void canGetSummary() throws IOException { + String anotherTest = TEST + 1; + db.getSet(TEST).add(TEST); + db.getSet(anotherTest).add(anotherTest); + + String actualSummary = db.summary(); + // Name - Type - Number of "rows" + String expectedSummary = format("%s - Set - 1\n%s - Set - 1", TEST, anotherTest); + + assertEquals("Actual DB summary does not match that of the expected", expectedSummary, actualSummary); + } + + @Test + public void canGetInfo() throws IOException { + db.getSet(TEST).add(TEST); + + String actualInfo = db.info(TEST); + // JSON + String expectedInfo = "TEST - Set - 1"; + + assertEquals("Actual DB structure info does not match that of the expected", expectedInfo, actualInfo); + } + + @Test(expected = IllegalStateException.class) + public void cantGetInfoFromNonexistentDBStructureName() throws IOException { + db.info(TEST); + } + + @After + public void tearDown() throws IOException { + db.clear(); + db.close(); + } +} diff --git a/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/objects/AbilityTest.java b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/objects/AbilityTest.java new file mode 100644 index 00000000..5778dab3 --- /dev/null +++ b/telegrambots-abilities/src/test/java/org/telegram/abilitybots/api/objects/AbilityTest.java @@ -0,0 +1,58 @@ +package org.telegram.abilitybots.api.objects; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.telegram.abilitybots.api.bot.DefaultBot.getDefaultBuilder; + +public class AbilityTest { + @Test(expected = IllegalArgumentException.class) + public void argumentsCannotBeNegative() { + getDefaultBuilder().input(-4).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void nameCannotBeEmpty() { + getDefaultBuilder().name("").build(); + } + + @Test(expected = IllegalArgumentException.class) + public void nameCannotBeNull() { + getDefaultBuilder().name(null).build(); + } + + @Test(expected = NullPointerException.class) + public void consumerCannotBeNull() { + getDefaultBuilder().action(null).build(); + } + + @Test(expected = NullPointerException.class) + public void localityCannotBeNull() { + getDefaultBuilder().locality(null).build(); + } + + @Test(expected = NullPointerException.class) + public void privacyCannotBeNull() { + getDefaultBuilder().privacy(null).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void nameCannotContainSpaces() { + getDefaultBuilder().name("test test").build(); + } + + @Test + public void abilityEqualsMethod() { + Ability ability1 = getDefaultBuilder().build(); + Ability ability2 = getDefaultBuilder().build(); + Ability ability3 = getDefaultBuilder().name("anotherconsumer").build(); + Ability ability4 = getDefaultBuilder().action((context) -> { + }).build(); + + assertEquals("Abilities should not be equal", ability1, ability2); + assertEquals("Abilities should not be equal", ability1, ability4); + assertNotEquals("Abilities should be equal", ability1, ability3); + } +} +