From 41eb06d247ae45e88646ffd1ed652bbfb77af1d9 Mon Sep 17 00:00:00 2001 From: Alex Savin Date: Mon, 8 Sep 2025 13:27:09 -0400 Subject: [PATCH] first commit --- .DS_Store | Bin 0 -> 6148 bytes .github/workflow/CI.yaml | 48 ++++++++++ .gitignore | 2 + README.md | 65 +++++++++++++ bus/bus.go | 48 ++++++++++ bus/bus_test.go | 138 +++++++++++++++++++++++++++ client/client.go | 190 +++++++++++++++++++++++++++++++++++++ client/client_api_test.go | 179 ++++++++++++++++++++++++++++++++++ client/client_test.go | 58 ++++++++++++ client/request.go | 13 +++ client/response.go | 63 ++++++++++++ config.yml | 7 ++ config/config.go | 86 +++++++++++++++++ config/config_test.go | 108 +++++++++++++++++++++ go.mod | 14 +++ go.sum | 12 +++ main.go | 88 +++++++++++++++++ utils/encoder.go | 31 ++++++ utils/encoder_test.go | 66 +++++++++++++ watcher/watcher.go | 60 ++++++++++++ watcher/watcher_test.go | 195 ++++++++++++++++++++++++++++++++++++++ 21 files changed, 1471 insertions(+) create mode 100644 .DS_Store create mode 100644 .github/workflow/CI.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 bus/bus.go create mode 100644 bus/bus_test.go create mode 100644 client/client.go create mode 100644 client/client_api_test.go create mode 100644 client/client_test.go create mode 100644 client/request.go create mode 100644 client/response.go create mode 100644 config.yml create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 utils/encoder.go create mode 100644 utils/encoder_test.go create mode 100644 watcher/watcher.go create mode 100644 watcher/watcher_test.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..db05bd78c59feecbfa4a4d3041643d0ba8719f05 GIT binary patch literal 6148 zcmeHK%Z}496uq9l(ixDt0BJTzk=RzHBVbT9n^J}aTY_K#s3eopiAdv7lTx9oQr7Sf z`~q9PgnwZL=h`lE(_uG4Xyhx|$B%Pwd{1S&CL%Ff#79IOBJxle>-(rKF`nnLV|!-g z22iLN@>wd+qEtpZTsPnpa0>i?3h>@-P)G}kDIt6R@{#ZurT0sjTDrxImidL*>EEM^ zG|7v8|A*MC)f>&0*YXa$x52qw21Qsb@=-W{&Ak^=rqQMyMlX}ea@@J~L}o>pWRtma zNa6`7Z(b!?ESDp>$l_f2271D4d+l-OaJ70k7@e&Lvz4-h1$;6)vyEj=PVzf4J16Pc);n;`6iO)1)bUdSFV5x4G(<{A&G@8*Yvq zXB95zXmeYL7-G#CV&e;!MN7mjkm)(}n^)p;b~YPbXIH9# z*%#iyl}2^JoB~dP>!1Li4?YUx&|s-i9UZ9D698Dpurjpynq(i};Lu>H5i>BMU4hzF zm?wtN?ilwCZ)mX8sNG4J$A>Uq7UqQ_)YlQ;S9B6XjjnbII0d#9sOfHp@BgQNfB$bg zxt>$NDR8Y65cSjGbcB@5-MW(;-?cu5BiGPueq@J|)^4Fy8T!T> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6efd836 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.communication +audio diff --git a/README.md b/README.md new file mode 100644 index 0000000..7199c1f --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# go-iar-notificator + +A Go client and utility for interacting with the IamResponding (IaR) API, designed to send alerts and keep-alive signals for pager groups. This project is structured for extensibility and integration with custom notification workflows. + +## Features +- API client for IaR with keep-alive and alerting support +- Configurable via YAML and Go structs +- Modular codebase for easy extension +- Logging with slog + +## Project Structure +``` +config.yml # Example configuration file +main.go # Entry point +bus/ # Event bus logic +client/ # IaR API client and helpers +config/ # Configuration structs and loading +utils/ # Utility functions +watcher/ # File or event watcher logic +``` + +## Getting Started + +### Prerequisites +- Go 1.20 or newer +- Access to the IamResponding API (credentials required) + +### Installation +Clone the repository: +```sh +git clone +cd go-iar-notificator +``` + +Install dependencies: +```sh +go mod tidy +``` + +### Configuration +Edit `config.yml` with your IaR credentials and settings. + +### Usage +Build and run the main application: +```sh +go run main.go +``` + +Or build a binary: +```sh +go build -o iar-notificator main.go +./iar-notificator +``` + +## Testing +Run all tests: +```sh +go test ./... +``` + +## License +MIT License + +## Author +Alex Savin diff --git a/bus/bus.go b/bus/bus.go new file mode 100644 index 0000000..cc2283f --- /dev/null +++ b/bus/bus.go @@ -0,0 +1,48 @@ +package bus + +import "sync" + +// Subscriber represents a named handler for an event. +type Subscriber struct { + Name string + Handler func(any) +} + +// CustomEventBus is a simple in-memory event bus using Go primitives. +type CustomEventBus struct { + mu sync.RWMutex + subscribers map[string][]Subscriber +} + +func NewCustomEventBus() *CustomEventBus { + return &CustomEventBus{ + subscribers: make(map[string][]Subscriber), + } +} + +func (eb *CustomEventBus) Subscribe(event string, name string, handler func(any)) { + eb.mu.Lock() + defer eb.mu.Unlock() + eb.subscribers[event] = append(eb.subscribers[event], Subscriber{Name: name, Handler: handler}) +} + +func (eb *CustomEventBus) Publish(event string, data any) { + eb.mu.RLock() + subs := eb.subscribers[event] + eb.mu.RUnlock() + for _, sub := range subs { + go sub.Handler(data) // Run handlers asynchronously + } +} + +func (eb *CustomEventBus) GetSubscribers() map[string][]string { + eb.mu.RLock() + defer eb.mu.RUnlock() + result := make(map[string][]string) + for event, subs := range eb.subscribers { + for _, sub := range subs { + result[event] = append(result[event], sub.Name) + } + } + return result +} diff --git a/bus/bus_test.go b/bus/bus_test.go new file mode 100644 index 0000000..08ae9cf --- /dev/null +++ b/bus/bus_test.go @@ -0,0 +1,138 @@ +package bus_test + +import ( + "sync" + "testing" + "time" + + "git.savin.nyc/alex/go-iar-notificator/bus" +) + +func TestCustomEventBus_SubscribeAndPublish(t *testing.T) { + eb := bus.NewCustomEventBus() + + // Channel to signal handler execution + handlerCalled := make(chan string, 1) + + // Subscribe to an event + eb.Subscribe("test_event", "handler1", func(data any) { + if str, ok := data.(string); ok { + handlerCalled <- str + } + }) + + // Publish the event + eb.Publish("test_event", "test_data") + + // Wait for the handler to be called + select { + case received := <-handlerCalled: + if received != "test_data" { + t.Errorf("Expected 'test_data', got %s", received) + } + case <-time.After(1 * time.Second): + t.Error("Handler was not called within timeout") + } +} + +func TestCustomEventBus_MultipleSubscribers(t *testing.T) { + eb := bus.NewCustomEventBus() + + var wg sync.WaitGroup + results := make(chan string, 2) + + // Subscribe two handlers + eb.Subscribe("test_event", "handler1", func(data any) { + results <- "handler1" + wg.Done() + }) + eb.Subscribe("test_event", "handler2", func(data any) { + results <- "handler2" + wg.Done() + }) + + wg.Add(2) + + // Publish the event + eb.Publish("test_event", nil) + + // Wait for both handlers + wg.Wait() + close(results) + + // Check that both handlers were called + handlerCount := 0 + for range results { + handlerCount++ + } + if handlerCount != 2 { + t.Errorf("Expected 2 handlers to be called, got %d", handlerCount) + } +} + +func TestCustomEventBus_GetSubscribers(t *testing.T) { + eb := bus.NewCustomEventBus() + + // Subscribe to events + eb.Subscribe("event1", "handler1", func(data any) {}) + eb.Subscribe("event1", "handler2", func(data any) {}) + eb.Subscribe("event2", "handler3", func(data any) {}) + + subscribers := eb.GetSubscribers() + + // Check event1 + if len(subscribers["event1"]) != 2 { + t.Errorf("Expected 2 subscribers for event1, got %d", len(subscribers["event1"])) + } + if subscribers["event1"][0] != "handler1" || subscribers["event1"][1] != "handler2" { + t.Errorf("Unexpected subscribers for event1: %v", subscribers["event1"]) + } + + // Check event2 + if len(subscribers["event2"]) != 1 { + t.Errorf("Expected 1 subscriber for event2, got %d", len(subscribers["event2"])) + } + if subscribers["event2"][0] != "handler3" { + t.Errorf("Unexpected subscriber for event2: %v", subscribers["event2"]) + } + + // Check non-existent event + if len(subscribers["event3"]) != 0 { + t.Errorf("Expected 0 subscribers for event3, got %d", len(subscribers["event3"])) + } +} + +func TestCustomEventBus_NoSubscribers(t *testing.T) { + eb := bus.NewCustomEventBus() + + // Publish to an event with no subscribers (should not panic) + eb.Publish("no_subscribers", "data") + + // Should complete without issues +} + +func TestCustomEventBus_ConcurrentAccess(t *testing.T) { + eb := bus.NewCustomEventBus() + + var wg sync.WaitGroup + + // Concurrently subscribe and publish + for i := 0; i < 10; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + eb.Subscribe("concurrent_event", string(rune('A'+id)), func(data any) {}) + }(i) + } + + wg.Wait() + + // Publish + eb.Publish("concurrent_event", nil) + + // Check subscribers + subscribers := eb.GetSubscribers() + if len(subscribers["concurrent_event"]) != 10 { + t.Errorf("Expected 10 subscribers, got %d", len(subscribers["concurrent_event"])) + } +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..dda7966 --- /dev/null +++ b/client/client.go @@ -0,0 +1,190 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "sync" + "time" + + "git.savin.nyc/alex/go-iar-notificator/config" + resty "resty.dev/v3" +) + +const ( + GET = "GET" + POST = "POST" +) + +// Config holds the API configuration +// type Config struct { +// URL string +// SecretKey string +// AuthToken string +// SubscriberID int +// PagerGroupID string +// } + +// Client represents a MySubaru API client that interacts with the MySubaru API. +type Client struct { + credentials *config.Credentials + IAR IaR + HTTPClient *resty.Client + isAlive bool + logger *slog.Logger + sync.RWMutex +} + +type IaR struct { + PagerGroupID string + PagerGroupName string + Type string +} + +// New function creates a New MySubaru API client +func New(credentials *config.Credentials, logger *slog.Logger) (*Client, error) { + + client := &Client{ + credentials: credentials, + IAR: IaR{ + PagerGroupID: "", + PagerGroupName: "", + Type: "", + }, + isAlive: false, + logger: logger, + } + + httpClient := resty.New() + httpClient. + SetBaseURL("https://ttd.iamresponding.com/ttd"). + SetHeaders(map[string]string{ + "User-Agent": "python-requests/2.22.0", + "Accept-Encoding": "gzip, deflate", + "Accept": "*/*", + "Connection": "keep-alive", + "Content-Type": "application/json", + "SecretKey": client.credentials.SecretKey, + "Authorization": "TTDApiKey " + client.credentials.TTDApiKey, + }, + ) + + client.HTTPClient = httpClient + return client, nil +} + +func (c *Client) KeepAlive() error { + query := `query {getMutualPagerGroupsList {_id subscriberId name type tone_tolerance gaplength ignore_after record_delay record_seconds release_time playback_during_record post_email_command alert_command atone btone atonelength btonelength longtone longtonelength createDate},getTTDSetting(keySetting: "pollingSeconds"){keySetting keyValue}}` + resp, err := c.execute(POST, "", map[string]string{"query": query}) + if err != nil { + return err + } + if resp != nil && len(resp.Data.GetMutualPagerGroupsList) > 0 { + pg := resp.Data.GetMutualPagerGroupsList[0] + c.IAR.PagerGroupID = pg.ID + c.IAR.PagerGroupName = pg.Name + c.IAR.Type = pg.Type + } + return nil +} + +func (c *Client) PreAlert() (string, error) { + ttdReceivedDate := time.Now().UTC().Format(time.RFC3339) + query := `mutation {addAlert(ttdReceivedDate: "` + ttdReceivedDate + `", pagerGroup: ["` + c.IAR.PagerGroupID + `"]){_id textAlert pagerGroup subscriberId}}` + resp, err := c.execute(POST, "", map[string]string{"query": query}) + if err != nil { + return "", err + } + return resp.Data.AddAlert.ID, nil +} + +func (c *Client) Alert(audioBase64 string) error { + var err error + var alertID string + ttdReceivedDate := time.Now().UTC().Format(time.RFC3339) + if alertID, err = c.PreAlert(); err != nil { + return errors.New("failed to create pre-alert") + } + query := `mutation {addAlert(_id: "` + alertID + `", ttdReceivedDate: "` + ttdReceivedDate + `", pagerGroup: ["` + c.IAR.PagerGroupID + `"], audio: "` + audioBase64 + `"){_id textAlert pagerGroup audioUrl subscriberId}}` + resp, err := c.execute(POST, "", map[string]string{"query": query}) + if err != nil { + return err + } + c.logger.Info("Alert sent successfully", "alertID", resp.Data.AddAlert.ID, "audioURL", resp.Data.AddAlert.AudioUrl) + + return nil +} + +// ServeKeepAlive starts a goroutine that sends keep-alive requests at the specified interval. +func (c *Client) ServeKeepAlive(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + slog.Info("Starting keep-alive sender", "interval", interval) + + for { + select { + case <-ctx.Done(): + slog.Info("Stopping keep-alive sender") + return + case <-ticker.C: + if err := c.KeepAlive(); err != nil { + slog.Error("Failed to send keep-alive", "error", err) + } else { + slog.Info("Sent keep-alive successfully") + } + } + } +} + +// IsAlive checks if the Client instance is alive +func (c *Client) IsAlive() bool { + return c.isAlive +} + +// execute executes an HTTP request based on the method, URL, and parameters provided. +func (c *Client) execute(method string, url string, params map[string]string) (*Response, error) { + c.Lock() + var resp *resty.Response + var err error + c.logger.Debug("executing http request", "method", method, "url", url, "params", params) + + // POST Requests + if method == POST { + resp, err = c.HTTPClient. + R(). + SetBody(params). + Post(url) + if err != nil { + c.logger.Error("error while executing POST request", "request", "execute", "method", method, "url", url, "error", err.Error()) + return nil, err + } + c.logger.Debug("executed POST request", "method", method, "url", url, "params", params) + } + + if resp.IsSuccess() { + resBytes, err := io.ReadAll(resp.Body) + if err != nil { + c.logger.Error("error while getting body", "error", err.Error()) + } + c.logger.Debug("parsed http request output", "data", string(resBytes)) + + c.HTTPClient.SetCookies(resp.Cookies()) + + var r Response + if err := json.Unmarshal(resBytes, &r); err != nil { + c.logger.Error("error while unmarshalling response", "error", err.Error()) + c.isAlive = false + c.Unlock() + return nil, err + } + c.isAlive = true + c.Unlock() + return &r, nil + } + c.isAlive = false + c.Unlock() + return nil, errors.New("request is not successfull, HTTP code: " + resp.Status()) +} diff --git a/client/client_api_test.go b/client/client_api_test.go new file mode 100644 index 0000000..711ee67 --- /dev/null +++ b/client/client_api_test.go @@ -0,0 +1,179 @@ +package client_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "git.savin.nyc/alex/go-iar-notificator/client" +) + +const keepAliveResponse = `{ + "data": { + "getMutualPagerGroupsList": [ + { + "_id": "672783715f6ed07deb857afb", + "subscriberId": 536873008, + "name": "NPFD - 672783715f6ed07deb857afb", + "type": "ab tone", + "tone_tolerance": 0.02, + "gaplength": 0, + "ignore_after": 60, + "record_delay": 2, + "record_seconds": 15, + "release_time": 3, + "playback_during_record": 0, + "post_email_command": "", + "alert_command": "", + "atone": 1656.4, + "btone": 656.1, + "atonelength": 0.9, + "btonelength": 3, + "longtone": null, + "longtonelength": null, + "createDate": "2024-11-03T14:06:41.765Z" + } + ], + "getTTDSetting": { + "keySetting": "pollingSeconds", + "keyValue": "900" + } + } +}` + +const preAlertResponse = `{ + "data": { + "addAlert": { + "_id": "68bb90e0b91373858dd8f214", + "textAlert": "NPFD Received at 21:39:44 09/05/2025", + "pagerGroup": "NPFD", + "subscriberId": 536873008 + } + } +}` + +const alertResponse = `{ + "data": { + "addAlert": { + "_id": "68bb90e0b91373858dd8f214", + "textAlert": "NPFD Received at 21:40:05 09/05/2025", + "pagerGroup": "NPFD", + "audioUrl": "https://storage.iamresponding.com/v3/xzPAzU4ZEXlRtMLtj2NlquvalB26xQfS.mp3", + "subscriberId": 536873008 + } + } +}` + +// patchClientHTTP replaces the HTTPClient's transport with a test server +func patchClientHTTP(c *client.Client, handler http.HandlerFunc) { + ts := httptest.NewServer(handler) + c.HTTPClient.SetBaseURL(ts.URL) +} + +func TestClient_KeepAlive_Success(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, err := client.New(creds, logger) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(keepAliveResponse)) + }) + + err = c.KeepAlive() + if err != nil { + t.Errorf("KeepAlive() error = %v, want nil", err) + } + if !c.IsAlive() { + t.Error("expected client to be alive after successful KeepAlive") + } + // Check if IAR is set + if c.IAR.PagerGroupID != "672783715f6ed07deb857afb" { + t.Errorf("expected PagerGroupID to be set, got %s", c.IAR.PagerGroupID) + } +} + +func TestClient_KeepAlive_Failure(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, err := client.New(creds, logger) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "fail"}`)) + }) + + err = c.KeepAlive() + if err == nil { + t.Error("expected error from KeepAlive, got nil") + } + if c.IsAlive() { + t.Error("expected client to not be alive after failed KeepAlive") + } +} + +func TestClient_PreAlert_Success(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, err := client.New(creds, logger) + if err != nil { + t.Fatalf("New() error: %v", err) + } + // Set IAR + c.IAR.PagerGroupID = "test_group" + + patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(preAlertResponse)) + }) + + alertID, err := c.PreAlert() + if err != nil { + t.Errorf("PreAlert() error = %v, want nil", err) + } + expectedID := "68bb90e0b91373858dd8f214" + if alertID != expectedID { + t.Errorf("expected alertID %s, got %s", expectedID, alertID) + } +} + +func TestClient_Alert_Success(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, err := client.New(creds, logger) + if err != nil { + t.Fatalf("New() error: %v", err) + } + // Set IAR + c.IAR.PagerGroupID = "test_group" + + callCount := 0 + patchClientHTTP(c, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if callCount == 0 { + // First call is preAlert + w.Write([]byte(preAlertResponse)) + } else { + // Second call is Alert + w.Write([]byte(alertResponse)) + } + callCount++ + }) + + err = c.Alert("base64_audio") + if err != nil { + t.Errorf("Alert() error = %v, want nil", err) + } + if callCount != 2 { + t.Errorf("expected 2 HTTP calls, got %d", callCount) + } +} diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..aef4e67 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,58 @@ +package client_test + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "git.savin.nyc/alex/go-iar-notificator/client" + "git.savin.nyc/alex/go-iar-notificator/config" +) + +// mockLogger returns a no-op slog.Logger for testing +func mockLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})) +} + +// mockCredentials returns dummy credentials for testing +func mockCredentials() *config.Credentials { + return &config.Credentials{ + SecretKey: "dummy-secret", + TTDApiKey: "dummy-api-key", + } +} + +func TestNewClient(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, err := client.New(creds, logger) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if c == nil { + t.Fatal("expected client, got nil") + } +} + +func TestIsAliveDefault(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, _ := client.New(creds, logger) + if c.IsAlive() { + t.Error("expected isAlive to be false by default") + } +} + +func TestServeKeepAliveCancel(t *testing.T) { + logger := mockLogger() + creds := mockCredentials() + c, _ := client.New(creds, logger) + ctx, cancel := context.WithCancel(context.Background()) + go c.ServeKeepAlive(ctx, 10*time.Millisecond) + // Let it run briefly + time.Sleep(20 * time.Millisecond) + cancel() + // No panic or deadlock expected +} diff --git a/client/request.go b/client/request.go new file mode 100644 index 0000000..8470abe --- /dev/null +++ b/client/request.go @@ -0,0 +1,13 @@ +package client + +// Add a keep-alive query +// query {getMutualPagerGroupsList {_id subscriberId name type tone_tolerance gaplength ignore_after record_delay record_seconds release_time playback_during_record post_email_command alert_command atone btone atonelength btonelength longtone longtonelength createDate},getTTDSetting(keySetting: \"pollingSeconds\"){keySetting keyValue}} +// +// Add a Pre-Alert request structure +// mutation {addAlert(ttdReceivedDate: \"2025-09-06T01:39:38Z\", pagerGroup: [\"672783715f6ed07deb857afb\"]){_id textAlert pagerGroup subscriberId}} +// +// Add an audio Alert request structure +// mutation {addAlert(_id: \"68bb90e0b91373858dd8f214\", ttdReceivedDate: \"2025-09-06T01:39:38Z\", pagerGroup: [\"672783715f6ed07deb857afb\"],audio: \"MP3-BASE64-ENCODED-AUDIO-HERE\"){_id textAlert pagerGroup audioUrl subscriberId}} +type Request struct { + Query string `json:"query"` +} diff --git a/client/response.go b/client/response.go new file mode 100644 index 0000000..4479f2d --- /dev/null +++ b/client/response.go @@ -0,0 +1,63 @@ +package client + +import ( + "encoding/json" + "errors" +) + +type PagerGroup struct { + ID string `json:"_id"` + SubscriberID int `json:"subscriberId"` + Name string `json:"name"` + Type string `json:"type"` + ToneTolerance float64 `json:"tone_tolerance"` + GapLength int `json:"gaplength"` + IgnoreAfter int `json:"ignore_after"` + RecordDelay int `json:"record_delay"` + RecordSeconds int `json:"record_seconds"` + ReleaseTime int `json:"release_time"` + PlaybackDuringRecord int `json:"playback_during_record"` + PostEmailCommand string `json:"post_email_command"` + AlertCommand string `json:"alert_command"` + ATone float64 `json:"atone"` + BTone float64 `json:"btone"` + AToneLength float64 `json:"atonelength"` + BToneLength float64 `json:"btonelength"` + LongTone *float64 `json:"longtone"` + LongToneLength *float64 `json:"longtonelength"` + CreateDate string `json:"createDate"` +} + +type TTDSetting struct { + KeySetting string `json:"keySetting"` + KeyValue string `json:"keyValue"` +} + +type AddAlert struct { + ID string `json:"_id"` + TextAlert string `json:"textAlert"` + PagerGroup string `json:"pagerGroup"` + AudioUrl *string `json:"audioUrl,omitempty"` + SubscriberID int `json:"subscriberId"` +} + +type Data struct { + GetMutualPagerGroupsList []PagerGroup `json:"getMutualPagerGroupsList,omitempty"` + GetTTDSetting TTDSetting `json:"getTTDSetting,omitempty"` + AddAlert AddAlert `json:"addAlert,omitempty"` +} + +type Response struct { + Data Data `json:"data"` +} + +func (r *Response) Unmarshal(data []byte) (*Response, error) { + if data == nil { + return nil, errors.New("response data is nil") + } + var resp Response + if err := json.Unmarshal(data, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..72b16bc --- /dev/null +++ b/config.yml @@ -0,0 +1,7 @@ +--- +credentials: + secret_key: "8KfK3whJ6ju4VlKcGo9nbC" + ttd_api_key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWJzY3JpYmVySWQiOjUzNjg3MzAwOCwiaWF0IjoxNzI3NjU0MzE5fQ.BAz49lQkYinOTRdM1zgz9vdqzjHlDgGbvOgCdLG41c4" +directory: "./audio/mp3" +watch_debounce_seconds: 3 +log_level: "debug" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..19fc2ac --- /dev/null +++ b/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "errors" + "log/slog" + "os" + + "gopkg.in/yaml.v3" +) + +const ( + LoggingOutputJson = "JSON" + LoggingOutputText = "TEXT" +) + +type config struct { + Credentials Credentials `yaml:"credentials"` + Directory string `yaml:"directory"` + WatchDebounceSeconds int `yaml:"watch_debounce_seconds"` + LoggingConfig LoggingConfig `yaml:"logging" json:"logging"` +} + +type LoggingConfig struct { + Level string +} + +func (lc LoggingConfig) ToLogger() *slog.Logger { + var level slog.Level + if err := level.UnmarshalText([]byte(lc.Level)); err != nil { + level = slog.LevelInfo + } + + leveler := new(slog.LevelVar) + leveler.Set(level) + return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: leveler, + })) +} + +type Credentials struct { + SecretKey string `yaml:"secret_key"` + TTDApiKey string `yaml:"ttd_api_key"` +} + +type Config struct { + Credentials Credentials + Directory string + WatchDebounceSeconds int + Logger *slog.Logger +} + +func LoadConfig(filePath string) (*Config, error) { + // Set up slog as the main logger with default JSON handler + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + slog.SetDefault(logger) + + data, err := os.ReadFile(filePath) + if err != nil { + slog.Error("Failed to read configuration file", "error", err, "file", filePath) + return nil, err + } + + var cfg config + if err := yaml.Unmarshal(data, &cfg); err != nil { + slog.Error("Failed to unmarshal YAML configuration", "error", err) + return nil, err + } + + c := new(Config) + c.Logger = cfg.LoggingConfig.ToLogger() + + if cfg.Credentials.SecretKey == "" || cfg.Credentials.TTDApiKey == "" { + c.Logger.Error("Missing required credentials in configuration") + return nil, errors.New("missing required credentials in configuration") + } + c.Credentials = cfg.Credentials + + c.Directory = cfg.Directory + c.WatchDebounceSeconds = cfg.WatchDebounceSeconds + // Log the selected provider data + slog.Info("Successfully loaded configuration", + "directory", c.Directory, + "watch_debounce_seconds", c.WatchDebounceSeconds, + ) + return c, nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..1726a88 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,108 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.savin.nyc/alex/go-iar-notificator/config" +) + +func TestLoadConfig_Success(t *testing.T) { + // Create a temporary config file + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "test_config.yml") + yamlContent := ` +credentials: + secret_key: "test_secret" + ttd_api_key: "test_api_key" +directory: "/test/dir" +watch_debounce_seconds: 5 +logging: + level: "INFO" +` + err := os.WriteFile(configFile, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + + // Test LoadConfig + cfg, err := config.LoadConfig(configFile) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the config + if cfg.Credentials.SecretKey != "test_secret" { + t.Errorf("Expected SecretKey 'test_secret', got %s", cfg.Credentials.SecretKey) + } + if cfg.Credentials.TTDApiKey != "test_api_key" { + t.Errorf("Expected TTDApiKey 'test_api_key', got %s", cfg.Credentials.TTDApiKey) + } + if cfg.Directory != "/test/dir" { + t.Errorf("Expected Directory '/test/dir', got %s", cfg.Directory) + } + if cfg.WatchDebounceSeconds != 5 { + t.Errorf("Expected WatchDebounceSeconds 5, got %d", cfg.WatchDebounceSeconds) + } + if cfg.Logger == nil { + t.Error("Expected Logger to be set, got nil") + } +} + +func TestLoadConfig_FileNotFound(t *testing.T) { + _, err := config.LoadConfig("/nonexistent/config.yml") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + if !strings.Contains(err.Error(), "no such file") { + t.Errorf("Expected error message to contain 'no such file', got %v", err) + } +} + +func TestLoadConfig_InvalidYAML(t *testing.T) { + // Create a temporary file with invalid YAML + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "invalid_config.yml") + invalidYAML := ` +credentials: + secret_key: "test" +invalid yaml here +` + err := os.WriteFile(configFile, []byte(invalidYAML), 0644) + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + + _, err = config.LoadConfig(configFile) + if err == nil { + t.Error("Expected error for invalid YAML, got nil") + } + if !strings.Contains(err.Error(), "yaml") { + t.Errorf("Expected error message to contain 'yaml', got %v", err) + } +} + +func TestLoadConfig_MissingCredentials(t *testing.T) { + // Create a temporary config file with missing credentials + tempDir := t.TempDir() + configFile := filepath.Join(tempDir, "missing_creds_config.yml") + yamlContent := ` +directory: "/test/dir" +watch_debounce_seconds: 5 +logging: + level: "INFO" +` + err := os.WriteFile(configFile, []byte(yamlContent), 0644) + if err != nil { + t.Fatalf("Failed to create temp config file: %v", err) + } + + _, err = config.LoadConfig(configFile) + if err == nil { + t.Error("Expected error for missing credentials, got nil") + } else if !strings.Contains(err.Error(), "missing required credentials") { + t.Errorf("Expected error message to contain 'missing required credentials', got %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1b21a8c --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.savin.nyc/alex/go-iar-notificator + +go 1.25 + +require ( + github.com/fsnotify/fsnotify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 + resty.dev/v3 v3.0.0-beta.3 +) + +require ( + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6be7ce4 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E= +resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..815e0a2 --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "git.savin.nyc/alex/go-iar-notificator/bus" + "git.savin.nyc/alex/go-iar-notificator/client" + "git.savin.nyc/alex/go-iar-notificator/config" + "git.savin.nyc/alex/go-iar-notificator/utils" + "git.savin.nyc/alex/go-iar-notificator/watcher" +) + +func main() { + + // Set up slog logger with JSON handler for structured output. + // By default, log to stdout. You can adjust level based on config later. + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, // Default to Info; will adjust if verbose. + })) + + config, err := config.LoadConfig("config.yml") // Replace with your file path + if err != nil { + logger.Error("Error loading config", "error", err) + os.Exit(1) + } + + // Start watching the directory for new MP3 files + if config.WatchDebounceSeconds == 0 { + config.WatchDebounceSeconds = 1 + } + + iar, err := client.New(&config.Credentials, logger) + if err != nil { + logger.Error("Error creating IAR client", "error", err) + os.Exit(1) + } + + eb := bus.NewCustomEventBus() + + // Subscribe to new MP3 event to handle uploading + eb.Subscribe("new_mp3", "mp3_upload_handler", func(data any) { + path, ok := data.(string) + if !ok { + slog.Error("Invalid data type for new MP3 event") + return + } + + mp3Enc, err := utils.Mp3ToBase64(path) + if err != nil { + slog.Error("Failed to encode MP3 file", "error", err, "path", path) + return + } + + if err := iar.Alert(mp3Enc); err != nil { + slog.Error("Failed to upload MP3", "error", err, "path", path) + } else { + slog.Info("Successfully uploaded MP3", "path", path) + } + }) + + // Context for graceful shutdown + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start directory watcher in a goroutine + go watcher.WatchDirectory(ctx, config.Directory, eb, logger) + + // Start keep-alive sender in a goroutine + go iar.ServeKeepAlive(ctx, 15*time.Minute) + + err = iar.KeepAlive() + if err != nil { + slog.Error("Initial keep-alive failed", "error", err) + } + + // Handle OS signals for shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + slog.Info("Shutting down...") + cancel() + time.Sleep(1 * time.Second) // Allow goroutines to exit gracefully +} diff --git a/utils/encoder.go b/utils/encoder.go new file mode 100644 index 0000000..16f9cc3 --- /dev/null +++ b/utils/encoder.go @@ -0,0 +1,31 @@ +package utils + +import ( + "encoding/base64" + "fmt" + "io" + "os" + "strings" +) + +// mp3ToBase64 reads an MP3 file and returns its base64-encoded content +func Mp3ToBase64(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("failed to open MP3 file: %v", err) + } + defer file.Close() + + // Verify file is an MP3 + if !strings.HasSuffix(strings.ToLower(filePath), ".mp3") { + return "", fmt.Errorf("file is not an MP3: %s", filePath) + } + + data, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("failed to read MP3 file: %v", err) + } + + encoded := base64.StdEncoding.EncodeToString(data) + return encoded, nil +} diff --git a/utils/encoder_test.go b/utils/encoder_test.go new file mode 100644 index 0000000..45c4601 --- /dev/null +++ b/utils/encoder_test.go @@ -0,0 +1,66 @@ +package utils_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.savin.nyc/alex/go-iar-notificator/utils" +) + +func TestMp3ToBase64_Success(t *testing.T) { + // Create a temporary MP3 file + tempDir := t.TempDir() + mp3File := filepath.Join(tempDir, "test.mp3") + testData := "fake mp3 data" + err := os.WriteFile(mp3File, []byte(testData), 0644) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + // Test the function + result, err := utils.Mp3ToBase64(mp3File) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify the result is base64 encoded + expected := "ZmFrZSBtcDMgZGF0YQ==" // base64 of "fake mp3 data" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } +} + +func TestMp3ToBase64_FileNotFound(t *testing.T) { + _, err := utils.Mp3ToBase64("/nonexistent/file.mp3") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } + if !strings.Contains(err.Error(), "failed to open MP3 file") { + t.Errorf("Expected error message to contain 'failed to open MP3 file', got %v", err) + } +} + +func TestMp3ToBase64_NotMP3(t *testing.T) { + // Create a temporary non-MP3 file + tempDir := t.TempDir() + txtFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(txtFile, []byte("text data"), 0644) + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + + _, err = utils.Mp3ToBase64(txtFile) + if err == nil { + t.Error("Expected error for non-MP3 file, got nil") + } + if !strings.Contains(err.Error(), "file is not an MP3") { + t.Errorf("Expected error message to contain 'file is not an MP3', got %v", err) + } +} + +func TestMp3ToBase64_ReadError(t *testing.T) { + // This is harder to test without mocking, but we can assume the file creation/read works as above + // For now, the success test covers the happy path +} diff --git a/watcher/watcher.go b/watcher/watcher.go new file mode 100644 index 0000000..f5c0a99 --- /dev/null +++ b/watcher/watcher.go @@ -0,0 +1,60 @@ +package watcher + +import ( + "context" + "log/slog" + "os" + "path/filepath" + + "git.savin.nyc/alex/go-iar-notificator/bus" + "github.com/fsnotify/fsnotify" +) + +const ( + EventNewMP3 = "new_mp3" // Event name for new MP3 detection +) + +func WatchDirectory(ctx context.Context, dirToWatch string, eb *bus.CustomEventBus, logger *slog.Logger) { + watcher, err := fsnotify.NewWatcher() + if err != nil { + logger.Error("Failed to create directory watcher", "error", err) + return + } + defer watcher.Close() + + // Ensure the directory exists + if err := os.MkdirAll(dirToWatch, os.ModePerm); err != nil { + logger.Error("Failed to create watched directory", "error", err, "dir", dirToWatch) + return + } + + if err := watcher.Add(dirToWatch); err != nil { + logger.Error("Failed to add watch on directory", "error", err, "dir", dirToWatch) + return + } + + logger.Info("Watching directory for MP3 files", "dir", dirToWatch) + + for { + select { + case <-ctx.Done(): + logger.Info("Stopping directory watcher") + return + case event, ok := <-watcher.Events: + if !ok { + return + } + // Trigger on file creation or write (e.g., when saved) + if (event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write) && + filepath.Ext(event.Name) == ".mp3" { + logger.Info("Detected new or saved MP3 file", "path", event.Name) + eb.Publish(EventNewMP3, event.Name) + } + case err, ok := <-watcher.Errors: + if !ok { + return + } + logger.Error("Directory watcher error", "error", err) + } + } +} diff --git a/watcher/watcher_test.go b/watcher/watcher_test.go new file mode 100644 index 0000000..59d9ff2 --- /dev/null +++ b/watcher/watcher_test.go @@ -0,0 +1,195 @@ +package watcher_test + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "git.savin.nyc/alex/go-iar-notificator/bus" + "git.savin.nyc/alex/go-iar-notificator/watcher" +) + +func TestWatchDirectory_NewMP3File(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create event bus + eb := bus.NewCustomEventBus() + + // Channel to capture published events + eventReceived := make(chan string, 1) + + // Subscribe to new_mp3 event + eb.Subscribe("new_mp3", "test_handler", func(data any) { + if path, ok := data.(string); ok { + eventReceived <- path + } + }) + + // Create logger (discard output) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + // Start watcher in a goroutine + ctx, cancel := context.WithCancel(context.Background()) + go watcher.WatchDirectory(ctx, tempDir, eb, logger) + + // Give watcher time to start + time.Sleep(100 * time.Millisecond) + + // Create a new MP3 file + mp3File := filepath.Join(tempDir, "test.mp3") + err := os.WriteFile(mp3File, []byte("fake mp3 data"), 0644) + if err != nil { + t.Fatalf("Failed to create MP3 file: %v", err) + } + + // Wait for the event + select { + case receivedPath := <-eventReceived: + if receivedPath != mp3File { + t.Errorf("Expected path %s, got %s", mp3File, receivedPath) + } + case <-time.After(2 * time.Second): + t.Error("Event was not received within timeout") + } + + // Clean up + cancel() +} + +func TestWatchDirectory_NonMP3File(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create event bus + eb := bus.NewCustomEventBus() + + // Channel to capture published events + eventReceived := make(chan string, 1) + + // Subscribe to new_mp3 event + eb.Subscribe("new_mp3", "test_handler", func(data any) { + if path, ok := data.(string); ok { + eventReceived <- path + } + }) + + // Create logger + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + // Start watcher + ctx, cancel := context.WithCancel(context.Background()) + go watcher.WatchDirectory(ctx, tempDir, eb, logger) + + // Give watcher time to start + time.Sleep(100 * time.Millisecond) + + // Create a non-MP3 file + txtFile := filepath.Join(tempDir, "test.txt") + err := os.WriteFile(txtFile, []byte("text data"), 0644) + if err != nil { + t.Fatalf("Failed to create text file: %v", err) + } + + // Wait a bit and check no event was received + time.Sleep(500 * time.Millisecond) + select { + case <-eventReceived: + t.Error("Event should not be received for non-MP3 file") + default: + // Expected: no event + } + + // Clean up + cancel() +} + +func TestWatchDirectory_DirectoryCreation(t *testing.T) { + // Use a non-existent directory path + tempDir := t.TempDir() + nonExistentDir := filepath.Join(tempDir, "new_dir") + + // Create event bus + eb := bus.NewCustomEventBus() + + // Create logger + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + // Start watcher (should create the directory) + ctx, cancel := context.WithCancel(context.Background()) + go watcher.WatchDirectory(ctx, nonExistentDir, eb, logger) + + // Give time for directory creation + time.Sleep(200 * time.Millisecond) + + // Check if directory was created + if _, err := os.Stat(nonExistentDir); os.IsNotExist(err) { + t.Error("Directory should have been created") + } + + // Clean up + cancel() +} + +func TestWatchDirectory_WriteEvent(t *testing.T) { + // Create a temporary directory + tempDir := t.TempDir() + + // Create event bus + eb := bus.NewCustomEventBus() + + // Channel to capture published events + eventReceived := make(chan string, 1) + + // Subscribe to new_mp3 event + eb.Subscribe("new_mp3", "test_handler", func(data any) { + if path, ok := data.(string); ok { + eventReceived <- path + } + }) + + // Create logger + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + + // Start watcher + ctx, cancel := context.WithCancel(context.Background()) + go watcher.WatchDirectory(ctx, tempDir, eb, logger) + + // Give watcher time to start + time.Sleep(100 * time.Millisecond) + + // Create and then write to an MP3 file (simulating save) + mp3File := filepath.Join(tempDir, "test.mp3") + err := os.WriteFile(mp3File, []byte("initial data"), 0644) + if err != nil { + t.Fatalf("Failed to create MP3 file: %v", err) + } + + // Wait for initial event + select { + case <-eventReceived: + // Expected + case <-time.After(1 * time.Second): + t.Error("Initial event was not received") + } + + // Now write again (should trigger again) + err = os.WriteFile(mp3File, []byte("updated data"), 0644) + if err != nil { + t.Fatalf("Failed to update MP3 file: %v", err) + } + + // Wait for second event + select { + case <-eventReceived: + // Expected + case <-time.After(1 * time.Second): + t.Error("Second event was not received") + } + + // Clean up + cancel() +}