first commit
This commit is contained in:
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
|
||||
}
|
Reference in New Issue
Block a user