sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
package ari
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
type ActionClient interface {
AnswerChannel(ctx context.Context, channelID string) error
HangupChannel(ctx context.Context, channelID string) error
GetChannel(ctx context.Context, channelID string) (*ARIChannel, error)
}
type HandoffActionClient interface {
RedirectChannel(ctx context.Context, channelID string, endpoint string) error
ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error
}
type BridgeActionClient interface {
CreateBridge(ctx context.Context, bridgeID string, bridgeType string) error
AddChannelToBridge(ctx context.Context, bridgeID string, channelID string) error
RemoveChannelFromBridge(ctx context.Context, bridgeID string, channelID string) error
DeleteBridge(ctx context.Context, bridgeID string) error
}
type ExternalMediaClient interface {
CreateExternalMediaChannel(ctx context.Context, req ExternalMediaRequest) (*ARIChannel, error)
GetChannelVariable(ctx context.Context, channelID string, variable string) (string, error)
}
type MediaActionClient interface {
ActionClient
BridgeActionClient
ExternalMediaClient
}
type ExternalMediaRequest struct {
ChannelID string
App string
ExternalHost string
Encapsulation string
Transport string
ConnectionType string
Format string
Direction string
Data string
}
func (c *Client) AnswerChannel(ctx context.Context, channelID string) error {
return c.doNoBody(ctx, http.MethodPost, "channels/"+url.PathEscape(channelID)+"/answer", successCodes(), false)
}
func (c *Client) HangupChannel(ctx context.Context, channelID string) error {
return c.doNoBody(ctx, http.MethodDelete, "channels/"+url.PathEscape(channelID), cleanupCodes(), true)
}
func (c *Client) PlayChannel(ctx context.Context, channelID string, media string) error {
q := url.Values{"media": {media}}
return c.doNoBody(ctx, http.MethodPost, "channels/"+url.PathEscape(channelID)+"/play?"+q.Encode(), successCodesWithCreated(), false)
}
func (c *Client) RedirectChannel(ctx context.Context, channelID string, endpoint string) error {
q := url.Values{"endpoint": {endpoint}}
return c.doNoBody(ctx, http.MethodPost, "channels/"+url.PathEscape(channelID)+"/redirect?"+q.Encode(), successCodes(), false)
}
func (c *Client) ContinueInDialplan(ctx context.Context, channelID, dialplanContext, extension string, priority int) error {
q := url.Values{"context": {dialplanContext}, "extension": {extension}, "priority": {fmt.Sprintf("%d", priority)}}
return c.doNoBody(ctx, http.MethodPost, "channels/"+url.PathEscape(channelID)+"/continue?"+q.Encode(), successCodes(), false)
}
func (c *Client) GetChannel(ctx context.Context, channelID string) (*ARIChannel, error) {
resp, err := c.authenticatedRequest(ctx, http.MethodGet, "channels/"+url.PathEscape(channelID))
if err != nil {
return nil, err
}
defer drainAndClose(resp)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get channel returned HTTP %d", resp.StatusCode)
}
var ch ARIChannel
if err := json.NewDecoder(resp.Body).Decode(&ch); err != nil {
return nil, err
}
return &ch, nil
}
func (c *Client) CreateBridge(ctx context.Context, bridgeID string, bridgeType string) error {
q := url.Values{"type": {bridgeType}, "bridgeId": {bridgeID}}
return c.doNoBody(ctx, http.MethodPost, "bridges?"+q.Encode(), successCodesWithCreated(), false)
}
func (c *Client) AddChannelToBridge(ctx context.Context, bridgeID string, channelID string) error {
q := url.Values{"channel": {channelID}}
return c.doNoBody(ctx, http.MethodPost, "bridges/"+url.PathEscape(bridgeID)+"/addChannel?"+q.Encode(), successCodes(), false)
}
func (c *Client) RemoveChannelFromBridge(ctx context.Context, bridgeID string, channelID string) error {
q := url.Values{"channel": {channelID}}
return c.doNoBody(ctx, http.MethodPost, "bridges/"+url.PathEscape(bridgeID)+"/removeChannel?"+q.Encode(), cleanupCodes(), true)
}
func (c *Client) DeleteBridge(ctx context.Context, bridgeID string) error {
return c.doNoBody(ctx, http.MethodDelete, "bridges/"+url.PathEscape(bridgeID), cleanupCodes(), true)
}
func (c *Client) CreateExternalMediaChannel(ctx context.Context, req ExternalMediaRequest) (*ARIChannel, error) {
ch, err := c.createExternalMediaChannel(ctx, req, true)
if err == nil {
return ch, nil
}
if isBadRequest(err) && req.Data != "" {
return c.createExternalMediaChannel(ctx, req, false)
}
return nil, err
}
func (c *Client) createExternalMediaChannel(ctx context.Context, req ExternalMediaRequest, includeData bool) (*ARIChannel, error) {
q := url.Values{}
q.Set("app", req.App)
q.Set("external_host", req.ExternalHost)
q.Set("encapsulation", req.Encapsulation)
q.Set("transport", req.Transport)
q.Set("connection_type", req.ConnectionType)
q.Set("format", req.Format)
q.Set("direction", req.Direction)
if req.ChannelID != "" {
q.Set("channelId", req.ChannelID)
}
if includeData && req.Data != "" {
q.Set("data", req.Data)
}
resp, err := c.authenticatedRequest(ctx, http.MethodPost, "channels/externalMedia?"+q.Encode())
if err != nil {
return nil, err
}
defer drainAndClose(resp)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("create external media returned HTTP %d", resp.StatusCode)
}
var ch ARIChannel
if err := json.NewDecoder(resp.Body).Decode(&ch); err != nil {
return nil, err
}
return &ch, nil
}
func (c *Client) GetChannelVariable(ctx context.Context, channelID string, variable string) (string, error) {
q := url.Values{"variable": {variable}}
resp, err := c.authenticatedRequest(ctx, http.MethodGet, "channels/"+url.PathEscape(channelID)+"/variable?"+q.Encode())
if err != nil {
return "", err
}
defer drainAndClose(resp)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("get channel variable returned HTTP %d", resp.StatusCode)
}
var payload struct {
Value string `json:"value"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
if payload.Value == "" {
return "", fmt.Errorf("channel variable %s is empty", variable)
}
return payload.Value, nil
}
func (c *Client) doNoBody(ctx context.Context, method, path string, ok map[int]bool, cleanup404 bool) error {
resp, err := c.authenticatedRequest(ctx, method, path)
if err != nil {
return err
}
defer drainAndClose(resp)
if ok[resp.StatusCode] {
return nil
}
if cleanup404 && resp.StatusCode == http.StatusNotFound {
return nil
}
return fmt.Errorf("ARI %s %s returned HTTP %d", method, path, resp.StatusCode)
}
func (c *Client) authenticatedRequest(ctx context.Context, method, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.endpoint(path), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.user, c.password)
return c.httpClient.Do(req)
}
func successCodes() map[int]bool { return map[int]bool{200: true, 202: true, 204: true} }
func successCodesWithCreated() map[int]bool {
return map[int]bool{200: true, 201: true, 202: true, 204: true}
}
func cleanupCodes() map[int]bool { return map[int]bool{200: true, 202: true, 204: true, 404: true} }
func isBadRequest(err error) bool {
return err != nil && err.Error() == "create external media returned HTTP 400"
}
+87
View File
@@ -0,0 +1,87 @@
package ari
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"ai-operator/internal/config"
)
func TestActions(t *testing.T) {
var methods []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
methods = append(methods, r.Method+" "+r.URL.Path)
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/answer"):
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodGet:
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"c1","state":"Up"}`))
}
}))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "p"})
if err := client.AnswerChannel(context.Background(), "c1"); err != nil {
t.Fatal(err)
}
if err := client.HangupChannel(context.Background(), "c1"); err != nil {
t.Fatal(err)
}
ch, err := client.GetChannel(context.Background(), "c1")
if err != nil || ch.ID != "c1" {
t.Fatalf("get %v %v", ch, err)
}
}
func TestHangup404NonFatalAndBadStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) }))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "secret"})
if err := client.HangupChannel(context.Background(), "gone"); err != nil {
t.Fatal(err)
}
if err := client.AnswerChannel(context.Background(), "gone"); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("bad err: %v", err)
}
}
func TestHandoffActions(t *testing.T) {
var seen []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "secret"})
if err := client.RedirectChannel(context.Background(), "c 1", "PJSIP/operator"); err != nil {
t.Fatal(err)
}
if err := client.ContinueInDialplan(context.Background(), "c 1", "handoff", "100", 1); err != nil {
t.Fatal(err)
}
got := strings.Join(seen, "\n")
if !strings.Contains(got, "/channels/c 1/redirect?endpoint=PJSIP%2Foperator") {
t.Fatalf("redirect not encoded: %s", got)
}
if !strings.Contains(got, "/channels/c 1/continue?context=handoff&extension=100&priority=1") {
t.Fatalf("continue not encoded: %s", got)
}
}
func TestHandoffActionErrorNoSecret(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) }))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "secret-password"})
err := client.RedirectChannel(context.Background(), "c1", "PJSIP/user:pass@example")
if err == nil {
t.Fatal("expected error")
}
if strings.Contains(err.Error(), "secret-password") {
t.Fatalf("error leaked password: %v", err)
}
}
+63
View File
@@ -0,0 +1,63 @@
package ari
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"ai-operator/internal/config"
)
type Client struct {
baseURL string
user string
password string
httpClient *http.Client
}
func NewClient(cfg config.AsteriskConfig) *Client {
return &Client{
baseURL: strings.TrimRight(cfg.ARIURL, "/"),
user: cfg.ARIUser,
password: cfg.ARIPassword,
httpClient: &http.Client{
Timeout: 5 * time.Second,
},
}
}
func (c *Client) AuthenticatedGET(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint(path), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.user, c.password)
return c.httpClient.Do(req)
}
func (c *Client) UnauthenticatedGET(ctx context.Context, path string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint(path), nil)
if err != nil {
return nil, err
}
return c.httpClient.Do(req)
}
func (c *Client) endpoint(path string) string {
return c.baseURL + "/" + strings.TrimLeft(path, "/")
}
func drainAndClose(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
func statusError(name string, got int, want int) string {
return fmt.Sprintf("%s returned HTTP %d, want %d", name, got, want)
}
+54
View File
@@ -0,0 +1,54 @@
package ari
import "encoding/json"
func ParseEvent(data []byte) (Event, error) {
var base BaseEvent
if err := json.Unmarshal(data, &base); err != nil {
return nil, err
}
switch base.Type {
case EventStasisStart:
var event StasisStartEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
case EventStasisEnd:
var event StasisEndEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
case EventChannelStateChange:
var event ChannelStateChangeEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
case EventChannelHangupRequest:
var event ChannelHangupRequestEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
case EventChannelDestroyed:
var event ChannelDestroyedEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
case EventApplicationReplaced:
var event ApplicationReplacedEvent
if err := json.Unmarshal(data, &event); err != nil {
return nil, err
}
return event, nil
default:
var raw map[string]any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
}
return GenericEvent{BaseEvent: base, Raw: raw}, nil
}
}
+44
View File
@@ -0,0 +1,44 @@
package ari
import "testing"
func TestParseStasisStart(t *testing.T) {
event, err := ParseEvent([]byte(`{"type":"StasisStart","args":["test"],"channel":{"id":"c1","caller":{"number":"+77771234567"}}}`))
if err != nil {
t.Fatal(err)
}
start, ok := event.(StasisStartEvent)
if !ok {
t.Fatalf("type %T", event)
}
if start.Channel.ID != "c1" || start.Args[0] != "test" {
t.Fatalf("bad parse: %+v", start)
}
}
func TestParseStasisEnd(t *testing.T) {
event, err := ParseEvent([]byte(`{"type":"StasisEnd","channel":{"id":"c1"}}`))
if err != nil {
t.Fatal(err)
}
if event.ChannelID() != "c1" {
t.Fatalf("channel=%s", event.ChannelID())
}
}
func TestParseHangupDestroyedUnknownInvalid(t *testing.T) {
if event, err := ParseEvent([]byte(`{"type":"ChannelHangupRequest","channel":{"id":"c1"},"cause":16}`)); err != nil || event.ChannelID() != "c1" {
t.Fatalf("hangup %v %v", event, err)
}
if event, err := ParseEvent([]byte(`{"type":"ChannelDestroyed","channel":{"id":"c2"},"cause_txt":"Normal"}`)); err != nil || event.ChannelID() != "c2" {
t.Fatalf("destroyed %v %v", event, err)
}
if event, err := ParseEvent([]byte(`{"type":"SomethingNew","value":1}`)); err != nil {
t.Fatal(err)
} else if _, ok := event.(GenericEvent); !ok {
t.Fatalf("want generic, got %T", event)
}
if _, err := ParseEvent([]byte(`{bad json`)); err == nil {
t.Fatal("want invalid json error")
}
}
+43
View File
@@ -0,0 +1,43 @@
package ari
import (
"context"
"strings"
"time"
)
const resourcesPath = "api-docs/resources.json"
func (c *Client) HealthCheck(ctx context.Context) HealthStatus {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
status := HealthStatus{ResourcesEndpoint: c.endpoint(resourcesPath)}
authResp, err := c.AuthenticatedGET(ctx, resourcesPath)
if err != nil {
status.Error = "authenticated ARI request failed: " + err.Error()
return status
}
drainAndClose(authResp)
if authResp.StatusCode == 200 {
status.AuthenticatedOK = true
} else {
status.Error = statusError("authenticated ARI request", authResp.StatusCode, 200)
return status
}
unauthResp, err := c.UnauthenticatedGET(ctx, resourcesPath)
if err != nil {
status.Error = "unauthenticated ARI request failed: " + err.Error()
return status
}
drainAndClose(unauthResp)
if unauthResp.StatusCode == 401 {
status.UnauthenticatedReturns401 = true
} else {
status.Error = statusError("unauthenticated ARI request", unauthResp.StatusCode, 401)
}
status.ResourcesEndpoint = strings.Replace(status.ResourcesEndpoint, "///", "//", 1)
return status
}
+48
View File
@@ -0,0 +1,48 @@
package ari
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"ai-operator/internal/config"
)
func TestHealthCheck(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if ok && user == "ai_operator" && pass == "secret" {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "ai_operator", ARIPassword: "secret"})
status := client.HealthCheck(context.Background())
if !status.AuthenticatedOK {
t.Fatal("authenticated request should be OK")
}
if !status.UnauthenticatedReturns401 {
t.Fatal("unauthenticated request should return 401")
}
}
func TestHealthCheckBadCredentials(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
client := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "bad", ARIPassword: "bad"})
status := client.HealthCheck(context.Background())
if status.AuthenticatedOK {
t.Fatal("authenticated request should fail")
}
if status.Error == "" {
t.Fatal("expected helpful health error")
}
}
+33
View File
@@ -0,0 +1,33 @@
package ari
import (
"fmt"
"os"
"syscall"
)
type LockFile struct {
path string
file *os.File
}
func AcquireLock(path string) (*LockFile, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
return nil, err
}
if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
_ = file.Close()
return nil, fmt.Errorf("ARI events listener lock is already held: %s", path)
}
_, _ = file.WriteString(fmt.Sprintf("%d\n", os.Getpid()))
return &LockFile{path: path, file: file}, nil
}
func (l *LockFile) Release() error {
if l == nil || l.file == nil {
return nil
}
_ = syscall.Flock(int(l.file.Fd()), syscall.LOCK_UN)
return l.file.Close()
}
+26
View File
@@ -0,0 +1,26 @@
package ari
import (
"path/filepath"
"testing"
)
func TestLockFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "listener.lock")
first, err := AcquireLock(path)
if err != nil {
t.Fatal(err)
}
if second, err := AcquireLock(path); err == nil {
_ = second.Release()
t.Fatal("second lock should fail")
}
if err := first.Release(); err != nil {
t.Fatal(err)
}
third, err := AcquireLock(path)
if err != nil {
t.Fatal(err)
}
_ = third.Release()
}
@@ -0,0 +1,83 @@
package ari
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"ai-operator/internal/config"
)
func TestBridgeActionsAndExternalMedia(t *testing.T) {
var seen []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Method+" "+r.URL.Path+"?"+r.URL.RawQuery)
switch {
case r.Method == http.MethodPost && r.URL.Path == "/bridges":
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/addChannel"):
w.WriteHeader(http.StatusNoContent)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/play"):
w.WriteHeader(http.StatusCreated)
case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/removeChannel"):
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/bridges/"):
w.WriteHeader(http.StatusNotFound)
case r.Method == http.MethodPost && r.URL.Path == "/channels/externalMedia":
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"id":"aiop-media-1"}`))
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/variable"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"value":"conn-1"}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
c := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "p"})
ctx := context.Background()
if err := c.CreateBridge(ctx, "b1", "mixing,dtmf_events"); err != nil {
t.Fatal(err)
}
if err := c.AddChannelToBridge(ctx, "b1", "c1"); err != nil {
t.Fatal(err)
}
if err := c.PlayChannel(ctx, "c1", "sound:hello-world"); err != nil {
t.Fatal(err)
}
if err := c.RemoveChannelFromBridge(ctx, "b1", "c1"); err != nil {
t.Fatal(err)
}
if err := c.DeleteBridge(ctx, "b1"); err != nil {
t.Fatal(err)
}
ch, err := c.CreateExternalMediaChannel(ctx, ExternalMediaRequest{ChannelID: "aiop-media-1", App: "ai-operator", ExternalHost: "INCOMING", Encapsulation: "none", Transport: "websocket", ConnectionType: "server", Format: "slin16", Direction: "both", Data: "call-1"})
if err != nil || ch.ID != "aiop-media-1" {
t.Fatalf("external=%+v err=%v", ch, err)
}
v, err := c.GetChannelVariable(ctx, "aiop-media-1", "MEDIA_WEBSOCKET_CONNECTION_ID")
if err != nil || v != "conn-1" {
t.Fatalf("var=%q err=%v", v, err)
}
joined := strings.Join(seen, "\n")
for _, want := range []string{"transport=websocket", "encapsulation=none", "external_host=INCOMING", "connection_type=server", "format=slin16", "direction=both", "channelId=aiop-media-1", "media=sound%3Ahello-world"} {
if !strings.Contains(joined, want) {
t.Fatalf("missing %s in %s", want, joined)
}
}
}
func TestGetChannelVariableErrors(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"value":""}`))
}))
defer server.Close()
c := NewClient(config.AsteriskConfig{ARIURL: server.URL, ARIUser: "u", ARIPassword: "secret"})
_, err := c.GetChannelVariable(context.Background(), "c1", "MISSING")
if err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("err=%v", err)
}
}
+95
View File
@@ -0,0 +1,95 @@
package ari
type HealthStatus struct {
AuthenticatedOK bool
UnauthenticatedReturns401 bool
ResourcesEndpoint string
Error string
}
type EventType string
const (
EventStasisStart EventType = "StasisStart"
EventStasisEnd EventType = "StasisEnd"
EventChannelStateChange EventType = "ChannelStateChange"
EventChannelHangupRequest EventType = "ChannelHangupRequest"
EventChannelDestroyed EventType = "ChannelDestroyed"
EventApplicationReplaced EventType = "ApplicationReplaced"
)
type Event interface {
EventType() EventType
ChannelID() string
}
type BaseEvent struct {
Type EventType `json:"type"`
Application string `json:"application,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
}
func (e BaseEvent) EventType() EventType { return e.Type }
func (e BaseEvent) ChannelID() string { return "" }
type ARIChannel struct {
ID string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Caller ARICallerID `json:"caller,omitempty"`
Connected ARICallerID `json:"connected,omitempty"`
}
type ARICallerID struct {
Name string `json:"name,omitempty"`
Number string `json:"number,omitempty"`
}
type StasisStartEvent struct {
BaseEvent
Args []string `json:"args"`
Channel ARIChannel `json:"channel"`
}
func (e StasisStartEvent) ChannelID() string { return e.Channel.ID }
type StasisEndEvent struct {
BaseEvent
Channel ARIChannel `json:"channel"`
}
func (e StasisEndEvent) ChannelID() string { return e.Channel.ID }
type ChannelStateChangeEvent struct {
BaseEvent
Channel ARIChannel `json:"channel"`
}
func (e ChannelStateChangeEvent) ChannelID() string { return e.Channel.ID }
type ChannelHangupRequestEvent struct {
BaseEvent
Channel ARIChannel `json:"channel"`
Cause int `json:"cause,omitempty"`
Soft bool `json:"soft,omitempty"`
}
func (e ChannelHangupRequestEvent) ChannelID() string { return e.Channel.ID }
type ChannelDestroyedEvent struct {
BaseEvent
Channel ARIChannel `json:"channel"`
Cause int `json:"cause,omitempty"`
CauseTxt string `json:"cause_txt,omitempty"`
}
func (e ChannelDestroyedEvent) ChannelID() string { return e.Channel.ID }
type ApplicationReplacedEvent struct {
BaseEvent
}
type GenericEvent struct {
BaseEvent
Raw map[string]any
}
+47
View File
@@ -0,0 +1,47 @@
package ari
import (
"net/url"
"ai-operator/internal/config"
)
type WSAuthMode string
const (
WSAuthBasic WSAuthMode = "basic"
WSAuthQueryAPIKey WSAuthMode = "query_api_key"
WSAuthAuto WSAuthMode = "auto"
)
type WebSocketURL struct {
URL string
Sanitized string
AuthMode WSAuthMode
}
func BuildWebSocketURL(cfg config.AsteriskConfig, mode WSAuthMode) (WebSocketURL, error) {
if mode == "" {
mode = WSAuthMode(cfg.ARIWSAuthMode)
}
if mode == "" {
mode = WSAuthBasic
}
parsed, err := url.Parse(cfg.ARIWSURL)
if err != nil {
return WebSocketURL{}, err
}
q := parsed.Query()
q.Set("app", cfg.ARIApp)
if mode == WSAuthQueryAPIKey {
q.Set("api_key", cfg.ARIUser+":"+cfg.ARIPassword)
}
parsed.RawQuery = q.Encode()
sanitized := *parsed
if mode == WSAuthQueryAPIKey {
sq := sanitized.Query()
sq.Set("api_key", "***MASKED***")
sanitized.RawQuery = sq.Encode()
}
return WebSocketURL{URL: parsed.String(), Sanitized: sanitized.String(), AuthMode: mode}, nil
}
+34
View File
@@ -0,0 +1,34 @@
package ari
import (
"strings"
"testing"
"ai-operator/internal/config"
)
func TestBuildWebSocketURLBasic(t *testing.T) {
got, err := BuildWebSocketURL(config.AsteriskConfig{ARIWSURL: "ws://127.0.0.1:8088/ari/events", ARIApp: "ai-operator", ARIUser: "u", ARIPassword: "p"}, WSAuthBasic)
if err != nil {
t.Fatal(err)
}
if got.URL != "ws://127.0.0.1:8088/ari/events?app=ai-operator" {
t.Fatalf("url=%s", got.URL)
}
if strings.Contains(got.Sanitized, "p") && strings.Contains(got.Sanitized, "api_key") {
t.Fatalf("sanitized leaked: %s", got.Sanitized)
}
}
func TestBuildWebSocketURLQueryAPIKeyMasks(t *testing.T) {
got, err := BuildWebSocketURL(config.AsteriskConfig{ARIWSURL: "ws://127.0.0.1:8088/ari/events?x=1", ARIApp: "ai-operator", ARIUser: "user", ARIPassword: "secret"}, WSAuthQueryAPIKey)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got.URL, "x=1") || !strings.Contains(got.URL, "app=ai-operator") || !strings.Contains(got.URL, "api_key=") {
t.Fatalf("url=%s", got.URL)
}
if strings.Contains(got.Sanitized, "secret") || !strings.Contains(got.Sanitized, "%2A%2A%2AMASKED%2A%2A%2A") {
t.Fatalf("sanitized=%s", got.Sanitized)
}
}
+148
View File
@@ -0,0 +1,148 @@
package ari
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/gorilla/websocket"
"ai-operator/internal/config"
)
type EventHandler interface {
HandleEvent(ctx context.Context, event Event) error
}
type EventListener struct {
cfg config.AsteriskConfig
mode WSAuthMode
handler EventHandler
logger *slog.Logger
dialer *websocket.Dialer
stopOnApplicationReplaced bool
}
func NewEventListener(cfg config.AsteriskConfig, mode WSAuthMode, handler EventHandler, logger *slog.Logger) *EventListener {
if mode == "" {
mode = WSAuthMode(cfg.ARIWSAuthMode)
}
if mode == "" {
mode = WSAuthBasic
}
return &EventListener{cfg: cfg, mode: mode, handler: handler, logger: logger, dialer: &websocket.Dialer{HandshakeTimeout: 5 * time.Second}, stopOnApplicationReplaced: true}
}
func (l *EventListener) Run(ctx context.Context) error {
backoffs := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second}
attempt := 0
for {
if err := ctx.Err(); err != nil {
return nil
}
err := l.runOnce(ctx)
if err == nil || errors.Is(err, context.Canceled) {
return nil
}
if errors.Is(err, ErrApplicationReplaced) {
return err
}
d := backoffs[min(attempt, len(backoffs)-1)]
attempt++
l.logWarn("ari websocket disconnected", "error", err, "reconnect_in", d.String())
select {
case <-ctx.Done():
return nil
case <-time.After(d):
}
}
}
var ErrApplicationReplaced = errors.New("ARI application replaced by another websocket")
func (l *EventListener) runOnce(ctx context.Context) error {
wsURL, err := BuildWebSocketURL(l.cfg, l.mode)
if err != nil {
return err
}
header := http.Header{}
if wsURL.AuthMode == WSAuthBasic || wsURL.AuthMode == WSAuthAuto {
req, _ := http.NewRequest(http.MethodGet, wsURL.URL, nil)
req.SetBasicAuth(l.cfg.ARIUser, l.cfg.ARIPassword)
header = req.Header
}
l.logInfo("connecting ari websocket", "url", wsURL.Sanitized, "auth_mode", string(wsURL.AuthMode))
conn, resp, err := l.dialer.DialContext(ctx, wsURL.URL, header)
if err != nil && wsURL.AuthMode == WSAuthAuto && resp != nil && resp.StatusCode == http.StatusUnauthorized {
fallback, ferr := BuildWebSocketURL(l.cfg, WSAuthQueryAPIKey)
if ferr != nil {
return ferr
}
l.logWarn("basic auth rejected, trying query api_key fallback", "url", fallback.Sanitized)
conn, _, err = l.dialer.DialContext(ctx, fallback.URL, nil)
}
if err != nil {
return err
}
defer conn.Close()
l.logInfo("ari websocket connected", "app", l.cfg.ARIApp)
for {
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
event, err := ParseEvent(data)
if err != nil {
l.logWarn("invalid ari event json", "error", err)
continue
}
if event.EventType() == EventApplicationReplaced {
l.logWarn("ari application replaced", "app", l.cfg.ARIApp)
if l.stopOnApplicationReplaced {
return ErrApplicationReplaced
}
}
if l.handler != nil {
if err := l.handler.HandleEvent(ctx, event); err != nil {
l.logWarn("ari event handler error", "event_type", string(event.EventType()), "error", err)
}
}
}
}
func (l *EventListener) logInfo(msg string, args ...any) {
if l.logger != nil {
l.logger.Info(msg, args...)
}
}
func (l *EventListener) logWarn(msg string, args ...any) {
if l.logger != nil {
l.logger.Warn(msg, args...)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func CheckWebSocketConnect(ctx context.Context, cfg config.AsteriskConfig, mode WSAuthMode) error {
wsURL, err := BuildWebSocketURL(cfg, mode)
if err != nil {
return err
}
header := http.Header{}
if wsURL.AuthMode == WSAuthBasic || wsURL.AuthMode == WSAuthAuto {
req, _ := http.NewRequest(http.MethodGet, wsURL.URL, nil)
req.SetBasicAuth(cfg.ARIUser, cfg.ARIPassword)
header = req.Header
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL.URL, header)
if err != nil {
return err
}
return conn.Close()
}