Add more commands and improve other things

Fixes #1
Fixes #4
This commit is contained in:
Tulir Asokan 2018-10-27 01:16:28 +03:00
parent 1ab388d503
commit c59e0c99b7
2 changed files with 208 additions and 45 deletions

View file

@ -13,12 +13,16 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Type from typing import Awaitable, Type, Optional
import json
import html
from sqlalchemy.engine.base import Engine from sqlalchemy.engine.base import Engine
from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent
from mautrix.types import Event, StateEvent from mautrix.types import (Event, StateEvent, EventID, UserID, FileInfo, MessageType,
MediaMessageEventContent)
from mautrix.client.api.types.event.message import media_reply_fallback_body_map
from .db import make_tables, Karma, KarmaCache, Version from .db import make_tables, Karma, KarmaCache, Version
@ -26,9 +30,15 @@ COMMAND_PASSIVE_UPVOTE = "xyz.maubot.karma.up"
COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down" COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down"
ARG_LIST = "$list" ARG_LIST = "$list"
ARG_LIST_MATCHES = "top|bot(?:tom)?|best|worst"
COMMAND_KARMA_LIST = f"karma {ARG_LIST}" COMMAND_KARMA_LIST = f"karma {ARG_LIST}"
ARG_USER = "$user"
COMMAND_OWN_KARMA = "karma" ARG_USER_MATCHES = ".+"
COMMAND_KARMA_VIEW = f"karma {ARG_USER}"
COMMAND_KARMA_STATS = "karma stats"
COMMAND_OWN_KARMA_VIEW = "karma"
COMMAND_OWN_KARMA_BREAKDOWN = "karma breakdown"
COMMAND_OWN_KARMA_EXPORT = "karma export"
COMMAND_UPVOTE = "upvote" COMMAND_UPVOTE = "upvote"
COMMAND_DOWNVOTE = "downvote" COMMAND_DOWNVOTE = "downvote"
@ -54,10 +64,16 @@ class KarmaBot(Plugin):
self.db = self.request_db_engine() self.db = self.request_db_engine()
self.karma_cache, self.karma, self.version = make_tables(self.db) self.karma_cache, self.karma, self.version = make_tables(self.db)
self.set_command_spec(CommandSpec(commands=[ self.set_command_spec(CommandSpec(commands=[
Command(syntax=COMMAND_KARMA_STATS, description="View global karma statistics"),
Command(syntax=COMMAND_OWN_KARMA_VIEW, description="View your karma"),
Command(syntax=COMMAND_OWN_KARMA_BREAKDOWN, description="View your karma breakdown"),
Command(syntax=COMMAND_OWN_KARMA_EXPORT, description="Export the data of your karma"),
Command(syntax=COMMAND_KARMA_LIST, description="View the karma top lists", Command(syntax=COMMAND_KARMA_LIST, description="View the karma top lists",
arguments={ARG_LIST: Argument(matches="(top|bot(tom)?|high(score)?|low)", arguments={ARG_LIST: Argument(matches=ARG_LIST_MATCHES, required=True,
required=True, description="The list to view")}), description="The list to view")}),
Command(syntax=COMMAND_OWN_KARMA, description="View your karma"), Command(syntax=COMMAND_KARMA_VIEW, description="View the karma of a specific user",
arguments={ARG_USER: Argument(matches=ARG_USER_MATCHES, required=True,
description="The user whose karma to view")}),
Command(syntax=COMMAND_UPVOTE, description="Upvote a message"), Command(syntax=COMMAND_UPVOTE, description="Upvote a message"),
Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"), Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"),
], passive_commands=[ ], passive_commands=[
@ -69,24 +85,40 @@ class KarmaBot(Plugin):
self.client.add_command_handler(COMMAND_PASSIVE_DOWNVOTE, self.downvote) self.client.add_command_handler(COMMAND_PASSIVE_DOWNVOTE, self.downvote)
self.client.add_command_handler(COMMAND_UPVOTE, self.upvote) self.client.add_command_handler(COMMAND_UPVOTE, self.upvote)
self.client.add_command_handler(COMMAND_DOWNVOTE, self.downvote) self.client.add_command_handler(COMMAND_DOWNVOTE, self.downvote)
self.client.add_command_handler(COMMAND_KARMA_LIST, self.view_karma_list)
self.client.add_command_handler(COMMAND_OWN_KARMA, self.view_karma) self.client.add_command_handler(COMMAND_KARMA_LIST, self.karma_list)
self.client.add_command_handler(COMMAND_KARMA_VIEW, self.view_karma)
self.client.add_command_handler(COMMAND_KARMA_STATS, self.karma_stats)
self.client.add_command_handler(COMMAND_OWN_KARMA_VIEW, self.view_own_karma)
self.client.add_command_handler(COMMAND_OWN_KARMA_BREAKDOWN, self.own_karma_breakdown)
self.client.add_command_handler(COMMAND_OWN_KARMA_EXPORT, self.export_own_karma)
async def stop(self) -> None: async def stop(self) -> None:
self.client.remove_command_handler(COMMAND_PASSIVE_UPVOTE, self.upvote) self.client.remove_command_handler(COMMAND_PASSIVE_UPVOTE, self.upvote)
self.client.remove_command_handler(COMMAND_PASSIVE_DOWNVOTE, self.downvote) self.client.remove_command_handler(COMMAND_PASSIVE_DOWNVOTE, self.downvote)
self.client.remove_command_handler(COMMAND_UPVOTE, self.upvote) self.client.remove_command_handler(COMMAND_UPVOTE, self.upvote)
self.client.remove_command_handler(COMMAND_DOWNVOTE, self.downvote) self.client.remove_command_handler(COMMAND_DOWNVOTE, self.downvote)
self.client.remove_command_handler(COMMAND_KARMA_LIST, self.view_karma_list)
self.client.remove_command_handler(COMMAND_OWN_KARMA, self.view_karma)
@staticmethod self.client.remove_command_handler(COMMAND_KARMA_LIST, self.karma_list)
def parse_content(evt: Event) -> str: self.client.remove_command_handler(COMMAND_KARMA_VIEW, self.view_karma)
self.client.remove_command_handler(COMMAND_KARMA_STATS, self.karma_stats)
self.client.remove_command_handler(COMMAND_OWN_KARMA_VIEW, self.view_own_karma)
self.client.remove_command_handler(COMMAND_OWN_KARMA_BREAKDOWN, self.own_karma_breakdown)
self.client.remove_command_handler(COMMAND_OWN_KARMA_EXPORT, self.export_own_karma)
def parse_content(self, evt: Event) -> str:
if isinstance(evt, MessageEvent): if isinstance(evt, MessageEvent):
return "message event" if evt.content.msgtype in (MessageType.NOTICE, MessageType.TEXT, MessageType.EMOTE):
if evt.content.msgtype == MessageType.EMOTE:
evt.content.body = "/me " + evt.content.body
return (html.escape(evt.content.body[:50]) + " \u2026"
if len(evt.content.body) > 60
else html.escape(evt.content.body))
name = media_reply_fallback_body_map[evt.content.msgtype]
return f"[{name}]({self.client.get_download_url(evt.content.url)})"
elif isinstance(evt, StateEvent): elif isinstance(evt, StateEvent):
return "state event" return "a state event"
return "unknown event" return "an unknown event"
@staticmethod @staticmethod
def sign(value: int) -> str: def sign(value: int) -> str:
@ -97,11 +129,13 @@ class KarmaBot(Plugin):
else: else:
return "±0" return "±0"
async def vote(self, evt: MessageEvent, value: int) -> None: async def vote(self, evt: MessageEvent, target: EventID, value: int) -> None:
reply_to = evt.content.get_reply_to() if not target:
if not reply_to:
return return
karma_target = await self.client.get_event(evt.room_id, reply_to) if self.karma.is_vote_event(target):
await evt.reply("Sorry, you can't vote on votes.")
return
karma_target = await self.client.get_event(evt.room_id, target)
if not karma_target: if not karma_target:
return return
if karma_target.sender == evt.sender and value > 0: if karma_target.sender == evt.sender and value > 0:
@ -122,12 +156,81 @@ class KarmaBot(Plugin):
await evt.mark_read() await evt.mark_read()
def upvote(self, evt: MessageEvent) -> Awaitable[None]: def upvote(self, evt: MessageEvent) -> Awaitable[None]:
return self.vote(evt, +1) return self.vote(evt, evt.content.get_reply_to(), +1)
def downvote(self, evt: MessageEvent) -> Awaitable[None]: def downvote(self, evt: MessageEvent) -> Awaitable[None]:
return self.vote(evt, -1) return self.vote(evt, evt.content.get_reply_to(), -1)
async def karma_stats(self, evt: MessageEvent) -> None:
await evt.reply("Not yet implemented :(")
def denotify(self, mxid: UserID) -> str:
localpart, _ = self.client.parse_mxid(mxid)
return localpart.replace("", "\u2063")
async def karma_list(self, evt: MessageEvent) -> None:
list_type = evt.content.command.arguments[ARG_LIST]
if not list_type:
await evt.reply("**Usage**: !karma [top|bottom|best|worst]")
return
message = None
if list_type in ("top", "bot", "bottom"):
message = self.karma_user_list(list_type)
elif list_type in ("best", "worst"):
message = self.karma_message_list(list_type)
if message is not None:
await evt.reply(message)
def karma_user_list(self, list_type: str) -> Optional[str]:
if list_type == "top":
karma_list = self.karma_cache.get_high()
message = "#### Highest karma\n\n"
elif list_type in ("bot", "bottom"):
karma_list = self.karma_cache.get_low()
message = "#### Lowest karma\n\n"
else:
return None
message += "\n".join(
f"{index + 1}. [{self.denotify(karma.user_id)}](https://matrix.to/#/{karma.user_id}): "
f"{self.sign(karma.total)} (+{karma.positive}/-{karma.negative})"
for index, karma in enumerate(karma_list))
return message
def karma_message_list(self, list_type: str) -> Optional[str]:
if list_type == "best":
karma_list = self.karma.get_best_events()
message = "#### Best messages\n\n"
elif list_type == "worst":
karma_list = self.karma.get_worst_events()
message = "#### Worst messages\n\n"
else:
return None
message += "\n".join(
f"{index + 1}. [Event](https://matrix.to/#/{event.event_id}) by "
f"[{self.denotify(event.sender)}](https://matrix.to/#/{event.sender}) with "
f"{self.sign(event.total)} karma (+{event.positive}/-{event.negative})\n"
f" > {event.content}"
for index, event in enumerate(karma_list))
return message
async def view_karma(self, evt: MessageEvent) -> None: async def view_karma(self, evt: MessageEvent) -> None:
pass
async def export_own_karma(self, evt: MessageEvent) -> None:
karma_list = [karma.to_dict() for karma in self.karma.export(evt.sender)]
data = json.dumps(karma_list).encode("utf-8")
url = await self.client.upload_media(data, mime_type="application/json")
await evt.reply(MediaMessageEventContent(
msgtype=MessageType.FILE,
body=f"karma-{evt.sender}.json",
url=url,
info=FileInfo(
mimetype="application/json",
size=len(data),
)
))
async def view_own_karma(self, evt: MessageEvent) -> None:
karma = self.karma_cache.get_karma(evt.sender) karma = self.karma_cache.get_karma(evt.sender)
if karma is None: if karma is None:
await evt.reply("You don't have any karma :(") await evt.reply("You don't have any karma :(")
@ -136,20 +239,5 @@ class KarmaBot(Plugin):
await evt.reply(f"You have {karma.total} karma (+{karma.positive}/-{karma.negative})" await evt.reply(f"You have {karma.total} karma (+{karma.positive}/-{karma.negative})"
f" and are #{index} on the top list.") f" and are #{index} on the top list.")
async def view_karma_list(self, evt: MessageEvent) -> None: async def own_karma_breakdown(self, evt: MessageEvent) -> None:
list_type = evt.content.command.arguments[ARG_LIST] await evt.reply("Not yet implemented :(")
if not list_type:
await evt.reply("**Usage**: !karma [top|bottom]")
return
if list_type in ("top", "high", "highscore"):
karma_list = self.karma_cache.get_high()
message = "#### Highest karma\n\n"
elif list_type in ("bot", "bottom", "low"):
karma_list = self.karma_cache.get_low()
message = "#### Lowest karma\n\n"
else:
return
message += "\n".join(f"{index + 1}. [{karma.user_id}](https://matrix.to/#/{karma.user_id}):"
f" {karma.total} (+{karma.positive}/-{karma.negative})"
for index, karma in enumerate(karma_list))
await evt.reply(message)

View file

@ -13,10 +13,11 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Tuple, Optional, Type from typing import List, Tuple, Optional, Type, Iterable, Dict, Any, NamedTuple
from time import time from time import time
from sqlalchemy import Column, String, Integer, BigInteger, Text, Table, select, and_ from sqlalchemy import (Column, String, Integer, BigInteger, Text, Table,
select, and_, or_, func, case, asc, desc)
from sqlalchemy.sql.base import ImmutableColumnCollection from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.engine.base import Engine, Connection from sqlalchemy.engine.base import Engine, Connection
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@ -116,6 +117,11 @@ class KarmaCache:
negative=negative_diff).insert(conn) negative=negative_diff).insert(conn)
EventKarmaStats = NamedTuple("EventKarmaStats", event_id=EventID, sender=UserID, content=str,
total=int, positive=int, negative=int)
UserKarmaStats = NamedTuple("UserKarmaStats", user_id=UserID, total=int, positive=int, negative=int)
class Karma: class Karma:
__tablename__ = "karma" __tablename__ = "karma"
db: Engine = None db: Engine = None
@ -128,17 +134,86 @@ class Karma:
given_in: RoomID = Column(String(255), primary_key=True) given_in: RoomID = Column(String(255), primary_key=True)
given_for: EventID = Column(String(255), primary_key=True) given_for: EventID = Column(String(255), primary_key=True)
given_from: EventID = Column(String(255)) given_from: EventID = Column(String(255), unique=True)
given_at: int = Column(BigInteger) given_at: int = Column(BigInteger)
value: int = Column(Integer) value: int = Column(Integer)
content: str = Column(Text) content: str = Column(Text)
def to_dict(self) -> Dict[str, Any]:
return {
"to": self.given_to,
"by": self.given_by,
"in": self.given_in,
"for": self.given_for,
"from": self.given_from,
"at": self.given_at,
"value": self.value,
"content": self.content,
}
@classmethod @classmethod
def all(cls, user_id: UserID) -> List['Karma']: def get_best_events(cls, limit: int = 10) -> Iterable['EventKarmaStats']:
return [cls(given_to=given_to, given_by=given_by, given_in=given_in, given_for=given_for, return cls.get_event_stats(direction=desc, limit=limit)
@classmethod
def get_worst_events(cls, limit: int = 10) -> Iterable['EventKarmaStats']:
return cls.get_event_stats(direction=asc, limit=limit)
@classmethod
def get_top_users(cls, limit: int = 10) -> Iterable['UserKarmaStats']:
return cls.get_user_stats(direction=desc, limit=limit)
@classmethod
def get_bottom_users(cls, limit: int = 10) -> Iterable['UserKarmaStats']:
return cls.get_user_stats(direction=asc, limit=limit)
@classmethod
def get_event_stats(cls, direction, limit: int = 10) -> Iterable['EventKarmaStats']:
c = cls.c
return (EventKarmaStats(*row) for row in cls.db.execute(
select([c.given_for, c.given_to, c.content,
func.sum(c.value).label("total"),
func.sum(case([(c.value > 0, c.value)], else_=0)).label("positive"),
func.abs(func.sum(case([(c.value < 0, c.value)], else_=0))).label("negative")])
.group_by(c.given_for)
.order_by(direction("total"))
.limit(limit)))
@classmethod
def get_user_stats(cls, direction, limit: int = 10) -> Iterable['UserKarmaStats']:
c = cls.c
return (UserKarmaStats(*row) for row in cls.db.execute(
select([c.given_to,
func.sum(c.value).label("total"),
func.sum(case([(c.value > 0, c.value)], else_=0)).label("positive"),
func.abs(func.sum(case([(c.value < 0, c.value)], else_=0))).label("negative")])
.group_by(c.given_to)
.order_by(direction("total"))
.limit(limit)))
@classmethod
def all(cls, user_id: UserID) -> Iterable['Karma']:
return (cls(given_to=given_to, given_by=given_by, given_in=given_in, given_for=given_for,
given_from=given_from, given_at=given_at, value=value, content=content) given_from=given_from, given_at=given_at, value=value, content=content)
for given_to, given_by, given_in, given_for, given_from, given_at, value, content for given_to, given_by, given_in, given_for, given_from, given_at, value, content
in cls.db.execute(cls.t.select().where(cls.c.given_to == user_id))] in cls.db.execute(cls.t.select().where(cls.c.given_to == user_id)))
@classmethod
def export(cls, user_id: UserID) -> Iterable['Karma']:
return (cls(given_to=given_to, given_by=given_by, given_in=given_in, given_for=given_for,
given_from=given_from, given_at=given_at, value=value, content=content)
for given_to, given_by, given_in, given_for, given_from, given_at, value, content
in cls.db.execute(cls.t.select().where(or_(cls.c.given_to == user_id,
cls.c.given_by == user_id))))
@classmethod
def is_vote_event(cls, event_id: EventID) -> bool:
rows = cls.db.execute(cls.t.select().where(cls.c.given_from == event_id))
try:
next(rows)
return True
except StopIteration:
return False
@classmethod @classmethod
def get(cls, given_to: UserID, given_by: UserID, given_in: RoomID, given_for: Event def get(cls, given_to: UserID, given_by: UserID, given_in: RoomID, given_for: Event