Skip to content

Commit 0753350

Browse files
committed
feat: implement push token report api
1 parent 81ed831 commit 0753350

File tree

11 files changed

+271
-11
lines changed

11 files changed

+271
-11
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ The proxy is configured via environment variables. Minimal required env:
1818
- `AS_USER_ID` (optional): the user ID of the Application Service bot (default: `@_acrobits_proxy:matrix.example`)
1919
- `LOGLEVEL` (optional): logging verbosity level - `DEBUG`, `INFO`, `WARNING`, `CRITICAL` (default: `INFO`)
2020
- `MAPPING_FILE` (optional): path to a JSON file containing SMS-to-Matrix mappings to load at startup
21+
- `PUSH_TOKEN_DB_PATH` (optional): path to a database file for storing push tokens
2122

2223
Building and running
2324

@@ -74,10 +75,10 @@ Implemented APIs:
7475

7576
- https://doc.acrobits.net/api/client/fetch_messages_modern.html
7677
- https://doc.acrobits.net/api/client/send_message.html
78+
- https://doc.acrobits.net/api/client/push_token_reporter.html
7779

7880
## TODO
7981

8082
The following features are not yet implemented:
8183

8284
- implement password validation on send messages, currently the password is ignored
83-
- implement https://doc.acrobits.net/api/client/push_token_reporter.html

api/routes.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ func RegisterRoutes(e *echo.Echo, svc *service.MessageService, adminToken string
1818
h := handler{svc: svc, adminToken: adminToken}
1919
e.POST("/api/client/send_message", h.sendMessage)
2020
e.POST("/api/client/fetch_messages", h.fetchMessages)
21+
e.POST("/api/client/push_token_report", h.pushTokenReport)
2122
e.POST("/api/internal/map_sms_to_matrix", h.postMapping)
2223
e.GET("/api/internal/map_sms_to_matrix", h.getMapping)
2324
}
@@ -68,6 +69,25 @@ func (h handler) fetchMessages(c echo.Context) error {
6869
return c.JSON(http.StatusOK, resp)
6970
}
7071

72+
func (h handler) pushTokenReport(c echo.Context) error {
73+
var req models.PushTokenReportRequest
74+
if err := c.Bind(&req); err != nil {
75+
logger.Warn().Str("endpoint", "push_token_report").Err(err).Msg("invalid request payload")
76+
return echo.NewHTTPError(http.StatusBadRequest, "invalid payload")
77+
}
78+
79+
logger.Debug().Str("endpoint", "push_token_report").Str("selector", req.Selector).Msg("processing push token report")
80+
81+
resp, err := h.svc.ReportPushToken(c.Request().Context(), &req)
82+
if err != nil {
83+
logger.Error().Str("endpoint", "push_token_report").Str("selector", req.Selector).Err(err).Msg("failed to report push token")
84+
return mapServiceError(err)
85+
}
86+
87+
logger.Info().Str("endpoint", "push_token_report").Str("selector", req.Selector).Msg("push token reported successfully")
88+
return c.JSON(http.StatusOK, resp)
89+
}
90+
7191
func (h handler) postMapping(c echo.Context) error {
7292
if err := h.ensureAdminAccess(c); err != nil {
7393
return err

api/routes_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package api
22

33
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
48
"testing"
59

10+
"github.com/labstack/echo/v4"
11+
"github.com/nethesis/matrix2acrobits/models"
12+
"github.com/nethesis/matrix2acrobits/service"
613
"github.com/stretchr/testify/assert"
714
)
815

@@ -28,3 +35,64 @@ func TestIsLocalhost(t *testing.T) {
2835
})
2936
}
3037
}
38+
39+
func TestPushTokenReport(t *testing.T) {
40+
e := echo.New()
41+
svc := service.NewMessageService(nil, nil)
42+
43+
t.Run("valid push token report", func(t *testing.T) {
44+
reqBody := models.PushTokenReportRequest{
45+
Selector: "12869E0E6E553673C54F29105A0647204C416A2A:7C3A0D14",
46+
TokenMsgs: "QVBBOTFiRzlhcVd2bW54bllCWldHOWh4dnRrZ3pUWFNvcGZpdWZ6bWM2dFAzS2J",
47+
AppIDMsgs: "com.cloudsoftphone.app",
48+
TokenCalls: "Udl99X2JFP1bWwS5gR/wGeLE1hmAB2CMpr1Ej0wxkrY=",
49+
AppIDCalls: "com.cloudsoftphone.app.pushkit",
50+
}
51+
52+
body, _ := json.Marshal(reqBody)
53+
req := httptest.NewRequest(http.MethodPost, "/api/client/push_token_report", bytes.NewBuffer(body))
54+
req.Header.Set("Content-Type", "application/json")
55+
rec := httptest.NewRecorder()
56+
57+
c := e.NewContext(req, rec)
58+
59+
h := handler{svc: svc, adminToken: "test"}
60+
err := h.pushTokenReport(c)
61+
62+
// Since we don't have a real database, this will fail with "database not initialized"
63+
// but we can verify the handler processes the request correctly
64+
assert.Error(t, err)
65+
})
66+
67+
t.Run("invalid json", func(t *testing.T) {
68+
req := httptest.NewRequest(http.MethodPost, "/api/client/push_token_report", bytes.NewBufferString("invalid json"))
69+
req.Header.Set("Content-Type", "application/json")
70+
rec := httptest.NewRecorder()
71+
72+
c := e.NewContext(req, rec)
73+
74+
h := handler{svc: svc, adminToken: "test"}
75+
err := h.pushTokenReport(c)
76+
77+
// Should return a bind error
78+
assert.Error(t, err)
79+
})
80+
81+
t.Run("empty selector", func(t *testing.T) {
82+
reqBody := models.PushTokenReportRequest{
83+
Selector: "",
84+
}
85+
86+
body, _ := json.Marshal(reqBody)
87+
req := httptest.NewRequest(http.MethodPost, "/api/client/push_token_report", bytes.NewBuffer(body))
88+
req.Header.Set("Content-Type", "application/json")
89+
rec := httptest.NewRecorder()
90+
91+
c := e.NewContext(req, rec)
92+
93+
h := handler{svc: svc, adminToken: "test"}
94+
err := h.pushTokenReport(c)
95+
96+
assert.Error(t, err)
97+
})
98+
}

docs/openapi.yaml

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,42 @@ paths:
108108
'401':
109109
description: Authentication failed (e.g., user not in AS namespace).
110110

111+
/api/client/push_token_report:
112+
post:
113+
summary: Report Push Token
114+
operationId: pushTokenReport
115+
description: |
116+
Receives and stores device push tokens from Acrobits clients.
117+
The tokens are persisted in a SQLite database for later use in push notification delivery.
118+
This endpoint follows the Acrobits Push Token Reporter API specification for POST JSON requests.
119+
requestBody:
120+
required: true
121+
content:
122+
application/json:
123+
schema:
124+
$ref: '#/components/schemas/PushTokenReportRequest'
125+
responses:
126+
'200':
127+
description: Push token accepted and stored
128+
content:
129+
application/json:
130+
schema:
131+
type: object
132+
description: Empty JSON object response
133+
'400':
134+
description: Invalid request payload (e.g., missing selector).
135+
'500':
136+
description: Server error (e.g., database unavailable).
137+
example:
138+
request:
139+
selector: "12869E0E6E553673C54F29105A0647204C416A2A:7C3A0D14"
140+
token_msgs: "QVBBOTFiRzlhcVd2bW54bllCWldHOWh4dnRrZ3pUWFNvcGZpdWZ6bWM2dFAzS2J"
141+
appid_msgs: "com.cloudsoftphone.app"
142+
token_calls: "Udl99X2JFP1bWwS5gR/wGeLE1hmAB2CMpr1Ej0wxkrY="
143+
appid_calls: "com.cloudsoftphone.app.pushkit"
144+
response:
145+
{}
146+
111147
/api/internal/map_sms_to_matrix:
112148
post:
113149
summary: Register or update an SMS-to-Matrix mapping
@@ -248,4 +284,35 @@ components:
248284
type: string
249285
format: date-time
250286
description: When the mapping was last written.
287+
PushTokenReportRequest:
288+
type: object
289+
required:
290+
- selector
291+
properties:
292+
selector:
293+
type: string
294+
description: |
295+
SHA1 hash with suffix that uniquely identifies the account.
296+
Used to reference the account in push notification delivery requests.
297+
Example: 12869E0E6E553673C54F29105A0647204C416A2A:7C3A0D14
298+
token_msgs:
299+
type: string
300+
description: |
301+
Base64-encoded push token for regular notifications (APNs remote-notification).
302+
Required for iOS push notifications.
303+
appid_msgs:
304+
type: string
305+
description: |
306+
Apple application ID for regular notifications.
307+
Used in conjunction with token_msgs.
308+
token_calls:
309+
type: string
310+
description: |
311+
Base64-encoded push token for VoIP/incoming call notifications (APNs pushkit).
312+
Required for iOS incoming call notifications.
313+
appid_calls:
314+
type: string
315+
description: |
316+
Apple application ID for incoming call notifications.
317+
Used in conjunction with token_calls.
251318

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ go 1.24.10
44

55
require (
66
github.com/labstack/echo/v4 v4.13.4
7+
github.com/mattn/go-sqlite3 v1.14.32
8+
github.com/rs/zerolog v1.34.0
79
github.com/stretchr/testify v1.11.1
810
maunium.net/go/mautrix v0.26.0
911
)
@@ -15,7 +17,6 @@ require (
1517
github.com/mattn/go-colorable v0.1.14 // indirect
1618
github.com/mattn/go-isatty v0.0.20 // indirect
1719
github.com/pmezard/go-difflib v1.0.0 // indirect
18-
github.com/rs/zerolog v1.34.0 // indirect
1920
github.com/tidwall/gjson v1.18.0 // indirect
2021
github.com/tidwall/match v1.1.1 // indirect
2122
github.com/tidwall/pretty v1.2.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
1515
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
1616
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
1717
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
18+
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
19+
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
1820
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
1921
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2022
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

main.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/labstack/echo/v4"
77
"github.com/labstack/echo/v4/middleware"
88
"github.com/nethesis/matrix2acrobits/api"
9+
"github.com/nethesis/matrix2acrobits/internal/db"
910
"github.com/nethesis/matrix2acrobits/logger"
1011
"github.com/nethesis/matrix2acrobits/matrix"
1112
"github.com/nethesis/matrix2acrobits/service"
@@ -65,7 +66,19 @@ func main() {
6566
logger.Fatal().Err(err).Msg("failed to initialize matrix client")
6667
}
6768

68-
svc := service.NewMessageService(matrixClient)
69+
// Initialize push token database
70+
pushTokenDBPath := os.Getenv("PUSH_TOKEN_DB_PATH")
71+
if pushTokenDBPath == "" {
72+
pushTokenDBPath = "/var/lib/matrix2acrobits/push_tokens.db"
73+
}
74+
75+
pushTokenDB, err := db.NewDatabase(pushTokenDBPath)
76+
if err != nil {
77+
logger.Fatal().Err(err).Str("path", pushTokenDBPath).Msg("failed to initialize push token database")
78+
}
79+
defer pushTokenDB.Close()
80+
81+
svc := service.NewMessageService(matrixClient, pushTokenDB)
6982
api.RegisterRoutes(e, svc, adminToken)
7083

7184
// Load mappings from file if MAPPING_FILE env var is set

main_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func startTestServer(cfg *testConfig) (*echo.Echo, error) {
146146
return nil, fmt.Errorf("initialize matrix client: %w", err)
147147
}
148148

149-
svc := service.NewMessageService(matrixClient)
149+
svc := service.NewMessageService(matrixClient, nil)
150150
api.RegisterRoutes(e, svc, cfg.adminToken)
151151

152152
go func() {

models/messages.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,15 @@ type Message struct {
4141
ContentType string `json:"content_type"`
4242
StreamID string `json:"stream_id"`
4343
}
44+
45+
// PushTokenReportRequest mirrors the Acrobits push token reporter POST JSON schema.
46+
type PushTokenReportRequest struct {
47+
Selector string `json:"selector"`
48+
TokenMsgs string `json:"token_msgs"`
49+
AppIDMsgs string `json:"appid_msgs"`
50+
TokenCalls string `json:"token_calls"`
51+
AppIDCalls string `json:"appid_calls"`
52+
}
53+
54+
// PushTokenReportResponse is the successful response for push token reporting.
55+
type PushTokenReportResponse struct{}

service/messages.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"sync"
1111
"time"
1212

13+
"github.com/nethesis/matrix2acrobits/internal/db"
1314
"github.com/nethesis/matrix2acrobits/logger"
1415
"github.com/nethesis/matrix2acrobits/matrix"
1516
"github.com/nethesis/matrix2acrobits/models"
@@ -28,6 +29,7 @@ var (
2829
// MessageService handles sending/fetching messages plus the mapping store.
2930
type MessageService struct {
3031
matrixClient *matrix.MatrixClient
32+
pushTokenDB *db.Database
3133
now func() time.Time
3234

3335
mu sync.RWMutex
@@ -42,10 +44,11 @@ type mappingEntry struct {
4244
UpdatedAt time.Time
4345
}
4446

45-
// NewMessageService wires the provided Matrix client into the service layer.
46-
func NewMessageService(matrixClient *matrix.MatrixClient) *MessageService {
47+
// NewMessageService wires the provided Matrix client and push token database into the service layer.
48+
func NewMessageService(matrixClient *matrix.MatrixClient, pushTokenDB *db.Database) *MessageService {
4749
return &MessageService{
4850
matrixClient: matrixClient,
51+
pushTokenDB: pushTokenDB,
4952
now: time.Now,
5053
mappings: make(map[string]mappingEntry),
5154
}
@@ -479,3 +482,38 @@ func isPhoneNumber(s string) bool {
479482
func isPhoneNumberRune(r rune) bool {
480483
return (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '+' || r == '(' || r == ')'
481484
}
485+
486+
// ReportPushToken saves a push token to the database.
487+
// It accepts selector, token_msgs, appid_msgs, token_calls, and appid_calls from the Acrobits client.
488+
func (s *MessageService) ReportPushToken(ctx context.Context, req *models.PushTokenReportRequest) (*models.PushTokenReportResponse, error) {
489+
if req == nil {
490+
return nil, errors.New("request cannot be nil")
491+
}
492+
493+
selector := strings.TrimSpace(req.Selector)
494+
if selector == "" {
495+
logger.Warn().Msg("push token report: empty selector")
496+
return nil, errors.New("selector is required")
497+
}
498+
499+
if s.pushTokenDB == nil {
500+
logger.Warn().Msg("push token report: database not initialized")
501+
return nil, errors.New("push token storage not available")
502+
}
503+
504+
// Save to database
505+
err := s.pushTokenDB.SavePushToken(
506+
selector,
507+
req.TokenMsgs,
508+
req.AppIDMsgs,
509+
req.TokenCalls,
510+
req.AppIDCalls,
511+
)
512+
if err != nil {
513+
logger.Error().Err(err).Str("selector", selector).Msg("failed to save push token")
514+
return nil, fmt.Errorf("failed to save push token: %w", err)
515+
}
516+
517+
logger.Info().Str("selector", selector).Msg("push token reported and saved")
518+
return &models.PushTokenReportResponse{}, nil
519+
}

0 commit comments

Comments
 (0)