1
Fork 0
mirror of https://github.com/RYGhub/royalnet.git synced 2024-11-30 15:04:18 +00:00
royalnet/royalpack/commands/matchmaking.py

538 lines
19 KiB
Python
Raw Normal View History

2020-07-02 19:40:33 +00:00
import contextlib
2020-04-30 19:50:09 +00:00
from typing import *
2020-04-08 20:00:57 +00:00
import datetime
import re
import dateparser
import typing
import random
2020-04-30 19:50:09 +00:00
import enum
import asyncio as aio
2020-04-08 20:00:57 +00:00
from telegram import Message as PTBMessage
from telegram import InlineKeyboardMarkup as InKM
from telegram import InlineKeyboardButton as InKB
2020-04-21 21:32:27 +00:00
from telegram.error import TelegramError
2020-04-30 19:50:09 +00:00
import royalnet.commands as rc
2020-04-08 20:00:57 +00:00
from royalnet.serf.telegram import escape as telegram_escape
2020-04-10 23:05:46 +00:00
from royalnet.utils import asyncify, sleep_until, sentry_async_wrap
2020-07-02 19:40:33 +00:00
import logging
2020-05-10 22:46:12 +00:00
from ..tables import MMEvent, MMResponse, FiorygiTransaction
2020-04-08 20:00:57 +00:00
from ..types import MMChoice, MMInterfaceDataTelegram
2020-04-30 19:50:09 +00:00
class Interrupts(enum.Enum):
TIME_RAN_OUT = enum.auto()
MANUAL_START = enum.auto()
MANUAL_DELETE = enum.auto()
2020-07-02 19:40:33 +00:00
mmchoice_sorting = {
MMChoice.YES: -4,
MMChoice.LATE_SHORT: -3,
MMChoice.LATE_MEDIUM: -2,
MMChoice.LATE_LONG: -1,
MMChoice.MAYBE: 0,
MMChoice.NO: 1
}
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
class MMTask:
def __init__(self, mmid: int, *, command: rc.Command):
log.debug(f"Creating task for: {mmid}")
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
self.loop: aio.AbstractEventLoop = command.loop
self.task: Optional[aio.Task] = None
self.queue: aio.Queue = aio.Queue(loop=self.loop)
self.command: rc.Command = command
self.mmid: int = mmid
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
self._session: Optional = None
self._EventT: Optional[Type[MMEvent]] = None
self._ResponseT: Optional[Type[MMResponse]] = None
self._mmevent: Optional[MMEvent] = None
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
@property
def is_running(self):
return self.task is not None
2020-04-08 20:00:57 +00:00
2020-07-02 20:07:40 +00:00
def sync(self):
self._session.refresh(self._mmevent)
2020-07-02 19:40:33 +00:00
def get_response_line(self, response: MMResponse):
2020-07-02 20:07:40 +00:00
self.sync()
2020-07-02 19:40:33 +00:00
# noinspection PyListCreation
line = []
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
# Emoji
line.append(f"{response.choice.value}")
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
# Mention the user if he said yes, otherwise just display his name
if response.choice == MMChoice.NO:
line.append(f"{response.user.telegram[0].name()}")
else:
line.append(f"{response.user.telegram[0].mention()}")
2020-04-29 16:46:46 +00:00
2020-07-02 19:40:33 +00:00
# Late time
if response.choice == MMChoice.LATE_SHORT:
td = self._mmevent.datetime + datetime.timedelta(minutes=10)
line.append(f"[{td.strftime('%H:%M')}]")
2020-04-29 16:46:46 +00:00
2020-07-02 19:40:33 +00:00
elif response.choice == MMChoice.LATE_MEDIUM:
td = self._mmevent.datetime + datetime.timedelta(minutes=30)
line.append(f"[{td.strftime('%H:%M')}]")
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
elif response.choice == MMChoice.LATE_LONG:
td = self._mmevent.datetime + datetime.timedelta(minutes=60)
line.append(f"[{td.strftime('%H:%M')}+]")
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
# Creator
if response.user == self._mmevent.creator:
line.append("👑")
# Result
return " ".join(line)
@property
def channel_text(self) -> str:
2020-07-02 20:07:40 +00:00
self.sync()
2020-07-02 19:40:33 +00:00
# noinspection PyListCreation
text = []
# First line
if self._mmevent.datetime is None:
text.append(f"🌐 [Prossimamente] [b]{self._mmevent.title}[/b]")
else:
text.append(f"🚩 [{self._mmevent.datetime.strftime('%Y-%m-%d %H:%M')}] [b]{self._mmevent.title}[/b]")
# Description
if self._mmevent.description:
text.append(f"{self._mmevent.description}")
# Spacer
text.append("")
# Responses
responses = sorted(self._mmevent.responses, key=lambda r: mmchoice_sorting[r.choice])
for response in responses:
text.append(self.get_response_line(response))
# Result
return "\n".join(text)
@property
def start_text(self) -> str:
2020-07-02 20:07:40 +00:00
self.sync()
2020-07-02 19:40:33 +00:00
# noinspection PyListCreation
text = []
# First line
if self._mmevent.datetime is None:
text.append(f"🌐 Le iscrizioni all'evento [b]{self._mmevent.title}[/b] sono terminate!")
else:
text.append(f"🚩 L'evento [b]{self._mmevent.title}[/b] è iniziato!")
# Description
if self._mmevent.description:
text.append(f"{self._mmevent.description}")
# Spacer
text.append("")
# Responses
responses = sorted(self._mmevent.responses, key=lambda r: mmchoice_sorting[r.choice])
for response in responses:
text.append(self.get_response_line(response))
# Result
return "\n".join(text)
@property
def delete_text(self) -> str:
return f"🗑 L'evento [b]{self._mmevent.title}[/b] è stato eliminato."
def get_answer_callback(self, choice: MMChoice):
2020-04-30 19:50:09 +00:00
async def callback(data: rc.CommandData):
2020-07-02 19:40:33 +00:00
# Find the user who clicked on the button
user = await data.get_author(error_if_none=True)
# Get the related MMEvent
mmevent: MMEvent = await asyncify(data.session.query(self._EventT).get, self.mmid)
# Check if the user had already responded
2020-04-08 20:00:57 +00:00
mmresponse: MMResponse = await asyncify(
2020-07-02 19:40:33 +00:00
data.session.query(self._ResponseT).filter_by(user=user, mmevent=mmevent).one_or_none
)
2020-04-08 20:00:57 +00:00
if mmresponse is None:
2020-07-02 19:40:33 +00:00
# If they didn't respond, create a new MMResponse
mmresponse = self._ResponseT(user=user, mmevent=mmevent, choice=choice)
2020-04-08 20:00:57 +00:00
data.session.add(mmresponse)
2020-07-02 19:40:33 +00:00
# Drop fiorygi
if random.randrange(100) < self.command.config["Matchmaking"]["fiorygi_award_chance"]:
await FiorygiTransaction.spawn_fiorygi(data, user, 1, "aver risposto a un matchmaking")
2020-04-08 20:00:57 +00:00
else:
2020-07-02 19:40:33 +00:00
# Change their response
2020-04-08 20:00:57 +00:00
mmresponse.choice = choice
await data.session_commit()
2020-07-02 19:40:33 +00:00
await self.telegram_channel_message_update()
await data.reply(f"{choice.value} Hai risposto al matchmaking!")
2020-04-30 19:50:09 +00:00
return callback
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
def get_delete_callback(self):
2020-04-30 19:50:09 +00:00
async def callback(data: rc.CommandData):
2020-07-02 19:40:33 +00:00
# Find the user who clicked on the button
user = await data.get_author(error_if_none=True)
# Get the related MMEvent
mmevent: MMEvent = await asyncify(data.session.query(self._EventT).get, self.mmid)
# Ensure the user has the required roles to start the matchmaking
if user != mmevent.creator and "admin" not in user.roles:
raise rc.UserError("Non hai i permessi per eliminare questo matchmaking!")
# Interrupt the matchmaking with the MANUAL_DELETE reason
await self.queue.put(Interrupts.MANUAL_DELETE)
2020-04-30 19:50:09 +00:00
await data.reply(f"🗑 Evento eliminato!")
2020-04-08 20:00:57 +00:00
return callback
2020-07-02 19:40:33 +00:00
def get_start_callback(self):
2020-04-30 19:50:09 +00:00
async def callback(data: rc.CommandData):
2020-07-02 19:40:33 +00:00
# Find the user who clicked on the button
user = await data.get_author(error_if_none=True)
2020-04-30 19:50:09 +00:00
2020-07-02 19:40:33 +00:00
# Get the related MMEvent
mmevent: MMEvent = await asyncify(data.session.query(self._EventT).get, self.mmid)
2020-04-30 19:50:09 +00:00
2020-07-02 19:40:33 +00:00
# Ensure the user has the required roles to start the matchmaking
if user != mmevent.creator and "admin" not in user.roles:
raise rc.UserError("Non hai i permessi per eliminare questo matchmaking!")
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
# Interrupt the matchmaking with the MANUAL_DELETE reason
await self.queue.put(Interrupts.MANUAL_START)
2020-04-08 20:00:57 +00:00
2020-07-02 19:40:33 +00:00
await data.reply(f"🚩 Evento avviato!")
return callback
@property
def royalnet_keyboard(self):
# noinspection PyListCreation
rows = []
rows.append([
rc.KeyboardKey(
interface=self.command.interface,
2020-04-08 20:00:57 +00:00
short=f"{MMChoice.YES.value}",
text="Ci sarò!",
2020-07-02 19:40:33 +00:00
callback=self.get_answer_callback(MMChoice.YES)
),
rc.KeyboardKey(
interface=self.command.interface,
2020-04-08 20:00:57 +00:00
short=f"{MMChoice.MAYBE.value}",
text="Forse...",
2020-07-02 19:40:33 +00:00
callback=self.get_answer_callback(MMChoice.MAYBE)
),
rc.KeyboardKey(
interface=self.command.interface,
2020-04-08 20:00:57 +00:00
short=f"{MMChoice.NO.value}",
text="Non mi interessa.",
2020-07-02 19:40:33 +00:00
callback=self.get_answer_callback(MMChoice.NO)
),
])
if self._mmevent.datetime is not None:
rows.append([
rc.KeyboardKey(
interface=self.command.interface,
short=f"{MMChoice.LATE_SHORT.value}",
text="10 min",
callback=self.get_answer_callback(MMChoice.LATE_SHORT)
),
rc.KeyboardKey(
interface=self.command.interface,
short=f"{MMChoice.LATE_MEDIUM.value}",
text="30 min",
callback=self.get_answer_callback(MMChoice.LATE_MEDIUM)
),
rc.KeyboardKey(
interface=self.command.interface,
short=f"{MMChoice.LATE_LONG.value}",
text="60 min",
callback=self.get_answer_callback(MMChoice.LATE_LONG)
)
])
rows.append([
rc.KeyboardKey(
interface=self.command.interface,
2020-04-30 19:50:09 +00:00
short=f"🗑",
text="Elimina",
2020-07-02 19:40:33 +00:00
callback=self.get_delete_callback()
),
rc.KeyboardKey(
interface=self.command.interface,
2020-04-30 19:50:09 +00:00
short=f"🚩",
text="Inizia",
2020-07-02 19:40:33 +00:00
callback=self.get_start_callback()
),
])
return rows
@property
def telegram_keyboard(self):
# noinspection PyListCreation
rows = []
key_id = 0
for r_row in self.royalnet_keyboard:
row = []
for r_key in r_row:
# Generate a unique callback string
callback_str = f"mm{self.mmid}_{key_id}"
# Create a InlineKeyboardButton with that callback string
row.append(InKB(f"{r_key.short} {r_key.text}", callback_data=callback_str))
# Increase the key_id
key_id += 1
rows.append(row)
# Return the resulting InlineKeyboardMarkup
return InKM(rows)
def register_telegram_keyboard(self, inkm: InKM):
# noinspection PyListCreation
royalnet_keyboard = self.royalnet_keyboard
for x, row in enumerate(inkm.inline_keyboard):
for y, key in enumerate(row):
key: InKB
self.command.interface.serf.register_keyboard_key(key.callback_data, key=royalnet_keyboard[x][y])
def unregister_telegram_keyboard(self, inkm: InKM):
for row in inkm.inline_keyboard:
for key in row:
key: InKB
self.command.interface.serf.unregister_keyboard_key(key.callback_data)
async def wait_until_due(self):
"""When the event is due, interrupt the MMTask with the TIME_RAN_OUT reason."""
if self._mmevent.datetime is None:
return
await sleep_until(self._mmevent.datetime)
await self.queue.put(Interrupts.TIME_RAN_OUT)
@property
def telegram_channel_id(self):
return self.command.config["Matchmaking"]["mm_telegram_channel_id"]
@property
def telegram_group_id(self):
return self.command.config["Matchmaking"]["mm_telegram_group_id"]
@contextlib.asynccontextmanager
async def telegram_channel_message(self):
# Generate the InlineKeyboardMarkup
inkm = self.telegram_keyboard
# Bind the Royalnet buttons to the Telegram keyboard
log.debug(f"Registering keyboard for: {self.mmid}")
self.register_telegram_keyboard(inkm)
# If the event has no associated interface data...
if self._mmevent.interface_data is None:
# Send the channel message
log.debug(f"Sending message for: {self.mmid}")
message: PTBMessage = await self.command.interface.serf.api_call(
self.command.interface.serf.client.send_message,
chat_id=self.telegram_channel_id,
text=telegram_escape(self.channel_text),
parse_mode="HTML",
disable_webpage_preview=True,
reply_markup=inkm
)
# Register the interface data on the database
self._mmevent.interface_data = MMInterfaceDataTelegram(
chat_id=self.telegram_channel_id,
message_id=message.message_id
)
self._session.commit()
# Wait until the event starts
yield
# Delete the channel message
log.debug(f"Deleting message for: {self.mmid}")
await self.command.interface.serf.api_call(
self.command.interface.serf.client.delete_message,
chat_id=self._mmevent.interface_data.chat_id,
message_id=self._mmevent.interface_data.message_id
)
# Unregister the Telegram keyboard bindings
log.debug(f"Unregistering keyboard for: {self.mmid}")
self.unregister_telegram_keyboard(inkm)
async def telegram_channel_message_update(self):
2020-07-02 20:07:40 +00:00
log.debug(f"Updating message for: {self.mmid}")
2020-07-02 19:40:33 +00:00
try:
2020-07-02 20:00:46 +00:00
await asyncify(
2020-07-02 19:40:33 +00:00
self.command.interface.serf.client.edit_message_text,
chat_id=self._mmevent.interface_data.chat_id,
text=telegram_escape(self.channel_text),
message_id=self._mmevent.interface_data.message_id,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=self.telegram_keyboard
)
except TelegramError as e:
log.warning(f"TelegramError during update: {e}")
async def telegram_group_message_start(self):
await self.command.interface.serf.api_call(
self.command.interface.serf.client.send_message,
chat_id=self.telegram_group_id,
text=telegram_escape(self.start_text),
parse_mode="HTML",
disable_webpage_preview=True
)
async def telegram_group_message_delete(self):
await self.command.interface.serf.api_call(
self.command.interface.serf.client.send_message,
chat_id=self.telegram_group_id,
text=telegram_escape(self.delete_text),
parse_mode="HTML",
disable_webpage_preview=True
)
def start(self):
log.debug(f"Starting task for: {self.mmid}")
self.task = self.loop.create_task(self.run())
@sentry_async_wrap()
async def run(self):
log.debug(f"Running task for: {self.mmid}")
# Create a new session for the MMTask
self._session = self.command.alchemy.Session()
self._EventT = self.command.alchemy.get(MMEvent)
self._ResponseT = self.command.alchemy.get(MMResponse)
self._mmevent: MMEvent = self._session.query(self._EventT).get(self.mmid)
if self._mmevent is None:
raise rc.InvalidInputError(f"No event exists with the mmid {self.mmid}.")
if self._mmevent.interface != "telegram":
raise rc.UnsupportedError("Currently only the Telegram interface is supported.")
async with self.telegram_channel_message():
self.loop.create_task(self.wait_until_due())
# Sleep until something interrupts the task
interrupt = await self.queue.get()
# Mark the event as interrupted
self._mmevent.interrupted = True
await self._session.commit()
# Send a group notification if the MMEvent wasn't deleted
if interrupt != Interrupts.MANUAL_DELETE:
await self.telegram_group_message_start()
2020-04-08 20:00:57 +00:00
else:
2020-07-02 19:40:33 +00:00
await self.telegram_group_message_delete()
# Close the database session
await asyncify(self._session.close)
log = logging.getLogger(__name__)
class MatchmakingCommand(rc.Command):
name: str = "matchmaking"
description: str = "Cerca persone per una partita a qualcosa!"
syntax: str = ""
aliases = ["mm", "lfg"]
def __init__(self, interface: rc.CommandInterface):
super().__init__(interface)
# Find all active MMEvents and run the tasks for them
session = self.alchemy.Session()
# Create a new MMEvent and run it
2020-04-08 20:00:57 +00:00
if self.interface.name == "telegram":
2020-07-02 19:40:33 +00:00
MMEventT = self.alchemy.get(MMEvent)
active_mmevents = (
session
.query(MMEventT)
.filter(
MMEventT.interface == self.interface.name,
MMEventT.interrupted == False
2020-04-30 19:50:09 +00:00
)
2020-07-02 19:40:33 +00:00
.all()
)
for mmevent in active_mmevents:
task = MMTask(mmevent.mmid, command=self)
task.start()
@staticmethod
def _parse_args(args) -> Tuple[Optional[datetime.datetime], str, str]:
"""Parse command arguments, either using the standard syntax or the Proto syntax."""
try:
timestring, title, description = args.match(r"(?:\[\s*([^]]+)\s*]\s*)?([^\n]+)\s*\n?\s*(.+)?\s*", re.DOTALL)
except rc.InvalidInputError:
timestring, title, description = args.match(r"(?:\s*(.+?)\s*\n\s*)?([^\n]+)\s*\n?\s*(.+)?\s*", re.DOTALL)
if timestring is not None:
try:
dt: typing.Optional[datetime.datetime] = dateparser.parse(timestring, settings={
"PREFER_DATES_FROM": "future"
})
except OverflowError:
dt = None
if dt is None:
raise rc.InvalidInputError("La data che hai specificato non è valida.")
if dt <= datetime.datetime.now():
raise rc.InvalidInputError("La data che hai specificato è nel passato.")
if dt - datetime.datetime.now() >= datetime.timedelta(days=366):
raise rc.InvalidInputError("Hai specificato una data tra più di un anno!\n"
"Se volevi scrivere un'orario, ricordati che le ore sono separate da "
"due punti (:) e non da punto semplice!")
2020-04-30 19:50:09 +00:00
else:
2020-07-02 19:40:33 +00:00
dt = None
return dt, title, description
async def run(self, args: rc.CommandArgs, data: rc.CommandData) -> None:
"""Handle a matchmaking command call."""
author = await data.get_author(error_if_none=True)
# Parse the arguments, either with the standard syntax or with the Proto syntax
dt, title, description = self._parse_args(args)
# Add the MMEvent to the database
mmevent: MMEvent = self.alchemy.get(MMEvent)(creator=author,
datetime=dt,
title=title,
description=description,
interface=self.interface.name)
data.session.add(mmevent)
await data.session_commit()
# Create and run a task for the newly created MMEvent
task = MMTask(mmevent.mmid, command=self)
task.start()
await data.reply(f"🚩 Matchmaking creato!")