package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.wperron.io/sqliteexporter"
"go.wperron.io/themis"
"go.wperron.io/themis/correlation"
)
const (
CONN_STRING_PATTERN = "file:%s?cache=shared&mode=rw&_journal_mode=WAL"
)
var (
dbFile = flag . String ( "db" , "" , "SQlite database file path." )
debug = flag . Bool ( "debug" , false , "Set log level to DEBUG." )
store * themis . Store
tracer trace . Tracer
propagator = propagation . TraceContext { }
)
type Handler func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error
func main ( ) {
log . Info ( ) . Msg ( "startup." )
start := time . Now ( )
ctx , cancel := signal . NotifyContext ( context . Background ( ) , syscall . SIGTERM , syscall . SIGKILL , syscall . SIGINT )
defer cancel ( )
flag . Parse ( )
zerolog . SetGlobalLevel ( zerolog . InfoLevel )
if * debug {
zerolog . SetGlobalLevel ( zerolog . DebugLevel )
}
log . Logger = log . Output ( zerolog . ConsoleWriter { Out : os . Stdout } )
log . Logger = log . Logger . Hook ( correlation . TraceContextHook { } )
zerolog . DurationFieldUnit = time . Millisecond
go func ( ) {
if err := serve ( ":8080" ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to serve requests" )
}
cancel ( )
} ( )
err := touchDbFile ( * dbFile )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to touch database file" )
}
connString := fmt . Sprintf ( CONN_STRING_PATTERN , * dbFile )
log . Debug ( ) . Str ( "connection_string" , connString ) . Msg ( "opening sqlite3 database" )
db , err := sql . Open ( "sqlite3" , connString )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to open database" )
}
store , err = themis . NewStore ( db )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to initialize database" )
}
defer store . Close ( )
if err := initTracing ( ctx , db ) ; err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to initialize tracing" )
}
notifChan := make ( chan struct { } )
notifier := themis . NewNotifier ( notifChan )
go notifier . Start ( ctx )
authToken , ok := os . LookupEnv ( "DISCORD_TOKEN" )
if ! ok {
log . Fatal ( ) . Err ( err ) . Msg ( "no auth token found at DISCORD_TOKEN env var" )
}
appId , ok := os . LookupEnv ( "DISCORD_APP_ID" )
if ! ok {
log . Fatal ( ) . Err ( err ) . Msg ( "no app id found at DISCORD_APP_ID env var" )
}
guildId , ok := os . LookupEnv ( "DISCORD_GUILD_ID" )
if ! ok {
log . Fatal ( ) . Err ( err ) . Msg ( "no guild id found at DISCORD_GUILD_ID env var" )
}
channelId , ok := os . LookupEnv ( "DISCORD_BOT_CHANNEL_ID" )
if ! ok {
log . Fatal ( ) . Err ( err ) . Msg ( "no channel id found at DISCORD_BOT_CHANNEL_ID env var" )
}
discord , err := discordgo . New ( fmt . Sprintf ( "Bot %s" , authToken ) )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to initialize discord session" )
}
log . Info ( ) . Str ( "app_id" , appId ) . Str ( "guild_id" , guildId ) . Msg ( "connected to discord" )
commands := [ ] * discordgo . ApplicationCommand {
// Server info commands
{
Name : "info" ,
Description : "Server Information" ,
Type : discordgo . ChatApplicationCommand ,
} ,
// EU4 claims commands
{
Name : "list-claims" ,
Description : "List current claims" ,
Type : discordgo . ChatApplicationCommand ,
} ,
{
Name : "claim" ,
Description : "Take a claim on provinces" ,
Type : discordgo . ChatApplicationCommand ,
Options : [ ] * discordgo . ApplicationCommandOption {
{
Name : "claim-type" ,
Description : "one of `area`, `region` or `trade`" ,
Type : discordgo . ApplicationCommandOptionString ,
Choices : [ ] * discordgo . ApplicationCommandOptionChoice {
{ Name : "Area" , Value : themis . CLAIM_TYPE_AREA } ,
{ Name : "Region" , Value : themis . CLAIM_TYPE_REGION } ,
{ Name : "Trade Node" , Value : themis . CLAIM_TYPE_TRADE } ,
} ,
} ,
{
Name : "name" ,
Description : "the name of zone claimed" ,
Type : discordgo . ApplicationCommandOptionString ,
Autocomplete : true ,
} ,
} ,
} ,
{
Name : "describe-claim" ,
Description : "Get details on a claim" ,
Type : discordgo . ChatApplicationCommand ,
Options : [ ] * discordgo . ApplicationCommandOption {
{
Name : "id" ,
Description : "Numerical ID for the claim" ,
Type : discordgo . ApplicationCommandOptionInteger ,
} ,
} ,
} ,
{
Name : "delete-claim" ,
Description : "Release one of your claims" ,
Type : discordgo . ChatApplicationCommand ,
Options : [ ] * discordgo . ApplicationCommandOption {
{
Name : "id" ,
Description : "numerical ID for the claim" ,
Type : discordgo . ApplicationCommandOptionInteger ,
} ,
} ,
} ,
{
Name : "flush" ,
Description : "Remove all claims from the database and prepare for the next game!" ,
Type : discordgo . ChatApplicationCommand ,
} ,
{
Name : "query" ,
Description : "Run a raw SQL query on the database" ,
Type : discordgo . ChatApplicationCommand ,
Options : [ ] * discordgo . ApplicationCommandOption {
{
Name : "query" ,
Description : "Raw SQL query" ,
Type : discordgo . ApplicationCommandOptionString ,
} ,
} ,
} ,
// Scheduling commands
{
Name : "schedule" ,
Description : "Get the schedule for the following weeks." ,
Type : discordgo . ChatApplicationCommand ,
} ,
{
Name : "send-schedule" ,
Description : "Trigger the scheduled message. Admins only" ,
Type : discordgo . ChatApplicationCommand ,
DefaultMemberPermissions : new ( int64 ) , // default 0 == admins only
} ,
{
Name : "absent" ,
Description : "Mark yourself as absent for a session" ,
Type : discordgo . ChatApplicationCommand ,
Options : [ ] * discordgo . ApplicationCommandOption {
{
Name : "date" ,
Required : false ,
Description : "Date of the session you can't make it to. YYYY-MM-DD format." ,
Type : discordgo . ApplicationCommandOptionString ,
} ,
} ,
} ,
}
handlers := map [ string ] Handler {
"info" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
uptime , err := themis . Uptime ( ctx )
if err != nil {
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Oops, something went wrong! :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to get server uptime: %w" , err )
}
claimCount , uniquePlayers , err := store . CountClaims ( ctx )
if err != nil {
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Oops, something went wrong! :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to count claims: %w" , err )
}
ev , err := store . LastOf ( ctx , themis . EventFlush )
var lastFlush string
if err != nil {
if err != themis . ErrNever {
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Oops, something went wrong! :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed get last flush event: %w" , err )
}
lastFlush = "never"
} else {
lastFlush = ev . Timestamp . Format ( time . DateTime )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : fmt . Sprintf ( "Server has been up for %s, has %d claims from %d unique players.\nThe last time claims were flushed was: %s." , uptime , claimCount , uniquePlayers , lastFlush ) ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"list-claims" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
claims , err := store . ListClaims ( ctx )
if err != nil {
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Oops, something went wrong! :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to list claims: %w" , err )
}
sb := strings . Builder { }
sb . WriteString ( fmt . Sprintf ( "There are currently %d claims:\n" , len ( claims ) ) )
sb . WriteString ( "```\n" )
sb . WriteString ( formatClaimsTable ( claims ) )
sb . WriteString ( "```\n" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : sb . String ( ) ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"claim" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
if i . Type == discordgo . InteractionApplicationCommandAutocomplete {
log . Debug ( ) . Ctx ( ctx ) . Msg ( "command type interaction autocomplete" )
handleClaimAutocomplete ( ctx , store , s , i )
return nil
}
opts := i . ApplicationCommandData ( ) . Options
if len ( opts ) != 2 {
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "`claim-type` and `name` are mandatory parameters" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
}
claimType , err := themis . ClaimTypeFromString ( opts [ 0 ] . StringValue ( ) )
if err != nil {
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "You can only take claims of types `area`, `region` or `trade`" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to parse claim: %w" , err )
}
name := opts [ 1 ] . StringValue ( )
player := i . Member . Nick
if player == "" {
player = i . Member . User . Username
}
userId := i . Member . User . ID
_ , err = store . Claim ( ctx , userId , player , name , claimType )
if err != nil {
conflict , ok := err . ( themis . ErrConflict )
if ok {
sb := strings . Builder { }
sb . WriteString ( "Some provinces are already claimed:\n```\n" )
for _ , c := range conflict . Conflicts {
sb . WriteString ( fmt . Sprintf ( " - %s\n" , c ) )
}
sb . WriteString ( "```\n" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : sb . String ( ) ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction" )
}
return nil
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "failed to acquire claim :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to acquire claim: %w" , err )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : fmt . Sprintf ( "Claimed %s for %s!" , name , player ) ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"describe-claim" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
id := i . ApplicationCommandData ( ) . Options [ 0 ]
detail , err := store . DescribeClaim ( ctx , int ( id . IntValue ( ) ) )
if err != nil {
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "woops, something went wrong :(" ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to describe claim: %w" , err )
}
sb := strings . Builder { }
sb . WriteString ( fmt . Sprintf ( "#%d %s %s (%s)\n" , detail . ID , detail . Name , detail . Type , detail . Player ) )
for _ , p := range detail . Provinces {
sb . WriteString ( fmt . Sprintf ( " - %s\n" , p ) )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : sb . String ( ) ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"delete-claim" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
id := i . ApplicationCommandData ( ) . Options [ 0 ]
userId := i . Member . User . ID
err := store . DeleteClaim ( ctx , int ( id . IntValue ( ) ) , userId )
if err != nil {
msg := "Oops, something went wrong :( blame @wperron"
if errors . Is ( err , themis . ErrNoSuchClaim ) {
msg = fmt . Sprintf ( "Claim #%d not found for %s" , id . IntValue ( ) , i . Member . Nick )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : msg ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to delete claim: %w" , err )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Got it chief." ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"flush" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
baggage := make ( url . Values )
propagator . Inject ( ctx , correlation . UrlValuesCarrier ( baggage ) )
sb := strings . Builder { }
sb . WriteString ( "modal_flush" )
if len ( baggage ) != 0 {
sb . WriteRune ( ':' )
sb . WriteString ( baggage . Encode ( ) )
}
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseModal ,
Data : & discordgo . InteractionResponseData {
CustomID : sb . String ( ) ,
Title : "Are you sure?" ,
Components : [ ] discordgo . MessageComponent {
discordgo . ActionsRow {
Components : [ ] discordgo . MessageComponent {
// Note: Currently, Discord *requires* modal have at least one field present, it's not
// possible to simply have a cancel/confirm box. That is why this modal uses an old
// school [y/N] confirmation.
//
// source: https://stackoverflow.com/questions/71874810/discord-modals-without-textinputcomponent-but-a-text-and-button-only#answer-74317739
discordgo . TextInput {
CustomID : "confirmation" ,
Label : "Delete all claims permanently? [y/N]" ,
Style : discordgo . TextInputShort ,
Placeholder : "" ,
Value : "" ,
Required : true ,
MinLength : 1 ,
MaxLength : 45 ,
} ,
} ,
} ,
} ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"query" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
roDB , err := sql . Open ( "sqlite3" , fmt . Sprintf ( "file:%s?cache=private&mode=ro" , * dbFile ) )
if err != nil {
return fmt . Errorf ( "failed to open read-only copy of database: %w" , err )
}
q := i . ApplicationCommandData ( ) . Options [ 0 ] . StringValue ( )
deadlined , cancelDeadline := context . WithTimeout ( ctx , 15 * time . Second )
defer cancelDeadline ( )
rows , err := roDB . QueryContext ( deadlined , q )
if err != nil {
return fmt . Errorf ( "faied to exec user-provided query: %w" , err )
}
fmtd , err := themis . FormatRows ( ctx , rows )
if err != nil {
return fmt . Errorf ( "failed to format rows: %w" , err )
}
// 2000 is a magic number here, it's the character limit for a discord
// message, we're cutting slightly under that to allow the backticks
// for the monospaced block.
table := fmt . Sprintf ( "```\n%s\n```" , fmtd [ : min ( len ( fmtd ) , 1990 ) ] )
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : table ,
} ,
} ) ; err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to respond to interaction" )
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"schedule" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
// get schedule from now to 4 mondays into the future
sched , err := store . GetSchedule ( ctx , themis . NextMonday ( ) , themis . NextMonday ( ) . Add ( 4 * 7 * 24 * time . Hour ) )
if err != nil {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "failed to get schedule, check logs for more info." ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to get schedule: %w" , err )
}
sb := strings . Builder { }
keys := make ( [ ] string , 0 , len ( sched ) )
for k , abs := range sched {
for i , a := range abs {
a = fmt . Sprintf ( "<@%s>" , a )
abs [ i ] = a
}
keys = append ( keys , k )
}
sort . Strings ( keys )
for _ , d := range keys {
sb . WriteString ( d + ": " )
if len ( sched [ d ] ) == 0 {
sb . WriteString ( "Everyone is available!\n" )
} else {
sb . WriteString ( themis . FormatStringSlice ( sched [ d ] ) + " won't be able to make it\n" )
}
}
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : sb . String ( ) ,
} ,
} ) ; err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to respond to interaction" )
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"send-schedule" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
notifier . Send ( )
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Done." ,
} ,
} ) ; err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to respond to interaction" )
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
"absent" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
var rawDate string
if len ( i . ApplicationCommandData ( ) . Options ) == 0 {
rawDate = themis . NextMonday ( ) . Format ( time . DateOnly )
} else {
rawDate = i . ApplicationCommandData ( ) . Options [ 0 ] . StringValue ( )
}
date , err := time . Parse ( time . DateOnly , rawDate )
if err != nil {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "failed to parse provided date, make sure to use the YYYY-MM-DD format." ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
}
if date . Before ( time . Now ( ) ) {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "The date must be some time in the future." ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
}
if date . Weekday ( ) != time . Monday {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "The date you provided is not a Monday." ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
// TODO(wperron) suggest Mondays before and after?
return nil
}
userId := i . Member . User . ID
if err := store . AddAbsence ( ctx , date , userId ) ; err != nil {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "something went wrong recording your absence, check logs for more info." ,
} ,
} ) ; err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return fmt . Errorf ( "failed to record absence: %w" , err )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Okey dokey." ,
} ,
} )
if err != nil {
return fmt . Errorf ( "failed to respond to interaction: %w" , err )
}
return nil
} ,
}
registerHandlers ( discord , handlers )
err = discord . Open ( )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to open discord websocket" )
}
defer discord . Close ( )
log . Debug ( ) . Int ( "count" , len ( commands ) ) . Msg ( "registering commands via bulk overwrite" )
created , err := discord . ApplicationCommandBulkOverwrite ( appId , guildId , commands )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to register commands with discord" )
}
log . Info ( ) . Int ( "count" , len ( created ) ) . Dur ( "startup_latency_ms" , time . Since ( start ) ) . Msg ( "registered commands, ready to operate" )
go notifier . NotifyFunc ( ctx , func ( ) {
log . Info ( ) . Msg ( "sending weekly reminder" )
absentees , err := store . GetAbsentees ( ctx , themis . NextMonday ( ) )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to get absentees for next session" )
return
}
for i , a := range absentees {
a = fmt . Sprintf ( "<@%s>" , a )
absentees [ i ] = a
}
var msg string
var components [ ] discordgo . MessageComponent
if len ( absentees ) == 0 {
msg = "Everybody can make it next Monday, see you then!"
components = [ ] discordgo . MessageComponent {
discordgo . ActionsRow {
Components : [ ] discordgo . MessageComponent {
discordgo . Button {
CustomID : "schedule-response" ,
Label : "I Can't Make It" ,
Style : discordgo . DangerButton ,
Disabled : false ,
Emoji : discordgo . ComponentEmoji {
Name : "🙁" ,
} ,
} ,
} ,
} ,
}
} else {
msg = fmt . Sprintf ( "%s can't make it next Monday. :sad:" , themis . FormatStringSlice ( absentees ) )
}
_ , err = discord . ChannelMessageSendComplex ( channelId , & discordgo . MessageSend {
Content : msg ,
Components : components ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to send scheduled notification" )
}
} )
<- ctx . Done ( )
log . Info ( ) . Msg ( "context cancelled, exiting" )
os . Exit ( 0 )
}
func touchDbFile ( path string ) error {
log . Debug ( ) . Str ( "path" , path ) . Msg ( "touching database file" )
f , err := os . Open ( path )
if err != nil {
if errors . Is ( err , os . ErrNotExist ) {
f , err := os . Create ( path )
if err != nil {
return err
}
f . Close ( )
} else {
return err
}
}
f . Close ( )
return nil
}
func registerHandlers ( sess * discordgo . Session , handlers map [ string ] Handler ) {
sess . AddHandler ( func ( s * discordgo . Session , r * discordgo . Ready ) {
log . Info ( ) . Str ( "user_id" , fmt . Sprintf ( "%s#%s" , s . State . User . Username , s . State . User . Discriminator ) ) . Msg ( "logged in" )
} )
sess . AddHandler ( func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
switch i . Type {
case discordgo . InteractionApplicationCommand :
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
if h , ok := handlers [ i . ApplicationCommandData ( ) . Name ] ; ok {
withLogging ( i . ApplicationCommandData ( ) . Name , h ) ( ctx , s , i )
}
case discordgo . InteractionModalSubmit :
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
state , err := parseCustomIDState ( i . ModalSubmitData ( ) . CustomID )
if err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "unexpected error occurred while parsing state from custom id, returning early." )
return
}
ctx = propagator . Extract ( ctx , correlation . UrlValuesCarrier ( state ) )
if strings . HasPrefix ( i . ModalSubmitData ( ) . CustomID , "modal_flush" ) {
sub := i . ModalSubmitData ( ) . Components [ 0 ] . ( * discordgo . ActionsRow ) . Components [ 0 ] . ( * discordgo . TextInput ) . Value
sub = strings . ToLower ( sub )
log . Debug ( ) . Ctx ( ctx ) . Str ( "value" , sub ) . Msg ( "flush modal submitted" )
if sub == "y" || sub == "ye" || sub == "yes" {
err := store . Flush ( ctx , i . Member . User . ID )
msg := "Flushed all claims!"
if err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to flush claims" )
msg = "failed to flush claims from database"
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : msg ,
} ,
} )
if err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to respond to interaction" )
}
return
}
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "Aborted..." ,
} ,
} )
if err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "failed to respond to interaction" )
}
return
}
case discordgo . InteractionMessageComponent :
ctx , cancel := context . WithTimeout ( context . Background ( ) , 5 * time . Second )
defer cancel ( )
state , err := parseCustomIDState ( i . MessageComponentData ( ) . CustomID )
if err != nil {
log . Error ( ) . Ctx ( ctx ) . Err ( err ) . Msg ( "unexpected error occured while parsing state from custom id, returning early." )
return
}
ctx = propagator . Extract ( ctx , correlation . UrlValuesCarrier ( state ) )
switch i . MessageComponentData ( ) . CustomID {
case "schedule-response" :
userId := i . Member . User . ID
log . Info ( ) . Ctx ( ctx ) . Str ( "message_component" , "schedule-response" ) . Str ( "userid" , userId ) . Msg ( "handling message component interaction" )
if err := store . AddAbsence ( ctx , themis . NextMonday ( ) , userId ) ; err != nil {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : "something went wrong recording your absence, check logs for more info." ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
}
return
}
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Content : fmt . Sprintf ( "Looks like <@%s> can't make it after all." , userId ) ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
}
}
}
} )
}
const TABLE_PATTERN = "| %-*s | %-*s | %-*s | %-*s |\n"
func formatClaimsTable ( claims [ ] themis . Claim ) string {
sb := strings . Builder { }
maxLengths := [ ] int { 2 , 6 , 4 , 4 } // id, player, type, name
for _ , c := range claims {
sid := strconv . Itoa ( c . ID )
if len ( sid ) > maxLengths [ 0 ] {
maxLengths [ 0 ] = len ( sid )
}
if len ( c . Player ) > maxLengths [ 1 ] {
maxLengths [ 1 ] = len ( c . Player )
}
// The raw claim value is different from the formatted string
strType := c . Type . String ( )
if len ( strType ) > maxLengths [ 2 ] {
maxLengths [ 2 ] = len ( strType )
}
if len ( c . Name ) > maxLengths [ 3 ] {
maxLengths [ 3 ] = len ( c . Name )
}
}
sb . WriteString ( fmt . Sprintf ( TABLE_PATTERN , maxLengths [ 0 ] , "ID" , maxLengths [ 1 ] , "Player" , maxLengths [ 2 ] , "Type" , maxLengths [ 3 ] , "Name" ) )
sb . WriteString ( fmt . Sprintf ( TABLE_PATTERN , maxLengths [ 0 ] , strings . Repeat ( "-" , maxLengths [ 0 ] ) , maxLengths [ 1 ] , strings . Repeat ( "-" , maxLengths [ 1 ] ) , maxLengths [ 2 ] , strings . Repeat ( "-" , maxLengths [ 2 ] ) , maxLengths [ 3 ] , strings . Repeat ( "-" , maxLengths [ 3 ] ) ) )
for _ , c := range claims {
sb . WriteString ( fmt . Sprintf ( TABLE_PATTERN , maxLengths [ 0 ] , strconv . Itoa ( c . ID ) , maxLengths [ 1 ] , c . Player , maxLengths [ 2 ] , c . Type , maxLengths [ 3 ] , c . Name ) )
}
return sb . String ( )
}
func handleClaimAutocomplete ( ctx context . Context , store * themis . Store , s * discordgo . Session , i * discordgo . InteractionCreate ) {
log . Debug ( ) . Msg ( "getting autocomplete data for claim" )
opts := i . ApplicationCommandData ( ) . Options
claimType , err := themis . ClaimTypeFromString ( opts [ 0 ] . StringValue ( ) )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to parse claim type" )
return
}
availability , err := store . ListAvailability ( ctx , claimType , opts [ 1 ] . StringValue ( ) )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to list availabilities" )
return
}
choices := make ( [ ] * discordgo . ApplicationCommandOptionChoice , 0 , len ( availability ) )
for _ , s := range availability {
choices = append ( choices , & discordgo . ApplicationCommandOptionChoice {
Name : s ,
Value : s ,
} )
}
log . Debug ( ) . Int ( "len" , len ( choices ) ) . Msg ( "found autocomplete suggestions" )
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionApplicationCommandAutocompleteResult ,
Data : & discordgo . InteractionResponseData {
Choices : choices [ : min ( len ( choices ) , 25 ) ] ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
}
}
func serve ( address string ) error {
http . Handle ( "/health" , http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
w . Write ( [ ] byte ( "OK" ) ) //nolint:errcheck // this is expected to always work, 'trust me bro' guaranteed
} ) )
return http . ListenAndServe ( address , nil )
}
func withLogging ( name string , h Handler ) Handler {
return func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
ctx , span := tracer . Start ( ctx , fmt . Sprintf ( "discord_command %s" , name ) )
defer span . End ( )
start := time . Now ( )
logCommandInvocation ( ctx , name , s , i )
err := h ( ctx , s , i )
debugCommandCompletion ( ctx , name , time . Since ( start ) , err , s , i )
return nil
}
}
func logCommandInvocation ( ctx context . Context , name string , s * discordgo . Session , i * discordgo . InteractionCreate ) {
span := trace . SpanFromContext ( ctx )
span . SetAttributes (
attribute . String ( "user_id" , i . Member . User . ID ) ,
attribute . String ( "username" , i . Member . User . Username ) ,
attribute . String ( "command_name" , name ) ,
)
for _ , o := range i . ApplicationCommandData ( ) . Options {
switch o . Type {
case discordgo . ApplicationCommandOptionSubCommand , discordgo . ApplicationCommandOptionSubCommandGroup :
panic ( "unreachable" )
case discordgo . ApplicationCommandOptionString :
span . SetAttributes ( attribute . String ( fmt . Sprintf ( "command_option.%s" , o . Name ) , o . StringValue ( ) ) )
case discordgo . ApplicationCommandOptionInteger :
span . SetAttributes ( attribute . Int64 ( fmt . Sprintf ( "command_option.%s" , o . Name ) , o . IntValue ( ) ) )
case discordgo . ApplicationCommandOptionBoolean :
span . SetAttributes ( attribute . Bool ( fmt . Sprintf ( "command_option.%s" , o . Name ) , o . BoolValue ( ) ) )
default :
span . SetAttributes ( attribute . String ( fmt . Sprintf ( "command_option.%s" , o . Name ) , "unsupported_type" ) )
}
}
log . Info ( ) . Ctx ( ctx ) . Msg ( "command invoked" )
}
func debugCommandCompletion ( ctx context . Context , name string , dur time . Duration , err error , s * discordgo . Session , i * discordgo . InteractionCreate ) {
span := trace . SpanFromContext ( ctx )
if err != nil {
span . SetStatus ( codes . Error , err . Error ( ) )
}
log . Info ( ) . Ctx ( ctx ) . Dur ( "latency_ms" , dur ) . Msg ( "command completed" )
}
func min ( a , b int ) int {
if a < b {
return a
}
return b
}
func parseCustomIDState ( qs string ) ( url . Values , error ) {
parts := strings . Split ( qs , ":" )
if len ( parts ) == 1 {
return make ( url . Values ) , nil
}
v , err := url . ParseQuery ( parts [ 1 ] )
if err != nil {
return nil , err
}
return v , nil
}
func initTracing ( ctx context . Context , db * sql . DB ) error {
res , err := resource . New ( ctx ,
resource . WithHost ( ) ,
resource . WithOS ( ) ,
resource . WithProcess ( ) ,
resource . WithTelemetrySDK ( ) ,
resource . WithAttributes ( semconv . ServiceName ( "themis" ) ) ,
)
ex , err := sqliteexporter . NewSqliteSDKTraceExporterWithDB ( db )
if err != nil {
return fmt . Errorf ( "failed to create span exporter: %w" , err )
}
tp := sdktrace . NewTracerProvider (
sdktrace . WithBatcher ( ex , sdktrace . WithExportTimeout ( time . Second ) ) ,
sdktrace . WithResource ( res ) ,
)
otel . SetTracerProvider ( tp )
tracer = tp . Tracer ( "themis" )
return nil
}