Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 0a7b423
Showing
7 changed files
with
976 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
*.mbp |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
# karma | ||
A [maubot](https://github.com/maubot/maubot) that tracks the karma of users. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
#!/bin/bash | ||
zip -9r karma.mbp karma/ maubot.ini |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
[maubot] | ||
ID = xyz.maubot.karma | ||
Version = 0.1.0 | ||
Modules = karma | ||
MainClass = KarmaBot |