diff --git a/TelegramBots.wiki/abilities/Advanced.md b/TelegramBots.wiki/abilities/Advanced.md index 044a55b0..45f90300 100644 --- a/TelegramBots.wiki/abilities/Advanced.md +++ b/TelegramBots.wiki/abilities/Advanced.md @@ -31,4 +31,7 @@ As an example, if you want to restrict the updates to photos only, then you may public boolean checkGlobalFlags(Update update) { return Flag.PHOTO; } -``` \ No newline at end of file +``` + +## Statistics +AbilityBot can accrue basic statistics about the usage of your abilities and replies. Simply `enableStats()` on an Ability builder or `enableStats()` on replies to activate this feature. Once activated, you may call `/stats` and the bot will print a basic list of statistics. At the moment, AbilityBot only tracks hits. In the future, this will be enhanced to track more stats. \ No newline at end of file diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java index c9979ae9..c3bc3e31 100644 --- a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/BaseAbilityBot.java @@ -1,6 +1,6 @@ package org.telegram.abilitybots.api.bot; -import com.google.common.collect.ImmutableList; +import com.google.common.collect.*; import com.google.common.collect.ImmutableList.Builder; import com.google.common.collect.ImmutableMap; import org.jetbrains.annotations.NotNull; @@ -33,15 +33,18 @@ import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; +import static com.google.common.collect.Sets.difference; import static java.lang.String.format; import static java.time.ZonedDateTime.now; import static java.util.Arrays.stream; import static java.util.Optional.ofNullable; import static java.util.regex.Pattern.CASE_INSENSITIVE; import static java.util.regex.Pattern.compile; +import static java.util.stream.Collectors.toSet; import static org.telegram.abilitybots.api.objects.Locality.*; import static org.telegram.abilitybots.api.objects.MessageContext.newContext; import static org.telegram.abilitybots.api.objects.Privacy.*; +import static org.telegram.abilitybots.api.objects.Stats.createStats; import static org.telegram.abilitybots.api.util.AbilityMessageCodes.*; import static org.telegram.abilitybots.api.util.AbilityUtils.*; @@ -87,6 +90,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability public static final String USERS = "USERS"; public static final String USER_ID = "USER_ID"; public static final String BLACKLIST = "BLACKLIST"; + public static final String STATS = "ABILITYBOT_STATS"; // DB and sender protected final DBContext db; @@ -102,6 +106,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability // Ability registry private Map abilities; + private Map stats; // Reply registry private List replies; @@ -119,6 +124,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability silent = new SilentSender(sender); registerAbilities(); + initStats(); } /** @@ -149,6 +155,13 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability return db.getSet(ADMINS); } + /** + * @return a mapping of ability and reply names to their corresponding statistics + */ + protected Map stats() { + return stats; + } + /** * @return the immutable map of */ @@ -163,6 +176,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability return replies; } + /** * This method contains the stream of actions that are applied on any update. *

@@ -189,6 +203,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability .filter(this::checkMessageFlags) .map(this::getContext) .map(this::consumeUpdate) + .map(this::updateStats) .forEach(this::postConsumption); // Commit to DB now after all the actions have been dealt @@ -276,6 +291,19 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability } } + private void initStats() { + Set enabledStats = Stream.concat( + replies.stream().filter(Reply::statsEnabled).map(Reply::name), + abilities.entrySet().stream() + .filter(entry -> entry.getValue().statsEnabled()) + .map(Map.Entry::getKey)).collect(toSet()); + stats = db.getMap(STATS); + Set toBeRemoved = difference(stats.keySet(), enabledStats); + toBeRemoved.forEach(stats::remove); + enabledStats.forEach(abName -> stats.computeIfAbsent(abName, + name -> createStats(abName, 0))); + } + /** * @param clazz the type to be tested * @return a predicate testing the return type of the method corresponding to the class parameter @@ -345,6 +373,26 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability return pair; } + Pair updateStats(Pair pair) { + Ability ab = pair.b(); + if (ab.statsEnabled()) { + updateStats(pair.b().name()); + } + return pair; + } + + private void updateReplyStats(Reply reply) { + if (reply.statsEnabled()) { + updateStats(reply.name()); + } + } + + void updateStats(String name) { + Stats statsObj = stats.get(name); + statsObj.hit(); + stats.put(name, statsObj); + } + Pair getContext(Trio trio) { Update update = trio.a(); User user = AbilityUtils.getUser(update); @@ -511,6 +559,7 @@ public abstract class BaseAbilityBot extends DefaultAbsSender implements Ability .filter(reply -> reply.isOkFor(update)) .map(reply -> { reply.actOn(update); + updateReplyStats(reply); return false; }) .reduce(true, Boolean::logicalAnd); diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/DefaultAbilities.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/DefaultAbilities.java index bdcf9456..6e1139bb 100644 --- a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/DefaultAbilities.java +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/bot/DefaultAbilities.java @@ -26,6 +26,7 @@ import java.io.PrintStream; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.StringJoiner; import static com.google.common.base.Strings.isNullOrEmpty; import static com.google.common.collect.MultimapBuilder.hashKeys; @@ -76,6 +77,7 @@ public final class DefaultAbilities implements AbilityExtension { public static final String RECOVER = "recover"; public static final String COMMANDS = "commands"; public static final String REPORT = "report"; + public static final String STATS = "stats"; private static final Logger log = LoggerFactory.getLogger(DefaultAbilities.class); private final BaseAbilityBot bot; @@ -179,6 +181,26 @@ public final class DefaultAbilities implements AbilityExtension { .build(); } + /** + * @return the ability to report statistics for abilities and replies. + */ + public Ability reportStats() { + return builder() + .name(STATS) + .locality(ALL) + .privacy(ADMIN) + .input(0) + .action(ctx -> { + String stats = bot.stats().entrySet().stream() + .map(entry -> String.format("%s: %d", entry.getKey(), entry.getValue().hits())) + .reduce(new StringJoiner("\n"), StringJoiner::add, StringJoiner::merge) + .toString(); + + bot.silent.send(stats, ctx.chatId()); + }) + .build(); + } + /** * This backup ability returns the object defined by {@link DBContext#backup()} as a message document. *

@@ -212,6 +234,7 @@ public final class DefaultAbilities implements AbilityExtension { .build(); } + /** * Recovers the bot database using {@link DBContext#recover(Object)}. *

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 index f9ddcd71..0b6691bb 100644 --- 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 @@ -42,13 +42,14 @@ public final class Ability { private final Locality locality; private final Privacy privacy; private final int argNum; + private final boolean statsEnabled; private final Consumer action; private final Consumer postAction; private final List replies; private final List> flags; @SafeVarargs - private Ability(String name, String info, Locality locality, Privacy privacy, int argNum, Consumer action, Consumer postAction, List replies, Predicate... flags) { + private Ability(String name, String info, Locality locality, Privacy privacy, int argNum, boolean statsEnabled, 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); @@ -70,6 +71,7 @@ public final class Ability { this.postAction = postAction; this.replies = replies; + this.statsEnabled = statsEnabled; } public static AbilityBuilder builder() { @@ -96,6 +98,10 @@ public final class Ability { return argNum; } + public boolean statsEnabled() { + return statsEnabled; + } + public Consumer action() { return action; } @@ -147,12 +153,14 @@ public final class Ability { private Privacy privacy; private Locality locality; private int argNum; + private boolean statsEnabled; private Consumer action; private Consumer postAction; private List replies; private Predicate[] flags; private AbilityBuilder() { + statsEnabled = false; replies = newArrayList(); } @@ -186,6 +194,11 @@ public final class Ability { return this; } + public AbilityBuilder enableStats() { + statsEnabled = true; + return this; + } + public AbilityBuilder privacy(Privacy privacy) { this.privacy = privacy; return this; @@ -202,6 +215,11 @@ public final class Ability { return this; } + public final AbilityBuilder reply(Reply reply) { + replies.add(reply); + return this; + } + public AbilityBuilder basedOn(Ability ability) { replies.clear(); replies.addAll(ability.replies()); @@ -216,7 +234,7 @@ public final class Ability { } public Ability build() { - return new Ability(name, info, locality, privacy, argNum, action, postAction, replies, flags); + return new Ability(name, info, locality, privacy, argNum, statsEnabled, action, postAction, replies, flags); } } } 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 index 6af4c921..e7479489 100644 --- 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 @@ -26,12 +26,15 @@ import static java.util.Arrays.asList; public class Reply { public final List> conditions; public final Consumer action; + private boolean statsEnabled; + private String name; Reply(List> conditions, Consumer action) { this.conditions = ImmutableList.>builder() .addAll(conditions) .build(); this.action = action; + statsEnabled = false; } public static Reply of(Consumer action, List> conditions) { @@ -65,6 +68,20 @@ public class Reply { return Stream.of(this); } + public Reply enableStats(String name) { + this.name = name; + statsEnabled = true; + return this; + } + + public boolean statsEnabled() { + return statsEnabled; + } + + public String name() { + return name; + } + @Override public boolean equals(Object o) { if (this == o) diff --git a/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Stats.java b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Stats.java new file mode 100644 index 00000000..43b6b350 --- /dev/null +++ b/telegrambots-abilities/src/main/java/org/telegram/abilitybots/api/objects/Stats.java @@ -0,0 +1,64 @@ +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.json.JSONPropertyIgnore; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Basic POJO to track ability and reply hits. The current implementation is NOT thread safe. + * + * @author Abbas Abou Daya + */ +public final class Stats implements Serializable { + @JsonProperty + private final String name; + @JsonProperty + private long hits; + + private Stats(String name) { + this.name = name; + } + + @JsonCreator + public static Stats createStats(@JsonProperty("name") String name, @JsonProperty("hits") long hits) { + return new Stats(name); + } + + public String name() { + return name; + } + + public long hits() { + return hits; + } + + public void hit() { + hits++; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Stats that = (Stats) o; + return hits == that.hits && + Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, hits); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("name", name) + .add("hits", hits) + .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 index f266d27b..476b3b1e 100644 --- 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 @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; import org.telegram.abilitybots.api.db.DBContext; import org.telegram.abilitybots.api.objects.*; import org.telegram.abilitybots.api.sender.MessageSender; @@ -175,6 +176,30 @@ public class AbilityBotTest { verify(sender, times(1)).sendDocument(any()); } + @Test + void canReportStatistics() { + MessageContext context = defaultContext(); + + defaultAbs.reportStats().action().accept(context); + + verify(silent, times(1)).send("count: 0\nmustreply: 0", GROUP_ID); + } + + @Test + void canReportUpdatedStatistics() { + Update upd1 = mockFullUpdate(bot, CREATOR, "/count 1 2 3 4"); + bot.onUpdateReceived(upd1); + Update upd2 = mockFullUpdate(bot, CREATOR, "must reply"); + bot.onUpdateReceived(upd2); + + Mockito.reset(silent); + + Update statUpd = mockFullUpdate(bot, CREATOR, "/stats"); + bot.onUpdateReceived(statUpd); + + verify(silent, times(1)).send("count: 1\nmustreply: 1", CREATOR.getId()); + } + @Test void canRecoverDB() throws TelegramApiException, IOException { Update update = mockBackupUpdate(); @@ -598,7 +623,7 @@ public class AbilityBotTest { defaultAbs.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"; + 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/stats\n/unban\nCREATOR\n/backup\n/claim\n/recover\n/report"; verify(silent, times(1)).send(expected, GROUP_ID); } 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 index 12db53f1..462453da 100644 --- 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 @@ -3,6 +3,7 @@ package org.telegram.abilitybots.api.bot; 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.objects.Reply; import org.telegram.abilitybots.api.toggle.AbilityToggle; import static org.telegram.abilitybots.api.objects.Ability.builder; @@ -41,7 +42,7 @@ public class DefaultBot extends AbilityBot { return getDefaultBuilder() .name(DEFAULT) .info("dis iz default command") - .reply(upd -> silent.send("reply", upd.getMessage().getChatId()), MESSAGE, update -> update.getMessage().getText().equals("must reply")) + .reply(Reply.of(upd -> silent.send("reply", upd.getMessage().getChatId()), MESSAGE, update -> update.getMessage().getText().equals("must reply")).enableStats("mustreply")) .reply(upd -> silent.send("reply", upd.getCallbackQuery().getMessage().getChatId()), CALLBACK_QUERY) .build(); } @@ -67,6 +68,7 @@ public class DefaultBot extends AbilityBot { .privacy(PUBLIC) .locality(USER) .input(4) + .enableStats() .build(); }