first commit

This commit is contained in:
2025-09-08 13:27:09 -04:00
commit 41eb06d247
21 changed files with 1471 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

48
.github/workflow/CI.yaml vendored Normal file
View File

@@ -0,0 +1,48 @@
---
name: Golan Testing
on:
workflow_dispatch:
push:
branches:
- main
- develop
paths-ignore:
- '.github/**'
- 'README.md'
pull_request:
branches:
- main
- develop
paths-ignore:
- '.github/**'
- 'README.md'
jobs:
testing:
runs-on: ubuntu-latest
strategy:
matrix:
go-version:
- 1.25.x
os:
- ubuntu-latest
steps:
- name: Set up Golang environment
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- name: Checkout Code
uses: actions/checkout@v5
- name: Go Get / Go Test
run: |
go get -u .
go test ./...
- name: Output Summary
id: output-summary
shell: bash
run: |
echo "CI pipeline has been compiled for ${{ github.repository }} with a new version ${{ steps.vars.outputs.COMMIT_SHORT_SHA }}" >> $GITHUB_STEP_SUMMARY

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.communication
audio

65
README.md Normal file
View File

@@ -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 <repo-url>
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

48
bus/bus.go Normal file
View File

@@ -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
}

138
bus/bus_test.go Normal file
View File

@@ -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"]))
}
}

190
client/client.go Normal file
View File

@@ -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())
}

179
client/client_api_test.go Normal file
View File

@@ -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)
}
}

58
client/client_test.go Normal file
View File

@@ -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
}

13
client/request.go Normal file
View File

@@ -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"`
}

63
client/response.go Normal file
View File

@@ -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
}

7
config.yml Normal file
View File

@@ -0,0 +1,7 @@
---
credentials:
secret_key: "8KfK3whJ6ju4VlKcGo9nbC"
ttd_api_key: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWJzY3JpYmVySWQiOjUzNjg3MzAwOCwiaWF0IjoxNzI3NjU0MzE5fQ.BAz49lQkYinOTRdM1zgz9vdqzjHlDgGbvOgCdLG41c4"
directory: "./audio/mp3"
watch_debounce_seconds: 3
log_level: "debug"

86
config/config.go Normal file
View File

@@ -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
}

108
config/config_test.go Normal file
View File

@@ -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)
}
}

14
go.mod Normal file
View File

@@ -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
)

12
go.sum Normal file
View File

@@ -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=

88
main.go Normal file
View File

@@ -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
}

31
utils/encoder.go Normal file
View File

@@ -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
}

66
utils/encoder_test.go Normal file
View File

@@ -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
}

60
watcher/watcher.go Normal file
View File

@@ -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)
}
}
}

195
watcher/watcher_test.go Normal file
View File

@@ -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()
}