Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,24 @@ Sometimes it happens that the phone doesn't get the push notification in time an
possible to set a timeout for which a message is valid and if a message becomes expired after the timeout elapses, you
will be notified.

### USSD Support

You can send and receive USSD (Unstructured Supplementary Service Data) messages through your Android phone.
USSD sessions are fully tracked with status management (pending, active, completed, failed, timed-out) and
support both MO-to-App (mobile originated) and App-to-MO (application originated) directions.

```go
// Sending a USSD response using Go
client := httpsms.New(httpsms.WithAPIKey(/* API Key */))

client.USSD.Send(context.Background(), &httpsms.USSDSendParams{
To: "+18005550199",
From: "+18005550100",
Content: "Welcome to USSD menu",
SessionID: "USSDSESSION12345",
})
```

## API Clients

- [x] Go: https://github.com/NdoleStudio/httpsms-go
Expand Down
61 changes: 61 additions & 0 deletions api/pkg/di/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ func NewContainer(projectID string, version string) (container *Container) {
container.RegisterPhoneAPIKeyRoutes()
container.RegisterPhoneAPIKeyListeners()

container.RegisterUSSDListeners()
container.RegisterUSSDRoutes()

container.RegisterMarketingListeners()
container.RegisterWebsocketListeners()

Expand Down Expand Up @@ -414,6 +417,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK (
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneAPIKey{})))
}

if err = db.AutoMigrate(&entities.USSD{}); err != nil {
container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.USSD{})))
}

return container.db
}

Expand Down Expand Up @@ -1793,6 +1800,60 @@ func (container *Container) UserRistrettoCache() *ristretto.Cache[string, entiti
return ristrettoCache
}

// USSDRepository creates a new instance of repositories.USSDRepository
func (container *Container) USSDRepository() repositories.USSDRepository {
container.logger.Debug("creating GORM repositories.USSDRepository")
return repositories.NewGormUSSDRepository(
container.Logger(),
container.Tracer(),
container.DB(),
)
}

// USSDService creates a new instance of services.USSDService
func (container *Container) USSDService() *services.USSDService {
container.logger.Debug("creating services.USSDService")
return services.NewUSSDService(
container.Logger(),
container.Tracer(),
container.USSDRepository(),
container.EventDispatcher(),
)
}

// USSDHandlerValidator creates a new instance of validators.USSDHandlerValidator
func (container *Container) USSDHandlerValidator() *validators.USSDHandlerValidator {
container.logger.Debug("creating validators.USSDHandlerValidator")
return validators.NewUSSDHandlerValidator(
container.Logger(),
container.Tracer(),
)
}

// USSDHandler creates a new instance of handlers.USSDHandler
func (container *Container) USSDHandler() *handlers.USSDHandler {
container.logger.Debug("creating handlers.USSDHandler")
return handlers.NewUSSDHandler(
container.Logger(),
container.Tracer(),
container.USSDService(),
container.USSDHandlerValidator(),
)
}

// RegisterUSSDRoutes registers routes for the /ussd prefix
func (container *Container) RegisterUSSDRoutes() {
container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.USSDHandler{}))
container.USSDHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware())
container.USSDHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware())
}

// RegisterUSSDListeners registers event listeners for USSD events
func (container *Container) RegisterUSSDListeners() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add listener to delete all USSD messages when the phone is deleted and when the user account is deleted.

container.logger.Debug(fmt.Sprintf("registering listeners for USSD"))
// USSD events are handled directly by the service, no additional listeners needed
}

// InitializeTraceProvider initializes the open telemetry trace provider
func (container *Container) InitializeTraceProvider() func() {
return container.initializeAxiomTraceProvider(container.version, container.projectID)
Expand Down
116 changes: 116 additions & 0 deletions api/pkg/entities/ussd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package entities

import (
"time"

"github.com/google/uuid"
)

// USSDType represents the type of USSD session
type USSDType string

const (
// USSDTypeRequest is a USSD request from a mobile phone
USSDTypeRequest = USSDType("request")
// USSDTypeResponse is a USSD response from the application
USSDTypeResponse = USSDType("response")
)

// USSDDirection represents the direction of USSD communication
type USSDDirection string

const (
// USSDDirectionMoToApp means the USSD message comes from the mobile phone to the application
USSDDirectionMoToApp = USSDDirection("MO-to-App")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the same MobileOriginated or MobileTerminated directions which we use for the Message entity.

// USSDDirectionAppToMO means the USSD message goes from the application to the mobile phone
USSDDirectionAppToMO = USSDDirection("App-to-MO")
)

// USSDStatus represents the status of a USSD session
type USSDStatus string

const (
// USSDStatusPending means the USSD session is pending
USSDStatusPending = USSDStatus("pending")
// USSDStatusActive means the USSD session is active
USSDStatusActive = USSDStatus("active")
// USSDStatusCompleted means the USSD session is completed
USSDStatusCompleted = USSDStatus("completed")
// USSDStatusFailed means the USSD session has failed
USSDStatusFailed = USSDStatus("failed")
// USSDStatusTimedOut means the USSD session has timed out
USSDStatusTimedOut = USSDStatus("timed-out")
)

// USSD represents a USSD session on the phone
type USSD struct {
ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
UserID UserID `json:"user_id" gorm:"index:idx_ussds__user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"`
PhoneID uuid.UUID `json:"phone_id" gorm:"type:uuid;index:idx_ussds__phone_id" example:"32343a19-da5e-4b1b-a767-3298a73703cb"`
Owner string `json:"owner" example:"+18005550199"`
SessionID string `json:"session_id" example:"USSDSESSION12345" gorm:"index:idx_ussds__session_id"`
Type USSDType `json:"type" example:"request"`
Direction USSDDirection `json:"direction" example:"MO-to-App"`
Content string `json:"content" example:"*123#"`
Response *string `json:"response" example:"Welcome to USSD menu" validate:"optional"`
Status USSDStatus `json:"status" example:"pending" gorm:"default:pending"`
SIM SIM `json:"sim" example:"SIM1"`
Timestamp time.Time `json:"timestamp" example:"2022-06-05T14:26:09.527976+03:00"`
CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"`
UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"`
}

// TableName specifies the table name for the USSD entity
func (USSD) TableName() string {
return "ussds"
}

// IsActive checks if the USSD session is active
func (ussd *USSD) IsActive() bool {
return ussd.Status == USSDStatusActive
}

// IsPending checks if the USSD session is pending
func (ussd *USSD) IsPending() bool {
return ussd.Status == USSDStatusPending
}

// IsCompleted checks if the USSD session is completed
func (ussd *USSD) IsCompleted() bool {
return ussd.Status == USSDStatusCompleted
}

// IsFailed checks if the USSD session has failed
func (ussd *USSD) IsFailed() bool {
return ussd.Status == USSDStatusFailed
}

// IsTimedOut checks if the USSD session has timed out
func (ussd *USSD) IsTimedOut() bool {
return ussd.Status == USSDStatusTimedOut
}

// MarkActive marks the USSD session as active
func (ussd *USSD) MarkActive() *USSD {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this you can use SetStatusActive() etc.

ussd.Status = USSDStatusActive
return ussd
}

// MarkCompleted marks the USSD session as completed
func (ussd *USSD) MarkCompleted(response string) *USSD {
ussd.Status = USSDStatusCompleted
ussd.Response = &response
return ussd
}

// MarkFailed marks the USSD session as failed
func (ussd *USSD) MarkFailed() *USSD {
ussd.Status = USSDStatusFailed
return ussd
}

// MarkTimedOut marks the USSD session as timed out
func (ussd *USSD) MarkTimedOut() *USSD {
ussd.Status = USSDStatusTimedOut
return ussd
}
24 changes: 24 additions & 0 deletions api/pkg/events/ussd_received_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package events

import (
"time"

"github.com/NdoleStudio/httpsms/pkg/entities"

"github.com/google/uuid"
)

// EventTypeUSSDReceived is emitted when a USSD request is received from a mobile phone
const EventTypeUSSDReceived = "ussd.phone.received"

// USSDReceivedPayload is the payload of the EventTypeUSSDReceived event
type USSDReceivedPayload struct {
USSDID uuid.UUID `json:"ussd_id"`
UserID entities.UserID `json:"user_id"`
PhoneID uuid.UUID `json:"phone_id"`
Owner string `json:"owner"`
SessionID string `json:"session_id"`
Content string `json:"content"`
SIM entities.SIM `json:"sim"`
Timestamp time.Time `json:"timestamp"`
}
24 changes: 24 additions & 0 deletions api/pkg/events/ussd_sent_event.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package events

import (
"time"

"github.com/NdoleStudio/httpsms/pkg/entities"

"github.com/google/uuid"
)

// EventTypeUSSDResponse is emitted when a USSD response is sent to a mobile phone
const EventTypeUSSDResponse = "ussd.phone.sent"

// USSDResponsePayload is the payload of the EventTypeUSSDResponse event
type USSDResponsePayload struct {
USSDID uuid.UUID `json:"ussd_id"`
UserID entities.UserID `json:"user_id"`
PhoneID uuid.UUID `json:"phone_id"`
Owner string `json:"owner"`
SessionID string `json:"session_id"`
Response string `json:"response"`
SIM entities.SIM `json:"sim"`
Timestamp time.Time `json:"timestamp"`
}
Loading