diff --git a/karma/__init__.py b/karma/__init__.py index 90e10c0..2e79892 100644 --- a/karma/__init__.py +++ b/karma/__init__.py @@ -13,12 +13,14 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Awaitable +from typing import Awaitable, Type + +from sqlalchemy.engine.base import Engine from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent from mautrix.types import Event, StateEvent -from .db import make_tables +from .db import make_tables, Karma, KarmaCache, Version COMMAND_PASSIVE_UPVOTE = "xyz.maubot.karma.up" COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down" @@ -26,28 +28,36 @@ COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down" ARG_LIST = "$list" COMMAND_KARMA_LIST = f"karma {ARG_LIST}" +COMMAND_OWN_KARMA = "karma" + 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_EMOJI = r"(?:\U0001F44D[\U0001F3FB-\U0001F3FF]?)" +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_EMOJI = r"(?:\U0001F44E[\U0001F3FB-\U0001F3FF]?)" +DOWNVOTE_EMOJI_SHORTHAND = r"(?:\:-1\:)|(?:\:thumbsdown\:)" +DOWNVOTE_TEXT = r"(?:-(?:1|-)?)" DOWNVOTE = f"{DOWNVOTE_EMOJI}|{DOWNVOTE_EMOJI_SHORTHAND}|{DOWNVOTE_TEXT}" class KarmaBot(Plugin): + karma_cache: Type[KarmaCache] + karma: Type[Karma] + version: Type[Version] + db: Engine + async def start(self) -> None: self.db = self.request_db_engine() - self.KarmaCache, self.Karma = make_tables(self.db) + self.karma_cache, self.karma, self.version = make_tables(self.db) self.set_command_spec(CommandSpec(commands=[ - Command(syntax=COMMAND_KARMA_LIST, description="View your karma or 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)", - required=False, description="The list to view")}), + required=True, description="The list to view")}), + Command(syntax=COMMAND_OWN_KARMA, description="View your karma"), Command(syntax=COMMAND_UPVOTE, description="Upvote a message"), Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"), ], passive_commands=[ @@ -60,6 +70,7 @@ class KarmaBot(Plugin): 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) + self.client.add_command_handler(COMMAND_OWN_KARMA, self.view_karma) async def stop(self) -> None: self.client.remove_command_handler(COMMAND_PASSIVE_UPVOTE, self.upvote) @@ -67,6 +78,7 @@ class KarmaBot(Plugin): 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) + self.client.remove_command_handler(COMMAND_OWN_KARMA, self.view_karma) @staticmethod def parse_content(evt: Event) -> str: @@ -94,14 +106,14 @@ class KarmaBot(Plugin): 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) + 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, + karma = self.karma(**karma_id, given_from=evt.event_id, value=value, content=self.parse_content(karma_target)) karma.insert() await evt.mark_read() @@ -112,23 +124,25 @@ class KarmaBot(Plugin): def downvote(self, evt: MessageEvent) -> Awaitable[None]: return self.vote(evt, -1) + async def view_karma(self, evt: MessageEvent) -> None: + karma = self.karma_cache.get_karma(evt.sender) + if karma is None: + await evt.reply("You don't have any karma :(") + return + index = self.karma_cache.find_index_from_top(evt.sender) + await evt.reply(f"You have {karma} karma and are #{index} on the top list.") + 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.") + await evt.reply("**Usage**: !karma [top|bottom]") return - if list_type in ("top", "high", "highscore"): - karma_list = self.KarmaCache.get_high() - message = "### Highest karma\n\n" + karma_list = self.karma_cache.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" + karma_list = self.karma_cache.get_low() + message = "#### Lowest karma\n\n" else: return message += "\n".join(f"{index + 1}. [{mxid}](https://matrix.to/#/{mxid}): {karma}" diff --git a/karma/db.py b/karma/db.py index da14ab5..883fe65 100644 --- a/karma/db.py +++ b/karma/db.py @@ -13,10 +13,10 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import List, Tuple, Optional +from typing import List, Tuple, Optional, Type from time import time -from sqlalchemy import Column, String, Integer, BigInteger, Text, Table, select +from sqlalchemy import Column, String, Integer, BigInteger, Text, Table, select, and_ from sqlalchemy.sql.base import ImmutableColumnCollection from sqlalchemy.engine.base import Engine, Connection from sqlalchemy.ext.declarative import declarative_base @@ -24,146 +24,171 @@ from sqlalchemy.ext.declarative import declarative_base from mautrix.types import Event, UserID, EventID, RoomID -def make_tables(engine: Engine): +class KarmaCache: + __tablename__ = "karma_cache" + db: Engine = None + t: Table = None + c: ImmutableColumnCollection = None + Karma: Type['Karma'] = 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: + 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 cls.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: + if ignore_if_not_exist: + return + conn.execute(cls.t.insert().values(user_id=user_id, karma=value_diff)) + + +class Karma: + __tablename__ = "karma" + db: Engine = None + t: Table = None + c: ImmutableColumnCollection = None + KarmaCache: Type[KarmaCache] = 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 [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(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(and_( + 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_from, given_at, value, content) = next(rows) + except StopIteration: + return None + 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) + + def delete(self) -> None: + with self.db.begin() as txn: + txn.execute(self.t.delete().where(and_( + 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))) + self.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)) + self.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(and_( + 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)) + self.KarmaCache.update(self.given_to, value_diff, txn) + + +class Version: + __tablename__ = "version" + db: Engine = None + t: Table = None + c: ImmutableColumnCollection = None + + version: int = Column(Integer, primary_key=True) + + +def make_tables(engine: Engine) -> Tuple[Type[KarmaCache], Type[Karma], Type[Version]]: base = declarative_base() - class KarmaCache(base): - __tablename__ = "karma_cache" - db: Engine = engine - t: Table = None - c: ImmutableColumnCollection = None + class KarmaCacheImpl(KarmaCache, base): + __table__: Table - user_id: UserID = Column(String(255), primary_key=True) - karma: int = Column(Integer) + class KarmaImpl(Karma, base): + __table__: Table - @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) + class VersionImpl(Version, base): + __table__: Table base.metadata.bind = engine - KarmaCache.t = KarmaCache.__table__ - KarmaCache.c = KarmaCache.t.c - Karma.t = Karma.__table__ - Karma.c = Karma.t.c + for table in KarmaCacheImpl, KarmaImpl, VersionImpl: + table.db = engine + table.t = table.__table__ + table.c = table.__table__.c + table.Karma = KarmaImpl + table.KarmaCache = KarmaCacheImpl # TODO replace with alembic base.metadata.create_all() - return KarmaCache, Karma + return KarmaCacheImpl, KarmaImpl, VersionImpl diff --git a/karma/migrations.py b/karma/migrations.py new file mode 100644 index 0000000..344ed10 --- /dev/null +++ b/karma/migrations.py @@ -0,0 +1,25 @@ +# 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 . +from sqlalchemy import select +from sqlalchemy.engine.base import Engine +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def run(engine: Engine): + conn = engine.connect() + ctx = MigrationContext.configure(conn) + op = Operations(ctx)