Files
payphone/payphone/cogs/calls/cog.py
T
2024-10-18 14:08:04 -03:00

886 lines
28 KiB
Python

from datetime import datetime, timezone, timedelta
import secrets
import discord
from discord import errors
from discord.ext import commands, tasks
from discord.utils import find
from tortoise import transactions, exceptions
import asyncpg.exceptions
import aiohttp
from payphone import bot
from payphone.db import models, utils
from payphone.cogs import base
from payphone.cogs.calls import embeds
from payphone.core import constants, checks, censorship, formatting
class Calls(base.Cog):
def __init__(self, bot_instance: bot.Payphone):
self.bot = bot_instance
self.hangup_calls.add_exception_type(exceptions.ConfigurationError)
self.hangup_calls.start()
def cog_unload(self) -> None:
self.hangup_calls.cancel()
return super().cog_unload()
@tasks.loop(seconds=15)
async def hangup_calls(self) -> None:
"""Hangs up inactive calls.
Hangs up inactive calls. Calls are hung up when they either:
- Have had no messages and more than one minute has passed since the start of the call.
- Have had no messages for the past 15 minutes.
In case they are hung up, both channels are attempted to be notified. In case of failure,
that channel is not notified and hung up silently.
"""
calls = await models.Call.filter(ended_at=None).prefetch_related("channels")
hung_up_channel_ids = []
for call in calls:
message = (
await models.Message.filter(call=call).order_by("-id").limit(1).first()
)
if (
message is None
and call.started_at < datetime.now(timezone.utc) - timedelta(minutes=1)
) or (
message is not None
and message.created_at
< datetime.now(timezone.utc) - timedelta(minutes=15)
):
for channel in call.channels:
hung_up_channel_ids.append(channel.id)
try:
await self.bot.get_partial_messageable(channel.id).send(
"☎️ The call was hung up because nobody replied."
)
except errors.DiscordException:
pass
if len(hung_up_channel_ids) > 0:
await models.Channel.filter(id__in=hung_up_channel_ids).update(call=None)
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET, *hung_up_channel_ids
)
@commands.command(aliases=["c"])
@commands.guild_only()
@checks.is_unbanned()
@transactions.atomic()
async def call(self, ctx: commands.Context) -> None:
"""
Call and connect with other people.
"""
if ctx.channel.is_nsfw():
await ctx.send("❌ Calls cannot be started in NSFW channels.")
return
user_id = ctx.author.id
channel_id = ctx.channel.id
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if is_on_call:
await ctx.reply(
f"☎️ You're already on a call! Use `{ctx.clean_prefix}hangup` to hangup."
)
return
is_searching = await self.bot.redis.sismember(
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
)
if is_searching:
await ctx.reply(
f"☎️ You're already searching for a call! Use `{ctx.clean_prefix}hangup` to stop searching."
)
return
last_channel_calls = [
i[1]
for i in (
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
.limit(5)
.distinct()
.order_by("-id")
.values_list("id", "call_id")
)
]
last_user_calls = [
i[1]
for i in (
await models.CallUserChannelRelationship.filter(user_id=user_id)
.limit(5)
.limit(5)
.distinct()
.order_by("-id")
.values_list("id", "call_id")
)
]
last_calls = set(last_user_calls + last_channel_calls)
last_calls_rss = await models.CallUserChannelRelationship.filter(
call_id__in=last_calls
)
last_calls_rss_channels_map = {}
for call_rs in last_calls_rss:
if call_rs.user_id == user_id or call_rs.channel_id == channel_id:
last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id
last_calls_other_channels_ids = set()
for call_rs in last_calls_rss:
if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id:
last_calls_other_channels_ids.add(call_rs.channel_id)
other_channel_id: int = await self.bot.redis.spop(
constants.CHANNELS_SEARCH_QUEUE
)
i = 0
while True:
# For some weird reason, sometimes it returns None and sometimes b"None".
if other_channel_id is None or other_channel_id == b"None":
other_channel_id = None
break
# Redis returns the ID as a bytes string.
other_channel_id = int(other_channel_id)
if i == 5:
break
i += 1
if other_channel_id in last_calls_other_channels_ids:
await self.bot.redis.sadd(
constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id)
)
other_channel_id = await self.bot.redis.spop(
constants.CHANNELS_SEARCH_QUEUE
)
if other_channel_id is None:
break
continue
break
channel = await utils.get_channel(ctx.channel)
if other_channel_id is not None:
await self.bot.redis.sadd(
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id)
)
msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}"
try:
await self.bot.get_partial_messageable(other_channel_id).send(
msg_phone_answered
)
call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw())
user, profile = await utils.get_user(ctx.author)
await models.CallUserChannelRelationship.create(
call=call,
channel_id=channel_id,
user=user,
profile=profile,
is_masked=user.is_masked,
mask=secrets.choice(constants.MASKS_LIST).value,
)
await models.Channel.filter(
id__in=[other_channel_id, channel.id]
).update(call=call)
except errors.DiscordException:
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET,
str(channel_id),
str(other_channel_id),
)
await self.bot.redis.sadd(
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
)
msg_phone_answered = ""
await ctx.send(
(
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
)
+ msg_phone_answered
)
else:
await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
await ctx.send(
(
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
)
)
@commands.command(aliases=["s"])
@commands.guild_only()
@checks.is_unbanned()
@transactions.atomic()
async def skip(self, ctx: commands.Context) -> None:
"""
Hang up the current call and search for another.
"""
if ctx.channel.is_nsfw():
await ctx.send("❌ Calls cannot be started in NSFW channels.")
return
user_id = ctx.author.id
channel_id = ctx.channel.id
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if not is_on_call:
await ctx.send("❌ You're not in a call!")
return
call = await models.Call.get(channels__id=channel_id).prefetch_related(
"channels"
)
call.ended_at = datetime.now(timezone.utc)
await call.save()
channel = find(lambda c: c.id == channel_id, call.channels)
other_channel = find(lambda c: c.id != channel_id, call.channels)
other_channel.call = None
await other_channel.save()
try:
await self.bot.get_partial_messageable(other_channel.id).send(
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
)
except errors.DiscordException:
pass
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel.id)
)
last_channel_calls = [
i[1]
for i in (
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
.limit(5)
.distinct()
.order_by("-id")
.values_list("id", "call_id")
)
]
last_user_calls = [
i[1]
for i in (
await models.CallUserChannelRelationship.filter(user_id=user_id)
.limit(5)
.limit(5)
.distinct()
.order_by("-id")
.values_list("id", "call_id")
)
]
last_calls = set(last_user_calls + last_channel_calls)
last_calls_rss = await models.CallUserChannelRelationship.filter(
call_id__in=last_calls
)
last_calls_rss_channels_map = {}
for call_rs in last_calls_rss:
if call_rs.user_id == user_id or call_rs.channel_id == channel_id:
last_calls_rss_channels_map[call_rs.call_id] = call_rs.channel_id
last_calls_other_channels_ids = set()
for call_rs in last_calls_rss:
if last_calls_rss_channels_map[call_rs.call_id] != call_rs.channel_id:
last_calls_other_channels_ids.add(call_rs.channel_id)
other_channel_id: int = await self.bot.redis.spop(
constants.CHANNELS_SEARCH_QUEUE
)
i = 0
while True:
# For some weird reason, sometimes it returns None and sometimes b"b'None'".
if other_channel_id is None or other_channel_id == b"b'None'":
other_channel_id = None
break
# Redis returns the ID as a bytes string.
other_channel_id = int(other_channel_id)
if i == 5:
break
i += 1
if other_channel_id in last_calls_other_channels_ids:
await self.bot.redis.sadd(
constants.CHANNELS_SEARCH_QUEUE, str(other_channel_id)
)
other_channel_id = await self.bot.redis.spop(
constants.CHANNELS_SEARCH_QUEUE
)
if other_channel_id is None:
break
continue
break
if other_channel_id is not None:
await self.bot.redis.sadd(
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel_id)
)
msg_phone_answered = f"☎️ {secrets.choice(constants.MSGS_PHONE_ANSWERED)}"
try:
await self.bot.get_partial_messageable(other_channel_id).send(
msg_phone_answered
)
call = await models.Call.create(is_nsfw=ctx.channel.is_nsfw())
user, profile = await utils.get_user(ctx.author)
await models.CallUserChannelRelationship.create(
call=call,
channel_id=channel_id,
user=user,
profile=profile,
is_masked=user.is_masked,
mask=secrets.choice(constants.MASKS_LIST).value,
)
await models.Channel.filter(
id__in=[other_channel_id, channel.id]
).update(call=call)
except errors.DiscordException:
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET,
str(channel_id),
str(other_channel_id),
)
await self.bot.redis.sadd(
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
)
msg_phone_answered = ""
await ctx.send(
(
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
f"☎️ You skipped the call.\n"
)
+ (
msg_phone_answered
if msg_phone_answered
else f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
)
)
else:
await self.bot.redis.sadd(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
channel.call = None
await channel.save()
await ctx.send(
(
f"**TIP**: {secrets.choice(constants.TIPS).replace("{prefix}", ctx.clean_prefix)}\n"
f"☎️ You skipped the call.\n"
f"☎️ {secrets.choice(constants.MSGS_PHONE_CALLING)}\n"
)
)
@commands.command(aliases=["hang", "h"], extras={"help_name": "Hang Up"})
@commands.guild_only()
@checks.is_unbanned()
@transactions.atomic()
async def hangup(self, ctx: commands.Context) -> None:
"""
Hang up the phone.
"""
channel_id = ctx.channel.id
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if is_on_call:
channel = await utils.get_channel(ctx.channel)
call = await models.Call.get(id=channel.call_id).prefetch_related(
"channels"
)
other_channel: models.Channel = find(
lambda c: c.id != channel_id, call.channels
)
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET, str(channel_id), str(other_channel.id)
)
if other_channel is not None:
try:
await (await self.bot.get_or_fetch_channel(other_channel.id)).send(
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
)
except errors.DiscordException:
pass
other_channel.call = None
await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}")
channel.call = None
await models.Channel.bulk_update([channel] + ([other_channel] if other_channel is not None else []), ["call_id"])
call.ended_at = datetime.now(timezone.utc)
await call.save()
is_searching = await self.bot.redis.sismember(
constants.CHANNELS_SEARCH_QUEUE, str(channel_id)
)
if is_searching:
await self.bot.redis.srem(constants.CHANNELS_SEARCH_QUEUE, str(channel_id))
await ctx.send(f"☎️ {secrets.choice(constants.MSGS_PHONE_NO_ANSWER)}")
if not is_searching and not is_on_call:
await ctx.send(f"❌ {secrets.choice(constants.MSGS_PHONE_NOT_IN_CALL)}")
@commands.command()
@commands.guild_only()
@checks.is_unbanned()
async def report(
self, ctx: commands.Context, call_id: int | None = None, *, reason: str
) -> None:
"""
Report a call by it's ID, or the current/last call if no ID is specified.
Options:
- `[<call_id>]`: The ID of the call to report.
- `<reason>`: The reason why you're reporting the call.
"""
user, _ = await utils.get_user(ctx.author)
channel_id = ctx.channel.id
if call_id is None:
call_rs = (
await models.CallUserChannelRelationship.filter(channel_id=channel_id)
.order_by("-id")
.limit(1)
.first()
)
if call_rs is None:
await ctx.send("❌ No calls occurred on this channel.")
return
call_id = call_rs.call_id
call = await models.Call.get_or_none(id=call_id).prefetch_related(
"channels", "report", "call_rss"
)
if call is None:
await ctx.send(f"❌ There is no call with ID `{call_id}`.")
if call.report is not None:
await ctx.send(
"✅ This call has already been reported. Our moderator team will review it as soon as possible."
)
return
if not any(map(lambda rs: rs.user_id == user.id, call.call_rss)):
await ctx.send("❌ You have not participated in this call.")
return
await models.Report.create(
call_id=call_id, reported_by_id=user.id, reason=reason, priority=1
)
message = "✅ The call has been reported."
if call.ended_at is None:
call.ended_at = datetime.now(timezone.utc)
await call.save()
updated_channels = []
for channel in call.channels:
channel.call = None
updated_channels.append(channel)
await models.Channel.bulk_update(updated_channels, ["call_id"])
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET,
str(updated_channels[0]),
str(updated_channels[1]),
)
message += f"\n☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_ME)}"
other_channel = find(lambda c: c.id != channel_id, updated_channels)
await self.bot.redis.srem(
constants.CHANNELS_ONCALL_SET, str(other_channel.id), str(channel_id)
)
try:
await self.bot.get_partial_messageable(other_channel.id).send(
f"☎️ {secrets.choice(constants.MSGS_PHONE_HANGUP_YOU)}"
)
except errors.DiscordException:
pass
await ctx.send(message)
@commands.command(aliases=["fr"], extras={"help_name": "Friend Request"})
@commands.guild_only()
@checks.is_unbanned()
@checks.is_on_call()
async def friendreq(self, ctx: commands.Context) -> None:
"""
Ask the other party to send you a friend request.
"""
user_id = ctx.author.id
channel_id = ctx.channel.id
call = await models.Call.get(channels__id=channel_id).prefetch_related(
"channels", "call_rss", "call_rss__profile"
)
other_channel = find(lambda c: c.id != channel_id, call.channels)
call_rs: models.CallUserChannelRelationship = find(
lambda rs: rs.user_id == user_id, call.call_rss
)
try:
await self.bot.get_partial_messageable(other_channel.id).send(
f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** (`{ctx.author.name}`) wants you to add them as a friend on Discord."
)
except errors.DiscordException:
await ctx.send("⚠️ Your friend request couldn't be sent.")
return
await ctx.send("✅ Your friend request was sent!")
@commands.Cog.listener()
@commands.guild_only()
@transactions.atomic()
async def on_message(self, message: discord.Message) -> None:
"""Processes every message received.
Args:
message (discord.Message): The message received.
"""
if (
message.author.bot
or message.is_system()
or len(message.stickers) > 0
or not message.content
):
return
if (await self.bot.get_context(message)).valid:
return
user_id = message.author.id
channel_id = message.channel.id
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if not is_on_call:
return
is_banned = await self.bot.is_banned(user_id)
if is_banned:
return
channel = await utils.get_channel(message.channel)
try:
call = await models.Call.get(id=channel.call_id).prefetch_related(
"channels"
)
except exceptions.DoesNotExist:
await self.bot.redis.srem(constants.CHANNELS_ONCALL_SET, str(channel_id))
return
other_channel: models.Channel = find(
lambda c: c.id != channel_id, call.channels
)
user, profile = await utils.get_user(message.author)
call_rss = await models.CallUserChannelRelationship.filter(
call=call, channel=channel
).select_related("profile")
used_masks = [rs.mask for rs in call_rss]
call_rs, _ = await models.CallUserChannelRelationship.get_or_create(
{
"mask": secrets.choice(
[i for i in constants.MASKS_LIST if i not in used_masks]
),
"is_masked": user.is_masked,
"profile": profile,
},
call=call,
user=user,
channel=channel,
)
# Since .select_related() can't be used and profile was already fetched, we need to
# manually set it.
call_rs.profile = profile
blocked_word = censorship.moderate(message.content)
webhook_message = None
cleaned_content = formatting.clean(message, call_rss)
if blocked_word:
try:
await self.bot.get_partial_messageable(channel_id).send(
f"Your message wasn't delivered!\nBlocked word: `{blocked_word}`."
)
except errors.DiscordException:
pass
prefix = await self.bot.get_prefix(server_id=other_channel.server_id)
try:
await self.bot.get_partial_messageable(other_channel.id).send(
f"**{call_rs.mask_emoji + " " if call_rs.is_masked else ""}{call_rs.display_name}** said something that's not allowed. If you think they're doing something inappropiate, use `{prefix}report`."
)
except errors.DiscordException:
pass
else:
async with aiohttp.ClientSession() as session:
webhook = other_channel.webhook(session)
embed = None
if message.reference:
embed = await embeds.ReplyEmbed(message.reference, call_id=call.id)
webhook_message = await webhook.send(
cleaned_content,
username=call_rs.display_name,
avatar_url=call_rs.display_avatar,
embed=embed,
wait=True,
)
while True:
try:
message_row = await models.Message.create(
id=message.id,
call=call,
channel=channel,
user=user,
profile=profile,
mask=call_rs.mask if call_rs.is_masked else None,
propagation_id=webhook_message.id if webhook_message else None,
propagation_channel=other_channel if webhook_message else None,
)
break
except asyncpg.exceptions.DeadlockDetectedError:
continue
await models.MessageContent.create(
message=message_row,
content=cleaned_content,
was_propagated=not bool(blocked_word),
)
@commands.Cog.listener()
@commands.guild_only()
@transactions.atomic()
async def on_raw_message_delete(
self, payload: discord.RawMessageDeleteEvent
) -> None:
"""Processes a message deletion received.
Args:
payload (discord.RawMessageDeleteEvent): The received payload.
"""
message_id = payload.message_id
channel_id = payload.channel_id
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if not is_on_call:
return
message = await models.Message.get_or_none(id=message_id).select_related(
"propagation_channel"
)
if message is None:
return
other_channel = message.propagation_channel
if other_channel.call_id != message.call_id:
return
async with aiohttp.ClientSession() as session:
webhook = other_channel.webhook(session)
try:
await webhook.delete_message(message.propagation_id)
except errors.DiscordException:
# This message was already deleted.
pass
for replying_message in await models.Message.filter(
replying_to_id=message_id
).exclude(propagation_channel_id=channel_id):
try:
await webhook.edit_message(
replying_message.propagation_id, embed=None
)
except errors.DiscordException:
# This message was deleted.
pass
@commands.Cog.listener()
@commands.guild_only()
@transactions.atomic()
async def on_raw_message_edit(self, payload: discord.RawMessageUpdateEvent) -> None:
"""Processes a message edit received.
Args:
payload (discord.RawMessageUpdateEvent): The event payload.
"""
if (
payload.cached_message is not None
and (
payload.cached_message.author.bot or payload.cached_message.is_system()
)
or "content" not in payload.data
):
return
message_id = payload.message_id
channel_id = payload.channel_id
content = payload.data["content"]
if censorship.moderate(content) is not None:
return
is_on_call = await self.bot.redis.sismember(
constants.CHANNELS_ONCALL_SET, str(channel_id)
)
if not is_on_call:
return
message = await models.Message.get_or_none(id=message_id).select_related(
"propagation_channel"
)
if message is None:
return
other_channel = message.propagation_channel
if other_channel.call_id != message.call_id:
return
async with aiohttp.ClientSession() as session:
webhook = other_channel.webhook(session)
try:
await webhook.edit_message(message.propagation_id, content=content)
except errors.DiscordException:
# This message was deleted.
pass
# TODO: Edit replies.
await models.MessageContent.create(message=message, content=content)
def setup(b: bot.Payphone) -> None:
"""Load the cog into the bot.
Args:
b (bot.Payphone): The bot instance to load the cog into.
"""
b.add_cog(Calls(b))