first commit
This commit is contained in:
48
.github/workflow/CI.yaml
vendored
Normal file
48
.github/workflow/CI.yaml
vendored
Normal 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
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.communication
|
||||||
|
audio
|
65
README.md
Normal file
65
README.md
Normal 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
48
bus/bus.go
Normal 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
138
bus/bus_test.go
Normal 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
190
client/client.go
Normal 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
179
client/client_api_test.go
Normal 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
58
client/client_test.go
Normal 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
13
client/request.go
Normal 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
63
client/response.go
Normal 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
7
config.yml
Normal 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
86
config/config.go
Normal 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
108
config/config_test.go
Normal 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
14
go.mod
Normal 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
12
go.sum
Normal 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
88
main.go
Normal 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
31
utils/encoder.go
Normal 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
66
utils/encoder_test.go
Normal 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
60
watcher/watcher.go
Normal 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
195
watcher/watcher_test.go
Normal 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()
|
||||||
|
}
|
Reference in New Issue
Block a user