parent
1ab388d503
commit
c59e0c99b7
2 changed files with 208 additions and 45 deletions
166
karma/bot.py
166
karma/bot.py
|
@ -13,12 +13,16 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Awaitable, Type
|
from typing import Awaitable, Type, Optional
|
||||||
|
import json
|
||||||
|
import html
|
||||||
|
|
||||||
from sqlalchemy.engine.base import Engine
|
from sqlalchemy.engine.base import Engine
|
||||||
|
|
||||||
from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent
|
from maubot import Plugin, CommandSpec, Command, PassiveCommand, Argument, MessageEvent
|
||||||
from mautrix.types import Event, StateEvent
|
from mautrix.types import (Event, StateEvent, EventID, UserID, FileInfo, MessageType,
|
||||||
|
MediaMessageEventContent)
|
||||||
|
from mautrix.client.api.types.event.message import media_reply_fallback_body_map
|
||||||
|
|
||||||
from .db import make_tables, Karma, KarmaCache, Version
|
from .db import make_tables, Karma, KarmaCache, Version
|
||||||
|
|
||||||
|
@ -26,9 +30,15 @@ COMMAND_PASSIVE_UPVOTE = "xyz.maubot.karma.up"
|
||||||
COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down"
|
COMMAND_PASSIVE_DOWNVOTE = "xyz.maubot.karma.down"
|
||||||
|
|
||||||
ARG_LIST = "$list"
|
ARG_LIST = "$list"
|
||||||
|
ARG_LIST_MATCHES = "top|bot(?:tom)?|best|worst"
|
||||||
COMMAND_KARMA_LIST = f"karma {ARG_LIST}"
|
COMMAND_KARMA_LIST = f"karma {ARG_LIST}"
|
||||||
|
ARG_USER = "$user"
|
||||||
COMMAND_OWN_KARMA = "karma"
|
ARG_USER_MATCHES = ".+"
|
||||||
|
COMMAND_KARMA_VIEW = f"karma {ARG_USER}"
|
||||||
|
COMMAND_KARMA_STATS = "karma stats"
|
||||||
|
COMMAND_OWN_KARMA_VIEW = "karma"
|
||||||
|
COMMAND_OWN_KARMA_BREAKDOWN = "karma breakdown"
|
||||||
|
COMMAND_OWN_KARMA_EXPORT = "karma export"
|
||||||
|
|
||||||
COMMAND_UPVOTE = "upvote"
|
COMMAND_UPVOTE = "upvote"
|
||||||
COMMAND_DOWNVOTE = "downvote"
|
COMMAND_DOWNVOTE = "downvote"
|
||||||
|
@ -54,10 +64,16 @@ class KarmaBot(Plugin):
|
||||||
self.db = self.request_db_engine()
|
self.db = self.request_db_engine()
|
||||||
self.karma_cache, self.karma, self.version = make_tables(self.db)
|
self.karma_cache, self.karma, self.version = make_tables(self.db)
|
||||||
self.set_command_spec(CommandSpec(commands=[
|
self.set_command_spec(CommandSpec(commands=[
|
||||||
|
Command(syntax=COMMAND_KARMA_STATS, description="View global karma statistics"),
|
||||||
|
Command(syntax=COMMAND_OWN_KARMA_VIEW, description="View your karma"),
|
||||||
|
Command(syntax=COMMAND_OWN_KARMA_BREAKDOWN, description="View your karma breakdown"),
|
||||||
|
Command(syntax=COMMAND_OWN_KARMA_EXPORT, description="Export the data of your karma"),
|
||||||
Command(syntax=COMMAND_KARMA_LIST, description="View the 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)",
|
arguments={ARG_LIST: Argument(matches=ARG_LIST_MATCHES, required=True,
|
||||||
required=True, description="The list to view")}),
|
description="The list to view")}),
|
||||||
Command(syntax=COMMAND_OWN_KARMA, description="View your karma"),
|
Command(syntax=COMMAND_KARMA_VIEW, description="View the karma of a specific user",
|
||||||
|
arguments={ARG_USER: Argument(matches=ARG_USER_MATCHES, required=True,
|
||||||
|
description="The user whose karma to view")}),
|
||||||
Command(syntax=COMMAND_UPVOTE, description="Upvote a message"),
|
Command(syntax=COMMAND_UPVOTE, description="Upvote a message"),
|
||||||
Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"),
|
Command(syntax=COMMAND_DOWNVOTE, description="Downvote a message"),
|
||||||
], passive_commands=[
|
], passive_commands=[
|
||||||
|
@ -69,24 +85,40 @@ class KarmaBot(Plugin):
|
||||||
self.client.add_command_handler(COMMAND_PASSIVE_DOWNVOTE, self.downvote)
|
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_UPVOTE, self.upvote)
|
||||||
self.client.add_command_handler(COMMAND_DOWNVOTE, self.downvote)
|
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)
|
self.client.add_command_handler(COMMAND_KARMA_LIST, self.karma_list)
|
||||||
|
self.client.add_command_handler(COMMAND_KARMA_VIEW, self.view_karma)
|
||||||
|
self.client.add_command_handler(COMMAND_KARMA_STATS, self.karma_stats)
|
||||||
|
self.client.add_command_handler(COMMAND_OWN_KARMA_VIEW, self.view_own_karma)
|
||||||
|
self.client.add_command_handler(COMMAND_OWN_KARMA_BREAKDOWN, self.own_karma_breakdown)
|
||||||
|
self.client.add_command_handler(COMMAND_OWN_KARMA_EXPORT, self.export_own_karma)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self.client.remove_command_handler(COMMAND_PASSIVE_UPVOTE, self.upvote)
|
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_PASSIVE_DOWNVOTE, self.downvote)
|
||||||
self.client.remove_command_handler(COMMAND_UPVOTE, self.upvote)
|
self.client.remove_command_handler(COMMAND_UPVOTE, self.upvote)
|
||||||
self.client.remove_command_handler(COMMAND_DOWNVOTE, self.downvote)
|
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
|
self.client.remove_command_handler(COMMAND_KARMA_LIST, self.karma_list)
|
||||||
def parse_content(evt: Event) -> str:
|
self.client.remove_command_handler(COMMAND_KARMA_VIEW, self.view_karma)
|
||||||
|
self.client.remove_command_handler(COMMAND_KARMA_STATS, self.karma_stats)
|
||||||
|
self.client.remove_command_handler(COMMAND_OWN_KARMA_VIEW, self.view_own_karma)
|
||||||
|
self.client.remove_command_handler(COMMAND_OWN_KARMA_BREAKDOWN, self.own_karma_breakdown)
|
||||||
|
self.client.remove_command_handler(COMMAND_OWN_KARMA_EXPORT, self.export_own_karma)
|
||||||
|
|
||||||
|
def parse_content(self, evt: Event) -> str:
|
||||||
if isinstance(evt, MessageEvent):
|
if isinstance(evt, MessageEvent):
|
||||||
return "message event"
|
if evt.content.msgtype in (MessageType.NOTICE, MessageType.TEXT, MessageType.EMOTE):
|
||||||
|
if evt.content.msgtype == MessageType.EMOTE:
|
||||||
|
evt.content.body = "/me " + evt.content.body
|
||||||
|
return (html.escape(evt.content.body[:50]) + " \u2026"
|
||||||
|
if len(evt.content.body) > 60
|
||||||
|
else html.escape(evt.content.body))
|
||||||
|
name = media_reply_fallback_body_map[evt.content.msgtype]
|
||||||
|
return f"[{name}]({self.client.get_download_url(evt.content.url)})"
|
||||||
elif isinstance(evt, StateEvent):
|
elif isinstance(evt, StateEvent):
|
||||||
return "state event"
|
return "a state event"
|
||||||
return "unknown event"
|
return "an unknown event"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def sign(value: int) -> str:
|
def sign(value: int) -> str:
|
||||||
|
@ -97,11 +129,13 @@ class KarmaBot(Plugin):
|
||||||
else:
|
else:
|
||||||
return "±0"
|
return "±0"
|
||||||
|
|
||||||
async def vote(self, evt: MessageEvent, value: int) -> None:
|
async def vote(self, evt: MessageEvent, target: EventID, value: int) -> None:
|
||||||
reply_to = evt.content.get_reply_to()
|
if not target:
|
||||||
if not reply_to:
|
|
||||||
return
|
return
|
||||||
karma_target = await self.client.get_event(evt.room_id, reply_to)
|
if self.karma.is_vote_event(target):
|
||||||
|
await evt.reply("Sorry, you can't vote on votes.")
|
||||||
|
return
|
||||||
|
karma_target = await self.client.get_event(evt.room_id, target)
|
||||||
if not karma_target:
|
if not karma_target:
|
||||||
return
|
return
|
||||||
if karma_target.sender == evt.sender and value > 0:
|
if karma_target.sender == evt.sender and value > 0:
|
||||||
|
@ -122,12 +156,81 @@ class KarmaBot(Plugin):
|
||||||
await evt.mark_read()
|
await evt.mark_read()
|
||||||
|
|
||||||
def upvote(self, evt: MessageEvent) -> Awaitable[None]:
|
def upvote(self, evt: MessageEvent) -> Awaitable[None]:
|
||||||
return self.vote(evt, +1)
|
return self.vote(evt, evt.content.get_reply_to(), +1)
|
||||||
|
|
||||||
def downvote(self, evt: MessageEvent) -> Awaitable[None]:
|
def downvote(self, evt: MessageEvent) -> Awaitable[None]:
|
||||||
return self.vote(evt, -1)
|
return self.vote(evt, evt.content.get_reply_to(), -1)
|
||||||
|
|
||||||
|
async def karma_stats(self, evt: MessageEvent) -> None:
|
||||||
|
await evt.reply("Not yet implemented :(")
|
||||||
|
|
||||||
|
def denotify(self, mxid: UserID) -> str:
|
||||||
|
localpart, _ = self.client.parse_mxid(mxid)
|
||||||
|
return localpart.replace("", "\u2063")
|
||||||
|
|
||||||
|
async def karma_list(self, evt: MessageEvent) -> None:
|
||||||
|
list_type = evt.content.command.arguments[ARG_LIST]
|
||||||
|
if not list_type:
|
||||||
|
await evt.reply("**Usage**: !karma [top|bottom|best|worst]")
|
||||||
|
return
|
||||||
|
message = None
|
||||||
|
if list_type in ("top", "bot", "bottom"):
|
||||||
|
message = self.karma_user_list(list_type)
|
||||||
|
elif list_type in ("best", "worst"):
|
||||||
|
message = self.karma_message_list(list_type)
|
||||||
|
if message is not None:
|
||||||
|
await evt.reply(message)
|
||||||
|
|
||||||
|
def karma_user_list(self, list_type: str) -> Optional[str]:
|
||||||
|
if list_type == "top":
|
||||||
|
karma_list = self.karma_cache.get_high()
|
||||||
|
message = "#### Highest karma\n\n"
|
||||||
|
elif list_type in ("bot", "bottom"):
|
||||||
|
karma_list = self.karma_cache.get_low()
|
||||||
|
message = "#### Lowest karma\n\n"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
message += "\n".join(
|
||||||
|
f"{index + 1}. [{self.denotify(karma.user_id)}](https://matrix.to/#/{karma.user_id}): "
|
||||||
|
f"{self.sign(karma.total)} (+{karma.positive}/-{karma.negative})"
|
||||||
|
for index, karma in enumerate(karma_list))
|
||||||
|
return message
|
||||||
|
|
||||||
|
def karma_message_list(self, list_type: str) -> Optional[str]:
|
||||||
|
if list_type == "best":
|
||||||
|
karma_list = self.karma.get_best_events()
|
||||||
|
message = "#### Best messages\n\n"
|
||||||
|
elif list_type == "worst":
|
||||||
|
karma_list = self.karma.get_worst_events()
|
||||||
|
message = "#### Worst messages\n\n"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
message += "\n".join(
|
||||||
|
f"{index + 1}. [Event](https://matrix.to/#/{event.event_id}) by "
|
||||||
|
f"[{self.denotify(event.sender)}](https://matrix.to/#/{event.sender}) with "
|
||||||
|
f"{self.sign(event.total)} karma (+{event.positive}/-{event.negative})\n"
|
||||||
|
f" > {event.content}"
|
||||||
|
for index, event in enumerate(karma_list))
|
||||||
|
return message
|
||||||
|
|
||||||
async def view_karma(self, evt: MessageEvent) -> None:
|
async def view_karma(self, evt: MessageEvent) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def export_own_karma(self, evt: MessageEvent) -> None:
|
||||||
|
karma_list = [karma.to_dict() for karma in self.karma.export(evt.sender)]
|
||||||
|
data = json.dumps(karma_list).encode("utf-8")
|
||||||
|
url = await self.client.upload_media(data, mime_type="application/json")
|
||||||
|
await evt.reply(MediaMessageEventContent(
|
||||||
|
msgtype=MessageType.FILE,
|
||||||
|
body=f"karma-{evt.sender}.json",
|
||||||
|
url=url,
|
||||||
|
info=FileInfo(
|
||||||
|
mimetype="application/json",
|
||||||
|
size=len(data),
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
async def view_own_karma(self, evt: MessageEvent) -> None:
|
||||||
karma = self.karma_cache.get_karma(evt.sender)
|
karma = self.karma_cache.get_karma(evt.sender)
|
||||||
if karma is None:
|
if karma is None:
|
||||||
await evt.reply("You don't have any karma :(")
|
await evt.reply("You don't have any karma :(")
|
||||||
|
@ -136,20 +239,5 @@ class KarmaBot(Plugin):
|
||||||
await evt.reply(f"You have {karma.total} karma (+{karma.positive}/-{karma.negative})"
|
await evt.reply(f"You have {karma.total} karma (+{karma.positive}/-{karma.negative})"
|
||||||
f" and are #{index} on the top list.")
|
f" and are #{index} on the top list.")
|
||||||
|
|
||||||
async def view_karma_list(self, evt: MessageEvent) -> None:
|
async def own_karma_breakdown(self, evt: MessageEvent) -> None:
|
||||||
list_type = evt.content.command.arguments[ARG_LIST]
|
await evt.reply("Not yet implemented :(")
|
||||||
if not list_type:
|
|
||||||
await evt.reply("**Usage**: !karma [top|bottom]")
|
|
||||||
return
|
|
||||||
if list_type in ("top", "high", "highscore"):
|
|
||||||
karma_list = self.karma_cache.get_high()
|
|
||||||
message = "#### Highest karma\n\n"
|
|
||||||
elif list_type in ("bot", "bottom", "low"):
|
|
||||||
karma_list = self.karma_cache.get_low()
|
|
||||||
message = "#### Lowest karma\n\n"
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
message += "\n".join(f"{index + 1}. [{karma.user_id}](https://matrix.to/#/{karma.user_id}):"
|
|
||||||
f" {karma.total} (+{karma.positive}/-{karma.negative})"
|
|
||||||
for index, karma in enumerate(karma_list))
|
|
||||||
await evt.reply(message)
|
|
||||||
|
|
87
karma/db.py
87
karma/db.py
|
@ -13,10 +13,11 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# 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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import List, Tuple, Optional, Type
|
from typing import List, Tuple, Optional, Type, Iterable, Dict, Any, NamedTuple
|
||||||
from time import time
|
from time import time
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Integer, BigInteger, Text, Table, select, and_
|
from sqlalchemy import (Column, String, Integer, BigInteger, Text, Table,
|
||||||
|
select, and_, or_, func, case, asc, desc)
|
||||||
from sqlalchemy.sql.base import ImmutableColumnCollection
|
from sqlalchemy.sql.base import ImmutableColumnCollection
|
||||||
from sqlalchemy.engine.base import Engine, Connection
|
from sqlalchemy.engine.base import Engine, Connection
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
@ -116,6 +117,11 @@ class KarmaCache:
|
||||||
negative=negative_diff).insert(conn)
|
negative=negative_diff).insert(conn)
|
||||||
|
|
||||||
|
|
||||||
|
EventKarmaStats = NamedTuple("EventKarmaStats", event_id=EventID, sender=UserID, content=str,
|
||||||
|
total=int, positive=int, negative=int)
|
||||||
|
UserKarmaStats = NamedTuple("UserKarmaStats", user_id=UserID, total=int, positive=int, negative=int)
|
||||||
|
|
||||||
|
|
||||||
class Karma:
|
class Karma:
|
||||||
__tablename__ = "karma"
|
__tablename__ = "karma"
|
||||||
db: Engine = None
|
db: Engine = None
|
||||||
|
@ -128,17 +134,86 @@ class Karma:
|
||||||
given_in: RoomID = 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_for: EventID = Column(String(255), primary_key=True)
|
||||||
|
|
||||||
given_from: EventID = Column(String(255))
|
given_from: EventID = Column(String(255), unique=True)
|
||||||
given_at: int = Column(BigInteger)
|
given_at: int = Column(BigInteger)
|
||||||
value: int = Column(Integer)
|
value: int = Column(Integer)
|
||||||
content: str = Column(Text)
|
content: str = Column(Text)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"to": self.given_to,
|
||||||
|
"by": self.given_by,
|
||||||
|
"in": self.given_in,
|
||||||
|
"for": self.given_for,
|
||||||
|
"from": self.given_from,
|
||||||
|
"at": self.given_at,
|
||||||
|
"value": self.value,
|
||||||
|
"content": self.content,
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def all(cls, user_id: UserID) -> List['Karma']:
|
def get_best_events(cls, limit: int = 10) -> Iterable['EventKarmaStats']:
|
||||||
return [cls(given_to=given_to, given_by=given_by, given_in=given_in, given_for=given_for,
|
return cls.get_event_stats(direction=desc, limit=limit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_worst_events(cls, limit: int = 10) -> Iterable['EventKarmaStats']:
|
||||||
|
return cls.get_event_stats(direction=asc, limit=limit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_top_users(cls, limit: int = 10) -> Iterable['UserKarmaStats']:
|
||||||
|
return cls.get_user_stats(direction=desc, limit=limit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_bottom_users(cls, limit: int = 10) -> Iterable['UserKarmaStats']:
|
||||||
|
return cls.get_user_stats(direction=asc, limit=limit)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_event_stats(cls, direction, limit: int = 10) -> Iterable['EventKarmaStats']:
|
||||||
|
c = cls.c
|
||||||
|
return (EventKarmaStats(*row) for row in cls.db.execute(
|
||||||
|
select([c.given_for, c.given_to, c.content,
|
||||||
|
func.sum(c.value).label("total"),
|
||||||
|
func.sum(case([(c.value > 0, c.value)], else_=0)).label("positive"),
|
||||||
|
func.abs(func.sum(case([(c.value < 0, c.value)], else_=0))).label("negative")])
|
||||||
|
.group_by(c.given_for)
|
||||||
|
.order_by(direction("total"))
|
||||||
|
.limit(limit)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_user_stats(cls, direction, limit: int = 10) -> Iterable['UserKarmaStats']:
|
||||||
|
c = cls.c
|
||||||
|
return (UserKarmaStats(*row) for row in cls.db.execute(
|
||||||
|
select([c.given_to,
|
||||||
|
func.sum(c.value).label("total"),
|
||||||
|
func.sum(case([(c.value > 0, c.value)], else_=0)).label("positive"),
|
||||||
|
func.abs(func.sum(case([(c.value < 0, c.value)], else_=0))).label("negative")])
|
||||||
|
.group_by(c.given_to)
|
||||||
|
.order_by(direction("total"))
|
||||||
|
.limit(limit)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def all(cls, user_id: UserID) -> Iterable['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)
|
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
|
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))]
|
in cls.db.execute(cls.t.select().where(cls.c.given_to == user_id)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def export(cls, user_id: UserID) -> Iterable['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(or_(cls.c.given_to == user_id,
|
||||||
|
cls.c.given_by == user_id))))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_vote_event(cls, event_id: EventID) -> bool:
|
||||||
|
rows = cls.db.execute(cls.t.select().where(cls.c.given_from == event_id))
|
||||||
|
try:
|
||||||
|
next(rows)
|
||||||
|
return True
|
||||||
|
except StopIteration:
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, given_to: UserID, given_by: UserID, given_in: RoomID, given_for: Event
|
def get(cls, given_to: UserID, given_by: UserID, given_in: RoomID, given_for: Event
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue