Add Discord bot

master
John Montagu, the 4th Earl of Sandvich 2025-05-15 09:54:52 -07:00
parent c05794ad99
commit 2d3f7f68fe
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
18 changed files with 478 additions and 50 deletions

View File

@ -4,6 +4,7 @@
/* eslint-disable */
import type { PlayerSchema } from './PlayerSchema';
export type GetUserResponse = {
discordId?: (string | null);
isAdmin?: boolean;
realUser: (PlayerSchema | null);
steamId: string;

View File

@ -3,6 +3,7 @@
/* tslint:disable */
/* eslint-disable */
export type PlayerSchema = {
discordId?: (string | null);
isAdmin?: boolean;
steamId: string;
username: string;

View File

@ -6,6 +6,7 @@ import type { RoleSchema } from './RoleSchema';
export type ViewTeamMembersResponse = {
availability: Array<number>;
createdAt: string;
discordId?: (string | null);
isAdmin?: boolean;
isTeamLeader?: boolean;
playtime: number;

View File

@ -16,6 +16,7 @@ export const useAuthStore = defineStore("auth", () => {
const hasCheckedAuth = ref(false);
const isAdmin = ref(false);
const realUser = ref<PlayerSchema | null>(null);
const discordId = ref<string | null>("");
const router = useRouter();
@ -39,6 +40,7 @@ export const useAuthStore = defineStore("auth", () => {
user.value = response;
isAdmin.value = response.isAdmin || (response.realUser?.isAdmin ?? false);
realUser.value = response.realUser ?? null;
discordId.value = response.discordId ?? "";
return response;
},
@ -116,6 +118,7 @@ export const useAuthStore = defineStore("auth", () => {
username,
isAdmin,
realUser,
discordId,
isLoggedIn,
hasCheckedAuth,
isRegistering,

View File

@ -1,6 +1,20 @@
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
//const baseUrl = window.location.origin;
const redirectUri = computed(() => {
return encodeURIComponent(window.location.origin + "/settings");
});
const oauthUrl = computed(() => {
return "https://discord.com/oauth2/authorize" +
"?client_id=1372254613692219392" +
"&response_type=code" +
`&redirect_uri=${redirectUri.value}` +
"&scope=identify";
});
const displayName = ref("");
@ -25,6 +39,29 @@ onMounted(() => {
</h3>
<input v-model="displayName" />
</div>
<div class="form-group margin">
<h3>
Discord Account
</h3>
<!--p>
Link your Discord account to your profile to enable Discord
integration features. Contact
<a href="https://discord.com/users/195789918474207233">@pyrofromcsgo</a>
if you would like to manually link your account without logging in
through Discord.
</p-->
<p v-if="authStore.discordId">
Linked to Discord account <code>{{ authStore.discordId }}</code>.
</p>
<p v-else>
Discord OAuth is not yet implemented.
Contact <a href="https://discord.com/users/195789918474207233">@pyrofromcsgo</a>
if you would like to link your Discord account.
</p>
<!--a :href="oauthUrl">
<button>Link Discord Account</button>
</a-->
</div>
<div class="form-group margin">
<div class="action-buttons">
<button class="accent" @click="save">Save</button>

View File

@ -1,5 +1,5 @@
# Use an official Python runtime as a parent image
FROM python:3.12-slim
FROM python:3.13-slim
COPY requirements.txt /

View File

@ -3,7 +3,8 @@ from flask import Flask
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.engine import create_engine
from sqlalchemy.orm import DeclarativeBase, scoped_session, sessionmaker
from celery import Celery, Task
class BaseModel(DeclarativeBase):
@ -74,7 +75,24 @@ metadata = MetaData(naming_convention=convention)
def create_db() -> SQLAlchemy:
return SQLAlchemy(model_class=BaseModel, metadata=metadata)
def create_isolated_db_session(database_uri: str | None):
database_uri = environ.get("DATABASE_URI") or DATABASE_URI
if not database_uri:
raise ValueError("Database URI is not provided")
engine = create_engine(database_uri)
isolated_db = scoped_session(
sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
)
return isolated_db
app = create_app()
#db = SQLAlchemy(model_class=BaseModel, metadata=metadata)
db = create_db()
db_session: scoped_session = db.session
migrate = Migrate(app, db, render_as_batch=True)

View File

@ -0,0 +1,155 @@
import os
import discord
from discord.ext import commands
import app_db
import models
from models.event import Event
from models.player import Player
from models.player_event import PlayerEvent
app_db.db_session = app_db.create_isolated_db_session(None)
guild_id_str = os.getenv("GUILD_ID")
guild_id = discord.Object(id=int(guild_id_str)) if guild_id_str else None
discord_token = os.getenv("DISCORD_TOKEN")
if not discord_token:
raise ValueError("DISCORD_TOKEN environment variable not set.")
class Client(commands.Bot):
async def on_ready(self):
if guild_id:
try:
synced = await self.tree.sync(guild=guild_id)
print(f"Ready! Synced {len(synced)} commands.")
except Exception as e:
print(f"Failed to sync commands: {e}")
pass
async def on_interaction(self, interaction: discord.Interaction):
if interaction.response.is_done():
return
if interaction.type == discord.InteractionType.component and interaction.message:
# Handle button interactions here
if interaction.data is None or not "custom_id" in interaction.data:
return
has_interaction = False
if interaction.data["custom_id"] == "click_attending":
has_interaction = True
elif interaction.data["custom_id"] == "click_pending":
has_interaction = True
elif interaction.data["custom_id"] == "click_not_attending":
has_interaction = True
if has_interaction:
interaction_type = interaction.data["custom_id"]
player = app_db.db_session.query(
Player
).where(
Player.discord_id == interaction.user.id
).one_or_none()
if not player:
await interaction.response.send_message(
"This Discord account is not linked to a player. " +
"Contact <@195789918474207233> to link your account.",
ephemeral=True
)
return
event = app_db.db_session.query(
Event
).where(
Event.discord_message_id == interaction.message.id
).one_or_none()
if event and player:
player_event = app_db.db_session.query(
PlayerEvent
).where(
PlayerEvent.player_id == player.steam_id,
PlayerEvent.event_id == event.id
).one_or_none()
if interaction_type == "click_not_attending":
if player_event:
app_db.db_session.delete(player_event)
app_db.db_session.commit()
event.update_discord_message()
await interaction.response.defer()
if not player_event:
player_event = PlayerEvent()
player_event.event_id = event.id
player_event.player_id = player.steam_id
app_db.db_session.add(player_event)
player_event.has_confirmed = interaction_type == "click_attending"
app_db.db_session.commit()
event.update_discord_message()
await interaction.response.defer()
intents = discord.Intents.default()
client = Client(command_prefix="!", intents=intents)
@client.tree.command(
name="setup-announcement-webhook",
description="Set up announcements webhook in this channel",
guild=guild_id
)
async def setup_announcements(interaction: discord.Interaction):
await interaction.response.send_message(
"Setting up announcement webhook. Any existing webhooks madde by " +
"this command will be deleted.",
ephemeral=True
)
channel = interaction.channel
if not isinstance(channel, discord.TextChannel):
await interaction.followup.send(
"This command can only be used in a text channel.",
ephemeral=True
)
return
for webhook in await channel.webhooks():
if webhook.user == client.user:
await webhook.delete()
webhook = await channel.create_webhook(name="availabili.tf webhook")
content = (
f"Webhook created: {webhook.url}\n" +
"Use this webhook URL in the Discord integration settings of your " +
"team to receive interactive announcements."
)
await interaction.followup.send(content, ephemeral=True)
client.run(discord_token)
#@client.tree.command(
# name="link-team",
# description="Link this Discord guild to a team with an invite key",
# guild=guild_id
#)
#@app_commands.describe(key="Team invite key")
#async def link_team(interaction: discord.Interaction, key: str):
# team_invite = db_session.query(
# models.TeamInvite
# ).where(
# models.TeamInvite.key == key
# ).one_or_none()
#
# if not team_invite:
# await interaction.response.send_message(
# "Invalid team invite key.",
# ephemeral=True
# )
# return
#
# # consume the invite and link the team to the guild

View File

@ -25,11 +25,12 @@ class GetUserResponse(PlayerSchema):
real_user: PlayerSchema | None
@classmethod
def from_model(cls, model: Player):
def from_model(cls, player: Player):
return GetUserResponse(
steam_id=str(model.steam_id),
username=model.username,
is_admin=model.is_admin,
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin,
discord_id=str(player.discord_id),
real_user=None,
)
@ -44,12 +45,9 @@ class GetUserResponse(PlayerSchema):
@requires_authentication
def get_user(player: Player, auth_session: AuthSession):
if auth_session.player.steam_id != player.steam_id:
return GetUserResponse(
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin,
real_user=PlayerSchema.from_model(auth_session.player)
).dict(by_alias=True)
response = GetUserResponse.from_model(player)
response.real_user = PlayerSchema.from_model(auth_session.player)
return response.dict(by_alias=True)
return GetUserResponse.from_model(player).dict(by_alias=True)
@api_login.post("/authenticate")

View File

@ -0,0 +1,32 @@
"""Make players.discord_id unique
Revision ID: 251a8108d7ff
Revises: f4330df94c44
Create Date: 2025-05-14 16:44:16.817690
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '251a8108d7ff'
down_revision = 'f4330df94c44'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.create_unique_constraint("uq_players_discord_id", ['discord_id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.drop_constraint("uq_players_discord_id", type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add uniqueness constraints
Revision ID: 8a37924fd1be
Revises: 251a8108d7ff
Create Date: 2025-05-15 09:52:01.887077
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a37924fd1be'
down_revision = '251a8108d7ff'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('events', schema=None) as batch_op:
batch_op.create_unique_constraint('uq_events_discord_message_id', ['discord_message_id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('events', schema=None) as batch_op:
batch_op.drop_constraint('uq_events_discord_message_id', type_='unique')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""Add players.discord_id field
Revision ID: f4330df94c44
Revises: f8588cdf998e
Create Date: 2025-05-14 14:06:29.368594
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f4330df94c44'
down_revision = 'f8588cdf998e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.add_column(sa.Column('discord_id', sa.BigInteger(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('players', schema=None) as batch_op:
batch_op.drop_column('discord_id')
# ### end Alembic commands ###

View File

@ -1,13 +1,14 @@
from datetime import datetime
import requests
import threading
from sqlalchemy.orm import mapped_column, relationship
from sqlalchemy.orm import mapped_column, relationship, scoped_session
from sqlalchemy.orm.attributes import Mapped
from sqlalchemy.orm.properties import ForeignKey
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import UniqueConstraint
from sqlalchemy.types import TIMESTAMP, BigInteger, Integer, String, Text
from sqlalchemy.sql import func
from sqlalchemy_utc import UtcDateTime
from discord_webhook import DiscordWebhook
import app_db
import spec
import os
@ -26,7 +27,7 @@ class Event(app_db.BaseModel):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
discord_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
discord_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, unique=True)
team: Mapped["Team"] = relationship("Team", back_populates="events")
players: Mapped[list["PlayerEvent"]] = relationship(
@ -40,7 +41,7 @@ class Event(app_db.BaseModel):
)
def get_maximum_matching(self):
players_teams_roles = app_db.db.session.query(
players_teams_roles = app_db.db_session.query(
PlayerTeamRole
).join(
PlayerTeam
@ -89,7 +90,10 @@ class Event(app_db.BaseModel):
if player.role:
player_info += f"**{player.role.role.name}:** "
player_info += f"{player.player.username}"
if player.player.discord_id:
player_info += f"<@{player.player.discord_id}>"
else:
player_info += f"{player.player.username}"
if player.has_confirmed:
player_info += ""
@ -105,8 +109,6 @@ class Event(app_db.BaseModel):
else:
ringers_needed_msg = f" **({ringers_needed} ringers needed)**"
domain = os.environ.get("DOMAIN", "availabili.tf")
return "\n".join([
f"# {self.name}",
"",
@ -115,47 +117,89 @@ class Event(app_db.BaseModel):
f"<t:{start_timestamp}:f>",
"\n".join(players_info),
f"Maximum roles filled: {matchings}" + ringers_needed_msg,
"",
"[Confirm attendance here]" +
f"(https://{domain}/team/id/{self.team.id})",
#"",
#"[Confirm attendance here]" +
# f"(https://{domain}/team/id/{self.team.id})",
])
def get_discord_message_components(self):
domain = os.environ.get("DOMAIN", "availabili.tf")
return [
{
"type": 10,
"content": self.get_discord_content(),
},
{
"type": 1,
"components": [
{
"type": 2,
"label": "✅ Attending",
"style": 3,
"custom_id": "click_attending"
},
{
"type": 2,
"label": "⌛ Pending",
"style": 2,
"custom_id": "click_pending"
},
{
"type": 2,
"label": "❌ Not attending",
"style": 2,
"custom_id": "click_not_attending"
},
{
"type": 2,
"label": "View in browser",
"style": 5,
"url": f"https://{domain}/team/id/{self.team_id}"
}
]
}
]
def get_or_create_webhook(self):
integration = app_db.db.session.query(
integration = app_db.db_session.query(
TeamDiscordIntegration
).where(
TeamDiscordIntegration.team_id == self.team_id
).first()
if not integration:
return None
return None, ""
if self.discord_message_id:
return DiscordWebhook(
integration.webhook_url,
id=str(self.discord_message_id),
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
)
else:
return DiscordWebhook(
integration.webhook_url,
username=integration.webhook_bot_name,
avatar_url=integration.webhook_bot_profile_picture,
)
webhook = {
"username": integration.webhook_bot_name,
"avatar_url": integration.webhook_bot_profile_picture,
"flags": 1 << 15,
}
return webhook, integration.webhook_url
def update_discord_message(self):
webhook = self.get_or_create_webhook()
domain = os.environ.get("DOMAIN", "availabili.tf")
webhook, webhook_url = self.get_or_create_webhook()
if webhook:
webhook.content = self.get_discord_content()
if webhook.id:
params = "?with_components=true&wait=true"
webhook["components"] = self.get_discord_message_components()
if self.discord_message_id:
# fire and forget
threading.Thread(target=webhook.edit).start()
#threading.Thread(target=webhook.edit).start()
del webhook["username"]
del webhook["avatar_url"]
webhook_url += f"/messages/{self.discord_message_id}"
requests.patch(webhook_url + params, json=webhook)
else:
webhook.execute()
if webhook_id := webhook.id:
#webhook.execute()
response = requests.post(webhook_url + params, json=webhook)
response = response.json()
if webhook_id := response["id"]:
self.discord_message_id = int(webhook_id)
app_db.db.session.commit()
app_db.db_session.commit()
else:
raise Exception("Failed to create webhook")

View File

@ -13,6 +13,7 @@ class Player(app_db.BaseModel):
steam_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str] = mapped_column(String(63))
is_admin: Mapped[bool] = mapped_column(default=False)
discord_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, unique=True)
teams: Mapped[list["PlayerTeam"]] = relationship(back_populates="player")
auth_sessions: Mapped[list["AuthSession"]] = relationship(back_populates="player")
@ -25,13 +26,15 @@ class PlayerSchema(spec.BaseModel):
steam_id: str
username: str
is_admin: bool = False
discord_id: str | None = None
@classmethod
def from_model(cls, player: Player):
return cls(
steam_id=str(player.steam_id),
username=player.username,
is_admin=player.is_admin
is_admin=player.is_admin,
discord_id=str(player.discord_id) if player.discord_id else None
)

View File

@ -9,7 +9,7 @@ Flask-SQLAlchemy
SQLAlchemy-Utc
# form/data validation
pydantic==2.9.2
pydantic==2.11.4
spectree==1.4.1 # generates OpenAPI documents for us to make TypeScript API
# clients based on our pydantic models
@ -22,6 +22,9 @@ requests
pytz # timezone handling
discord-webhook # for sending messages to Discord webhooks
discord.py
https://github.com/HumanoidSandvichDispenser/Clyde/releases/download/test/discord_clyde-0.2.0-py3-none-any.whl
#discord-clyde
celery[redis]

View File

@ -1,4 +1,6 @@
from flask import Blueprint, abort, make_response
import os
from flask import Blueprint, abort, make_response, request
import requests
from spectree import Response
from middleware import requires_admin, requires_authentication
from models.player import Player, PlayerSchema
@ -8,6 +10,11 @@ from app_db import db
api_user = Blueprint("user", __name__, url_prefix="/user")
# TODO: use env vars
DISCORD_TOKEN_URL = "https://discord.com/api/oauth2/token"
DISCORD_CLIENT_ID = "1372254613692219392" #os.getenv("DISCORD_CLIENT_ID")
DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET")
class SetUsernameJson(BaseModel):
username: str
@ -78,3 +85,34 @@ def unset_doas(**_):
resp = make_response({ }, 204)
resp.delete_cookie("doas", httponly=True)
return resp
#class DiscordAuthQuery(BaseModel):
# code: str
# redirect_uri: str
#
#@api_user.post("/discord-authenticate")
#@spec.validate(
# operation_id="discord_authenticate"
#)
#@requires_authentication
#def discord_authenticate(query: DiscordAuthQuery, player: Player, **_):
# if not DISCORD_CLIENT_ID or not DISCORD_CLIENT_SECRET:
# abort(500, "The site is not configured to use Discord authentication")
#
# data = {
# "client_id": DISCORD_CLIENT_ID,
# "client_secret": DISCORD_CLIENT_SECRET,
# "grant_type": "authorization_code",
# "code": query.code,
# "redirect_uri": query.redirect_uri,
# "scope": "identify"
# }
# response = requests.post(DISCORD_TOKEN_URL, data)
# access_token = response.json()["access_token"]
#
# headers = {
# "authorization": f"Bearer {access_token}"
# }
# response = requests.get("https://discord.com/api/v10/users/@me", headers=headers)
#
# id = response.json()[]

View File

@ -30,6 +30,8 @@ services:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
environment:
- FLASK_DEBUG=0
- FLASK_CELERY_BROKER_URL=redis://redis:6379/0
@ -43,7 +45,7 @@ services:
# ETL job (runs with the same source as the backend)
celery-worker:
container_name: worker-production
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1
image: backend-flask-production
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
@ -53,10 +55,24 @@ services:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
depends_on:
- redis
- db
# discord bot
discord-bot:
container_name: discord-bot
command: python discord_bot.py
image: backend-flask-production
volumes:
- ./backend-flask:/app
networks:
- prod-network
env_file:
- .env
# message broker
redis:
image: redis:alpine

View File

@ -22,7 +22,7 @@ services:
# ETL job (runs with the same source as the backend)
celery-worker:
container_name: worker
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1 -B
command: celery -A make_celery.celery_app worker --loglevel=info --concurrency=1
environment:
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
@ -35,6 +35,20 @@ services:
depends_on:
- redis
# discord bot
discord-bot:
container_name: discord-bot
command: python discord_bot.py
image: backend-flask
volumes:
- ./backend-flask:/app
networks:
- dev-network
env_file:
- .env
environment:
- DATABASE_URI=sqlite:///instance/db.sqlite3
# message broker
redis:
image: redis:alpine