sync: migrate ai-operator to Gitea (2026-08-10)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user