Initial commit

This commit is contained in:
Tulir Asokan 2018-10-21 21:55:58 +03:00
commit 0a7b423ea3
7 changed files with 976 additions and 0 deletions

136
karma/__init__.py Normal file
View file

@ -0,0 +1,136 @@
# karma - A maubot plugin to track the karma of users.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# 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/>.
from typing import Awaitable
from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent
from mautrix.types import Event, StateEvent
from .db import make_tables
COMMAND_PASSIVE_UPVOTE = "xyz.maubot.karma.up"
COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down"
ARG_LIST = "$list"
COMMAND_KARMA_LIST = f"karma {ARG_LIST}"
COMMAND_UPVOTE = "upvote"
COMMAND_DOWNVOTE = "downvote"
UPVOTE_EMOJI = r"(?:\x{1f44d}[\x{1f3fb}-\x{1f3ff}]?)"
UPVOTE_EMOJI_SHORTHAND = r"(?:\:\+1?|-?\:)|(?:\:thumbsup\:)"
UPVOTE_TEXT = r"(?:\+1?|\+?)"
UPVOTE = f"{UPVOTE_EMOJI}|{UPVOTE_EMOJI_SHORTHAND}|{UPVOTE_TEXT}"
DOWNVOTE_EMOJI = r"(?:\x{1f44e}[\x{1f3fb}-\x{1f3ff}]?)"
DOWNVOTE_EMOJI_SHORTHAND = r"(?:\:-1?|-?\:)|(?:\:thumbsdown\:)"
DOWNVOTE_TEXT = r"(?:-1?|-?)"
DOWNVOTE = f"{DOWNVOTE_EMOJI}|{DOWNVOTE_EMOJI_SHORTHAND}|{DOWNVOTE_TEXT}"
class KarmaBot(Plugin):
async def start(self) -> None:
self.db = self.request_db_engine()
self.KarmaCache, self.Karma = make_tables(self.db)
self.set_command_spec(CommandSpec(commands=[
Command(syntax=COMMAND_KARMA_LIST, description="View your karma or karma top lists",
arguments={ARG_LIST: Argument(matches="(top|bot(tom)?|high(score)?|low)",
required=False, description="The list to view")}),
Command(syntax=COMMAND_UPVOTE, description="Upvote a message"),
Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"),
], passive_commands=[
PassiveCommand(COMMAND_PASSIVE_UPVOTE, match_against="body", matches=UPVOTE),
PassiveCommand(COMMAND_PASSIVE_DOWNVOTE, match_against="body", matches=DOWNVOTE)
]))
self.client.add_command_handler(COMMAND_PASSIVE_UPVOTE, self.upvote)
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_DOWNVOTE, self.downvote)
self.client.add_command_handler(COMMAND_KARMA_LIST, self.view_karma_list)
async def stop(self) -> None:
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_UPVOTE, self.upvote)
self.client.remove_command_handler(COMMAND_DOWNVOTE, self.downvote)
self.client.remove_command_handler(COMMAND_KARMA_LIST, self.view_karma_list)
@staticmethod
def parse_content(evt: Event) -> str:
if isinstance(evt, MessageEvent):
return "message event"
elif isinstance(evt, StateEvent):
return "state event"
return "unknown event"
@staticmethod
def sign(value: int) -> str:
if value > 0:
return f"+{value}"
elif value < 0:
return str(value)
else:
return "±0"
async def vote(self, evt: MessageEvent, value: int) -> None:
reply_to = evt.content.get_reply_to()
if not reply_to:
return
karma_target = await self.client.get_event(evt.room_id, reply_to)
if not karma_target:
return
karma_id = dict(given_to=karma_target.sender, given_by=evt.sender, given_in=evt.room_id,
given_for=karma_target.event_id)
existing = self.Karma.get(**karma_id)
if existing is not None:
if existing.value == value:
await evt.reply(f"You already {self.sign(value)}'d that message.")
return
existing.update(new_value=value)
else:
karma = self.Karma(**karma_id, given_from=evt.event_id, value=value,
content=self.parse_content(karma_target))
karma.insert()
await evt.mark_read()
def upvote(self, evt: MessageEvent) -> Awaitable[None]:
return self.vote(evt, +1)
def downvote(self, evt: MessageEvent) -> Awaitable[None]:
return self.vote(evt, -1)
async def view_karma_list(self, evt: MessageEvent) -> None:
list_type = evt.content.command.arguments[ARG_LIST]
if not list_type:
karma = self.KarmaCache.get_karma(evt.sender)
if karma is None:
await evt.reply("You don't have any karma :(")
return
index = self.KarmaCache.find_index_from_top(evt.sender)
await evt.reply(f"You have {karma} karma and are #{index} on the top list.")
return
if list_type in ("top", "high", "highscore"):
karma_list = self.KarmaCache.get_high()
message = "### Highest karma\n\n"
elif list_type in ("bot", "bottom", "low"):
karma_list = self.KarmaCache.get_low()
message = "### Lowest karma\n\n"
else:
return
message += "\n".join(f"{index + 1}. [{mxid}](https://matrix.to/#/{mxid}): {karma}"
for index, (mxid, karma) in enumerate(karma_list))
await evt.reply(message)

169
karma/db.py Normal file
View file

@ -0,0 +1,169 @@
# karma - A maubot plugin to track the karma of users.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# 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/>.
from typing import List, Tuple, Optional
from time import time
from sqlalchemy import Column, String, Integer, BigInteger, Text, Table, select
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.engine.base import Engine, Connection
from sqlalchemy.ext.declarative import declarative_base
from mautrix.types import Event, UserID, EventID, RoomID
def make_tables(engine: Engine):
base = declarative_base()
class KarmaCache(base):
__tablename__ = "karma_cache"
db: Engine = engine
t: Table = None
c: ImmutableColumnCollection = None
user_id: UserID = Column(String(255), primary_key=True)
karma: int = Column(Integer)
@classmethod
def get_karma(cls, user_id: UserID) -> Optional[int]:
rows = cls.db.execute(select([cls.c.karma]).where(cls.c.user_id == user_id))
try:
row = next(rows)
return row[0]
except (StopIteration, IndexError):
return None
@classmethod
def _set_karma(cls, user_id: UserID, karma: int, conn: Connection) -> None:
conn.execute(cls.t.delete().where(cls.c.user_id == user_id))
conn.execute(cls.t.insert().values(user_id=user_id, karma=karma))
@classmethod
def set_karma(cls, user_id: UserID, karma: int, conn: Optional[Connection] = None) -> None:
if conn:
cls._set_karma(user_id, karma, conn)
else:
with cls.db.begin() as conn:
cls._set_karma(user_id, karma, conn)
@classmethod
def get_high(cls, limit: int = 10) -> List[Tuple[UserID, int]]:
return list(cls.db.execute(cls.t.select().order_by(cls.c.karma.desc()).limit(limit)))
@classmethod
def get_low(cls, limit: int = 10) -> List[Tuple[UserID, int]]:
return list(cls.db.execute(cls.t.select().order_by(cls.c.karma.asc()).limit(limit)))
@classmethod
def find_index_from_top(cls, user_id: UserID) -> int:
i = 0
for (found,) in cls.db.execute(select([cls.c.user_id]).order_by(cls.c.karma.desc())):
i += 1
if found == user_id:
return i
return -1
@classmethod
def recalculate(cls, user_id: UserID) -> None:
with cls.db.begin() as txn:
cls.set_karma(user_id, sum(entry.value for entry in Karma.all(user_id)), txn)
@classmethod
def update(cls, user_id: UserID, value_diff: int, conn: Optional[Connection],
ignore_if_not_exist: bool = False) -> None:
if not conn:
conn = cls.db
existing = conn.execute(select([cls.c.karma]).where(cls.c.user_id == user_id))
try:
karma = next(existing)[0] + value_diff
conn.execute(cls.t.update().where(cls.c.user_id == user_id).values(karma=karma))
except (StopIteration, IndexError):
if ignore_if_not_exist:
return
conn.execute(cls.t.insert().values(user_id=user_id, karma=value_diff))
class Karma(base):
__tablename__ = "karma"
db: Engine = engine
t: Table = None
c: ImmutableColumnCollection = None
given_to: UserID = Column(String(255), primary_key=True)
given_by: UserID = 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_from: EventID = Column(String(255))
given_at: int = Column(BigInteger)
value: int = Column(Integer)
content: str = Column(Text)
@classmethod
def all(cls, user_id: UserID) -> List['Karma']:
return [Karma(*row) for row in
cls.db.execute(cls.t.select().where(cls.c.given_to == user_id))]
@classmethod
def get(cls, given_to: UserID, given_by: UserID, given_in: RoomID, given_for: Event
) -> Optional['Karma']:
rows = cls.db.execute(cls.t.select()
.where(cls.c.given_to == given_to, cls.c.given_by == given_by,
cls.c.given_in == given_in, cls.c.given_for == given_for))
try:
given_to, given_by, given_in, given_for, given_at, value, content = next(rows)
return Karma(given_to, given_by, given_in, given_for, given_at, value, content)
except (StopIteration, ValueError):
return None
def delete(self) -> None:
with self.db.begin() as txn:
txn.execute(self.t.delete().where(
self.c.given_to == self.given_to, self.c.given_by == self.given_by,
self.c.given_in == self.given_in, self.c.given_for == self.given_for))
KarmaCache.update(self.given_to, self.value, txn, ignore_if_not_exist=True)
def insert(self) -> None:
self.given_at = int(time() * 1000)
with self.db.begin() as txn:
txn.execute(self.t.insert().values(given_to=self.given_to, given_by=self.given_by,
given_in=self.given_in, given_for=self.given_for,
given_from=self.given_from, value=self.value,
given_at=self.given_at, content=self.content))
KarmaCache.update(self.given_to, self.value, txn)
def update(self, new_value: int) -> None:
self.given_at = int(time() * 1000)
value_diff = new_value - self.value
self.value = new_value
with self.db.begin() as txn:
txn.execute(self.t.update()
.where(self.c.given_to == self.given_to,
self.c.given_by == self.given_by,
self.c.given_in == self.given_in,
self.c.given_for == self.given_for)
.values(given_from=self.given_from, value=self.value,
given_at=self.given_at))
KarmaCache.update(self.given_to, value_diff, txn)
base.metadata.bind = engine
KarmaCache.t = KarmaCache.__table__
KarmaCache.c = KarmaCache.t.c
Karma.t = Karma.__table__
Karma.c = Karma.t.c
# TODO replace with alembic
base.metadata.create_all()
return KarmaCache, Karma