@ -7,8 +7,10 @@ import (
"flag"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"runtime/pprof"
"sort"
"strconv"
"strings"
@ -17,9 +19,21 @@ import (
"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/flydetector"
"go.wperron.io/sqliteexporter"
"go.wperron.io/themis"
"go.wperron.io/themis/correlation"
)
const (
@ -27,19 +41,52 @@ const (
)
var (
dbFile = flag . String ( "db" , "" , "SQlite database file path" )
dbFile = flag . String ( "db" , "" , "SQlite database file path." )
debug = flag . Bool ( "debug" , false , "Set log level to DEBUG." )
cpuProfile = flag . String ( "cpuprofile" , "" , "Output file for pprof profiling." )
store * themis . Store
tracer trace . Tracer
propagator = propagation . TraceContext { }
)
type Handler func ( s * discordgo . Session , i * discordgo . InteractionCreate )
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
if * cpuProfile != "" && os . Getenv ( "ENV" ) != "production" {
log . Info ( ) . Str ( "file" , * cpuProfile ) . Msg ( "starting profiler" )
f , err := os . Create ( * cpuProfile )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to create cpu profile output file" )
}
_ = pprof . StartCPUProfile ( f )
defer pprof . StopCPUProfile ( )
}
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" )
@ -47,13 +94,23 @@ func main() {
connString := fmt . Sprintf ( CONN_STRING_PATTERN , * dbFile )
store , err = themis . NewStore ( connString )
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 , log . Logger )
if err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to initialize database" )
}
defer store . Close ( )
notifChan := make ( chan struct { } )
if err := initTracing ( ctx , db ) ; err != nil {
log . Fatal ( ) . Err ( err ) . Msg ( "failed to initialize tracing" )
}
notifChan := make ( chan context . Context )
notifier := themis . NewNotifier ( notifChan )
go notifier . Start ( ctx )
@ -169,6 +226,12 @@ func main() {
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" ,
@ -184,10 +247,9 @@ func main() {
} ,
}
handlers := map [ string ] Handler {
"info" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
uptime , err := themis . Uptime ( )
"info" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
uptime , err := themis . Uptime ( ctx )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to get server uptime" )
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
@ -195,13 +257,13 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to count claims" )
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
@ -209,24 +271,45 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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" , uptime , claimCount , uniquePlayers ) ,
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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"list-claims" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"list-claims" : func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
claims , err := store . ListClaims ( ctx )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to list claims" )
err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
@ -234,8 +317,9 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return fmt . Errorf ( "failed to list claims: %w" , err )
}
sb := strings . Builder { }
@ -251,13 +335,16 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"claim" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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" )
// TODO(wperron) fix this
handleClaimAutocomplete ( ctx , store , s , i )
return
return nil
}
opts := i . ApplicationCommandData ( ) . Options
@ -265,28 +352,29 @@ func main() {
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "`claim-type` and `name` are mandatory parameters" ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return
return nil
}
claimType , err := themis . ClaimTypeFromString ( opts [ 0 ] . StringValue ( ) )
if err != nil {
log . Error ( ) . Err ( err ) . Str ( "claim_type" , opts [ 0 ] . StringValue ( ) ) . Msg ( "failed to parse claim" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "You can only take claims of types `area`, `region` or `trade`" ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return
return fmt . Errorf ( "failed to parse claim: %w" , err )
}
name := opts [ 1 ] . StringValue ( )
@ -299,6 +387,7 @@ func main() {
_ , err = store . Claim ( ctx , userId , player , name , claimType )
if err != nil {
// TODO(wperron) fix this error cast
conflict , ok := err . ( themis . ErrConflict )
if ok {
sb := strings . Builder { }
@ -311,26 +400,27 @@ func main() {
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : sb . String ( ) ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
return fmt . Errorf ( "failed to respond to interaction" )
}
return
return nil
}
log . Error ( ) . Err ( err ) . Msg ( "failed to acquire claim" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "failed to acquire claim :(" ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return
return fmt . Errorf ( "failed to acquire claim: %w" , err )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
@ -340,14 +430,14 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"describe-claim" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to describe claim" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
@ -355,14 +445,15 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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 ) )
sb . WriteString ( fmt . Sprintf ( " - %s\n", p ) )
}
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
@ -372,19 +463,19 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"delete-claim" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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"
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 )
}
log . Error ( ) . Err ( err ) . Msg ( "failed to delete claim" )
err = s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
@ -392,29 +483,48 @@ func main() {
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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 {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "Got it chief." ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"flush" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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 : "modals_flush_" + i . Interaction . Member . User . ID ,
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]" ,
@ -430,13 +540,14 @@ func main() {
} ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
"query" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to open read-only copy of datab se")
return fmt . Errorf ( "failed to open read-only copy of datab a se: %w ", err )
}
q := i . ApplicationCommandData ( ) . Options [ 0 ] . StringValue ( )
@ -444,13 +555,12 @@ func main() {
defer cancelDeadline ( )
rows , err := roDB . QueryContext ( deadlined , q )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to exec user-provided query" )
return
return fmt . Errorf ( "faied to exec user-provided query: %w" , err )
}
fmtd , err := themis . FormatRows ( rows)
fmtd , err := themis . FormatRows ( ctx, rows)
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to format rows" )
return fmt . Errorf ( "failed to format rows: %w" , err )
}
// 2000 is a magic number here, it's the character limit for a discord
@ -464,22 +574,24 @@ func main() {
Content : table ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
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 ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
"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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to get schedule" )
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 {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return fmt . Errorf ( "failed to get schedule: %w" , err )
}
sb := strings . Builder { }
@ -508,10 +620,27 @@ func main() {
Content : sb . String ( ) ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction" )
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 ( ctx )
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "Done." ,
} ,
"absent" : func ( s * discordgo . Session , i * discordgo . InteractionCreate ) {
} ) ; 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 )
@ -524,34 +653,40 @@ func main() {
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionResponseChannelMessageWithSource ,
Data : & discordgo . InteractionResponseData {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "failed to parse provided date, make sure to use the YYYY-MM-DD format." ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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 {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "The date must be some time in the future." ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
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 {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "The date you provided is not a Monday." ,
} ,
} ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
// TODO(wperron) suggest Mondays before and after?
return nil
}
userId := i . Member . User . ID
@ -562,19 +697,22 @@ func main() {
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 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 {
Flags : discordgo . MessageFlagsEphemeral ,
Content : "Okey dokey." ,
} ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to respond to interaction ")
return fmt . Errorf ( "failed to respond to interaction : %w ", err )
}
return nil
} ,
}
@ -586,25 +724,21 @@ func main() {
}
defer discord . Close ( )
registeredCommands := make ( [ ] * discordgo . ApplicationCommand , len ( commands ) )
for i , c := range commands {
command , err := discord . ApplicationCommandCreate ( appId , guildId , c )
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 command" )
}
registeredCommands [ i ] = command
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" )
log . Info ( ) . Int ( "count" , len ( registeredCommands ) ) . Msg ( "registered commands" )
go notifier . NotifyFunc ( ctx , func ( ctx context . Context ) {
ctx , cancel := context . WithTimeout ( ctx , 5 * time . Second )
defer cancel ( )
go func ( ) {
if err := serve ( ":8080" ) ; err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to serve requests" )
}
cancel ( )
} ( )
ctx , span := tracer . Start ( ctx , "weekly_notification" )
defer span . End ( )
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" )
@ -617,13 +751,32 @@ func main() {
}
var msg string
var components [ ] discordgo . MessageComponent
if len ( absentees ) == 0 {
msg = "Everybody can make it next Monday, see you then!"
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 ) )
msg = fmt . Sprintf ( "%s can't make it next Monday. 🙁 ", themis . FormatStringSlice ( absentees ) )
}
_ , err = discord . ChannelMessageSend ( channelId , msg )
_ , err = discord . ChannelMessageSendComplex ( channelId , & discordgo . MessageSend {
Content : msg ,
Components : components ,
} )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to send scheduled notification" )
}
@ -631,18 +784,11 @@ func main() {
<- ctx . Done ( )
log . Info ( ) . Msg ( "context cancelled, exiting" )
for _ , c := range registeredCommands {
err = discord . ApplicationCommandDelete ( appId , guildId , c . ID )
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to deregister commands" )
}
}
log . Info ( ) . Msg ( "deregistered commands, exiting" )
os . Exit ( 0 )
store . Close ( )
}
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 ) {
@ -667,18 +813,34 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
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 {
h ( s , i )
_ = inSpan( i . ApplicationCommandData ( ) . Name , wit hLogging ( i . ApplicationCommandData ( ) . Name , h ) ) ( ctx , s , i )
}
case discordgo . InteractionModalSubmit :
if strings . HasPrefix ( i . ModalSubmitData ( ) . CustomID , "modals_flush_" ) {
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 ( context . Background ( ) )
err := store . Flush ( c tx, i . Member . User . ID )
msg := "Flushed all claims!"
if err != nil {
log . Error ( ) . Err ( err ) . Msg ( "failed to flush claims" )
log . Error ( ) . Ctx( ctx ) . Err( err ) . Msg ( "failed to flush claims" )
msg = "failed to flush claims from database"
}
@ -689,7 +851,7 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
} ,
} )
if err != nil {
log . Error ( ) . Err( err ) . Msg ( "failed to respond to interaction" )
log . Error ( ) . Ctx( ctx ) . Err( err ) . Msg ( "failed to respond to interaction" )
}
return
}
@ -701,10 +863,49 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
} ,
} )
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" )
}
}
}
} )
}
@ -741,6 +942,7 @@ func formatClaimsTable(claims []themis.Claim) 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 {
@ -762,6 +964,8 @@ func handleClaimAutocomplete(ctx context.Context, store *themis.Store, s *discor
} )
}
log . Debug ( ) . Int ( "len" , len ( choices ) ) . Msg ( "found autocomplete suggestions" )
if err := s . InteractionRespond ( i . Interaction , & discordgo . InteractionResponse {
Type : discordgo . InteractionApplicationCommandAutocompleteResult ,
Data : & discordgo . InteractionResponseData {
@ -780,9 +984,108 @@ func serve(address string) error {
return http . ListenAndServe ( address , nil )
}
func inSpan ( 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 ( )
return h ( ctx , s , i )
}
}
func withLogging ( name string , h Handler ) Handler {
return func ( ctx context . Context , s * discordgo . Session , i * discordgo . InteractionCreate ) error {
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 {
fd , _ := flydetector . NewDetector ( ) . Detect ( ctx ) // this can't error
rd , err := resource . New ( ctx ,
resource . WithHost ( ) ,
resource . WithOS ( ) ,
resource . WithProcess ( ) ,
resource . WithTelemetrySDK ( ) ,
resource . WithAttributes ( semconv . ServiceName ( "themis" ) ) ,
)
if err != nil {
return fmt . Errorf ( "failed to create resource: %w" , err )
}
res , err := resource . Merge ( fd , rd )
if err != nil {
return fmt . Errorf ( "failed to merge resources: %w" , err )
}
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
}