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

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
}