mirror of
https://github.com/indig0fox/Arma3-AttendanceTracker.git/
synced 2025-12-08 09:51:47 -06:00
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:
BIN
extension/AttendanceTracker/cmd.exe
Normal file
BIN
extension/AttendanceTracker/cmd.exe
Normal file
Binary file not shown.
383
extension/AttendanceTracker/cmd/main.go
Normal file
383
extension/AttendanceTracker/cmd/main.go
Normal 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()
|
||||
}
|
||||
99
extension/AttendanceTracker/cmd/types.go
Normal file
99
extension/AttendanceTracker/cmd/types.go
Normal 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
|
||||
}
|
||||
35
extension/AttendanceTracker/go.mod
Normal file
35
extension/AttendanceTracker/go.mod
Normal 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
|
||||
)
|
||||
1785
extension/AttendanceTracker/go.sum
Normal file
1785
extension/AttendanceTracker/go.sum
Normal file
File diff suppressed because it is too large
Load Diff
62
extension/AttendanceTracker/internal/db/db.go
Normal file
62
extension/AttendanceTracker/internal/db/db.go
Normal 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
|
||||
}
|
||||
129
extension/AttendanceTracker/internal/logger/logger.go
Normal file
129
extension/AttendanceTracker/internal/logger/logger.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
65
extension/AttendanceTracker/internal/util/config.go
Normal file
65
extension/AttendanceTracker/internal/util/config.go
Normal 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
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "extensionCallback.h"
|
||||
|
||||
extern void goRVExtension(char *output, size_t outputSize, char *input);
|
||||
extern void goRVExtensionVersion(char *output, size_t outputSize);
|
||||
extern void goRVExtensionArgs(char *output, size_t outputSize, char *input, char **argv, int argc);
|
||||
extern void goRVExtensionRegisterCallback(extensionCallback fnc);
|
||||
|
||||
#ifdef WIN64
|
||||
__declspec(dllexport) void RVExtension(char *output, size_t outputSize, char *input)
|
||||
{
|
||||
goRVExtension(output, outputSize, input);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void RVExtensionVersion(char *output, size_t outputSize)
|
||||
{
|
||||
goRVExtensionVersion(output, outputSize);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void RVExtensionArgs(char *output, size_t outputSize, char *input, char **argv, int argc)
|
||||
{
|
||||
goRVExtensionArgs(output, outputSize, input, argv, argc);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void RVExtensionRegisterCallback(extensionCallback fnc)
|
||||
{
|
||||
goRVExtensionRegisterCallback(fnc);
|
||||
}
|
||||
#else
|
||||
__declspec(dllexport) void __stdcall _RVExtension(char *output, size_t outputSize, char *input)
|
||||
{
|
||||
goRVExtension(output, outputSize, input);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void __stdcall _RVExtensionVersion(char *output, size_t outputSize)
|
||||
{
|
||||
goRVExtensionVersion(output, outputSize);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void __stdcall _RVExtensionArgs(char *output, size_t outputSize, char *input, char **argv, int argc)
|
||||
{
|
||||
goRVExtensionArgs(output, outputSize, input, argv, argc);
|
||||
}
|
||||
|
||||
__declspec(dllexport) void __stdcall _RVExtensionRegisterCallback(extensionCallback fnc)
|
||||
{
|
||||
goRVExtensionRegisterCallback(fnc);
|
||||
}
|
||||
#endif
|
||||
// do this for all the other exported functions
|
||||
|
||||
// dll entrypoint
|
||||
// Path: RVExtension.c
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
|
||||
{
|
||||
return TRUE;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
$ENV:GOARCH = "amd64"
|
||||
$ENV:CGO_ENABLED = 1
|
||||
|
||||
go1.16.4 build -o ../@AttendanceTracker/AttendanceTracker_x64.dll -buildmode=c-shared .
|
||||
|
||||
go1.16.4 build -o buildDb.exe .
|
||||
Binary file not shown.
@@ -1,11 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef int (*extensionCallback)(char const *name, char const *function, char const *data);
|
||||
|
||||
/* https://golang.org/cmd/cgo/#hdr-C_references_to_Go */
|
||||
static inline int runExtensionCallback(extensionCallback fnc, char const *name, char const *function, char const *data)
|
||||
{
|
||||
return fnc(name, function, data);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
module main.go
|
||||
|
||||
go 1.16
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.7.0
|
||||
gorm.io/driver/mysql v1.5.1
|
||||
gorm.io/gorm v1.25.2
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
gorm.io/driver/mysql v1.5.1 h1:WUEH5VF9obL/lTtzjmML/5e6VfFR/788coz2uaVCAZw=
|
||||
gorm.io/driver/mysql v1.5.1/go.mod h1:Jo3Xu7mMhCyj8dlrb3WoCaRd1FhsVh+yMXb1jUInf5o=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho=
|
||||
gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
@@ -1,742 +0,0 @@
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "extensionCallback.h"
|
||||
*/
|
||||
import "C" // This is required to import the C code
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var EXTENSION_VERSION string = "0.0.1"
|
||||
var extensionCallbackFnc C.extensionCallback
|
||||
|
||||
// file paths
|
||||
var ADDON_FOLDER string = getDir() + "\\@AttendanceTracker"
|
||||
var LOG_FILE string = ADDON_FOLDER + "\\attendanceTracker.log"
|
||||
var CONFIG_FILE string = ADDON_FOLDER + "\\config.json"
|
||||
var SERVER_TIME_FILE string = ADDON_FOLDER + "\\lastServerTime.txt"
|
||||
|
||||
var ATTENDANCE_TABLE string = "attendance"
|
||||
var MISSIONS_TABLE string = "missions"
|
||||
var WORLDS_TABLE string = "worlds"
|
||||
|
||||
var LAST_SERVER_TIME uint64 = 0
|
||||
|
||||
// ! TODO make a hash to save key:netId from A3 value:rowId from join event
|
||||
|
||||
var Config AttendanceTrackerConfig
|
||||
var ATConfig ATSQLConfig
|
||||
var A3Config ArmaConfig
|
||||
|
||||
type ArmaConfig struct {
|
||||
DBUpdateIntervalSeconds int `json:"dbUpdateIntervalSeconds"`
|
||||
Debug bool `json:"debug"`
|
||||
ServerEventFillNullMinutes int `json:"serverEventFillNullMinutes"`
|
||||
MissionEventFillNullMinutes int `json:"missionEventFillNullMinutes"`
|
||||
}
|
||||
|
||||
type ATSQLConfig struct {
|
||||
MySQLHost string `json:"mysqlHost"`
|
||||
MySQLPort int `json:"mysqlPort"`
|
||||
MySQLUser string `json:"mysqlUser"`
|
||||
MySQLPassword string `json:"mysqlPassword"`
|
||||
MySQLDatabase string `json:"mysqlDatabase"`
|
||||
}
|
||||
|
||||
type AttendanceTrackerConfig struct {
|
||||
ArmaConfig ArmaConfig `json:"armaConfig"`
|
||||
SQLConfig ATSQLConfig `json:"sqlConfig"`
|
||||
}
|
||||
|
||||
// database connection
|
||||
var db *gorm.DB
|
||||
|
||||
// configure log output
|
||||
func init() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
// log to file
|
||||
f, err := os.OpenFile(LOG_FILE, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
|
||||
if err != nil {
|
||||
log.Fatalf("error opening file: %v", err)
|
||||
}
|
||||
// log to console as well
|
||||
// log.SetOutput(io.MultiWriter(f, os.Stdout))
|
||||
// log only to file
|
||||
log.SetOutput(f)
|
||||
}
|
||||
|
||||
func version() {
|
||||
functionName := "version"
|
||||
writeLog(functionName, fmt.Sprintf(`["AttendanceTracker Extension Version:%s", "INFO"]`, EXTENSION_VERSION))
|
||||
}
|
||||
|
||||
func getDir() string {
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
writeLog("getDir", fmt.Sprintf(`["Error getting working directory: %v", "ERROR"]`, err))
|
||||
return ""
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func loadConfig() {
|
||||
// load config from file as JSON
|
||||
functionName := "loadConfig"
|
||||
|
||||
// get location of this dll
|
||||
// dllPath, err := filepath.Abs(os.Args[0])
|
||||
// if err != nil {
|
||||
// writeLog(functionName, fmt.Sprintf(`["Error getting DLL path: %v", "ERROR"]`, err))
|
||||
// return
|
||||
// }
|
||||
|
||||
// set the addon directory to the parent directory of the dll
|
||||
// ADDON_FOLDER = filepath.Dir(dllPath)
|
||||
// LOG_FILE = ADDON_FOLDER + "\\attendanceTracker.log"
|
||||
// CONFIG_FILE = ADDON_FOLDER + "\\config.json"
|
||||
|
||||
file, err := os.OpenFile(CONFIG_FILE, os.O_RDONLY|os.O_CREATE, 0666)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// log.Println("Loading config from", CONFIG_FILE)
|
||||
|
||||
decoder := json.NewDecoder(file)
|
||||
err = decoder.Decode(&Config)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
A3Config = Config.ArmaConfig
|
||||
ATConfig = Config.SQLConfig
|
||||
|
||||
writeLog(functionName, `["Config loaded", "INFO"]`)
|
||||
}
|
||||
|
||||
func getSettings() string {
|
||||
// get settings from A3Config and send to Arma
|
||||
var settings string = `[`
|
||||
// iterate through keys in A3Config struct
|
||||
v := reflect.ValueOf(A3Config)
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
// get field name
|
||||
fieldName := v.Type().Field(i).Name
|
||||
// get field value
|
||||
fieldValue := v.Field(i).Interface()
|
||||
// if field value is a string, add quotes
|
||||
fieldValueString := fmt.Sprintf("%v", fieldValue)
|
||||
if reflect.TypeOf(fieldValue).Kind() == reflect.String {
|
||||
fieldValueString = fmt.Sprintf(`"%v"`, fieldValue)
|
||||
}
|
||||
// add to settings, key should be lowercase
|
||||
settings += fmt.Sprintf(`["%s", %s],`, strings.ToLower(fieldName), fieldValueString)
|
||||
}
|
||||
|
||||
// remove last comma
|
||||
settings = strings.TrimSuffix(settings, ",")
|
||||
settings += `]`
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
func getMissionHash() string {
|
||||
functionName := "getMissionHash"
|
||||
// get md5 hash of string
|
||||
// https://stackoverflow.com/questions/2377881/how-to-get-a-md5-hash-from-a-string-in-golang
|
||||
hash := md5.Sum([]byte(time.Now().Format("2006-01-02 15:04:05")))
|
||||
|
||||
// convert to string
|
||||
hashString := fmt.Sprintf(`%x`, hash)
|
||||
writeLog(functionName, fmt.Sprintf(`["Mission hash: %s", "INFO"]`, hashString))
|
||||
return hashString
|
||||
}
|
||||
|
||||
func updateServerTime(serverTime uint64) {
|
||||
functionName := "updateServerTime"
|
||||
|
||||
var err error
|
||||
|
||||
// check .txt file for server time
|
||||
// first, check if it exists
|
||||
if _, err := os.Stat(SERVER_TIME_FILE); os.IsNotExist(err) {
|
||||
// file does not exist, create it and write serverTime to it
|
||||
writeLog(functionName, `["Server time file does not exist, creating it", "DEBUG"]`)
|
||||
err = ioutil.WriteFile(SERVER_TIME_FILE, []byte(strconv.FormatUint(serverTime, 10)), 0666)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["Error writing server time to file: %v", "ERROR"]`, err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// file exists, read it
|
||||
line, err := ioutil.ReadFile(SERVER_TIME_FILE)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["Error reading server time file: %v", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// convert to uint64
|
||||
LAST_SERVER_TIME, err := strconv.ParseUint(string(line), 10, 64)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["Error converting server time to uint64: %v", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// if serverTime is less than last server time, close server events
|
||||
if serverTime < LAST_SERVER_TIME {
|
||||
closeServerEvents()
|
||||
}
|
||||
LAST_SERVER_TIME = serverTime
|
||||
|
||||
// write server time to file
|
||||
err = ioutil.WriteFile(SERVER_TIME_FILE, []byte(strconv.FormatUint(serverTime, 10)), 0666)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["Error writing server time to file: %v", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func closeServerEvents() {
|
||||
functionName := "closeServerEvents"
|
||||
writeLog(functionName, `["Filling missing disconnect events due to server restart.", "DEBUG"]`)
|
||||
// get all events with null DisconnectTime & set DisconnectTime to current time
|
||||
var events []AttendanceItem
|
||||
db.Where("disconnect_time_utc = '0000-00-00 00:00:00'").Find(&events)
|
||||
for _, event := range events {
|
||||
|
||||
// if difference between JoinTime and current time is greater than threshold, set to threshold
|
||||
if event.EventType == "Server" {
|
||||
var timeThreshold time.Time = event.JoinTimeUTC.Add(-time.Duration(A3Config.ServerEventFillNullMinutes) * time.Minute)
|
||||
if event.JoinTimeUTC.Before(timeThreshold) {
|
||||
event.DisconnectTimeUTC = timeThreshold
|
||||
} else {
|
||||
event.DisconnectTimeUTC = time.Now()
|
||||
}
|
||||
} else if event.EventType == "Mission" {
|
||||
var timeThreshold time.Time = event.JoinTimeUTC.Add(-time.Duration(A3Config.MissionEventFillNullMinutes) * time.Minute)
|
||||
if event.JoinTimeUTC.Before(timeThreshold) {
|
||||
event.DisconnectTimeUTC = timeThreshold
|
||||
} else {
|
||||
event.DisconnectTimeUTC = time.Now()
|
||||
}
|
||||
}
|
||||
db.Save(&event)
|
||||
if db.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["Error filling missing disconnects: %v", "ERROR"]`, db.Error))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// log how many
|
||||
writeLog(functionName, fmt.Sprintf(`["%d missing disconnects filled.", "INFO"]`, len(events)))
|
||||
}
|
||||
|
||||
func connectDB() error {
|
||||
|
||||
// load config
|
||||
loadConfig()
|
||||
|
||||
// connect to database
|
||||
var err error
|
||||
dsn := fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True",
|
||||
ATConfig.MySQLUser,
|
||||
ATConfig.MySQLPassword,
|
||||
ATConfig.MySQLHost,
|
||||
ATConfig.MySQLPort,
|
||||
ATConfig.MySQLDatabase,
|
||||
)
|
||||
|
||||
// log dsn and pause
|
||||
// writeLog("connectDB", fmt.Sprintf(`["DSN: %s", "INFO"]`, dsn))
|
||||
// fmt.Println(dsn)
|
||||
|
||||
if db != nil {
|
||||
// log success and return
|
||||
writeLog("connectDB", `["Database already connected", "INFO"]`)
|
||||
writeLog("connectDB", `["SUCCESS", "INFO"]`)
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
// log.Println(err)
|
||||
writeLog("connectDB", fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return err
|
||||
}
|
||||
|
||||
// Migrate the schema
|
||||
err = db.Set("gorm:table_options", "ENGINE=InnoDB").AutoMigrate(&World{}, &Mission{}, &AttendanceItem{})
|
||||
if err != nil {
|
||||
// log.Println(err)
|
||||
writeLog("connectDB", fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return err
|
||||
}
|
||||
|
||||
writeLog("connectDB", `["Database connected", "INFO"]`)
|
||||
writeLog("connectDB", `["SUCCESS", "INFO"]`)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func writeWorldInfo(worldInfo string) {
|
||||
functionName := "writeWorldInfo"
|
||||
// writeLog(functionName, fmt.Sprintf(`["%s", "DEBUG"]`, worldInfo))
|
||||
// worldInfo is json, parse it
|
||||
var wi World
|
||||
fixedString := fixEscapeQuotes(trimQuotes(worldInfo))
|
||||
err := json.Unmarshal([]byte(fixedString), &wi)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// prevent crash
|
||||
if db == nil {
|
||||
err := connectDB()
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// write world if not exist
|
||||
var world World
|
||||
var returnId uint
|
||||
db.Where("world_name = ?", wi.WorldName).First(&world)
|
||||
if world.ID == 0 {
|
||||
writeLog(functionName, `["World not found, writing new world", "INFO"]`)
|
||||
result := db.Create(&wi)
|
||||
if result.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, result.Error))
|
||||
return
|
||||
}
|
||||
writeLog(functionName, fmt.Sprintf(`["World written with ID %d", "INFO"]`, wi.ID))
|
||||
returnId = wi.ID
|
||||
} else {
|
||||
// return ID
|
||||
writeLog(functionName, fmt.Sprintf(`["World exists with ID %d", "INFO"]`, world.ID))
|
||||
returnId = world.ID
|
||||
}
|
||||
|
||||
writeLog(functionName, fmt.Sprintf(`["WORLD_ID", %d]`, returnId))
|
||||
}
|
||||
|
||||
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:"type:datetime"`
|
||||
MissionHash string `json:"missionHash" gorm:"index"`
|
||||
WorldName string `json:"worldName" gorm:"-"`
|
||||
WorldID uint
|
||||
World World `gorm:"foreignkey:WorldID"`
|
||||
Attendees []AttendanceItem
|
||||
}
|
||||
|
||||
func writeMission(missionJSON string) {
|
||||
functionName := "writeMission"
|
||||
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 {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// prevent crash
|
||||
if db == nil {
|
||||
err := connectDB()
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// get world from WorldName
|
||||
var world World
|
||||
db.Where("world_name = ?", mi.WorldName).First(&world)
|
||||
if world.ID == 0 {
|
||||
writeLog(functionName, fmt.Sprintf(`["World not found for %s, cannot write mission!", "ERROR"]`, mi.WorldName))
|
||||
return
|
||||
}
|
||||
mi.WorldID = world.ID
|
||||
|
||||
// write mission to database
|
||||
db.Create(&mi)
|
||||
if db.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, db.Error))
|
||||
return
|
||||
}
|
||||
writeLog(functionName, fmt.Sprintf(`["Mission written with ID %d", "INFO"]`, mi.ID))
|
||||
writeLog(functionName, fmt.Sprintf(`["MISSION_ID", %d]`, mi.ID))
|
||||
}
|
||||
|
||||
type AttendanceItem struct {
|
||||
gorm.Model
|
||||
MissionHash string `json:"missionHash"`
|
||||
EventType string `json:"eventType"`
|
||||
PlayerId string `json:"playerId"`
|
||||
PlayerUID string `json:"playerUID"`
|
||||
JoinTimeUTC time.Time
|
||||
DisconnectTimeUTC time.Time
|
||||
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 writeDisconnectEvent(data string) {
|
||||
functionName := "writeDisconnectEvent"
|
||||
var err error
|
||||
// data is json, parse it
|
||||
stringjson := fixEscapeQuotes(trimQuotes(data))
|
||||
var event AttendanceItem
|
||||
err = json.Unmarshal([]byte(stringjson), &event)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// prevent crash
|
||||
if db == nil {
|
||||
err := connectDB()
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// get all attendance rows of type without disconnect rows
|
||||
var attendanceRows []AttendanceItem
|
||||
db.Where("player_uid = ? AND event_type = ? AND disconnect_time_utc = '0000-00-00 00:00:00'", event.PlayerUID, event.EventType).Find(&attendanceRows)
|
||||
for _, row := range attendanceRows {
|
||||
// update disconnect time
|
||||
if row.JoinTimeUTC.Before(time.Now().Add(-1*time.Hour)) && row.EventType == "Mission" {
|
||||
// if mission JoinTime is more than 1 hour ago, simplify this to write DisconnectTime as 1 hour from JoinTime. this to account for crashes where people don't immediately rejoin
|
||||
row.DisconnectTimeUTC = row.JoinTimeUTC.Add(-1 * time.Hour)
|
||||
} else if row.JoinTimeUTC.Before(time.Now().Add(-6*time.Hour)) && row.EventType == "Server" {
|
||||
// if server JoinTime is more than 6 hours ago, simplify this to write DisconnectTime as 6 hours from JoinTime. this to account for server crashes where people don't immediately rejoin without overwriting valid (potentially lengthy) server sessions
|
||||
row.DisconnectTimeUTC = row.JoinTimeUTC.Add(-6 * time.Hour)
|
||||
} else {
|
||||
// otherwise, update DisconnectTime to now
|
||||
row.DisconnectTimeUTC = time.Now()
|
||||
}
|
||||
db.Save(&row)
|
||||
}
|
||||
|
||||
writeLog(functionName, fmt.Sprintf(`["Disconnect events written for %s", "DEBUG"]`, event.PlayerUID))
|
||||
}
|
||||
|
||||
func writeAttendance(data string) {
|
||||
functionName := "writeAttendance"
|
||||
var err error
|
||||
// data is json, parse it
|
||||
stringjson := fixEscapeQuotes(trimQuotes(data))
|
||||
var event AttendanceItem
|
||||
err = json.Unmarshal([]byte(stringjson), &event)
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
|
||||
// prevent crash
|
||||
if db == nil {
|
||||
err := connectDB()
|
||||
if err != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var playerUid string
|
||||
var rowId uint
|
||||
if event.EventType == "Server" {
|
||||
// check for most recent existing attendance row
|
||||
var attendance AttendanceItem
|
||||
db.Where("player_id = ? AND player_uid = ? AND event_type = ?", event.PlayerId, event.PlayerUID, event.EventType).Order("join_time_utc desc").First(&attendance)
|
||||
if attendance.ID != 0 {
|
||||
// update disconnect time
|
||||
row := db.Model(&attendance).Update("disconnect_time_utc", time.Now())
|
||||
if row.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, row.Error))
|
||||
return
|
||||
}
|
||||
rowId, playerUid = attendance.ID, attendance.PlayerUID
|
||||
|
||||
} else {
|
||||
// insert new row
|
||||
event.JoinTimeUTC = time.Now()
|
||||
row := db.Omit("MissionID").Omit("MissionHash").Create(&event)
|
||||
if row.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, row.Error))
|
||||
return
|
||||
}
|
||||
rowId, playerUid = event.ID, event.PlayerUID
|
||||
}
|
||||
} else if event.EventType == "Mission" {
|
||||
// use gorm to associate this event with the mission sharing a mission hash
|
||||
var mission Mission
|
||||
db.Where("mission_hash = ?", event.MissionHash).First(&mission)
|
||||
if mission.ID != 0 {
|
||||
event.MissionID = uint(mission.ID)
|
||||
} else {
|
||||
writeLog(functionName, fmt.Sprintf(`["Mission not found for hash %s", "ERROR"]`, event.MissionHash))
|
||||
return
|
||||
}
|
||||
|
||||
// check for most recent JoinTime for this player and event type
|
||||
var attendance AttendanceItem
|
||||
db.Where("player_id = ? AND player_uid = ? AND event_type = ? AND mission_hash = ?", event.PlayerId, event.PlayerUID, event.EventType, event.MissionHash).Order("join_time_utc desc").First(&attendance)
|
||||
if attendance.ID != 0 {
|
||||
// update disconnect time
|
||||
row := db.Model(&attendance).Update("disconnect_time_utc", time.Now())
|
||||
if row.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, row.Error))
|
||||
return
|
||||
}
|
||||
rowId, playerUid = attendance.ID, attendance.PlayerUID
|
||||
} else {
|
||||
event.JoinTimeUTC = time.Now()
|
||||
// insert new row
|
||||
row := db.Create(&event)
|
||||
if row.Error != nil {
|
||||
writeLog(functionName, fmt.Sprintf(`["%s", "ERROR"]`, row.Error))
|
||||
return
|
||||
}
|
||||
rowId, playerUid = event.ID, event.PlayerUID
|
||||
}
|
||||
}
|
||||
|
||||
writeLog(functionName, fmt.Sprintf(`["Saved attendance for %s to row id %d", "DEBUG"]`, playerUid, rowId))
|
||||
}
|
||||
|
||||
func runExtensionCallback(name *C.char, function *C.char, data *C.char) C.int {
|
||||
return C.runExtensionCallback(extensionCallbackFnc, name, function, data)
|
||||
}
|
||||
|
||||
//export goRVExtensionVersion
|
||||
func goRVExtensionVersion(output *C.char, outputsize C.size_t) {
|
||||
result := C.CString(EXTENSION_VERSION)
|
||||
defer C.free(unsafe.Pointer(result))
|
||||
var size = C.strlen(result) + 1
|
||||
if size > outputsize {
|
||||
size = outputsize
|
||||
}
|
||||
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
|
||||
}
|
||||
|
||||
//export goRVExtensionArgs
|
||||
func goRVExtensionArgs(output *C.char, outputsize C.size_t, input *C.char, argv **C.char, argc C.int) {
|
||||
var offset = unsafe.Sizeof(uintptr(0))
|
||||
var out []string
|
||||
for index := C.int(0); index < argc; index++ {
|
||||
out = append(out, C.GoString(*argv))
|
||||
argv = (**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(argv)) + offset))
|
||||
}
|
||||
|
||||
// temp := fmt.Sprintf("Function: %s nb params: %d params: %s!", C.GoString(input), argc, out)
|
||||
temp := fmt.Sprintf("Function: %s nb params: %d", C.GoString(input), argc)
|
||||
|
||||
switch C.GoString(input) {
|
||||
case "writeAttendance":
|
||||
{ // callExtension ["logAttendance", [_hash] call CBA_fnc_encodeJSON]];
|
||||
if argc == 1 {
|
||||
go writeAttendance(out[0])
|
||||
}
|
||||
}
|
||||
case "writeDisconnectEvent":
|
||||
{ // callExtension ["writeDisconnectEvent", [_hash] call CBA_fnc_encodeJSON]];
|
||||
if argc == 1 {
|
||||
go writeDisconnectEvent(out[0])
|
||||
}
|
||||
}
|
||||
case "logMission":
|
||||
if argc == 1 {
|
||||
go writeMission(out[0])
|
||||
}
|
||||
case "logWorld":
|
||||
if argc == 1 {
|
||||
go writeWorldInfo(out[0])
|
||||
}
|
||||
case "updateServerTime":
|
||||
if argc == 1 {
|
||||
// convert to uint64
|
||||
serverTime, err := strconv.ParseUint(out[0], 10, 64)
|
||||
if err != nil {
|
||||
writeLog("updateServerTime", fmt.Sprintf(`["%s", "ERROR"]`, err))
|
||||
temp = "ERROR parsing server time"
|
||||
} else {
|
||||
go updateServerTime(serverTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a result to Arma
|
||||
result := C.CString(temp)
|
||||
defer C.free(unsafe.Pointer(result))
|
||||
var size = C.strlen(result) + 1
|
||||
if size > outputsize {
|
||||
size = outputsize
|
||||
}
|
||||
|
||||
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
|
||||
}
|
||||
|
||||
func callBackExample() {
|
||||
name := C.CString("arma")
|
||||
defer C.free(unsafe.Pointer(name))
|
||||
function := C.CString("funcToExecute")
|
||||
defer C.free(unsafe.Pointer(function))
|
||||
// Make a callback to Arma
|
||||
for i := 0; i < 3; i++ {
|
||||
time.Sleep(2 * time.Second)
|
||||
param := C.CString(fmt.Sprintf("Loop: %d", i))
|
||||
defer C.free(unsafe.Pointer(param))
|
||||
runExtensionCallback(name, function, param)
|
||||
}
|
||||
}
|
||||
|
||||
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 writeLog(functionName string, data string) {
|
||||
// get calling function & line
|
||||
_, file, line, _ := runtime.Caller(1)
|
||||
log.Printf(`%s:%d:%s %s`, path.Base(file), line, functionName, data)
|
||||
|
||||
if extensionCallbackFnc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusName := C.CString("AttendanceTracker")
|
||||
defer C.free(unsafe.Pointer(statusName))
|
||||
statusFunction := C.CString(functionName)
|
||||
defer C.free(unsafe.Pointer(statusFunction))
|
||||
statusParam := C.CString(data)
|
||||
defer C.free(unsafe.Pointer(statusParam))
|
||||
|
||||
runExtensionCallback(statusName, statusFunction, statusParam)
|
||||
}
|
||||
|
||||
func disconnectDB() {
|
||||
if db != nil {
|
||||
db = nil
|
||||
}
|
||||
}
|
||||
|
||||
//export goRVExtension
|
||||
func goRVExtension(output *C.char, outputsize C.size_t, input *C.char) {
|
||||
|
||||
var temp string
|
||||
|
||||
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "DEBUG"]`, C.GoString(input)), true)
|
||||
|
||||
switch C.GoString(input) {
|
||||
case "version":
|
||||
temp = EXTENSION_VERSION
|
||||
case "getDir":
|
||||
temp = getDir()
|
||||
case "getSettings":
|
||||
loadConfig()
|
||||
temp = getSettings()
|
||||
case "getTimestamp":
|
||||
temp = fmt.Sprintf(`["%s"]`, getTimestamp())
|
||||
case "connectDB":
|
||||
temp = fmt.Sprintf(`["%s"]`, "Connecting to DB")
|
||||
connectDB()
|
||||
case "disconnectDB":
|
||||
temp = fmt.Sprintf(`["%s"]`, "Disconnecting from DB")
|
||||
disconnectDB()
|
||||
case "getMissionHash":
|
||||
temp = fmt.Sprintf(`["%s"]`, getMissionHash())
|
||||
default:
|
||||
temp = fmt.Sprintf(`["%s"]`, "Unknown Function")
|
||||
}
|
||||
|
||||
result := C.CString(temp)
|
||||
defer C.free(unsafe.Pointer(result))
|
||||
var size = C.strlen(result) + 1
|
||||
if size > outputsize {
|
||||
size = outputsize
|
||||
}
|
||||
|
||||
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
|
||||
// return
|
||||
}
|
||||
|
||||
//export goRVExtensionRegisterCallback
|
||||
func goRVExtensionRegisterCallback(fnc C.extensionCallback) {
|
||||
extensionCallbackFnc = fnc
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user