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)