Merge pull request #466 from addo37/dev

Expose abilities and replies, add report command and reformat /commands, closes #436
This commit is contained in:
Ruben Bermudez 2018-05-26 12:30:14 +02:00 committed by GitHub
commit ce1b0402ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 176 additions and 83 deletions

View File

@ -102,8 +102,9 @@ If you're in doubt that you're missing some code, the full code example can be i
Go ahead and "/hello" to your bot. It should respond back with "Hello World!".
Since you've implemented an AbilityBot, you get **factory abilities** as well. Try:
* /commands - Prints all commands supported by the bot
* This will essentially print "hello - says hello world!". Yes! This is the information we supplied to the ability. The bot prints the commands in the format accepted by BotFather. So, whenever you change, add or remove commands, you can simply /commands and forward that message to BotFather.
* /report - Prints all user-defined commands supported by the bot
* This will essentially print "hello - says hello world!". Yes! This is the information we supplied to the ability. The bot prints the commands in the format accepted by BotFather. So, whenever you change, add or remove commands, you can simply /report and forward that message to BotFather.
* /commands - Prints all commands exposed by the bot (factory and user-defined, with and without info)
* /claim - Claims this bot
* /backup - returns a backup of the bot database
* /recover - recovers the database

View File

@ -1,6 +1,12 @@
package org.telegram.abilitybots.api.bot;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Multimap;
import org.apache.commons.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import org.telegram.abilitybots.api.db.DBContext;
import org.telegram.abilitybots.api.objects.*;
import org.telegram.abilitybots.api.sender.DefaultSender;
@ -27,22 +33,24 @@ import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.*;
import java.util.Map.Entry;
import java.util.function.BiFunction;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static com.google.common.base.Strings.isNullOrEmpty;
import static com.google.common.collect.MultimapBuilder.hashKeys;
import static java.lang.String.format;
import static java.time.ZonedDateTime.now;
import static java.util.Arrays.stream;
import static java.util.Comparator.comparing;
import static java.util.Objects.nonNull;
import static java.util.Optional.ofNullable;
import static java.util.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 java.util.stream.Collectors.joining;
import static jersey.repackaged.com.google.common.base.Throwables.propagate;
import static org.apache.commons.lang3.StringUtils.isEmpty;
import static org.telegram.abilitybots.api.db.MapDBContext.onlineInstance;
import static org.telegram.abilitybots.api.objects.Ability.builder;
import static org.telegram.abilitybots.api.objects.Flag.*;
@ -64,10 +72,11 @@ import static org.telegram.abilitybots.api.util.AbilityUtils.*;
* <li>Sets the user as the {@link Privacy#CREATOR} of the bot</li>
* <li>Only the user with the ID returned by {@link AbilityBot#creatorId()} can genuinely claim the bot</li>
* </ul>
* <li>/commands - reports all user-defined commands (abilities)</li>
* <li>/report - reports all user-defined commands (abilities)</li>
* <ul>
* <li>The same format acceptable by BotFather</li>
* </ul>
* <li>/commands - returns a list of all possible bot commands based on the privacy of the requesting user</li>
* <li>/backup - returns a backup of the bot database</li>
* <li>/recover - recovers the database</li>
* <li>/promote <code>@username</code> - promotes user to bot admin</li>
@ -102,6 +111,7 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
protected static final String BACKUP = "backup";
protected static final String RECOVER = "recover";
protected static final String COMMANDS = "commands";
protected static final String REPORT = "report";
// DB and sender
protected final DBContext db;
@ -172,6 +182,20 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
return db.getSet(ADMINS);
}
/**
* @return the immutable map of String -> Ability
*/
public Map<String, Ability> abilities() {
return abilities;
}
/**
* @return the immutable list carrying the embedded replies
*/
public List<Reply> replies() {
return replies;
}
/**
* This method contains the stream of actions that are applied on any update.
* <p>
@ -260,7 +284,7 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
* Gets the user with the specified username. If user was not found, the bot will send a message on Telegram.
*
* @param username the username of the required user
* @param ctx the message context with the originating user
* @param ctx the message context with the originating user
* @return the id of the user
*/
protected int getUserIdSendError(String username, MessageContext ctx) {
@ -290,9 +314,9 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
*/
public Ability reportCommands() {
return builder()
.name(COMMANDS)
.name(REPORT)
.locality(ALL)
.privacy(PUBLIC)
.privacy(CREATOR)
.input(0)
.action(ctx -> {
String commands = abilities.entrySet().stream()
@ -311,6 +335,63 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
.build();
}
/**
* Default format:
* <p>
* PUBLIC
* <p>
* [command1] - [description1]
* <p>
* [command2] - [description2]
* <p>
* GROUP_ADMIN
* <p>
* [command1] - [description1]
* <p>
* ...
*
* @return the ability to print commands based on the privacy of the requesting user
*/
public Ability commands() {
return builder()
.name(COMMANDS)
.locality(USER)
.privacy(PUBLIC)
.input(0)
.action(ctx -> {
Privacy privacy = getPrivacy(ctx.update(), ctx.user().getId());
ListMultimap<Privacy, String> abilitiesPerPrivacy = abilities.entrySet().stream()
.map(entry -> {
String name = entry.getValue().name();
String info = entry.getValue().info();
if (!isEmpty(info))
return Pair.of(entry.getValue().privacy(), format("/%s - %s", name, info));
return Pair.of(entry.getValue().privacy(), format("/%s", name));
})
.sorted(comparing(Pair::b))
.collect(() -> hashKeys().arrayListValues().build(),
(map, pair) -> map.put(pair.a(), pair.b()),
Multimap::putAll);
String commands = abilitiesPerPrivacy.asMap().entrySet().stream()
.filter(entry -> privacy.compareTo(entry.getKey()) >= 0)
.sorted(comparing(Entry::getKey))
.map(entry ->
entry.getValue().stream()
.reduce(entry.getKey().toString(), (a, b) -> format("%s\n%s", a, b))
)
.collect(joining("\n"));
if (commands.isEmpty())
commands = getLocalizedMessage(ABILITY_COMMANDS_NOT_FOUND, ctx.user().getLanguageCode());
silent.send(commands, ctx.chatId());
})
.build();
}
/**
* This backup ability returns the object defined by {@link DBContext#backup()} as a message document.
* <p>
@ -507,22 +588,17 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
return builder()
.name(CLAIM)
.locality(ALL)
.privacy(PUBLIC)
.privacy(CREATOR)
.input(0)
.action(ctx -> {
if (ctx.user().getId() == creatorId()) {
Set<Integer> admins = admins();
int id = creatorId();
Set<Integer> admins = admins();
int id = creatorId();
if (admins.contains(id))
send(ABILITY_CLAIM_FAIL, ctx);
else {
admins.add(id);
send(ABILITY_CLAIM_SUCCESS, ctx);
}
} else {
// This is not a joke
abilities.get(BAN).action().accept(newContext(ctx.update(), ctx.user(), ctx.chatId(), ctx.user().getUserName()));
if (admins.contains(id))
send(ABILITY_CLAIM_FAIL, ctx);
else {
admins.add(id);
send(ABILITY_CLAIM_SUCCESS, ctx);
}
})
.post(commitTo(db))
@ -552,7 +628,10 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
abilities = stream(this.getClass().getMethods())
.filter(method -> method.getReturnType().equals(Ability.class))
.map(this::returnAbility)
.collect(toMap(ability -> ability.name().toLowerCase(), identity()));
.collect(ImmutableMap::<String, Ability>builder,
(b, a) -> b.put(a.name(), a),
(b1, b2) -> b1.putAll(b2.build()))
.build();
Stream<Reply> methodReplies = stream(this.getClass().getMethods())
.filter(method -> method.getReturnType().equals(Reply.class))
@ -561,7 +640,11 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
Stream<Reply> abilityReplies = abilities.values().stream()
.flatMap(ability -> ability.replies().stream());
replies = Stream.concat(methodReplies, abilityReplies).collect(toList());
replies = Stream.concat(methodReplies, abilityReplies).collect(
ImmutableList::<Reply>builder,
Builder::add,
(b1, b2) -> b1.addAll(b2.build()))
.build();
} catch (IllegalStateException e) {
BotLogger.error(TAG, "Duplicate names found while registering abilities. Make sure that the abilities declared don't clash with the reserved ones.", e);
throw propagate(e);
@ -661,7 +744,7 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
Privacy privacy;
int id = user.getId();
privacy = isCreator(id) ? CREATOR : isAdmin(id) ? ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ? GROUP_ADMIN : PUBLIC;
privacy = getPrivacy(update, id);
boolean isOk = privacy.compareTo(trio.b().privacy()) >= 0;
@ -674,6 +757,14 @@ public abstract class AbilityBot extends TelegramLongPollingBot {
return isOk;
}
@NotNull
private Privacy getPrivacy(Update update, int id) {
return isCreator(id) ?
CREATOR : isAdmin(id) ?
ADMIN : (isGroupUpdate(update) || isSuperGroupUpdate(update)) && isGroupAdmin(update, id) ?
GROUP_ADMIN : PUBLIC;
}
private boolean isGroupAdmin(Update update, int id) {
GetChatAdministrators admins = new GetChatAdministrators().setChatId(getChatId(update));

View File

@ -1,4 +1,4 @@
ability.commands.notFound=No public commands found.
ability.commands.notFound=No available commands found.
ability.recover.success=I have successfully recovered.
ability.recover.fail=Oops, something went wrong during recovery.

View File

@ -42,23 +42,23 @@ public class AbilityBotI18nTest {
}
@Test
public void missingPublicCommandsLocalizedCorrectly1() {
public void missingPublicCommandsLocalizedInEnglishByDefault() {
MessageContext context = mockContext(NO_LANGUAGE_USER);
bot.reportCommands().action().accept(context);
verify(silent, times(1))
.send("No public commands found.", NO_LANGUAGE_USER.getId());
.send("No available commands found.", NO_LANGUAGE_USER.getId());
}
@Test
public void missingPublicCommandsLocalizedCorrectly2() {
public void missingPublicCommandsLocalizedInItalian() {
MessageContext context = mockContext(ITALIAN_USER);
bot.reportCommands().action().accept(context);
verify(silent, times(1))
.send("Non sono presenti comandi pubblici.", ITALIAN_USER.getId());
.send("Non sono presenti comandi disponibile.", ITALIAN_USER.getId());
}
@After

View File

@ -198,8 +198,7 @@ public class AbilityBotTest {
@NotNull
private MessageContext defaultContext() {
MessageContext context = mockContext(CREATOR, GROUP_ID, USER.getUserName());
return context;
return mockContext(CREATOR, GROUP_ID, USER.getUserName());
}
@Test
@ -232,22 +231,6 @@ public class AbilityBotTest {
assertEquals("Creator was not properly added to the super admins set", expected, actual);
}
@Test
public void userGetsBannedIfClaimsBot() {
addUsers(USER);
MessageContext context = mockContext(USER, GROUP_ID);
bot.claimCreator().action().accept(context);
Set<Integer> actual = bot.blacklist();
Set<Integer> expected = newHashSet(USER.getId());
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.getId());
@ -265,7 +248,6 @@ public class AbilityBotTest {
public void canAddUser() {
Update update = mock(Update.class);
Message message = mock(Message.class);
User user = mock(User.class);
mockAlternateUser(update, message, USER);
@ -282,7 +264,6 @@ public class AbilityBotTest {
addUsers(USER);
Update update = mock(Update.class);
Message message = mock(Message.class);
User user = mock(User.class);
String newUsername = USER.getUserName() + "-test";
String newFirstName = USER.getFirstName() + "-test";
@ -306,8 +287,8 @@ public class AbilityBotTest {
Ability validAbility = getDefaultBuilder().build();
Trio<Update, Ability, String[]> 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));
assertFalse("Bot can't validate ability properly", bot.validateAbility(invalidPair));
assertTrue("Bot can't validate ability properly", bot.validateAbility(validPair));
}
@Test
@ -322,15 +303,15 @@ public class AbilityBotTest {
Trio<Update, Ability, String[]> trioOneArg = Trio.of(update, abilityWithOneInput, TEXT);
Trio<Update, Ability, String[]> trioZeroArg = Trio.of(update, abilityWithZeroInput, TEXT);
assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioOneArg));
assertTrue("Unexpected result when applying token filter", bot.checkInput(trioOneArg));
trioOneArg = Trio.of(update, abilityWithOneInput, addAll(TEXT, TEXT));
assertEquals("Unexpected result when applying token filter", false, bot.checkInput(trioOneArg));
assertFalse("Unexpected result when applying token filter", bot.checkInput(trioOneArg));
assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioZeroArg));
assertTrue("Unexpected result when applying token filter", bot.checkInput(trioZeroArg));
trioZeroArg = Trio.of(update, abilityWithZeroInput, EMPTY_ARRAY);
assertEquals("Unexpected result when applying token filter", true, bot.checkInput(trioZeroArg));
assertTrue("Unexpected result when applying token filter", bot.checkInput(trioZeroArg));
}
@Test
@ -350,10 +331,10 @@ public class AbilityBotTest {
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(groupAdminTrio));
assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(adminTrio));
assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(creatorTrio));
assertTrue("Unexpected result when checking for privacy", bot.checkPrivacy(publicTrio));
assertFalse("Unexpected result when checking for privacy", bot.checkPrivacy(groupAdminTrio));
assertFalse("Unexpected result when checking for privacy", bot.checkPrivacy(adminTrio));
assertFalse("Unexpected result when checking for privacy", bot.checkPrivacy(creatorTrio));
}
@Test
@ -374,7 +355,7 @@ public class AbilityBotTest {
when(silent.execute(any(GetChatAdministrators.class))).thenReturn(Optional.of(newArrayList(member)));
assertEquals("Unexpected result when checking for privacy", true, bot.checkPrivacy(groupAdminTrio));
assertTrue("Unexpected result when checking for privacy", bot.checkPrivacy(groupAdminTrio));
}
@Test
@ -391,7 +372,7 @@ public class AbilityBotTest {
when(silent.execute(any(GetChatAdministrators.class))).thenReturn(empty());
assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(groupAdminTrio));
assertFalse("Unexpected result when checking for privacy", bot.checkPrivacy(groupAdminTrio));
}
@Test
@ -406,7 +387,7 @@ public class AbilityBotTest {
bot.admins().add(USER.getId());
mockUser(update, message, user);
assertEquals("Unexpected result when checking for privacy", false, bot.checkPrivacy(creatorTrio));
assertFalse("Unexpected result when checking for privacy", bot.checkPrivacy(creatorTrio));
}
@Test
@ -425,9 +406,9 @@ public class AbilityBotTest {
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));
assertTrue("Unexpected result when checking for locality", bot.checkLocality(publicTrio));
assertTrue("Unexpected result when checking for locality", bot.checkLocality(userTrio));
assertFalse("Unexpected result when checking for locality", bot.checkLocality(groupTrio));
}
@Test
@ -449,7 +430,7 @@ public class AbilityBotTest {
@Test
public void defaultGlobalFlagIsTrue() {
Update update = mock(Update.class);
assertEquals("Unexpected result when checking for the default global flags", true, bot.checkGlobalFlags(update));
assertTrue("Unexpected result when checking for the default global flags", bot.checkGlobalFlags(update));
}
@Test(expected = ArithmeticException.class)
@ -536,8 +517,8 @@ public class AbilityBotTest {
Trio<Update, Ability, String[]> docTrio = Trio.of(update, documentAbility, TEXT);
Trio<Update, Ability, String[]> 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));
assertFalse("Unexpected result when checking for message flags", bot.checkMessageFlags(docTrio));
assertTrue("Unexpected result when checking for message flags", bot.checkMessageFlags(textTrio));
}
@Test
@ -568,6 +549,39 @@ public class AbilityBotTest {
return newContext(update, user, groupId, args);
}
@Test
public void canPrintCommandsBasedOnPrivacy() {
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 creatorCtx = newContext(update, CREATOR, GROUP_ID);
bot.commands().action().accept(creatorCtx);
String expected = "PUBLIC\n/commands\n/count\n/default - dis iz default command\n/group\n/test\nADMIN\n/admin\n/ban\n/demote\n/promote\n/unban\nCREATOR\n/backup\n/claim\n/recover\n/report";
verify(silent, times(1)).send(expected, GROUP_ID);
}
@Test
public void printsOnlyPublicCommandsForNormalUser() {
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 userCtx = newContext(update, USER, GROUP_ID);
bot.commands().action().accept(userCtx);
String expected = "PUBLIC\n/commands\n/count\n/default - dis iz default command\n/group\n/test";
verify(silent, times(1)).send(expected, GROUP_ID);
}
@After
public void tearDown() throws IOException {
db.clear();
@ -640,17 +654,4 @@ public class AbilityBotTest {
writer.close();
return backupFile;
}
public static User newUser(Integer id, String firstname, String lastname, String username, String languageCode) {
User user = mock(User.class);
when(user.getBot()).thenReturn(false);
when(user.getFirstName()).thenReturn(firstname);
when(user.getId()).thenReturn(id);
when(user.getLastName()).thenReturn(lastname);
when(user.getUserName()).thenReturn(username);
when(user.getLanguageCode()).thenReturn(languageCode);
return user;
}
}

View File

@ -1 +1 @@
ability.commands.notFound=Non sono presenti comandi pubblici.
ability.commands.notFound=Non sono presenti comandi disponibile.