[messages] add get sent messages endpoint

This commit is contained in:
Aleksandr Soloshenko 2025-08-06 06:04:13 +07:00 committed by Aleksandr
parent f496d676a7
commit 367729489c
14 changed files with 498 additions and 49 deletions

2
go.mod
View File

@ -6,7 +6,7 @@ toolchain go1.23.2
require (
firebase.google.com/go/v4 v4.12.1
github.com/android-sms-gateway/client-go v1.9.1
github.com/android-sms-gateway/client-go v1.9.2
github.com/ansrivas/fiberprometheus/v2 v2.6.1
github.com/capcom6/go-helpers v0.3.0
github.com/capcom6/go-infra-fx v0.2.3

6
go.sum
View File

@ -28,6 +28,12 @@ github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEV
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/android-sms-gateway/client-go v1.9.1 h1:i9hKf+kgaJo9ykWKoeno3MliOThoIlwO0N2eed6bY+Q=
github.com/android-sms-gateway/client-go v1.9.1/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.9.2-0.20250805133721-9ebe535d038a h1:ibYxk2m1Qcwydi/GDHpDnUCcLORqLpYGxm6XModDFnM=
github.com/android-sms-gateway/client-go v1.9.2-0.20250805133721-9ebe535d038a/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.9.2-0.20250805235002-a840b364bc1d h1:gurnY2hszJ1Yn8vt81Qn9yF/8zcmJK4YzhZAgsYUt/4=
github.com/android-sms-gateway/client-go v1.9.2-0.20250805235002-a840b364bc1d/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.9.2 h1:e9HFgvR+LRMV0dOJvFkxt998UxOMWNf8hfnXwMIc39I=
github.com/android-sms-gateway/client-go v1.9.2/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/ansrivas/fiberprometheus/v2 v2.6.1 h1:wac3pXaE6BYYTF04AC6K0ktk6vCD+MnDOJZ3SK66kXM=

View File

@ -5,7 +5,7 @@ import (
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/messages"
)
func MessageToDTO(m messages.MessageOut) smsgateway.MobileMessage {
func MessageToMobileDTO(m messages.MessageOut) smsgateway.MobileMessage {
var message string
var textMessage *smsgateway.TextMessage
var dataMessage *smsgateway.DataMessage
@ -41,3 +41,15 @@ func MessageToDTO(m messages.MessageOut) smsgateway.MobileMessage {
CreatedAt: m.CreatedAt,
}
}
func MessageStateToDTO(state messages.MessageStateOut) smsgateway.MessageState {
return smsgateway.MessageState{
ID: state.ID,
DeviceID: state.DeviceID,
State: smsgateway.ProcessingState(state.State),
IsHashed: state.IsHashed,
IsEncrypted: state.IsEncrypted,
Recipients: state.Recipients,
States: state.States,
}
}

View File

@ -79,7 +79,7 @@ func TestMessageToDTO(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Call the function under test
result := converters.MessageToDTO(tc.input)
result := converters.MessageToMobileDTO(tc.input)
// Assert the results
assert.Equal(t, tc.expected, result)

View File

@ -3,10 +3,12 @@ package messages
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/base"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/converters"
"github.com/android-sms-gateway/server/internal/sms-gateway/handlers/middlewares/userauth"
"github.com/android-sms-gateway/server/internal/sms-gateway/models"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/devices"
@ -169,6 +171,42 @@ func (h *ThirdPartyController) post(user models.User, c *fiber.Ctx) error {
})
}
// @Summary Get messages
// @Description Retrieves a list of messages with filtering and pagination
// @Security ApiAuth
// @Tags User, Messages
// @Produce json
// @Param from query string false "Start date in RFC3339 format" Format(date-time)
// @Param to query string false "End date in RFC3339 format" Format(date-time)
// @Param state query string false "Filter messages by processing state" Enum(Pending, Processed, Sent, Delivered, Failed)
// @Param deviceId query string false "Filter by device ID" min(21) max(21)
// @Param limit query int false "Pagination limit" default(50) min(1) max(100)
// @Param offset query int false "Pagination offset" default(0)
// @Success 200 {object} smsgateway.GetMessagesResponse "A list of messages"
// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /3rdparty/v1/messages [get]
//
// Get message history
func (h *ThirdPartyController) list(user models.User, c *fiber.Ctx) error {
params := getQueryParams{}
if err := h.QueryParserValidator(c, &params); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
messages, total, err := h.messagesSvc.SelectStates(user, params.ToFilter(), params.ToOptions())
if err != nil {
h.Logger.Error("Failed to get message history", zap.Error(err), zap.String("user_id", user.ID))
return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve message history")
}
c.Set("X-Total-Count", strconv.Itoa(int(total)))
return c.JSON(
slices.Map(messages, converters.MessageStateToDTO),
)
}
// @Summary Get message state
// @Description Returns message state by ID
// @Security ApiAuth
@ -194,15 +232,7 @@ func (h *ThirdPartyController) get(user models.User, c *fiber.Ctx) error {
return err
}
return c.JSON(smsgateway.GetMessageResponse{
ID: state.ID,
DeviceID: state.DeviceID,
State: smsgateway.ProcessingState(state.State),
IsHashed: state.IsHashed,
IsEncrypted: state.IsEncrypted,
Recipients: state.Recipients,
States: state.States,
})
return c.JSON(converters.MessageStateToDTO(state))
}
// @Summary Request inbox messages export
@ -242,8 +272,9 @@ func (h *ThirdPartyController) postInboxExport(user models.User, c *fiber.Ctx) e
}
func (h *ThirdPartyController) Register(router fiber.Router) {
router.Get("", userauth.WithUser(h.list))
router.Post("", userauth.WithUser(h.post))
router.Get(":id", userauth.WithUser(h.get))
router.Get(":id", userauth.WithUser(h.get)).Name(route3rdPartyGetMessage)
router.Post("inbox/export", userauth.WithUser(h.postInboxExport))
}

View File

@ -1,6 +1,75 @@
package messages
import (
"fmt"
"time"
"github.com/android-sms-gateway/server/internal/sms-gateway/modules/messages"
)
type postQueryParams struct {
SkipPhoneValidation bool `query:"skipPhoneValidation"`
DeviceActiveWithin uint `query:"deviceActiveWithin"`
}
type getQueryParams struct {
StartDate string `query:"from" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"`
EndDate string `query:"to" validate:"omitempty,datetime=2006-01-02T15:04:05Z07:00"`
State string `query:"state" validate:"omitempty,oneof=Pending Processed Sent Delivered Failed"`
DeviceID string `query:"deviceId" validate:"omitempty,len=21"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Offset int `query:"offset" validate:"omitempty,min=0"`
}
func (p *getQueryParams) Validate() error {
if p.StartDate != "" && p.EndDate != "" && p.StartDate > p.EndDate {
return fmt.Errorf("`from` date must be before `to` date")
}
return nil
}
func (p *getQueryParams) ToFilter() messages.MessagesSelectFilter {
filter := messages.MessagesSelectFilter{}
if p.StartDate != "" {
if t, err := time.Parse(time.RFC3339, p.StartDate); err == nil {
filter.StartDate = t
}
}
if p.EndDate != "" {
if t, err := time.Parse(time.RFC3339, p.EndDate); err == nil {
filter.EndDate = t
}
}
if p.State != "" {
filter.State = messages.ProcessingState(p.State)
}
if p.DeviceID != "" {
filter.DeviceID = p.DeviceID
}
return filter
}
func (p *getQueryParams) ToOptions() messages.MessagesSelectOptions {
options := messages.MessagesSelectOptions{
WithRecipients: true,
WithStates: true,
}
if p.Limit > 0 {
options.Limit = min(p.Limit, 100)
} else {
options.Limit = 50
}
if p.Offset > 0 {
options.Offset = p.Offset
}
return options
}

View File

@ -172,7 +172,7 @@ func (h *mobileHandler) getMessage(device models.Device, c *fiber.Ctx) error {
smsgateway.MobileGetMessagesResponse(
slices.Map(
msgs,
converters.MessageToDTO,
converters.MessageToMobileDTO,
),
),
)

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-sql-driver/mysql"
@ -15,46 +16,108 @@ const hashingLockName = "36444143-1ace-4dbf-891c-cc505911497e"
var ErrMessageNotFound = gorm.ErrRecordNotFound
var ErrMessageAlreadyExists = errors.New("duplicate id")
var ErrMultipleMessagesFound = errors.New("multiple messages found")
type repository struct {
db *gorm.DB
}
func (r *repository) SelectPending(deviceID string) (messages []Message, err error) {
err = r.db.
Where("device_id = ? AND state = ?", deviceID, ProcessingStatePending).
Order("priority DESC, id DESC").
Limit(100).
Preload("Recipients").
Find(&messages).
Error
func (r *repository) Select(filter MessagesSelectFilter, options MessagesSelectOptions) ([]Message, int64, error) {
query := r.db.Model(&Message{})
return
// Apply date range filter
if !filter.StartDate.IsZero() {
query = query.Where("messages.created_at >= ?", filter.StartDate)
}
if !filter.EndDate.IsZero() {
query = query.Where("messages.created_at < ?", filter.EndDate)
}
// Apply ID filter
if filter.ExtID != "" {
query = query.Where("messages.ext_id = ?", filter.ExtID)
}
// Apply user filter
if filter.UserID != "" {
query = query.
Joins("JOIN devices ON messages.device_id = devices.id").
Where("devices.user_id = ?", filter.UserID)
}
// Apply state filter
if filter.State != "" {
query = query.Where("messages.state = ?", filter.State)
}
// Apply device filter
if filter.DeviceID != "" {
query = query.Where("messages.device_id = ?", filter.DeviceID)
}
// Get total count
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination
if options.Limit > 0 {
query = query.Limit(options.Limit)
}
if options.Offset > 0 {
query = query.Offset(options.Offset)
}
// Apply ordering
query = query.Order("messages.priority DESC, messages.id DESC")
// Preload related data
if options.WithRecipients {
query = query.Preload("Recipients")
}
if filter.UserID == "" && options.WithDevice {
query = query.Joins("Device")
}
if options.WithStates {
query = query.Preload("States")
}
messages := make([]Message, 0, min(options.Limit, int(total)))
if err := query.Find(&messages).Error; err != nil {
return nil, 0, fmt.Errorf("can't select messages: %w", err)
}
return messages, total, nil
}
func (r *repository) Get(ID string, filter MessagesSelectFilter, options ...MessagesSelectOptions) (message Message, err error) {
query := r.db.Model(&message).
Where("ext_id = ?", ID)
func (r *repository) SelectPending(deviceID string) ([]Message, error) {
messages, _, err := r.Select(MessagesSelectFilter{
DeviceID: deviceID,
State: ProcessingStatePending,
}, MessagesSelectOptions{
WithRecipients: true,
Limit: 100,
})
if filter.DeviceID != "" {
query = query.Where("device_id = ?", filter.DeviceID)
return messages, err
}
func (r *repository) Get(filter MessagesSelectFilter, options MessagesSelectOptions) (Message, error) {
messages, _, err := r.Select(filter, options)
if err != nil {
return Message{}, fmt.Errorf("can't get message: %w", err)
}
if len(options) > 0 {
if options[0].WithRecipients {
query = query.Preload("Recipients")
}
if options[0].WithDevice {
query = query.Joins("Device")
}
if options[0].WithStates {
query = query.Preload("States")
}
if len(messages) == 0 {
return Message{}, ErrMessageNotFound
}
err = query.Take(&message).Error
if len(messages) > 1 {
return Message{}, ErrMultipleMessagesFound
}
return
return messages[0], nil
}
func (r *repository) Insert(message *Message) error {

View File

@ -1,11 +1,21 @@
package messages
import "time"
type MessagesSelectFilter struct {
DeviceID string
ExtID string
UserID string
DeviceID string
StartDate time.Time
EndDate time.Time
State ProcessingState
}
type MessagesSelectOptions struct {
WithRecipients bool
WithDevice bool
WithStates bool
Limit int
Offset int
}

View File

@ -102,7 +102,7 @@ func (s *Service) SelectPending(deviceID string) ([]MessageOut, error) {
}
func (s *Service) UpdateState(deviceID string, message MessageStateIn) error {
existing, err := s.messages.Get(message.ID, MessagesSelectFilter{DeviceID: deviceID})
existing, err := s.messages.Get(MessagesSelectFilter{ExtID: message.ID, DeviceID: deviceID}, MessagesSelectOptions{})
if err != nil {
return err
}
@ -132,20 +132,26 @@ func (s *Service) UpdateState(deviceID string, message MessageStateIn) error {
return nil
}
func (s *Service) SelectStates(user models.User, filter MessagesSelectFilter, options MessagesSelectOptions) ([]MessageStateOut, int64, error) {
filter.UserID = user.ID
messages, total, err := s.messages.Select(filter, options)
if err != nil {
return nil, 0, fmt.Errorf("can't select messages: %w", err)
}
return slices.Map(messages, modelToMessageState), total, nil
}
func (s *Service) GetState(user models.User, ID string) (MessageStateOut, error) {
message, err := s.messages.Get(
ID,
MessagesSelectFilter{},
MessagesSelectFilter{ExtID: ID, UserID: user.ID},
MessagesSelectOptions{WithRecipients: true, WithDevice: true, WithStates: true},
)
if err != nil {
return MessageStateOut{}, ErrMessageNotFound
}
if message.Device.UserID != user.ID {
return MessageStateOut{}, ErrMessageNotFound
}
return modelToMessageState(message), nil
}

View File

@ -14,7 +14,7 @@ Authorization: Bearer {{mobileToken}}
###
POST {{baseUrl}}/device HTTP/1.1
# Authorization: Bearer 123456789
Authorization: Basic {{credentials}}
# Authorization: Basic {{credentials}}
# Authorization: Code 065379
Content-Type: application/json

View File

@ -74,6 +74,14 @@ Authorization: Basic {{credentials}}
GET {{baseUrl}}/3rdparty/v1/messages/K56aIsVsQ2rECdv_ajzTd HTTP/1.1
Authorization: Basic {{credentials}}
###
GET {{baseUrl}}/3rdparty/v1/messages HTTP/1.1
Authorization: Basic {{credentials}}
###
GET {{baseUrl}}/3rdparty/v1/messages?from=2025-01-01T00:00:00.000Z&to=2025-12-31T23:59:59Z&state=Pending&deviceId=fL2m4IirEvh9BvTf6TIB0&limit=50&offset=0 HTTP/1.1
Authorization: Basic {{credentials}}
###
POST {{baseUrl}}/3rdparty/v1/messages/inbox/export HTTP/1.1
Authorization: Basic {{credentials}}

View File

@ -265,6 +265,93 @@
}
},
"/3rdparty/v1/messages": {
"get": {
"security": [
{
"ApiAuth": []
}
],
"description": "Retrieves a list of messages with filtering and pagination",
"produces": [
"application/json"
],
"tags": [
"User",
"Messages"
],
"summary": "Get messages",
"parameters": [
{
"type": "string",
"format": "date-time",
"description": "Start date in RFC3339 format",
"name": "from",
"in": "query"
},
{
"type": "string",
"format": "date-time",
"description": "End date in RFC3339 format",
"name": "to",
"in": "query"
},
{
"type": "string",
"description": "Filter messages by processing state",
"name": "state",
"in": "query"
},
{
"type": "string",
"description": "Filter by device ID",
"name": "deviceId",
"in": "query"
},
{
"type": "integer",
"default": 50,
"description": "Pagination limit",
"name": "limit",
"in": "query"
},
{
"type": "integer",
"default": 0,
"description": "Pagination offset",
"name": "offset",
"in": "query"
}
],
"responses": {
"200": {
"description": "A list of messages",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/smsgateway.MessageState"
}
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
@ -1613,6 +1700,63 @@
"PriorityMaximum"
]
},
"smsgateway.MessageState": {
"type": "object",
"required": [
"deviceId",
"id",
"recipients",
"state"
],
"properties": {
"deviceId": {
"description": "Device ID",
"type": "string",
"maxLength": 21,
"example": "PyDmBQZZXYmyxMwED8Fzy"
},
"id": {
"description": "Message ID",
"type": "string",
"maxLength": 36,
"example": "PyDmBQZZXYmyxMwED8Fzy"
},
"isEncrypted": {
"description": "Encrypted",
"type": "boolean",
"example": false
},
"isHashed": {
"description": "Hashed",
"type": "boolean",
"example": false
},
"recipients": {
"description": "Recipients states",
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/smsgateway.RecipientState"
}
},
"state": {
"description": "State",
"allOf": [
{
"$ref": "#/definitions/smsgateway.ProcessingState"
}
],
"example": "Pending"
},
"states": {
"description": "History of states",
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"smsgateway.MessagesExportRequest": {
"type": "object",
"required": [

View File

@ -309,6 +309,48 @@ definitions:
- PriorityDefault
- PriorityBypassThreshold
- PriorityMaximum
smsgateway.MessageState:
properties:
deviceId:
description: Device ID
example: PyDmBQZZXYmyxMwED8Fzy
maxLength: 21
type: string
id:
description: Message ID
example: PyDmBQZZXYmyxMwED8Fzy
maxLength: 36
type: string
isEncrypted:
description: Encrypted
example: false
type: boolean
isHashed:
description: Hashed
example: false
type: boolean
recipients:
description: Recipients states
items:
$ref: '#/definitions/smsgateway.RecipientState'
minItems: 1
type: array
state:
allOf:
- $ref: '#/definitions/smsgateway.ProcessingState'
description: State
example: Pending
states:
additionalProperties:
type: string
description: History of states
type: object
required:
- deviceId
- id
- recipients
- state
type: object
smsgateway.MessagesExportRequest:
properties:
deviceId:
@ -919,6 +961,64 @@ paths:
- System
- Logs
/3rdparty/v1/messages:
get:
description: Retrieves a list of messages with filtering and pagination
parameters:
- description: Start date in RFC3339 format
format: date-time
in: query
name: from
type: string
- description: End date in RFC3339 format
format: date-time
in: query
name: to
type: string
- description: Filter messages by processing state
in: query
name: state
type: string
- description: Filter by device ID
in: query
name: deviceId
type: string
- default: 50
description: Pagination limit
in: query
name: limit
type: integer
- default: 0
description: Pagination offset
in: query
name: offset
type: integer
produces:
- application/json
responses:
"200":
description: A list of messages
schema:
items:
$ref: '#/definitions/smsgateway.MessageState'
type: array
"400":
description: Invalid request
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Get messages
tags:
- User
- Messages
post:
consumes:
- application/json