add hemtt support, major refactor

- no longer supports server events
- can now more easily build using hemtt
- extension vastly improved in both structure and functionality
- tested on listen server
- includes schema change
This commit is contained in:
2023-09-20 01:15:13 -07:00
parent f692b94c5c
commit 29228bd192
51 changed files with 5008 additions and 1466 deletions

Binary file not shown.

View File

@@ -0,0 +1,383 @@
package main
/*
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
*/
import "C" // This is required to import the C code
import (
"crypto/md5"
"database/sql"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
"github.com/indig0fox/Arma3-AttendanceTracker/internal/db"
"github.com/indig0fox/Arma3-AttendanceTracker/internal/logger"
"github.com/indig0fox/Arma3-AttendanceTracker/internal/util"
"github.com/indig0fox/a3go/a3interface"
"github.com/indig0fox/a3go/assemblyfinder"
)
const EXTENSION_NAME string = "AttendanceTracker"
const ADDON_NAME string = "AttendanceTracker"
const EXTENSION_VERSION string = "0.9.0.1"
// file paths
const ATTENDANCE_TABLE string = "attendance"
const MISSIONS_TABLE string = "missions"
const WORLDS_TABLE string = "worlds"
var currentMissionID uint = 0
var RVExtensionChannels = map[string]chan string{
":START:": make(chan string),
":MISSION:HASH:": make(chan string),
":GET:SETTINGS:": make(chan string),
}
var RVExtensionArgsChannels = map[string]chan []string{
":LOG:MISSION:": make(chan []string),
":LOG:PRESENCE:": make(chan []string),
}
var (
modulePath string
modulePathDir string
initSuccess bool // default false
)
// configure log output
func init() {
a3interface.SetVersion(EXTENSION_VERSION)
a3interface.RegisterRvExtensionChannels(RVExtensionChannels)
a3interface.RegisterRvExtensionArgsChannels(RVExtensionArgsChannels)
go func() {
var err error
modulePath = assemblyfinder.GetModulePath()
// get absolute path of module path
modulePathAbs, err := filepath.Abs(modulePath)
if err != nil {
panic(err)
}
modulePathDir = filepath.Dir(modulePathAbs)
result, configErr := util.LoadConfig(modulePathDir)
logger.InitLoggers(&logger.LoggerOptionsType{
Path: filepath.Join(
modulePathDir,
fmt.Sprintf(
"%s_v%s.log",
EXTENSION_NAME,
EXTENSION_VERSION,
)),
AddonName: ADDON_NAME,
ExtensionName: EXTENSION_NAME,
Debug: util.ConfigJSON.GetBool("armaConfig.debug"),
Trace: util.ConfigJSON.GetBool("armaConfig.traceLogToFile"),
})
if configErr != nil {
logger.Log.Error().Err(configErr).Msgf(`Error loading config`)
return
} else {
logger.Log.Info().Msgf(result)
}
logger.ArmaOnly.Info().Msgf(`%s v%s started`, EXTENSION_NAME, "0.0.0")
logger.ArmaOnly.Info().Msgf(`Log path: %s`, logger.ActiveOptions.Path)
db.SetConfig(db.ConfigStruct{
MySQLHost: util.ConfigJSON.GetString("sqlConfig.mysqlHost"),
MySQLPort: util.ConfigJSON.GetInt("sqlConfig.mysqlPort"),
MySQLUser: util.ConfigJSON.GetString("sqlConfig.mysqlUser"),
MySQLPassword: util.ConfigJSON.GetString("sqlConfig.mysqlPassword"),
MySQLDatabase: util.ConfigJSON.GetString("sqlConfig.mysqlDatabase"),
})
err = db.Connect()
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error connecting to database`)
return
}
err = db.Client().Set("gorm:table_options", "ENGINE=InnoDB").AutoMigrate(
&World{},
&Mission{},
&Session{},
)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error migrating database schema`)
}
startA3CallHandlers()
initSuccess = true
logger.RotateLogs()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":READY:",
)
go finalizeUnendedSessions()
}()
}
func startA3CallHandlers() error {
go func() {
for {
select {
case <-RVExtensionChannels[":START:"]:
logger.Log.Trace().Msgf(`RVExtension :START: requested`)
if !initSuccess {
logger.Log.Warn().Msgf(`Received another :START: command before init was complete, ignoring.`)
continue
} else {
logger.RotateLogs()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":READY:",
)
}
case <-RVExtensionChannels[":MISSION:HASH:"]:
logger.Log.Trace().Msgf(`RVExtension :MISSION:HASH: requested`)
timestamp, hash := getMissionHash()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":MISSION:HASH:",
timestamp,
hash,
)
case <-RVExtensionChannels[":GET:SETTINGS:"]:
logger.Log.Trace().Msg(`Settings requested`)
armaConfig, err := util.ConfigArmaFormat()
if err != nil {
logger.Log.Error().Err(err).Msg(`Error when marshaling arma config`)
continue
}
logger.Log.Trace().Str("armaConfig", armaConfig).Send()
a3interface.WriteArmaCallback(
EXTENSION_NAME,
":GET:SETTINGS:",
armaConfig,
)
case v := <-RVExtensionArgsChannels[":LOG:MISSION:"]:
go func(data []string) {
writeWorldInfo(v[1])
writeMission(v[0])
}(v)
case v := <-RVExtensionArgsChannels[":LOG:PRESENCE:"]:
go writeAttendance(v[0])
}
}
}()
return nil
}
// getMissionHash will return the current time in UTC and an md5 hash of that time
func getMissionHash() (sqlTime, hashString string) {
// get md5 hash of string
// https://stackoverflow.com/questions/2377881/how-to-get-a-md5-hash-from-a-string-in-golang
nowTime := time.Now().UTC()
// mysql format
sqlTime = nowTime.Format("2006-01-02 15:04:05")
hash := md5.Sum([]byte(sqlTime))
hashString = fmt.Sprintf(`%x`, hash)
return
}
// finalizeUnendedSessions will fill in the disconnect time for any sessions that have not been ended with a time 1 update interval after the join time
func finalizeUnendedSessions() {
logger.Log.Debug().Msg("Filling missing disconnect events due to server restart.")
// get all events with null DisconnectTime & set DisconnectTime
var events []*Session
db.Client().Model(&Session{}).
Where("join_time_utc IS NOT NULL AND disconnect_time_utc IS NULL").
Find(&events)
for _, event := range events {
// if difference between JoinTime and current time is greater than threshold, set to threshold
if event.JoinTimeUTC.Time.Before(
time.Now().Add(-1 * util.ConfigJSON.GetDuration("armaConfig.dbUpdateInterval")),
) {
// if more than the update interval has passed, set disconnect time as 1 interval after join
event.DisconnectTimeUTC = sql.NullTime{
Time: event.JoinTimeUTC.Time.Add(util.ConfigJSON.GetDuration("armaConfig.dbUpdateInterval")),
Valid: true,
}
} else {
// otherwise, set disconnect time as now
event.DisconnectTimeUTC = sql.NullTime{
Time: time.Now(),
Valid: true,
}
}
db.Client().Save(&event)
if db.Client().Error != nil {
logger.Log.Error().Err(db.Client().Error).Msgf(`Error when updating disconnect time for event %d`, event.ID)
}
}
// log how many
logger.Log.Info().Msgf(`Filled disconnect time of %d events.`, len(events))
}
func writeWorldInfo(worldInfo string) {
// worldInfo is json, parse it
var wi World
fixedString := unescapeArmaQuotes(worldInfo)
err := json.Unmarshal([]byte(fixedString), &wi)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling world info`)
return
}
// write world if not exist
var dbWorld World
db.Client().Where("world_name = ?", wi.WorldName).First(&dbWorld)
if dbWorld.ID == 0 {
db.Client().Create(&wi)
if db.Client().Error != nil {
logger.Log.Error().Err(db.Client().Error).Msgf(`Error when creating world`)
return
}
logger.Log.Info().Msgf(`World %s created.`, wi.WorldName)
} else {
// don't do anything if exists
logger.Log.Debug().Msgf(`World %s exists with ID %d.`, wi.WorldName, dbWorld.ID)
}
}
func writeMission(missionJSON string) {
var err error
// writeLog(functionName, fmt.Sprintf(`["%s", "DEBUG"]`, Mission))
// Mission is json, parse it
var mi Mission
fixedString := fixEscapeQuotes(trimQuotes(missionJSON))
err = json.Unmarshal([]byte(fixedString), &mi)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling mission`)
return
}
// get world from WorldName
var dbWorld World
db.Client().Where("world_name = ?", mi.WorldName).First(&dbWorld)
if dbWorld.ID == 0 {
logger.Log.Error().Msgf(`World %s not found.`, mi.WorldName)
return
}
mi.WorldID = dbWorld.ID
// write mission to database
db.Client().Create(&mi)
if db.Client().Error != nil {
logger.Log.Error().Err(db.Client().Error).Msgf(`Error when creating mission`)
return
}
logger.Log.Info().Msgf(`Mission %s created with ID %d`, mi.MissionName, mi.ID)
currentMissionID = mi.ID
}
func writeAttendance(data string) {
var err error
// data is json, parse it
stringjson := unescapeArmaQuotes(data)
var event Session
err = json.Unmarshal([]byte(stringjson), &event)
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when unmarshalling attendance`)
return
}
// search existing event
var dbEvent Session
db.Client().
Where(
"player_uid = ? AND mission_hash = ?",
event.PlayerUID,
event.MissionHash,
).
Order("join_time_utc desc").
First(&dbEvent)
if dbEvent.ID != 0 {
// update disconnect time
dbEvent.DisconnectTimeUTC = sql.NullTime{
Time: time.Now(),
Valid: true,
}
err = db.Client().Save(&dbEvent).Error
if err != nil {
logger.Log.Error().Err(err).
Msgf(`Error when updating disconnect time for event %d`, dbEvent.ID)
return
}
logger.Log.Debug().Msgf(`Attendance updated for %s (%s)`,
dbEvent.ProfileName,
dbEvent.PlayerUID,
)
} else {
// insert new row
event.JoinTimeUTC = sql.NullTime{
Time: time.Now(),
Valid: true,
}
if currentMissionID == 0 {
logger.Log.Error().Msgf(`Current mission ID not set, cannot create attendance event`)
return
}
event.MissionID = currentMissionID
err = db.Client().Create(&event).Error
if err != nil {
logger.Log.Error().Err(err).Msgf(`Error when creating attendance event`)
return
}
logger.Log.Debug().Msgf(`Attendance created for %s (%s)`,
event.ProfileName,
event.PlayerUID,
)
}
}
func getTimestamp() string {
// get the current unix timestamp in nanoseconds
// return time.Now().Local().Unix()
return time.Now().Format("2006-01-02 15:04:05")
}
func trimQuotes(s string) string {
// trim the start and end quotes from a string
return strings.Trim(s, `"`)
}
func fixEscapeQuotes(s string) string {
// fix the escape quotes in a string
return strings.Replace(s, `""`, `"`, -1)
}
func unescapeArmaQuotes(s string) string {
return fixEscapeQuotes(trimQuotes(s))
}
func main() {
// loadConfig()
// fmt.Println("Running DB connect/migrate to build schema...")
// err := connectDB()
// if err != nil {
// fmt.Println(err)
// } else {
// fmt.Println("DB connect/migrate complete!")
// }
// fmt.Scanln()
}

View File

@@ -0,0 +1,99 @@
package main
import (
"database/sql"
"encoding/json"
"time"
"gorm.io/gorm"
)
type World struct {
gorm.Model
Author string `json:"author"`
WorkshopID string `json:"workshopID"`
DisplayName string `json:"displayName"`
WorldName string `json:"worldName"`
WorldNameOriginal string `json:"worldNameOriginal"`
WorldSize float32 `json:"worldSize"`
Latitude float32 `json:"latitude"`
Longitude float32 `json:"longitude"`
Missions []Mission
}
type Mission struct {
gorm.Model
MissionName string `json:"missionName"`
BriefingName string `json:"briefingName"`
MissionNameSource string `json:"missionNameSource"`
OnLoadName string `json:"onLoadName"`
Author string `json:"author"`
ServerName string `json:"serverName"`
ServerProfile string `json:"serverProfile"`
MissionStart time.Time `json:"missionStart" gorm:"index"`
MissionHash string `json:"missionHash" gorm:"index"`
WorldName string `json:"worldName" gorm:"-"`
WorldID uint
World World `gorm:"foreignkey:WorldID"`
Attendees []Session
}
func (m *Mission) UnmarshalJSON(data []byte) error {
type Alias Mission
aux := &struct {
*Alias
MissionStart string `json:"missionStart"`
}{Alias: (*Alias)(m)}
err := json.Unmarshal(data, &aux)
if err != nil {
return err
}
m.MissionStart, err = time.Parse(time.RFC3339, aux.MissionStart)
if err != nil {
return err
}
return nil
}
type Session struct {
ID uint `json:"id" gorm:"primaryKey"`
PlayerUID string `json:"playerUID" gorm:"index;primaryKey"`
MissionHash string `json:"missionHash"`
PlayerId string `json:"playerId"`
JoinTimeUTC sql.NullTime `json:"joinTimeUTC" gorm:"index"`
DisconnectTimeUTC sql.NullTime `json:"disconnectTimeUTC" gorm:"index"`
ProfileName string `json:"profileName"`
SteamName string `json:"steamName"`
IsJIP bool `json:"isJIP" gorm:"column:is_jip"`
RoleDescription string `json:"roleDescription"`
MissionID uint
Mission Mission `gorm:"foreignkey:MissionID"`
}
func (s *Session) UnmarshalJSON(data []byte) error {
type Alias Session
aux := &struct {
*Alias
JoinTimeUTC string `json:"joinTimeUTC"`
DisconnectTimeUTC string `json:"disconnectTimeUTC"`
}{Alias: (*Alias)(s)}
err := json.Unmarshal(data, &aux)
if err != nil {
return err
}
if aux.JoinTimeUTC != "" {
s.JoinTimeUTC.Time, err = time.Parse(time.RFC3339, aux.JoinTimeUTC)
if err != nil {
return err
}
s.JoinTimeUTC.Valid = true
}
if aux.DisconnectTimeUTC != "" {
s.DisconnectTimeUTC.Time, err = time.Parse(time.RFC3339, aux.DisconnectTimeUTC)
if err != nil {
return err
}
s.DisconnectTimeUTC.Valid = true
}
return nil
}

View File

@@ -0,0 +1,35 @@
module github.com/indig0fox/Arma3-AttendanceTracker
go 1.20
require (
github.com/go-sql-driver/mysql v1.7.1
github.com/indig0fox/a3go v0.2.0
github.com/rs/zerolog v1.30.0
github.com/spf13/viper v1.16.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.1
gorm.io/gorm v1.25.4
)
require (
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/peterstace/simplefeatures v0.44.0 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
package db
import (
"fmt"
"github.com/indig0fox/a3go/a3interface"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
var db *gorm.DB
var config ConfigStruct
type ConfigStruct struct {
MySQLHost string `json:"mysqlHost"`
MySQLPort int `json:"mysqlPort"`
MySQLUser string `json:"mysqlUser"`
MySQLPassword string `json:"mysqlPassword"`
MySQLDatabase string `json:"mysqlDatabase"`
}
func SetConfig(c ConfigStruct) {
config = c
}
func Client() *gorm.DB {
return db
}
func Connect() error {
// connect to database
var err error
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True",
config.MySQLUser,
config.MySQLPassword,
config.MySQLHost,
config.MySQLPort,
config.MySQLDatabase,
)
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return err
}
// try ping
sqlDB, err := db.DB()
if err != nil {
return err
}
err = sqlDB.Ping()
if err != nil {
return err
}
a3interface.WriteArmaCallback("connectDB", `["Database connected", "INFO"]`)
a3interface.WriteArmaCallback("connectDB", `["SUCCESS", "INFO"]`)
return nil
}

View File

@@ -0,0 +1,129 @@
package logger
import (
"fmt"
"strings"
"time"
"github.com/indig0fox/a3go/a3interface"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
var ll *lumberjack.Logger
var armaWriter *armaIoWriter
var Log, FileOnly, ArmaOnly zerolog.Logger
var ActiveOptions *LoggerOptionsType = &LoggerOptionsType{}
type LoggerOptionsType struct {
// LogPath is the path to the log file
Path string
// LogAddonName is the name of the addon that will be used to send log messages to arma
AddonName string
// LogExtensionName is the name of the extension that will be used to send log messages to arma
ExtensionName string
// ExtensionVersion is the version of this extension
ExtensionVersion string
// LogDebug determines if we should send Debug level messages to file & arma
Debug bool
// LogTrace is used to determine if file should receive trace level, regardless of debug
Trace bool
}
func RotateLogs() {
ll.Rotate()
}
// ArmaIoWriter is a custom type that implements the io.Writer interface and sends the output to Arma with the "log" callback
type armaIoWriter struct{}
func (w *armaIoWriter) Write(p []byte) (n int, err error) {
// write to arma log
a3interface.WriteArmaCallback(ActiveOptions.ExtensionName, ":LOG:", string(p))
return len(p), nil
}
// console writer
func InitLoggers(o *LoggerOptionsType) {
ActiveOptions = o
// create a new lumberjack file logger (adds log rotation and compression)
ll = &lumberjack.Logger{
Filename: ActiveOptions.Path,
MaxSize: 1,
MaxBackups: 5,
MaxAge: 14,
Compress: true,
LocalTime: true,
}
// create a new io writer using the a3go callback function
// this will be used to write to the arma log
armaWriter = new(armaIoWriter)
// create format functions for RPT log messages
armaLogFormatLevel := func(i interface{}) string {
return strings.ToUpper(
fmt.Sprintf(
"(%s)",
i,
))
}
armaLogFormatTimestamp := func(i interface{}) string {
return ""
}
FileOnly = zerolog.New(zerolog.ConsoleWriter{
Out: ll,
TimeFormat: time.RFC3339,
NoColor: true,
}).With().Timestamp().Caller().Logger()
if ActiveOptions.Trace {
FileOnly = FileOnly.Level(zerolog.TraceLevel)
} else if ActiveOptions.Debug {
FileOnly = FileOnly.Level(zerolog.DebugLevel)
} else {
FileOnly = FileOnly.Level(zerolog.InfoLevel)
}
ArmaOnly = zerolog.New(zerolog.ConsoleWriter{
Out: armaWriter,
TimeFormat: "",
NoColor: true,
FormatLevel: armaLogFormatLevel,
FormatTimestamp: armaLogFormatTimestamp,
}).With().Str("extension_version", ActiveOptions.ExtensionVersion).Logger()
if ActiveOptions.Debug {
ArmaOnly = ArmaOnly.Level(zerolog.DebugLevel)
} else {
ArmaOnly = ArmaOnly.Level(zerolog.InfoLevel)
}
// create something that can send the same message to both loggers
// this is used to send messages to the arma log
// and the file log
Log = zerolog.New(zerolog.MultiLevelWriter(
zerolog.ConsoleWriter{
Out: ll,
TimeFormat: time.RFC3339,
NoColor: true,
},
zerolog.ConsoleWriter{
Out: armaWriter,
TimeFormat: "",
NoColor: true,
FormatTimestamp: armaLogFormatTimestamp,
FormatLevel: armaLogFormatLevel,
},
)).With().Timestamp().Caller().Logger()
if ActiveOptions.Debug {
Log = Log.Level(zerolog.DebugLevel)
} else {
Log = Log.Level(zerolog.InfoLevel)
}
}

View File

@@ -0,0 +1,65 @@
package util
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/viper"
)
var ConfigJSON = viper.New()
func LoadConfig(modulePathDir string) (string, error) {
ConfigJSON.SetConfigName("AttendanceTracker.config")
ConfigJSON.SetConfigType("json")
ConfigJSON.AddConfigPath(".")
ConfigJSON.AddConfigPath(modulePathDir)
ConfigJSON.SetDefault("armaConfig.dbUpdateInterval", "90s")
ConfigJSON.SetDefault("armaConfig.debug", true)
ConfigJSON.SetDefault("sqlConfig", map[string]interface{}{
"mysqlHost": "localhost",
"mysqlPort": 3306,
"mysqlUser": "root",
"mysqlPassword": "password",
"mysqlDatabase": "a3attendance",
})
ConfigJSON.SetDefault("armaConfig", map[string]interface{}{
"debug": true,
"traceLogToFile": false,
"dbUpdateIntervalS": 60,
})
wd, err := os.Getwd()
if err != nil {
return "", err
}
if err := ConfigJSON.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found; ignore error if desired
return "", fmt.Errorf(
"config file not found, using defaults! searched in %s",
[]string{
ConfigJSON.ConfigFileUsed(),
modulePathDir,
wd,
},
)
} else {
// Config file was found but another error was produced
return "", err
}
}
return "Config loaded successfully!", nil
}
func ConfigArmaFormat() (string, error) {
armaConfig := ConfigJSON.GetStringMap("armaConfig")
bytes, err := json.Marshal(armaConfig)
if err != nil {
return "", err
}
return string(bytes), nil
}