Add/remove/fix/change things

This commit is contained in:
Tulir Asokan 2018-10-22 01:11:58 +03:00
parent 0a7b423ea3
commit f59f29bc1e
3 changed files with 223 additions and 159 deletions

View file

@ -13,12 +13,14 @@
#
# 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 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}"

View file

@ -13,10 +13,10 @@
#
# 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 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

25
karma/migrations.py Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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)