feat(bot): Everything.

This commit is contained in:
Nyeki.py
2024-10-16 13:21:04 -03:00
parent 3059ec8446
commit 5011ab5f90
34 changed files with 3444 additions and 39 deletions
@@ -0,0 +1,104 @@
from tortoise import BaseDBAsyncClient
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
CREATE TABLE IF NOT EXISTS "call" (
"id" SERIAL NOT NULL PRIMARY KEY,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ended_at" TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS "server" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"prefix" VARCHAR(3) NOT NULL DEFAULT 'p.'
);
CREATE TABLE IF NOT EXISTS "channel" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"enabled" BOOL,
"webhook_id" BIGINT,
"webhook_url" TEXT,
"call_id" INT REFERENCES "call" ("id") ON DELETE CASCADE,
"server_id" BIGINT NOT NULL REFERENCES "server" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "user" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"username" VARCHAR(32) NOT NULL,
"is_masked" BOOL NOT NULL DEFAULT False,
"is_moderator" BOOL NOT NULL DEFAULT False
);
CREATE TABLE IF NOT EXISTS "profile" (
"id" SERIAL NOT NULL PRIMARY KEY,
"avatar_url" TEXT NOT NULL,
"name" VARCHAR(32) NOT NULL,
"color" INT,
"server_id" BIGINT REFERENCES "server" ("id") ON DELETE CASCADE,
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_profile_user_id_903480" UNIQUE ("user_id", "server_id")
);
CREATE TABLE IF NOT EXISTS "calluserchannelrelationship" (
"id" SERIAL NOT NULL PRIMARY KEY,
"is_masked" BOOL NOT NULL,
"mask" BIGINT NOT NULL,
"call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE,
"channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE,
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
CONSTRAINT "uid_callusercha_call_id_fad9b8" UNIQUE ("call_id", "user_id", "channel_id")
);
CREATE TABLE IF NOT EXISTS "message" (
"id" BIGSERIAL NOT NULL PRIMARY KEY,
"mask" BIGINT,
"was_deleted" BOOL NOT NULL DEFAULT False,
"propagation_id" BIGINT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"call_id" INT NOT NULL REFERENCES "call" ("id") ON DELETE CASCADE,
"channel_id" BIGINT NOT NULL REFERENCES "channel" ("id") ON DELETE CASCADE,
"profile_id" INT NOT NULL REFERENCES "profile" ("id") ON DELETE CASCADE,
"propagation_channel_id" BIGINT REFERENCES "channel" ("id") ON DELETE CASCADE,
"replying_to_id" BIGINT REFERENCES "message" ("id") ON DELETE SET NULL,
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS "idx_message_propaga_f0803c" ON "message" ("propagation_id");
CREATE TABLE IF NOT EXISTS "messagecontent" (
"id" SERIAL NOT NULL PRIMARY KEY,
"content" VARCHAR(4096) NOT NULL,
"was_propagated" BOOL NOT NULL DEFAULT True,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"message_id" BIGINT NOT NULL REFERENCES "message" ("id") ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS "report" (
"id" SERIAL NOT NULL PRIMARY KEY,
"reason" VARCHAR(500) NOT NULL,
"priority" INT NOT NULL,
"status" SMALLINT NOT NULL DEFAULT 2,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reported_by_id" BIGINT REFERENCES "user" ("id") ON DELETE CASCADE,
"call_id" INT NOT NULL UNIQUE REFERENCES "call" ("id") ON DELETE CASCADE
);
COMMENT ON COLUMN "report"."status" IS 'ACCEPTED: 0\nDECLINED: 1\nON_REVIEW: 2';
CREATE TABLE IF NOT EXISTS "ban" (
"id" SERIAL NOT NULL PRIMARY KEY,
"notification_message_id" BIGINT,
"reason" VARCHAR(500) NOT NULL,
"appeal" VARCHAR(500),
"status" SMALLINT NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMPTZ,
"banned_by_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE CASCADE,
"report_id" INT REFERENCES "report" ("id") ON DELETE SET NULL,
"user_id" BIGINT NOT NULL REFERENCES "user" ("id") ON DELETE NO ACTION
);
CREATE INDEX IF NOT EXISTS "idx_ban_notific_ea7b58" ON "ban" ("notification_message_id");
CREATE INDEX IF NOT EXISTS "idx_ban_user_id_37a9cf" ON "ban" ("user_id", "expires_at");
COMMENT ON COLUMN "ban"."status" IS 'VALID: 0\nON_REVIEW: 1\nAPPEAL_ACCEPTED: 2\nAPPEAL_DECLINED: 3';
CREATE TABLE IF NOT EXISTS "aerich" (
"id" SERIAL NOT NULL PRIMARY KEY,
"version" VARCHAR(255) NOT NULL,
"app" VARCHAR(100) NOT NULL,
"content" JSONB NOT NULL
);"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
"""
+211 -34
View File
@@ -4,23 +4,42 @@ import enum
from tortoise import models, fields
from discord import webhook
import aiohttp
from payphone.core import config, constants
class User(models.Model):
id: int = fields.BigIntField(primary_key=True)
username: str = fields.CharField(max_length=32)
is_masked: bool = fields.BooleanField(default=False)
is_moderator: bool = fields.BooleanField(default=False)
profiles: fields.BackwardFKRelation["Profile"]
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
class Call(models.Model):
started_at = fields.DatetimeField(auto_now_add=True)
ended_at = fields.DatetimeField(null=True, default=None)
channels: fields.BackwardFKRelation["Channel"]
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
report: fields.BackwardOneToOneRelation["Report"]
class Profile(models.Model):
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
"models.User", related_name="profiles"
"models.User", related_name="profiles", on_delete=fields.OnDelete.CASCADE
)
server: fields.ForeignKeyRelation["Server"] = fields.ForeignKeyField(
"models.Server", related_name="profiles"
server: fields.ForeignKeyNullableRelation["Server"] = fields.ForeignKeyField(
"models.Server",
related_name="profiles",
null=True,
on_delete=fields.OnDelete.CASCADE,
)
image_url: str = fields.TextField()
avatar_url: str = fields.TextField()
name: str = fields.CharField(max_length=32)
color: int | None = fields.IntField(null=True, default=None)
@@ -32,6 +51,7 @@ class Profile(models.Model):
class Server(models.Model):
id: int = fields.BigIntField(primary_key=True)
prefix: str = fields.CharField(max_length=3, default="p.")
channels: fields.BackwardFKRelation["Channel"]
profiles: fields.BackwardFKRelation["Profile"]
@@ -39,32 +59,105 @@ class Server(models.Model):
class Channel(models.Model):
id = fields.BigIntField(primary_key=True)
server: fields.ForeignKeyRelation[Server] = fields.ForeignKeyField(
"models.Server", related_name="channels"
"models.Server", related_name="channels", on_delete=fields.OnDelete.CASCADE
)
is_nsfw = fields.BooleanField()
call = fields.ForeignKeyField("models.Call", null=True, related_name="channels")
webhook_id = fields.BigIntField(null=True)
webhook_url = fields.TextField()
call: fields.ForeignKeyNullableRelation["Call"] = fields.ForeignKeyField(
"models.Call", null=True, related_name="channels"
)
enabled: bool | None = fields.BooleanField(null=True, default=None)
webhook_id: int | None = fields.BigIntField(null=True)
webhook_url: str | None = fields.TextField(null=True)
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
messages: fields.BackwardFKRelation["Message"]
propagated_messages: fields.BackwardFKRelation["Message"]
def webhook(self, session: aiohttp.ClientSession) -> webhook.Webhook | None:
"""Initializes the channel's webhook instance.
class Call(models.Model):
is_nsfw = fields.BooleanField()
started_at = fields.DatetimeField(auto_now_add=True)
ended_at = fields.DatetimeField(null=True, default=None)
Args:
session (aiohttp.ClientSession): An initialized aiohttp client session.
channels: fields.BackwardFKRelation["Channel"]
call_rss: fields.BackwardFKRelation["CallUserChannelRelationship"]
Returns:
webhook.Webhook | None: The webhook, or None if not initialized.
"""
if self.webhook_url is None:
return None
return webhook.Webhook.from_url(
self.webhook_url, session=session, bot_token=config.TOKEN
)
class Meta:
index = [("server_id", "enabled")]
class CallUserChannelRelationship(models.Model):
call = fields.ForeignKeyField("models.Call", related_name="call_rss")
user = fields.ForeignKeyField("models.User", related_name="call_rss")
profile = fields.ForeignKeyField("models.Profile", related_name="call_rss")
channel = fields.ForeignKeyField("models.Channel", related_name="call_rss")
is_masked = fields.BooleanField()
mask = fields.BigIntField()
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
"models.Call", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
)
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
"models.User", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
)
profile: fields.ForeignKeyRelation["Profile"] = fields.ForeignKeyField(
"models.Profile", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
)
channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField(
"models.Channel", related_name="call_rss", on_delete=fields.OnDelete.CASCADE
)
is_masked: bool = fields.BooleanField()
mask: int = fields.BigIntField()
@property
def mask_name(self) -> str:
"""Returns the mask's name.
Returns:
str: The mask's name.
"""
return constants.Mask(self.mask).name.replace("_", " ").lower().title()
@property
def display_name(self) -> str:
"""Returns the displayed name.
Returns:
str: The displayed name.
"""
if self.is_masked:
return self.mask_name
return self.profile.name
@property
def display_avatar(self) -> str:
"""Returns the displayed avatar URL.
Returns:
str: The displayed avatar URL.
"""
if self.is_masked:
mask = constants.Mask(self.mask)
return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png"
return self.profile.avatar_url
@property
def mask_emoji(self) -> str:
"""Returns the mask's emoji.
Returns:
str: The mask's emoji (<:name:id>).
"""
mask = constants.Mask(self.mask)
mask_name = mask.name.lower().replace("_", "")
return f"<:{mask_name}:{self.mask}>"
class Meta:
unique_together = [("call_id", "user_id", "channel_id")]
@@ -72,19 +165,91 @@ class CallUserChannelRelationship(models.Model):
class Message(models.Model):
id = fields.BigIntField(primary_key=True)
channel = fields.ForeignKeyField("models.Channel")
user = fields.ForeignKeyField("models.User")
profile = fields.ForeignKeyField("models.Profile")
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField(
"models.Call", related_name="messages", on_delete=fields.OnDelete.CASCADE
)
channel: fields.ForeignKeyRelation["Channel"] = fields.ForeignKeyField(
"models.Channel", related_name="messages", on_delete=fields.OnDelete.CASCADE
)
user = fields.ForeignKeyRelation("models.User", on_delete=fields.OnDelete.CASCADE)
profile = fields.ForeignKeyField(
"models.Profile", on_delete=fields.OnDelete.CASCADE
)
replying_to: fields.ForeignKeyNullableRelation["Message"] = fields.ForeignKeyField(
"models.Message",
related_name="replies",
on_delete=fields.OnDelete.SET_NULL,
null=True,
default=None,
)
mask = fields.BigIntField(null=True)
was_deleted = fields.BooleanField(default=False)
was_propagated = fields.BooleanField(default=False)
propagation_id = fields.BigIntField(null=True, db_index=True)
propagation_channel: fields.ForeignKeyNullableRelation["Channel"] = (
fields.ForeignKeyField(
"models.Channel", related_name="propagated_messages", null=True
)
)
created_at = fields.DatetimeField(auto_now_add=True)
contents: fields.BackwardFKRelation["MessageContent"]
@property
def mask_name(self) -> str:
"""Returns the mask's name.
Returns:
str: The mask's name.
"""
return constants.Mask(self.mask).name.replace("_", " ").lower().title()
@property
def display_name(self) -> str:
"""Returns the displayed name.
Returns:
str: The displayed name.
"""
if self.mask is not None:
return self.mask_name
return self.profile.name
@property
def display_avatar(self) -> str:
"""Returns the displayed avatar URL.
Returns:
str: The displayed avatar URL.
"""
if self.mask is not None:
mask = constants.Mask(self.mask)
return f"https://payphone.nyeki.dev/assets/masks/{mask.name.lower().replace("_", "-")}.png"
return self.profile.avatar_url
@property
def mask_emoji(self) -> str:
"""Returns the mask's emoji.
Returns:
str: The mask's emoji (<:name:id>).
"""
mask = constants.Mask(self.mask)
mask_name = mask.name.lower().replace("_", "")
return f"<:{mask_name}:{self.mask}>"
class MessageContent(models.Model):
message = fields.ForeignKeyField("models.Message", related_name="contents")
message = fields.ForeignKeyField(
"models.Message", related_name="contents", on_delete=fields.OnDelete.CASCADE
)
content = fields.CharField(max_length=4096)
was_propagated = fields.BooleanField()
was_propagated = fields.BooleanField(default=True)
created_at = fields.DatetimeField(auto_now_add=True)
@@ -95,29 +260,41 @@ class ReportStatus(enum.IntEnum):
class Report(models.Model):
call: fields.ForeignKeyRelation["Call"] = fields.ForeignKeyField("models.Call")
call: fields.OneToOneRelation["Call"] = fields.OneToOneField(
"models.Call", related_name="report"
)
reported_by: fields.ForeignKeyNullableRelation["User"] = fields.ForeignKeyField(
"users.User", null=True
"models.User", null=True, on_delete=fields.OnDelete.CASCADE
)
reason: str = fields.CharField(max_length=500)
priority: int = fields.IntField()
status: ReportStatus = fields.IntEnumField(ReportStatus)
status: ReportStatus = fields.IntEnumField(
ReportStatus, default=ReportStatus.ON_REVIEW
)
created_at: datetime = fields.DatetimeField(auto_now_add=True)
class BanStatus(enum.IntEnum):
VALID = 0
APPEALED = 1
ON_REVIEW = 1
APPEAL_ACCEPTED = 2
APPEAL_DECLINED = 3
class Ban(models.Model):
user: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
"models.User", related_name="bans"
"models.User", related_name="bans", on_delete=fields.OnDelete.NO_ACTION
)
notification_message_id: int = fields.BigIntField(null=True)
report: fields.ForeignKeyNullableRelation["Report"] = fields.ForeignKeyField(
"models.Report",
related_name="bans",
null=True,
on_delete=fields.OnDelete.SET_NULL,
)
notification_message_id: int = fields.BigIntField(null=True, db_index=True)
reason: str = fields.CharField(max_length=500)
appeal: str | None = fields.CharField(max_length=500, null=True)
status: BanStatus = fields.IntEnumField(BanStatus)
status: BanStatus = fields.IntEnumField(BanStatus, default=BanStatus.VALID)
banned_by: fields.ForeignKeyRelation["User"] = fields.ForeignKeyField(
"models.User", related_name="bans_created"
)
+18
View File
@@ -0,0 +1,18 @@
from datetime import datetime, timezone
import tortoise
from payphone.db import models
from payphone.core import config
async def init() -> None:
"""Initialize the database and it's connections.
"""
await tortoise.Tortoise.init(config.TORTOISE_ORM, use_tz=True)
await tortoise.Tortoise.generate_schemas(safe=True)
await models.Call.filter(ended_at=None).update(ended_at=datetime.now(timezone.utc))
await models.Channel.exclude(call_id=None).update(call_id=None)
+82
View File
@@ -0,0 +1,82 @@
from typing import Tuple
import discord
from payphone.db import models
async def get_channel(channel: discord.TextChannel) -> models.Channel:
"""Returns a channel from the database, creating or updating it if it already exists.
Args:
channel (discord.TextChannel): The channel to fetch.
Returns:
models.Channel: The channel row.
"""
row, _ = await models.Channel.update_or_create(
id=channel.id, server=channel.guild.id
)
channel_name = channel.name.replace("discord", "d").replace("wumpus", "w")
webhook_name = f"Payphone #{channel_name}"
if row.webhook_id is None:
webhook = await channel.create_webhook(
name=webhook_name, reason="First call started in channel."
)
row.webhook_id = webhook.id
row.webhook_url = webhook.url
await row.save(update_fields=["webhook_id", "webhook_url"])
else:
found = False
for webhook in await channel.webhooks():
if webhook.id != row.webhook_id:
if webhook.token is None:
await webhook.delete(reason="Webhook is unaccessible by Payphone.")
break
else:
found = True
if not found:
webhook = await channel.create_webhook(
name=webhook_name, reason="Previous webhook was deleted."
)
row.webhook_id = webhook.id
row.webhook_url = webhook.url
await row.save(update_fields=["webhook_id", "webhook_url"])
return row
async def get_user(
user: discord.User | discord.Member,
) -> Tuple[models.User, models.Profile | None]:
"""Returns a user from the database, creating or updating it if it already exists.
Args:
user (discord.User | discord.Member): The user or member to fetch.
Returns:
Tuple[models.User, models.Profile]: The user and profile.
"""
user_row, _ = await models.User.update_or_create(id=user.id, username=user.name)
# TODO: Make per-server profiles a premium feature.
profile_row, _ = await models.Profile.update_or_create(
user=user_row,
server_id=None, # user.guild.id if isinstance(user, discord.Member) else None,
avatar_url=user.display_avatar.url,
name=user.display_name,
)
return (user_row, profile_row)