Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Oct 21, 2018
0 parents commit 0a7b423
Show file tree
Hide file tree
Showing 7 changed files with 976 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitignore
@@ -0,0 +1 @@
*.mbp
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions README.md
@@ -0,0 +1,2 @@
# karma
A [maubot](https://github.com/maubot/maubot) that tracks the karma of users.
2 changes: 2 additions & 0 deletions build.sh
@@ -0,0 +1,2 @@
#!/bin/bash
zip -9r karma.mbp karma/ maubot.ini
136 changes: 136 additions & 0 deletions karma/__init__.py
@@ -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 changes: 169 additions & 0 deletions karma/db.py
@@ -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
5 changes: 5 additions & 0 deletions maubot.ini
@@ -0,0 +1,5 @@
[maubot]
ID = xyz.maubot.karma
Version = 0.1.0
Modules = karma
MainClass = KarmaBot

0 comments on commit 0a7b423

Please sign in to comment.