Skip to content

Commit cc37ec2

Browse files
committed
fix: improve number mapping
1 parent 6783f1a commit cc37ec2

File tree

5 files changed

+262
-6
lines changed

5 files changed

+262
-6
lines changed

docs/DEPLOY_NS8.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,43 @@ curl -s -X POST https://synapse.gs.nethserver.net/m2a/api/client/send_message
140140

141141
Map SMS number (201) to Matrix user (giacomo):
142142
```
143-
144143
curl -v -X POST "http://127.0.0.1:8080/api/internal/map_sms_to_matrix" -H "Content-Type: application/json" -H "X-Super-Admin-Token: secret" -d '{
145144
"sms_number": "201",
146145
"matrix_id": "@giacomo:synapse.gs.nethserver.net",
147146
"room_id": "!giacomo-room:synapse.gs.nethserver.net"
148147
}'
149148
```
150149

151-
Retrieve current mapping:
150+
Map SMS number (202) to Matrix user (mario):
151+
```
152+
curl -v -X POST "http://127.0.0.1:8080/api/internal/map_sms_to_matrix" -H "Content-Type: application/json" -H "X-Super-Admin-Token: secret" -d '{
153+
"sms_number": "202",
154+
"matrix_id": "@mario:synapse.gs.nethserver.net",
155+
"room_id": "!mario-room:synapse.gs.nethserver.net"
156+
}'
157+
```
158+
159+
Retrieve current mappings:
160+
```
161+
curl "http://127.0.0.1:8080/api/internal/map_sms_to_matrix" -H "X-Super-Admin-Token: secret"
152162
```
153-
curl -v -G "http://127.0.0.1:8080/api/internal/map_sms_to_matrix" -H "X-Super-Admin-Token: secret" --data-urlencode "sms_number=201"
163+
164+
Send message using mapped SMS number (201) - Giacomo to Mario:
154165
```
166+
curl -s -X POST https://synapse.gs.nethserver.net/m2a/api/client/send_message -H "Content-Type: application/json" -d '{
167+
"from": "@giacomo:synapse.gs.nethserver.net",
168+
"sms_to": "202",
169+
"sms_body": "Hello Mario — this is Giacomo (curl test using mapped number)",
170+
"content_type": "text/plain"
171+
}'
172+
```
173+
174+
Send message using mapped SMS number (202) - Mario to Giacomo:
175+
```
176+
curl -s -X POST https://synapse.gs.nethserver.net/m2a/api/client/send_message -H "Content-Type: application/json" -d '{
177+
"from": "@mario:synapse.gs.nethserver.net",
178+
"sms_to": "201",
179+
"sms_body": "Hello Giacomo — this is Mario reply (curl test using mapped number)",
180+
"content_type": "text/plain"
181+
}'
182+
```

docs/openapi.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ paths:
5454
operationId: sendMessage
5555
description: |
5656
Sends an outgoing message on behalf of a user.
57+
58+
The `from` field can be:
59+
- A full Matrix ID (e.g., @user:server.com) - used directly
60+
- A phone number (e.g., +1234567890) - resolved to Matrix ID via SMS-to-Matrix mapping if available
61+
62+
If a phone number is provided and a mapping exists, the message is sent on behalf of the mapped Matrix user.
63+
If a phone number is provided but no mapping exists, the phone number is used as the sender ID.
64+
5765
Authentication is handled by the Application Service backend; the `password` field is ignored.
5866
requestBody:
5967
required: true
@@ -68,7 +76,7 @@ paths:
6876
properties:
6977
from:
7078
type: string
71-
description: The full Matrix ID (e.g., @user:server.com) of the user to impersonate.
79+
description: The sender - either a full Matrix ID (e.g., @user:server.com) or a phone number (e.g., +1234567890).
7280
password:
7381
type: string
7482
description: This field is ignored. Kept for client compatibility.

main_test.go

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/labstack/echo/v4"
18+
"github.com/labstack/echo/v4/middleware"
1719
"github.com/nethesis/matrix2acrobits/api"
1820
"github.com/nethesis/matrix2acrobits/matrix"
1921
"github.com/nethesis/matrix2acrobits/models"
2022
"github.com/nethesis/matrix2acrobits/service"
21-
"github.com/labstack/echo/v4"
22-
"github.com/labstack/echo/v4/middleware"
2323
"maunium.net/go/mautrix/id"
2424
)
2525

@@ -420,6 +420,112 @@ func TestIntegration_MappingAPI(t *testing.T) {
420420
})
421421
}
422422

423+
func TestIntegration_SendMessageWithPhoneNumberMapping(t *testing.T) {
424+
cfg := checkTestEnv(t)
425+
426+
// This test verifies that phone numbers in the 'from' field are resolved to Matrix IDs via mapping
427+
if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
428+
t.Skip("Skipping integration tests; set RUN_INTEGRATION_TESTS=1 to run.")
429+
}
430+
431+
server, err := startTestServer(cfg)
432+
if err != nil {
433+
t.Fatalf("failed to start test server: %v", err)
434+
}
435+
defer stopTestServer(server)
436+
437+
baseURL := "http://127.0.0.1:" + testServerPort
438+
user1Localpart := getLocalpart(cfg.user1)
439+
user2Localpart := getLocalpart(cfg.user2)
440+
user1MatrixID := fmt.Sprintf("@%s:%s", user1Localpart, cfg.serverName)
441+
user2MatrixID := fmt.Sprintf("@%s:%s", user2Localpart, cfg.serverName)
442+
443+
t.Run("SendMessageWithPhoneNumberFromField", func(t *testing.T) {
444+
// Step 1: Create a room as user1
445+
matrixClient, err := matrix.NewClient(matrix.Config{
446+
HomeserverURL: cfg.homeserverURL,
447+
AsUserID: id.UserID(cfg.asUser),
448+
AsToken: cfg.adminToken,
449+
})
450+
if err != nil {
451+
t.Fatalf("failed to create matrix client: %v", err)
452+
}
453+
454+
roomName := fmt.Sprintf("Phone Test Room %d", time.Now().Unix())
455+
createResp, err := matrixClient.CreateRoom(context.Background(), id.UserID(user1MatrixID), roomName, []id.UserID{id.UserID(user2MatrixID)})
456+
if err != nil {
457+
t.Fatalf("failed to create room: %v", err)
458+
}
459+
roomID := createResp.RoomID
460+
t.Logf("Created room %s", roomID)
461+
462+
// Step 2: Join the room as user2
463+
_, err = matrixClient.JoinRoom(context.Background(), id.UserID(user2MatrixID), roomID)
464+
if err != nil {
465+
t.Fatalf("failed for user2 to join room: %v", err)
466+
}
467+
t.Logf("User2 joined room %s", roomID)
468+
time.Sleep(1 * time.Second)
469+
470+
// Step 3: Create a mapping from user1's phone number to user1's Matrix ID
471+
phoneNumber := cfg.user1Number
472+
mappingReq := models.MappingRequest{
473+
SMSNumber: phoneNumber,
474+
MatrixID: user1MatrixID,
475+
RoomID: string(roomID),
476+
}
477+
headers := map[string]string{
478+
"X-Super-Admin-Token": cfg.adminToken,
479+
}
480+
resp, body, err := doRequest("POST", baseURL+"/api/internal/map_sms_to_matrix", mappingReq, headers)
481+
if err != nil {
482+
t.Fatalf("failed to create mapping: %v", err)
483+
}
484+
if resp.StatusCode != http.StatusOK {
485+
t.Fatalf("failed to create mapping: expected 200, got %d: %s", resp.StatusCode, string(body))
486+
}
487+
t.Logf("Created mapping: %s → %s", phoneNumber, user1MatrixID)
488+
489+
// Step 4: Send a message using the phone number as the 'from' field
490+
sendReq := models.SendMessageRequest{
491+
From: phoneNumber, // Using phone number instead of Matrix ID
492+
SMSTo: string(roomID),
493+
SMSBody: fmt.Sprintf("Message from phone number %d", time.Now().Unix()),
494+
}
495+
resp, body, err = doRequest("POST", baseURL+"/api/client/send_message", sendReq, nil)
496+
if err != nil {
497+
t.Fatalf("send message request failed: %v", err)
498+
}
499+
if resp.StatusCode != http.StatusOK {
500+
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(body))
501+
}
502+
var sendResp models.SendMessageResponse
503+
if err := json.Unmarshal(body, &sendResp); err != nil {
504+
t.Fatalf("failed to parse response: %v", err)
505+
}
506+
t.Logf("Message sent from phone number: %s", sendResp.SMSID)
507+
508+
// Step 5: Verify that user2 sees the message from the mapped Matrix user (user1)
509+
time.Sleep(2 * time.Second)
510+
fetchResp, err := fetchMessagesWithRetry(t, baseURL, user2MatrixID, 10*time.Second)
511+
if err != nil {
512+
t.Fatalf("fetch messages failed: %v", err)
513+
}
514+
515+
foundPhoneMessage := false
516+
for _, msg := range fetchResp.ReceivedSMSS {
517+
if strings.Contains(msg.SMSText, "Message from phone number") && msg.Sender == user1MatrixID {
518+
foundPhoneMessage = true
519+
t.Logf("User2 received message from phone-mapped user: sender=%s, text=%s", msg.Sender, msg.SMSText)
520+
break
521+
}
522+
}
523+
if !foundPhoneMessage {
524+
t.Errorf("user2 did not receive message sent from phone number mapping")
525+
}
526+
})
527+
}
528+
423529
func TestIntegration_RoomMessaging(t *testing.T) {
424530
cfg := checkTestEnv(t)
425531

service/messages.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ func (s *MessageService) SendMessage(ctx context.Context, req *models.SendMessag
5656
return nil, ErrAuthentication
5757
}
5858

59+
// If 'From' is a phone number, try to map it to a Matrix user
60+
if isPhoneNumber(req.From) {
61+
logger.Debug().Str("from", req.From).Msg("sender is a phone number, attempting to resolve to Matrix user")
62+
if entry, ok := s.getMapping(req.From); ok && entry.MatrixID != "" {
63+
resolvedUserID := id.UserID(entry.MatrixID)
64+
logger.Info().Str("phone_number", req.From).Str("matrix_id", entry.MatrixID).Msg("phone number mapped to Matrix user")
65+
userID = resolvedUserID
66+
} else {
67+
logger.Warn().Str("phone_number", req.From).Msg("phone number mapping not found, using original sender ID")
68+
}
69+
}
70+
5971
logger.Debug().Str("user_id", string(userID)).Str("recipient", req.SMSTo).Msg("resolving recipient")
6072

6173
roomID, err := s.resolveRecipient(ctx, userID, req.SMSTo)
@@ -319,3 +331,34 @@ func mapAuthErr(err error) error {
319331
}
320332
return err
321333
}
334+
335+
// isPhoneNumber checks if a string looks like a phone number.
336+
// Returns true if the string contains only digits, spaces, hyphens, plus signs, and/or parentheses.
337+
func isPhoneNumber(s string) bool {
338+
if s == "" {
339+
return false
340+
}
341+
trimmed := strings.TrimSpace(s)
342+
// Check if it starts with @ or ! or #, indicating it's a Matrix ID/room ID/alias
343+
if strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "!") || strings.HasPrefix(trimmed, "#") {
344+
return false
345+
}
346+
// A phone number contains only digits and optional formatting characters
347+
for _, r := range trimmed {
348+
if !isPhoneNumberRune(r) {
349+
return false
350+
}
351+
}
352+
// Must contain at least one digit
353+
for _, r := range trimmed {
354+
if r >= '0' && r <= '9' {
355+
return true
356+
}
357+
}
358+
return false
359+
}
360+
361+
// isPhoneNumberRune checks if a rune is a valid character in a phone number
362+
func isPhoneNumberRune(r rune) bool {
363+
return (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '+' || r == '(' || r == ')'
364+
}

service/messages_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,3 +194,74 @@ func TestListMappings(t *testing.T) {
194194
t.Fatalf("missing mapping for +222")
195195
}
196196
}
197+
198+
func TestIsPhoneNumber(t *testing.T) {
199+
tests := []struct {
200+
name string
201+
input string
202+
expected bool
203+
}{
204+
{
205+
name: "Simple phone number",
206+
input: "1234567890",
207+
expected: true,
208+
},
209+
{
210+
name: "Phone with plus prefix",
211+
input: "+1234567890",
212+
expected: true,
213+
},
214+
{
215+
name: "Phone with hyphens",
216+
input: "123-456-7890",
217+
expected: true,
218+
},
219+
{
220+
name: "Phone with spaces",
221+
input: "123 456 7890",
222+
expected: true,
223+
},
224+
{
225+
name: "Phone with parentheses",
226+
input: "(123) 456-7890",
227+
expected: true,
228+
},
229+
{
230+
name: "Matrix user ID",
231+
input: "@user:example.com",
232+
expected: false,
233+
},
234+
{
235+
name: "Room ID",
236+
input: "!room123:example.com",
237+
expected: false,
238+
},
239+
{
240+
name: "Room alias",
241+
input: "#alias:example.com",
242+
expected: false,
243+
},
244+
{
245+
name: "Empty string",
246+
input: "",
247+
expected: false,
248+
},
249+
{
250+
name: "Only formatting chars",
251+
input: "+-() ",
252+
expected: false,
253+
},
254+
{
255+
name: "With invalid characters",
256+
input: "123-ABC",
257+
expected: false,
258+
},
259+
}
260+
261+
for _, tt := range tests {
262+
t.Run(tt.name, func(t *testing.T) {
263+
result := isPhoneNumber(tt.input)
264+
assert.Equal(t, tt.expected, result)
265+
})
266+
}
267+
}

0 commit comments

Comments
 (0)