Rebrand CommGate to gsmnode and adopt new design system

Rename the whole project from CommGate to gsmnode and re-skin every
surface onto the new signal-green + ink design system (Space Grotesk /
IBM Plex Sans / JetBrains Mono, flat surfaces, two-arrow routing mark,
lowercase gsm+node wordmark).

- Web App + API panel: rewrite token layer, gn-* utilities, data-gsm-theme,
  new logo/favicon assets; both builds verified.
- Phone App (Flutter): green theme, new mark/wordmark widgets, adaptive
  launcher icons; rename Android applicationId/package to app.gsmnode.phone;
  bump AGP to 8.6.0 and google_fonts to 8.1.0; debug APK build verified.
- API Server (Go): panel routes, User-Agent, health service id, branding.
- Home Assistant Plugin: rename integration domain sms_gateway to gsmnode
  (folder, DOMAIN, services, entity ids, classes); py_compile verified.
- Update all READMEs; add .gitignore (excludes Design/, build artifacts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web-app",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "Web App/web",
"port": 5173
},
{
"name": "api-server",
"runtimeExecutable": "./api-server.exe",
"runtimeArgs": [],
"cwd": "API Server",
"port": 8080
}
]
}
+49
View File
@@ -0,0 +1,49 @@
{
"permissions": {
"allow": [
"WebFetch(domain:docs.sms-gate.app)",
"Bash(go version *)",
"Bash(npm --version)",
"Bash(flutter --version)",
"Bash(java -version)",
"Bash(curl -s -m 5 -o /dev/null -w \"%{http_code}\" http://10.2.1.10:8028/api/health)",
"Bash(winget --version)",
"Bash(winget install *)",
"WebFetch(domain:capcom6.github.io)",
"Bash(curl -s -m 5 http://10.2.1.10:8028/api/health)",
"Bash(curl -s -m 5 -o /dev/null -w \"superuser-auth endpoint: %{http_code}\\\\n\" -X POST http://10.2.1.10:8028/api/collections/_superusers/auth-with-password)",
"Bash(curl -s -m 5 -o /dev/null -w \"legacy admins endpoint: %{http_code}\\\\n\" -X POST http://10.2.1.10:8028/api/admins/auth-with-password)",
"Bash(ls \"C:/Users/jania/.claude/projects/E--VS-Code-Projects-SMS-Gateway-Project/memory/\" 2>&1)",
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(git --version)",
"Bash(winget search *)",
"PowerShell(winget install --id Microsoft.OpenJDK.17 -e --accept-source-agreements --accept-package-agreements --disable-interactivity 2>&1)",
"Bash(rm -f test/widget_test.dart)",
"Bash(rmdir test *)",
"Bash(cp /tmp/phone_login.png \"C:/Users/jania/AppData/Local/Temp/claude/E--VS-Code-Projects-SMS-Gateway-Project/1197fea8-15b7-4bf5-83ae-354f909b16ae/scratchpad/phone_login.png\" && echo copied && ls -lh \"C:/Users/jania/AppData/Local/Temp/claude/E--VS-Code-Projects-SMS-Gateway-Project/1197fea8-15b7-4bf5-83ae-354f909b16ae/scratchpad/phone_login.png\")",
"Bash(/c/Users/jania/AppData/Local/Android/sdk/platform-tools/adb.exe -s R3CT206F84W shell pidof app.smsgateway.sms_gateway_phone)",
"Bash(/c/Users/jania/AppData/Local/Android/sdk/platform-tools/adb.exe -s R3CT206F84W shell dumpsys activity activities)",
"Bash(python -m py_compile __init__.py const.py notify.py)",
"Bash(python -c \"import json; json.load\\(open\\('manifest.json'\\)\\); print\\('MANIFEST JSON OK'\\)\")",
"Bash(./api-server.exe)",
"Bash(./web-app.exe)",
"Bash(ipconfig)",
"Bash(curl -s -m 2 -o /dev/null http://127.0.0.1:8090/)",
"Bash(break)",
"Bash(curl -s -m 5 http://127.0.0.1:8090/)",
"Bash(curl -s -m 5 http://127.0.0.1:8090/api/health)",
"Bash(curl -s -m 5 -o /dev/null -w 'OPTIONS /api/auth/login -> %{http_code}\\\\n' -X OPTIONS -H 'Origin: http://10.2.1.101:8090' -H 'Access-Control-Request-Method: POST' -H 'Access-Control-Request-Headers: authorization,content-type' http://10.2.1.101:8080/api/auth/login)",
"Bash(curl -s -m 5 -D - -o /dev/null -X POST -H 'Origin: http://10.2.1.101:8090' -H 'Content-Type: application/json' -d '{\"email\":\"e2e@test.local\",\"password\":\"e2eTestPass123\"}' http://10.2.1.101:8080/api/auth/login)",
"Bash(curl -s -m 2 -o /dev/null http://127.0.0.1:8080/api/health)",
"Bash(node -e ' *)",
"Bash(python -c \"import json; print\\('manifest v', json.load\\(open\\('manifest.json'\\)\\)['version']\\)\")",
"Bash(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/health)",
"Bash(echo \"API :8080 -> $\\(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/health 2>&1\\)\")",
"Bash(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/)",
"Bash(echo \"Web :8090 -> $\\(curl -s -m 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:8090/ 2>&1\\)\")",
"Bash(tasklist //FI \"IMAGENAME eq api-server.exe\")",
"Bash(tasklist //FI \"IMAGENAME eq web-app.exe\")"
]
}
}
+31
View File
@@ -0,0 +1,31 @@
# Design assets / source kits — excluded per project decision
Design/
# Node
node_modules/
# Flutter / Dart
Phone App/build/
Phone App/.dart_tool/
Phone App/.flutter-plugins
Phone App/.flutter-plugins-dependencies
Phone App/.packages
Phone App/pubspec.lock
# Android / Gradle
.gradle/
.kotlin/
*.iml
Phone App/android/local.properties
Phone App/android/key.properties
# Go build output / binaries
*.exe
# Environment & secrets
.env
*.local
# OS / editor
.DS_Store
Thumbs.db
+28
View File
@@ -0,0 +1,28 @@
# API Server configuration
# Copy to .env and adjust. The server also reads plain environment variables.
# Address the API Server listens on
API_ADDR=:8080
# PocketBase base URL (no trailing slash)
POCKETBASE_URL=http://10.2.1.10:8028
# PocketBase superuser credentials. The API Server is the ONLY thing that talks
# to PocketBase, so it authenticates as a superuser and enforces ownership in
# application logic. Lock all collection API rules to superuser-only.
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me
# Secret used to sign client JWTs (use a long random string in production)
JWT_SECRET=dev-insecure-change-me-please
# Access token lifetime (Go duration: 30m, 24h, 168h ...)
JWT_ACCESS_TTL=24h
# How long a message/call may stay unprocessed before it's marked Failed.
# A device must pull it (Pending) and report a result (Processed) within this
# window, else the background sweeper fails it.
MESSAGE_TTL=5m
# CORS allowed origins for the Web App (comma separated, or * for any)
CORS_ALLOW_ORIGINS=*
+5
View File
@@ -0,0 +1,5 @@
.env
/server
/server.exe
/tmp/
*.log
+154
View File
@@ -0,0 +1,154 @@
# gsmnode — API Server
Go service that is the **single trusted entry point** in front of PocketBase.
The Web App and Phone App talk only to this server; it performs all PocketBase
access as a superuser and enforces ownership in application logic.
```
Web App ─┐
├─► API Server (:8080) ─► PocketBase (10.2.1.10:8028)
Phone App ─┘
```
The server root (`GET /`) serves a gsmnode-branded **web panel**: a live health
readout plus a quick reference of both API audiences. Open
http://localhost:8080/ in a browser to check the server at a glance.
The panel is a **Vue 3 + Tailwind v4** app in [`panel/`](panel/), built into
`internal/api/dist` and embedded into the Go binary at compile time:
```powershell
cd panel
npm install
npm run build # outputs to ../internal/api/dist
cd ..; go build -o api-server.exe ./cmd/server # embeds the fresh dist
```
For panel development with hot reload (proxies `/api` to a running server on
`:8080`): `cd panel; npm run dev` → http://localhost:5174.
## Requirements
- Go 1.26+ (`go version`)
- A reachable PocketBase v0.23+ instance and its **superuser** credentials
- Node 18+ (only for the setup scripts)
## 1. Configure
```powershell
Copy-Item .env.example .env
# edit .env: set PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, and a strong JWT_SECRET
```
| Variable | Purpose | Default |
|---|---|---|
| `API_ADDR` | Listen address | `:8080` |
| `POCKETBASE_URL` | PocketBase base URL | `http://10.2.1.10:8028` |
| `PB_ADMIN_EMAIL` / `PB_ADMIN_PASSWORD` | PocketBase superuser login | — (required) |
| `JWT_SECRET` | Signs client JWTs | dev placeholder |
| `JWT_ACCESS_TTL` | Access-token lifetime | `24h` |
| `CORS_ALLOW_ORIGINS` | Comma list, or `*` | `*` |
## 2. Set up PocketBase collections
Creates/updates `devices`, `messages`, `inbox`, `webhooks` (the default `users`
auth collection is reused). Idempotent.
```powershell
$env:POCKETBASE_URL="http://10.2.1.10:8028"
$env:PB_ADMIN_EMAIL="admin@example.com"
$env:PB_ADMIN_PASSWORD="your-password"
node scripts/setup-pocketbase.mjs
```
Create a login user:
```powershell
node scripts/create-user.mjs user@example.com "user-password" "Display Name"
```
## 3. Run
```powershell
./scripts/Run-ApiServer.ps1
# or: go run ./cmd/server
```
Health check: `GET http://localhost:8080/api/health`.
## API
### Authentication scheme
- **Client API** (`/api/...`): `Authorization: Bearer <JWT>` from `/api/auth/login`.
Used by the Web App and integrators.
- **Mobile API** (`/api/mobile/...`): `Authorization: Bearer <device_token>`
returned by device registration. Used by the Phone App.
### Client / 3rd-party endpoints
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/health` | Readiness probe (public) |
| `POST` | `/api/auth/login` | `{email, password}` → JWT + user |
| `POST` | `/api/auth/refresh` | New JWT (auth) |
| `GET` | `/api/auth/me` | Current user |
| `GET` | `/api/devices` | List your devices |
| `DELETE` | `/api/devices/{id}` | Remove a device |
| `POST` | `/api/messages` | Enqueue SMS `{phone_numbers[], text_message, device_id?, sim_number?, schedule_at?}` |
| `POST` | `/api/calls` | Enqueue a phone call `{phone_number, device_id?}` |
| `GET` | `/api/messages` | List messages (`?status=&device_id=&type=&page=&per_page=`) |
| `GET` | `/api/messages/{id}` | Message state |
| `GET` | `/api/inbox` | Received SMS |
| `GET` | `/api/webhooks` | List webhooks |
| `POST` | `/api/webhooks` | Register `{event, url, device_id?}` |
| `DELETE` | `/api/webhooks/{id}` | Delete webhook |
### Mobile / device endpoints
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/mobile/v1/device` | Register device (auth: **user JWT**) → returns `auth_token` |
| `POST` | `/api/mobile/v1/ping` | Heartbeat (auth: device token) |
| `GET` | `/api/mobile/v1/messages` | Pull pending messages; marks them `Processed` |
| `PATCH` | `/api/mobile/v1/messages/{id}` | Report `{status, error?}` (`Sent`/`Delivered`/`Failed`) |
| `POST` | `/api/mobile/v1/inbox` | Report received SMS `{phone_number, message, received_at?}` |
### Message lifecycle
`Pending` → (device pulls) `Processed``Sent``Delivered`, or `Failed`.
Calls are stored as messages with `type: "call"` (SMS is `type: "sms"`) and flow
through the same pull/report pipeline. The device dials the number natively and
reports `Sent` (a call has no delivery report) or `Failed`.
### Webhook events
`sms:received`, `sms:sent`, `sms:delivered`, `sms:failed`. Delivered as
`POST {event, device_id, payload, created_at}` to the registered URL.
## Quick smoke test
```powershell
# log in
$login = curl -s -X POST http://localhost:8080/api/auth/login `
-H "Content-Type: application/json" `
-d '{"email":"user@example.com","password":"user-password"}' | ConvertFrom-Json
$token = $login.access_token
# register a device (as the phone app would)
curl -s -X POST http://localhost:8080/api/mobile/v1/device `
-H "Authorization: Bearer $token" -H "Content-Type: application/json" `
-d '{"device_id":"test-1","name":"Test Phone","platform":"android"}'
```
## Project layout
```
cmd/server/main.go entry point, wiring, graceful shutdown
internal/config env/.env configuration
internal/pb PocketBase REST client (superuser)
internal/auth JWT issue/verify, device token generation
internal/api router, middleware, handlers
scripts/ PocketBase setup + run helpers
```
+64
View File
@@ -0,0 +1,64 @@
// Command server runs the gsmnode API Server. It is the single trusted
// entry point in front of PocketBase: the Web App and Phone App talk only to
// this service, which in turn performs all PocketBase access.
package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"smsgateway/apiserver/internal/api"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/config"
"smsgateway/apiserver/internal/pb"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[api] ")
cfg := config.Load()
client := pb.New(cfg.PocketBaseURL, cfg.PBAdminEmail, cfg.PBAdminPass)
jwtMgr := auth.NewManager(cfg.JWTSecret, cfg.JWTAccessTTL)
srv := api.New(cfg, client, jwtMgr)
// Background worker that fails messages no device processed in time.
workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel()
srv.StartExpiryWorker(workerCtx)
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("listening on %s (PocketBase: %s)", cfg.Addr, cfg.PocketBaseURL)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("stopped")
}
+5
View File
@@ -0,0 +1,5 @@
module smsgateway/apiserver
go 1.26
require github.com/golang-jwt/jwt/v5 v5.2.1
+2
View File
@@ -0,0 +1,2 @@
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"net/http"
"strings"
)
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type userDTO struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name,omitempty"`
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresAt string `json:"expires_at"`
User userDTO `json:"user"`
}
// handleLogin authenticates a user against PocketBase and issues a client JWT.
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.Email = strings.TrimSpace(req.Email)
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "email and password are required")
return
}
res, err := s.pb.AuthWithPassword(r.Context(), colUsers, req.Email, req.Password)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
user := recordToUser(res.Record)
token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"),
User: user,
})
}
// handleRefresh issues a fresh token for an already-authenticated user.
func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) {
id, email := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colUsers, id)
if err != nil {
writeUpstreamError(w, err)
return
}
user := recordToUser(rec)
if user.Email == "" {
user.Email = email
}
token, exp, err := s.jwt.Issue(user.ID, user.Email, user.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not issue token")
return
}
writeJSON(w, http.StatusOK, tokenResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresAt: exp.UTC().Format("2006-01-02T15:04:05Z07:00"),
User: user,
})
}
// handleMe returns the current user's profile.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
id, _ := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colUsers, id)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, recordToUser(rec))
}
func recordToUser(rec map[string]any) userDTO {
return userDTO{
ID: asString(rec["id"]),
Email: asString(rec["email"]),
Name: asString(rec["name"]),
}
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"net/http"
"strings"
"smsgateway/apiserver/internal/pb"
)
type enqueueCallRequest struct {
PhoneNumber string `json:"phone_number"`
DeviceID string `json:"device_id"`
}
// handleEnqueueCall queues an outbound phone call for one of the user's devices.
// A call is stored as a message with type "call"; the device pulls it through
// the same mobile pipeline as SMS and places the call natively.
func (s *Server) handleEnqueueCall(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req enqueueCallRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
phone := strings.TrimSpace(req.PhoneNumber)
if phone == "" {
writeError(w, http.StatusBadRequest, "phone_number is required")
return
}
deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID)
if err != nil {
writeUpstreamError(w, err)
return
}
if deviceRecID == "" {
writeError(w, http.StatusBadRequest, "no device available; register a device first")
return
}
rec, err := s.pb.Create(r.Context(), colMessages, pb.Record{
"phone_numbers": []string{phone},
"type": msgTypeCall,
"device": deviceRecID,
"owner": uid,
"status": statusPending,
})
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusAccepted, recordToMessage(rec))
}
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/pb"
)
type deviceDTO struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Status string `json:"status"`
LastSeenAt string `json:"last_seen_at"`
}
// onlineWindow is how recently a device must have pinged to count as online.
// The phone pings every ~60s, so this tolerates a couple of missed pings.
const onlineWindow = 3 * time.Minute
func recordToDevice(rec pb.Record) deviceDTO {
lastSeen := asString(rec["last_seen_at"])
status := "offline"
if deviceOnline(lastSeen) {
status = "online"
}
return deviceDTO{
ID: asString(rec["id"]),
DeviceID: asString(rec["device_id"]),
Name: asString(rec["name"]),
Platform: asString(rec["platform"]),
AppVersion: asString(rec["app_version"]),
Status: status,
LastSeenAt: lastSeen,
}
}
// deviceOnline reports whether last_seen_at is within the online window. It
// accepts the PocketBase datetime format as well as RFC3339.
func deviceOnline(lastSeenAt string) bool {
if lastSeenAt == "" {
return false
}
for _, layout := range []string{
"2006-01-02 15:04:05.999Z07:00",
"2006-01-02 15:04:05.999Z",
time.RFC3339,
} {
if t, err := time.Parse(layout, lastSeenAt); err == nil {
return time.Since(t) < onlineWindow
}
}
return false
}
// handleListDevices returns the authenticated user's devices.
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colDevices, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-created",
PerPage: 200,
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]deviceDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToDevice(rec))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
}
// handleDeleteDevice removes a device owned by the user.
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
id := r.PathValue("id")
rec, err := s.pb.GetOne(r.Context(), colDevices, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your device")
return
}
if err := s.pb.Delete(r.Context(), colDevices, id); err != nil {
writeUpstreamError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type registerDeviceRequest struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
PushToken string `json:"push_token"`
}
type registerDeviceResponse struct {
deviceDTO
AuthToken string `json:"auth_token"`
}
// handleRegisterDevice registers (or re-registers) a mobile device for the
// authenticated user and returns an opaque device token. Re-registering with
// the same device_id rotates the token and updates metadata.
func (s *Server) handleRegisterDevice(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req registerDeviceRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
req.DeviceID = strings.TrimSpace(req.DeviceID)
if req.DeviceID == "" {
writeError(w, http.StatusBadRequest, "device_id is required")
return
}
if req.Platform == "" {
req.Platform = "android"
}
token, err := auth.NewDeviceToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "could not generate token")
return
}
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
fields := pb.Record{
"device_id": req.DeviceID,
"name": req.Name,
"platform": req.Platform,
"app_version": req.AppVersion,
"push_token": req.PushToken,
"status": "online",
"last_seen_at": now,
"auth_token": token,
"owner": uid,
}
// Upsert by (owner, device_id).
existing, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid)+" && device_id = "+pbQuote(req.DeviceID), "")
if err != nil {
writeUpstreamError(w, err)
return
}
var rec pb.Record
if existing != nil {
rec, err = s.pb.Update(r.Context(), colDevices, asString(existing["id"]), fields)
} else {
rec, err = s.pb.Create(r.Context(), colDevices, fields)
}
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, registerDeviceResponse{
deviceDTO: recordToDevice(rec),
AuthToken: token,
})
}
// handlePing updates a device heartbeat (last_seen_at + online status).
func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
_, err := s.pb.Update(r.Context(), colDevices, asString(device["id"]), pb.Record{
"status": "online",
"last_seen_at": now,
})
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode · API Server</title>
<script type="module" crossorigin src="/assets/index-Hipq9mYq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-FjfYnLdU.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
package api
import "net/http"
// handleHealth reports server readiness.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "gsmnode-api",
})
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/pb"
)
type inboxDTO struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
PhoneNumber string `json:"phone_number"`
Message string `json:"message"`
ReceivedAt string `json:"received_at"`
}
func recordToInbox(rec pb.Record) inboxDTO {
return inboxDTO{
ID: asString(rec["id"]),
DeviceID: asString(rec["device"]),
PhoneNumber: asString(rec["phone_number"]),
Message: asString(rec["message"]),
ReceivedAt: asString(rec["received_at"]),
}
}
// handleListInbox lists received SMS for the authenticated user.
func (s *Server) handleListInbox(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colInbox, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-received_at",
Page: queryInt(r, "page", 1),
PerPage: clampPerPage(queryInt(r, "per_page", 50)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]inboxDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToInbox(rec))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "total": res.TotalItems, "page": res.Page,
})
}
type receiveSMSRequest struct {
PhoneNumber string `json:"phone_number"`
Message string `json:"message"`
ReceivedAt string `json:"received_at"`
}
// handleReceiveSMS records an incoming SMS reported by a device and fires
// sms:received webhooks.
func (s *Server) handleReceiveSMS(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
var req receiveSMSRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(req.PhoneNumber) == "" {
writeError(w, http.StatusBadRequest, "phone_number is required")
return
}
receivedAt := req.ReceivedAt
if receivedAt == "" {
receivedAt = time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
}
rec, err := s.pb.Create(r.Context(), colInbox, pb.Record{
"device": asString(device["id"]),
"owner": asString(device["owner"]),
"phone_number": req.PhoneNumber,
"message": req.Message,
"received_at": receivedAt,
})
if err != nil {
writeUpstreamError(w, err)
return
}
s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), "sms:received", map[string]any{
"inbox_id": asString(rec["id"]),
"phone_number": req.PhoneNumber,
"message": req.Message,
"received_at": receivedAt,
})
writeJSON(w, http.StatusCreated, recordToInbox(rec))
}
+314
View File
@@ -0,0 +1,314 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/pb"
)
// Message lifecycle states.
const (
statusPending = "Pending" // enqueued, not yet picked up by a device
statusProcessed = "Processed" // delivered to a device for sending
statusSent = "Sent" // device handed it to the SIM/radio
statusDelivered = "Delivered" // delivery report received
statusFailed = "Failed" // sending failed
)
var validReportStates = map[string]bool{
statusSent: true, statusDelivered: true, statusFailed: true, statusProcessed: true,
}
// Message kinds.
const (
msgTypeSMS = "sms"
msgTypeCall = "call"
)
type messageDTO struct {
ID string `json:"id"`
Type string `json:"type"`
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id,omitempty"`
SimNumber int `json:"sim_number,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
ScheduleAt string `json:"schedule_at,omitempty"`
CreatedAt string `json:"created_at"`
}
func recordToMessage(rec pb.Record) messageDTO {
msgType := asString(rec["type"])
if msgType == "" {
msgType = msgTypeSMS
}
return messageDTO{
ID: asString(rec["id"]),
Type: msgType,
PhoneNumbers: asStringSlice(rec["phone_numbers"]),
TextMessage: asString(rec["text_message"]),
DeviceID: asString(rec["device"]),
SimNumber: asInt(rec["sim_number"]),
Status: asString(rec["status"]),
Error: asString(rec["error"]),
ScheduleAt: asString(rec["schedule_at"]),
CreatedAt: asString(rec["created"]),
}
}
type enqueueRequest struct {
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id"`
SimNumber int `json:"sim_number"`
ScheduleAt string `json:"schedule_at"`
}
// handleEnqueueMessage queues an outbound SMS for one of the user's devices.
func (s *Server) handleEnqueueMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req enqueueRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
phones := cleanPhones(req.PhoneNumbers)
if len(phones) == 0 {
writeError(w, http.StatusBadRequest, "at least one phone number is required")
return
}
if strings.TrimSpace(req.TextMessage) == "" {
writeError(w, http.StatusBadRequest, "text_message is required")
return
}
// Resolve the target device: explicit device_id, else the user's first
// online device.
deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID)
if err != nil {
writeUpstreamError(w, err)
return
}
if deviceRecID == "" {
writeError(w, http.StatusBadRequest, "no device available; register a device first")
return
}
fields := pb.Record{
"phone_numbers": phones,
"text_message": req.TextMessage,
"type": msgTypeSMS,
"device": deviceRecID,
"owner": uid,
"status": statusPending,
}
if req.SimNumber > 0 {
fields["sim_number"] = req.SimNumber
}
if req.ScheduleAt != "" {
fields["schedule_at"] = req.ScheduleAt
}
rec, err := s.pb.Create(r.Context(), colMessages, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusAccepted, recordToMessage(rec))
}
// resolveDevice returns the PocketBase device record id to target. requested may
// be either the internal record id or the client-facing device_id.
func (s *Server) resolveDevice(r *http.Request, uid, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid)+" && (id = "+pbQuote(requested)+" || device_id = "+pbQuote(requested)+")", "")
if err != nil {
return "", err
}
if dev == nil {
return "", nil
}
return asString(dev["id"]), nil
}
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid), "-last_seen_at")
if err != nil || dev == nil {
return "", err
}
return asString(dev["id"]), nil
}
// handleListMessages lists the user's messages with optional filters.
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
filters := []string{"owner = " + pbQuote(uid)}
if st := r.URL.Query().Get("status"); st != "" {
filters = append(filters, "status = "+pbQuote(st))
}
if dev := r.URL.Query().Get("device_id"); dev != "" {
filters = append(filters, "device = "+pbQuote(dev))
}
if t := r.URL.Query().Get("type"); t != "" {
filters = append(filters, "type = "+pbQuote(t))
}
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: strings.Join(filters, " && "),
Sort: "-created",
Page: queryInt(r, "page", 1),
PerPage: clampPerPage(queryInt(r, "per_page", 50)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToMessage(rec))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "total": res.TotalItems, "page": res.Page,
})
}
// handleGetMessage returns a single message's state.
func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colMessages, r.PathValue("id"))
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your message")
return
}
writeJSON(w, http.StatusOK, recordToMessage(rec))
}
// handlePullMessages returns pending messages for the calling device and marks
// them Processed so they are not handed out twice.
func (s *Server) handlePullMessages(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
deviceID := asString(device["id"])
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: "device = " + pbQuote(deviceID) + " && status = " + pbQuote(statusPending),
Sort: "created",
PerPage: clampPerPage(queryInt(r, "limit", 20)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
id := asString(rec["id"])
updated, uerr := s.pb.Update(r.Context(), colMessages, id, pb.Record{"status": statusProcessed})
if uerr != nil {
// Skip a message we couldn't claim rather than failing the pull.
continue
}
out = append(out, recordToMessage(updated))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
type reportRequest struct {
Status string `json:"status"`
Error string `json:"error"`
}
// handleReportMessage records a delivery state reported by the device and fires
// the matching webhooks.
func (s *Server) handleReportMessage(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
id := r.PathValue("id")
var req reportRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validReportStates[req.Status] {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
rec, err := s.pb.GetOne(r.Context(), colMessages, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["device"]) != asString(device["id"]) {
writeError(w, http.StatusForbidden, "message not assigned to this device")
return
}
fields := pb.Record{"status": req.Status, "error": req.Error}
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
switch req.Status {
case statusSent:
fields["sent_at"] = now
case statusDelivered:
fields["delivered_at"] = now
}
updated, err := s.pb.Update(r.Context(), colMessages, id, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
// Dispatch webhooks for terminal/notable states.
if event := eventForStatus(req.Status); event != "" {
s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), event, map[string]any{
"message_id": id,
"phone_numbers": asStringSlice(updated["phone_numbers"]),
"status": req.Status,
"error": req.Error,
})
}
writeJSON(w, http.StatusOK, recordToMessage(updated))
}
func eventForStatus(status string) string {
switch status {
case statusSent:
return "sms:sent"
case statusDelivered:
return "sms:delivered"
case statusFailed:
return "sms:failed"
default:
return ""
}
}
func cleanPhones(in []string) []string {
out := make([]string, 0, len(in))
for _, p := range in {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func clampPerPage(n int) int {
if n <= 0 {
return 50
}
if n > 200 {
return 200
}
return n
}
+23
View File
@@ -0,0 +1,23 @@
package api
import (
"embed"
"io/fs"
"net/http"
)
// The gsmnode web panel: a Vue 3 + Tailwind app (source in panel/, built
// with `npm run build` into dist/) embedded at compile time and served at
// the server root.
//
//go:embed all:dist
var panelFS embed.FS
// panelHandler serves the built panel assets.
func panelHandler() http.Handler {
sub, err := fs.Sub(panelFS, "dist")
if err != nil {
panic(err) // embedded dist is malformed; unreachable in a valid build
}
return http.FileServerFS(sub)
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"smsgateway/apiserver/internal/pb"
)
// writeJSON writes v as a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if v == nil {
return
}
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err)
}
}
// errorBody is the standard error envelope.
type errorBody struct {
Error string `json:"error"`
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorBody{Error: msg})
}
// writeUpstreamError maps a PocketBase error onto an appropriate HTTP status.
func writeUpstreamError(w http.ResponseWriter, err error) {
var apiErr *pb.APIError
if errors.As(err, &apiErr) {
switch apiErr.Status {
case http.StatusNotFound:
writeError(w, http.StatusNotFound, "not found")
case http.StatusBadRequest:
writeError(w, http.StatusBadRequest, apiErr.Message)
case http.StatusForbidden:
writeError(w, http.StatusForbidden, "forbidden")
default:
log.Printf("upstream error: %v (%s)", apiErr, apiErr.Body)
writeError(w, http.StatusBadGateway, "upstream error")
}
return
}
log.Printf("internal error: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
}
// decodeJSON reads and decodes a JSON request body into v.
func decodeJSON(r *http.Request, v any) error {
defer io.Copy(io.Discard, r.Body)
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return err
}
return nil
}
+243
View File
@@ -0,0 +1,243 @@
package api
import (
"context"
"log"
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/auth"
"smsgateway/apiserver/internal/config"
"smsgateway/apiserver/internal/pb"
)
// Collection names in PocketBase.
const (
colUsers = "users"
colDevices = "devices"
colMessages = "messages"
colInbox = "inbox"
colWebhooks = "webhooks"
)
// Server wires together the HTTP handlers and their dependencies.
type Server struct {
cfg config.Config
pb *pb.Client
jwt *auth.Manager
}
// New constructs a Server.
func New(cfg config.Config, client *pb.Client, jwt *auth.Manager) *Server {
return &Server{cfg: cfg, pb: client, jwt: jwt}
}
// Handler returns the root HTTP handler with all routes registered.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Web panel (public) — embedded Vue + Tailwind app. Only the explicit
// panel paths are routed to it so unknown /api/* paths still 404 as JSON.
panel := panelHandler()
mux.Handle("GET /{$}", panel)
mux.Handle("GET /assets/", panel)
mux.Handle("GET /favicon.svg", panel)
mux.Handle("GET /favicon-32.png", panel)
mux.Handle("GET /gsmnode-horizontal.png", panel)
mux.Handle("GET /gsmnode-horizontal-white.png", panel)
// Health (public)
mux.HandleFunc("GET /api/health", s.handleHealth)
// Client / 3rd-party API (JWT auth) — used by the Web App and integrators.
mux.HandleFunc("POST /api/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/auth/refresh", s.requireUser(s.handleRefresh))
mux.HandleFunc("GET /api/auth/me", s.requireUser(s.handleMe))
mux.HandleFunc("GET /api/devices", s.requireUser(s.handleListDevices))
mux.HandleFunc("DELETE /api/devices/{id}", s.requireUser(s.handleDeleteDevice))
mux.HandleFunc("GET /api/messages", s.requireUser(s.handleListMessages))
mux.HandleFunc("POST /api/messages", s.requireUser(s.handleEnqueueMessage))
mux.HandleFunc("GET /api/messages/{id}", s.requireUser(s.handleGetMessage))
mux.HandleFunc("POST /api/calls", s.requireUser(s.handleEnqueueCall))
mux.HandleFunc("GET /api/inbox", s.requireUser(s.handleListInbox))
mux.HandleFunc("GET /api/webhooks", s.requireUser(s.handleListWebhooks))
mux.HandleFunc("POST /api/webhooks", s.requireUser(s.handleCreateWebhook))
mux.HandleFunc("DELETE /api/webhooks/{id}", s.requireUser(s.handleDeleteWebhook))
// Mobile / device API — the phone app registers, pulls work, and reports.
// Registration is authenticated with the user's JWT; everything else with
// the opaque device token returned at registration.
mux.HandleFunc("POST /api/mobile/v1/device", s.requireUser(s.handleRegisterDevice))
mux.HandleFunc("POST /api/mobile/v1/ping", s.requireDevice(s.handlePing))
mux.HandleFunc("GET /api/mobile/v1/messages", s.requireDevice(s.handlePullMessages))
mux.HandleFunc("PATCH /api/mobile/v1/messages/{id}", s.requireDevice(s.handleReportMessage))
mux.HandleFunc("POST /api/mobile/v1/inbox", s.requireDevice(s.handleReceiveSMS))
return s.withMiddleware(mux)
}
// withMiddleware applies CORS, request logging, and panic recovery globally.
func (s *Server) withMiddleware(next http.Handler) http.Handler {
return s.recoverer(s.cors(s.logger(next)))
}
func (s *Server) logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Millisecond))
})
}
func (s *Server) recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v", rec)
writeError(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) cors(next http.Handler) http.Handler {
allowed := map[string]bool{}
wildcard := false
for _, o := range s.cfg.AllowOrigins {
if o == "*" {
wildcard = true
}
allowed[o] = true
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && (wildcard || allowed[origin]) {
if wildcard {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// statusWriter captures the response status code for logging.
type statusWriter struct {
http.ResponseWriter
status int
wrote bool
}
func (w *statusWriter) WriteHeader(code int) {
if !w.wrote {
w.status = code
w.wrote = true
}
w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
w.wrote = true
return w.ResponseWriter.Write(b)
}
// --- auth context ---
type ctxKey int
const (
ctxUser ctxKey = iota
ctxDevice
)
// userFromCtx returns the authenticated user id/email from the request context.
func userFromCtx(ctx context.Context) (id, email string) {
if c, ok := ctx.Value(ctxUser).(*auth.Claims); ok {
return c.Subject, c.Email
}
return "", ""
}
// deviceFromCtx returns the authenticated device record from the request context.
func deviceFromCtx(ctx context.Context) pb.Record {
if d, ok := ctx.Value(ctxDevice).(pb.Record); ok {
return d
}
return nil
}
// requireUser wraps a handler to require a valid client JWT.
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing bearer token")
return
}
claims, err := s.jwt.Verify(token)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid or expired token")
return
}
ctx := context.WithValue(r.Context(), ctxUser, claims)
next(w, r.WithContext(ctx))
}
}
// requireDevice wraps a handler to require a valid device token. The matching
// device record is loaded and attached to the request context.
func (s *Server) requireDevice(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "missing device token")
return
}
device, err := s.pb.FindFirst(r.Context(), colDevices,
"auth_token = "+pbQuote(token), "")
if err != nil {
writeUpstreamError(w, err)
return
}
if device == nil {
writeError(w, http.StatusUnauthorized, "unknown device token")
return
}
ctx := context.WithValue(r.Context(), ctxDevice, device)
next(w, r.WithContext(ctx))
}
}
// bearerToken extracts a token from the Authorization header (with or without
// the "Bearer " prefix).
func bearerToken(r *http.Request) string {
h := r.Header.Get("Authorization")
if h == "" {
return ""
}
if after, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(after)
}
return strings.TrimSpace(h)
}
// pbQuote safely quotes a string value for a PocketBase filter expression.
func pbQuote(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
+74
View File
@@ -0,0 +1,74 @@
package api
import (
"context"
"log"
"time"
"smsgateway/apiserver/internal/pb"
)
// sweepInterval is how often stale messages are checked.
const sweepInterval = 30 * time.Second
// StartExpiryWorker launches a background loop that fails messages/calls which
// no device picked up or reported on within the configured MessageTTL. Without
// it, a message targeting an offline device would stay Pending forever.
func (s *Server) StartExpiryWorker(ctx context.Context) {
go func() {
ticker := time.NewTicker(sweepInterval)
defer ticker.Stop()
// Run once at startup so stale items clear promptly.
s.expireStaleMessages(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.expireStaleMessages(ctx)
}
}
}()
}
// expireStaleMessages marks Pending/Processed messages older than MessageTTL as
// Failed. "Pending" means no online device pulled it; "Processed" means a device
// pulled it but never reported a terminal state (e.g. it crashed).
func (s *Server) expireStaleMessages(ctx context.Context) {
cutoff := time.Now().UTC().Add(-s.cfg.MessageTTL).Format("2006-01-02 15:04:05.000Z")
filter := `(status = "` + statusPending + `" || status = "` + statusProcessed +
`") && updated < "` + cutoff + `"`
res, err := s.pb.List(ctx, colMessages, pb.ListOptions{Filter: filter, PerPage: 200})
if err != nil {
log.Printf("expiry sweep failed: %v", err)
return
}
expired := 0
for _, rec := range res.Items {
// Don't expire messages scheduled for the future.
if asString(rec["schedule_at"]) != "" {
continue
}
id := asString(rec["id"])
const reason = "expired: no device processed the message within the timeout"
if _, err := s.pb.Update(ctx, colMessages, id, pb.Record{
"status": statusFailed,
"error": reason,
}); err != nil {
log.Printf("expire message %s: %v", id, err)
continue
}
expired++
s.dispatchWebhooks(asString(rec["owner"]), asString(rec["device"]), "sms:failed", map[string]any{
"message_id": id,
"type": asString(rec["type"]),
"status": statusFailed,
"error": reason,
})
}
if expired > 0 {
log.Printf("expired %d stale message(s) older than %s", expired, s.cfg.MessageTTL)
}
}
+65
View File
@@ -0,0 +1,65 @@
package api
import (
"net/http"
"strconv"
)
// asString coerces an arbitrary JSON value to a string.
func asString(v any) string {
switch t := v.(type) {
case string:
return t
case nil:
return ""
default:
return ""
}
}
// asInt coerces a JSON number/string to an int.
func asInt(v any) int {
switch t := v.(type) {
case float64:
return int(t)
case int:
return t
case string:
if n, err := strconv.Atoi(t); err == nil {
return n
}
}
return 0
}
// asStringSlice coerces a JSON array (or single string) to []string.
func asStringSlice(v any) []string {
switch t := v.(type) {
case []any:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
case []string:
return t
case string:
if t == "" {
return nil
}
return []string{t}
}
return nil
}
// queryInt parses a query parameter as an int, falling back to def.
func queryInt(r *http.Request, key string, def int) int {
if v := r.URL.Query().Get(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"time"
"smsgateway/apiserver/internal/pb"
)
var validWebhookEvents = map[string]bool{
"sms:received": true,
"sms:sent": true,
"sms:delivered": true,
"sms:failed": true,
}
type webhookDTO struct {
ID string `json:"id"`
Event string `json:"event"`
URL string `json:"url"`
DeviceID string `json:"device_id,omitempty"`
}
func recordToWebhook(rec pb.Record) webhookDTO {
return webhookDTO{
ID: asString(rec["id"]),
Event: asString(rec["event"]),
URL: asString(rec["url"]),
DeviceID: asString(rec["device"]),
}
}
// handleListWebhooks lists the user's registered webhooks.
func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
res, err := s.pb.List(r.Context(), colWebhooks, pb.ListOptions{
Filter: "owner = " + pbQuote(uid),
Sort: "-created",
PerPage: 200,
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]webhookDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToWebhook(rec))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out, "total": res.TotalItems})
}
type createWebhookRequest struct {
Event string `json:"event"`
URL string `json:"url"`
DeviceID string `json:"device_id"`
}
// handleCreateWebhook registers a webhook for the user.
func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req createWebhookRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validWebhookEvents[req.Event] {
writeError(w, http.StatusBadRequest, "invalid event")
return
}
if req.URL == "" {
writeError(w, http.StatusBadRequest, "url is required")
return
}
fields := pb.Record{"owner": uid, "event": req.Event, "url": req.URL}
if req.DeviceID != "" {
fields["device"] = req.DeviceID
}
rec, err := s.pb.Create(r.Context(), colWebhooks, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusCreated, recordToWebhook(rec))
}
// handleDeleteWebhook removes a webhook owned by the user.
func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
id := r.PathValue("id")
rec, err := s.pb.GetOne(r.Context(), colWebhooks, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your webhook")
return
}
if err := s.pb.Delete(r.Context(), colWebhooks, id); err != nil {
writeUpstreamError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// dispatchWebhooks delivers an event to every matching webhook for the owner.
// Delivery is best-effort and runs in the background so it never blocks the
// device/client request.
func (s *Server) dispatchWebhooks(owner, deviceID, event string, payload map[string]any) {
if owner == "" {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := s.pb.List(ctx, colWebhooks, pb.ListOptions{
Filter: "owner = " + pbQuote(owner) + " && event = " + pbQuote(event),
PerPage: 200,
})
if err != nil {
log.Printf("webhook lookup failed for %s/%s: %v", owner, event, err)
return
}
body := map[string]any{
"event": event,
"device_id": deviceID,
"payload": payload,
"created_at": time.Now().UTC().Format(time.RFC3339),
}
raw, _ := json.Marshal(body)
for _, hook := range res.Items {
// A webhook scoped to a specific device only fires for that device.
if dev := asString(hook["device"]); dev != "" && dev != deviceID {
continue
}
deliverWebhook(ctx, asString(hook["url"]), raw)
}
}()
}
func deliverWebhook(ctx context.Context, url string, body []byte) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
log.Printf("webhook build request %s: %v", url, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "gsmnode-api/1.0")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("webhook delivery to %s failed: %v", url, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log.Printf("webhook %s returned %d", url, resp.StatusCode)
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package auth issues and verifies the client-facing JWTs used by the Web App
// and other integrators.
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims is the JWT payload for an authenticated user session.
type Claims struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
jwt.RegisteredClaims
}
// Manager issues and verifies JWTs.
type Manager struct {
secret []byte
ttl time.Duration
}
// NewManager creates a JWT manager.
func NewManager(secret []byte, ttl time.Duration) *Manager {
return &Manager{secret: secret, ttl: ttl}
}
// Issue creates a signed access token for the given user.
func (m *Manager) Issue(userID, email, name string) (token string, expiresAt time.Time, err error) {
now := time.Now()
expiresAt = now.Add(m.ttl)
claims := Claims{
Email: email,
Name: name,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := t.SignedString(m.secret)
return signed, expiresAt, err
}
// Verify parses and validates a token, returning its claims.
func (m *Manager) Verify(token string) (*Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return m.secret, nil
})
if err != nil {
return nil, err
}
if !parsed.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"crypto/rand"
"encoding/hex"
)
// NewDeviceToken returns a cryptographically random opaque token used to
// authenticate a registered mobile device.
func NewDeviceToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+95
View File
@@ -0,0 +1,95 @@
package config
import (
"log"
"os"
"strings"
"time"
)
// Config holds all runtime configuration for the API Server.
type Config struct {
Addr string
PocketBaseURL string
PBAdminEmail string
PBAdminPass string
JWTSecret []byte
JWTAccessTTL time.Duration
AllowOrigins []string
MessageTTL time.Duration
}
// Load reads configuration from environment variables, applying sensible
// defaults. A .env file, if present in the working directory, is loaded first.
func Load() Config {
loadDotEnv(".env")
cfg := Config{
Addr: getenv("API_ADDR", ":8080"),
PocketBaseURL: strings.TrimRight(getenv("POCKETBASE_URL", "http://10.2.1.10:8028"), "/"),
PBAdminEmail: getenv("PB_ADMIN_EMAIL", ""),
PBAdminPass: getenv("PB_ADMIN_PASSWORD", ""),
JWTSecret: []byte(getenv("JWT_SECRET", "dev-insecure-change-me-please")),
JWTAccessTTL: getdur("JWT_ACCESS_TTL", 24*time.Hour),
AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
MessageTTL: getdur("MESSAGE_TTL", 5*time.Minute),
}
if cfg.PBAdminEmail == "" || cfg.PBAdminPass == "" {
log.Println("WARNING: PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD are not set; PocketBase calls will fail")
}
return cfg
}
func getenv(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getdur(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
log.Printf("invalid duration for %s=%q, using default %s", key, v, def)
}
return def
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they
// are not already set. It is intentionally minimal (no quoting rules beyond
// trimming surrounding quotes).
func loadDotEnv(path string) {
data, err := os.ReadFile(path)
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.Trim(strings.TrimSpace(val), `"'`)
if _, exists := os.LookupEnv(key); !exists {
_ = os.Setenv(key, val)
}
}
}
+265
View File
@@ -0,0 +1,265 @@
// Package pb is a thin REST client for PocketBase (v0.23+). The API Server is
// the only component that talks to PocketBase: it authenticates as a superuser
// and performs all CRUD on behalf of users, enforcing ownership in app logic.
package pb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"sync"
"time"
)
// Record is a generic PocketBase record (a JSON object).
type Record map[string]any
// ListResult is the envelope returned by PocketBase list endpoints.
type ListResult struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items []Record `json:"items"`
}
// APIError is returned for non-2xx PocketBase responses.
type APIError struct {
Status int
Message string
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("pocketbase: %d %s", e.Status, e.Message)
}
// NotFound reports whether err is a 404 from PocketBase.
func NotFound(err error) bool {
var ae *APIError
if e, ok := err.(*APIError); ok {
ae = e
}
return ae != nil && ae.Status == http.StatusNotFound
}
// Client is a PocketBase REST client with automatic superuser token refresh.
type Client struct {
baseURL string
adminEmail string
adminPass string
http *http.Client
mu sync.Mutex
token string
tokenExp time.Time
}
// New creates a PocketBase client.
func New(baseURL, adminEmail, adminPass string) *Client {
return &Client{
baseURL: baseURL,
adminEmail: adminEmail,
adminPass: adminPass,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// AuthResult is returned by AuthWithPassword.
type AuthResult struct {
Token string `json:"token"`
Record Record `json:"record"`
}
// authenticate logs in as a superuser and caches the token.
func (c *Client) authenticate(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body := map[string]string{"identity": c.adminEmail, "password": c.adminPass}
var res AuthResult
if err := c.do(ctx, http.MethodPost,
"/api/collections/_superusers/auth-with-password", "", body, &res); err != nil {
return "", fmt.Errorf("superuser auth: %w", err)
}
c.token = res.Token
// PocketBase superuser tokens are long-lived; refresh conservatively.
c.tokenExp = time.Now().Add(10 * time.Minute)
return c.token, nil
}
// AuthWithPassword verifies credentials against an auth collection (e.g. "users")
// and returns the user record. Used to validate logins; the API Server then
// issues its own JWT.
func (c *Client) AuthWithPassword(ctx context.Context, collection, identity, password string) (*AuthResult, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
body := map[string]string{"identity": identity, "password": password}
var res AuthResult
path := "/api/collections/" + url.PathEscape(collection) + "/auth-with-password"
if err := c.do(ctx, http.MethodPost, path, token, body, &res); err != nil {
return nil, err
}
return &res, nil
}
// Create inserts a record into a collection.
func (c *Client) Create(ctx context.Context, collection string, data any) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records"
if err := c.do(ctx, http.MethodPost, path, token, data, &out); err != nil {
return nil, err
}
return out, nil
}
// Update patches a record.
func (c *Client) Update(ctx context.Context, collection, id string, data any) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodPatch, path, token, data, &out); err != nil {
return nil, err
}
return out, nil
}
// Delete removes a record.
func (c *Client) Delete(ctx context.Context, collection, id string) error {
token, err := c.authenticate(ctx)
if err != nil {
return err
}
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
return c.do(ctx, http.MethodDelete, path, token, nil, nil)
}
// GetOne fetches a single record by id.
func (c *Client) GetOne(ctx context.Context, collection, id string) (Record, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
var out Record
path := "/api/collections/" + url.PathEscape(collection) + "/records/" + url.PathEscape(id)
if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// ListOptions controls a list query.
type ListOptions struct {
Filter string
Sort string
Expand string
Page int
PerPage int
}
// List queries records with optional filter/sort/pagination.
func (c *Client) List(ctx context.Context, collection string, opt ListOptions) (*ListResult, error) {
token, err := c.authenticate(ctx)
if err != nil {
return nil, err
}
q := url.Values{}
if opt.Filter != "" {
q.Set("filter", opt.Filter)
}
if opt.Sort != "" {
q.Set("sort", opt.Sort)
}
if opt.Expand != "" {
q.Set("expand", opt.Expand)
}
if opt.Page > 0 {
q.Set("page", strconv.Itoa(opt.Page))
}
if opt.PerPage > 0 {
q.Set("perPage", strconv.Itoa(opt.PerPage))
}
path := "/api/collections/" + url.PathEscape(collection) + "/records?" + q.Encode()
var out ListResult
if err := c.do(ctx, http.MethodGet, path, token, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// FindFirst returns the first record matching filter, or (nil, nil) if none.
func (c *Client) FindFirst(ctx context.Context, collection, filter, sort string) (Record, error) {
res, err := c.List(ctx, collection, ListOptions{Filter: filter, Sort: sort, PerPage: 1})
if err != nil {
return nil, err
}
if len(res.Items) == 0 {
return nil, nil
}
return res.Items[0], nil
}
// do performs an HTTP request against PocketBase and decodes the JSON response.
func (c *Client) do(ctx context.Context, method, path, token string, body, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if token != "" {
req.Header.Set("Authorization", token)
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg := http.StatusText(resp.StatusCode)
var pbErr struct {
Message string `json:"message"`
}
if json.Unmarshal(raw, &pbErr) == nil && pbErr.Message != "" {
msg = pbErr.Message
}
return &APIError{Status: resp.StatusCode, Message: msg, Body: string(raw)}
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("decode response: %w", err)
}
}
return nil
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#2E9E6B" />
<title>gsmnode · API Server</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1974
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "gsmnode-api-panel",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" fill="#2E9E6B"></rect>
<g transform="translate(6.5 6.5) scale(0.475)">
<line x1="9" y1="15" x2="28" y2="15" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M25 10.5 L31 15 L25 19.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
<line x1="12" y1="25" x2="31" y2="25" stroke="#fff" stroke-width="3.4" stroke-linecap="round"></line>
<path d="M15 20.5 L9 25 L15 29.5" stroke="#fff" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+111
View File
@@ -0,0 +1,111 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import { Moon, Sun } from "@lucide/vue";
import { theme, toggleTheme } from "./theme";
import EndpointTable from "./components/EndpointTable.vue";
// Live health poll against this server.
const status = ref("checking"); // checking | ok | error | unreachable
const httpStatus = ref(null);
const latency = ref(null);
const checkedAt = ref(null);
let timer = null;
async function check() {
const started = performance.now();
try {
const r = await fetch("/api/health");
latency.value = Math.round(performance.now() - started);
httpStatus.value = r.status;
status.value = r.ok ? "ok" : "error";
} catch {
latency.value = null;
httpStatus.value = null;
status.value = "unreachable";
}
checkedAt.value = new Date();
}
onMounted(() => {
check();
timer = setInterval(check, 10000);
});
onUnmounted(() => clearInterval(timer));
const badge = {
checking: { label: "checking", cls: "bg-sunken text-secondary" },
ok: { label: "operational", cls: "bg-success-tint text-success" },
error: { label: "error", cls: "bg-danger-tint text-danger" },
unreachable: { label: "unreachable", cls: "bg-danger-tint text-danger" },
};
const clientApi = [
{ method: "POST", path: "/api/auth/login", desc: "Exchange email + password for a JWT" },
{ method: "GET", path: "/api/devices", desc: "List registered gateway devices" },
{ method: "DELETE", path: "/api/devices/{id}", desc: "Remove a device" },
{ method: "POST", path: "/api/messages", desc: "Queue an outbound SMS" },
{ method: "GET", path: "/api/messages", desc: "Outbound message history" },
{ method: "POST", path: "/api/calls", desc: "Queue an outbound phone call" },
{ method: "GET", path: "/api/inbox", desc: "Messages received by your devices" },
{ method: "GET", path: "/api/webhooks", desc: "List webhook subscriptions" },
{ method: "POST", path: "/api/webhooks", desc: "Register a webhook" },
];
const mobileApi = [
{ method: "POST", path: "/api/mobile/v1/device", desc: "Register this phone as a gateway" },
{ method: "GET", path: "/api/mobile/v1/messages", desc: "Pull pending messages to send" },
{ method: "PATCH", path: "/api/mobile/v1/messages/{id}", desc: "Report sent / delivered / failed" },
{ method: "POST", path: "/api/mobile/v1/inbox", desc: "Push a received SMS" },
{ method: "POST", path: "/api/mobile/v1/ping", desc: "Device heartbeat" },
];
</script>
<template>
<div class="mx-auto flex max-w-3xl flex-col gap-6 px-6 pt-12 pb-16">
<!-- Header -->
<div class="flex items-center gap-4">
<img
:src="theme === 'dark' ? '/gsmnode-horizontal-white.png' : '/gsmnode-horizontal.png'"
alt="gsmnode"
class="h-8"
/>
<span class="gn-eyebrow mt-1.5">API server</span>
<div class="flex-1"></div>
<button
class="gn-btn-sec gn-btn-sm"
:title="theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'"
@click="toggleTheme"
>
<Sun v-if="theme === 'dark'" class="h-4 w-4" />
<Moon v-else class="h-4 w-4" />
Theme
</button>
</div>
<!-- Status -->
<div class="rounded-lg border border-subtle bg-card shadow-sm">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">Status</div>
<span
class="inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-xs font-medium"
:class="badge[status].cls"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>
{{ badge[status].label }}<template v-if="status === 'error'"> {{ httpStatus }}</template>
</span>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4 font-mono text-xs text-secondary">
<span>GET /api/health</span>
<span>{{ latency !== null ? latency + "ms" : "—" }}</span>
<span>{{ checkedAt ? "checked " + checkedAt.toLocaleTimeString() : "—" }}</span>
</div>
</div>
<EndpointTable title="Client API" auth="Bearer JWT" :endpoints="clientApi" />
<EndpointTable title="Mobile API" auth="Device token" :endpoints="mobileApi" />
<p class="text-center font-mono text-[11px] text-muted">
gsmnode turn any Android phone into an SMS gateway.
</p>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: String,
auth: String,
endpoints: Array, // [{ method, path, desc }]
});
const methodClass = {
GET: "text-success",
POST: "text-brand-text",
PATCH: "text-warning",
DELETE: "text-danger",
};
</script>
<template>
<div class="overflow-hidden rounded-lg border border-subtle bg-card shadow-xs">
<div class="flex items-center justify-between border-b border-subtle px-5 py-4">
<div class="text-base font-semibold text-primary">{{ title }}</div>
<span class="gn-eyebrow">{{ auth }}</span>
</div>
<table class="w-full text-left text-sm">
<thead>
<tr class="gn-eyebrow">
<th class="px-5 py-2.5 font-medium">Endpoint</th>
<th class="px-5 py-2.5 font-medium">Description</th>
</tr>
</thead>
<tbody>
<tr v-for="e in endpoints" :key="e.method + e.path" class="transition-colors hover:bg-sunken">
<td class="border-t border-subtle px-5 py-2.5 font-mono text-xs whitespace-nowrap">
<span class="font-semibold" :class="methodClass[e.method]">{{ e.method }}</span>
<span class="text-primary"> {{ e.path }}</span>
</td>
<td class="border-t border-subtle px-5 py-2.5 text-secondary">{{ e.desc }}</td>
</tr>
</tbody>
</table>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
import "./theme";
createApp(App).mount("#app");
+336
View File
@@ -0,0 +1,336 @@
/* gsmnode design system — tokens from Design/SMS Gateway logo design/tokens/*,
mapped into Tailwind v4. Signal-green + ink, developer-tool aesthetic. */
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
@import "tailwindcss";
/* ============================================================
RAW RAMPS + SEMANTIC ALIASES (light)
============================================================ */
:root {
/* Signal green (brand) — shared chroma, stepped lightness */
--green-100: oklch(0.95 0.04 152);
--green-200: oklch(0.88 0.07 152);
--green-300: oklch(0.78 0.11 152);
--green-500: oklch(0.60 0.14 152); /* primary */
--green-600: oklch(0.52 0.13 152);
--green-700: oklch(0.44 0.11 152);
--green-on-dark: oklch(0.72 0.14 152);
/* Ink & paper */
--ink: #12161C;
--ink-900: #0A0D11;
--ink-800: #1A1F27;
--paper: #FAFAF9;
--white: #FFFFFF;
/* Cool gray ramp */
--gray-50: #F4F5F4;
--gray-100: #E9EBEA;
--gray-200: #DCDFDE;
--gray-300: #C2C7C6;
--gray-400: #9AA0A2;
--gray-500: #6B7278;
--gray-600: #4A5157;
--gray-700: #333A40;
/* Semantic status */
--success: oklch(0.60 0.14 152); /* green — also "delivered" */
--warning: oklch(0.72 0.15 75);
--danger: oklch(0.60 0.17 25);
--info: oklch(0.62 0.13 245);
--success-x: oklch(0.95 0.04 152);
--warning-x: oklch(0.95 0.05 80);
--danger-x: oklch(0.95 0.05 25);
--info-x: oklch(0.95 0.04 245);
/* Semantic aliases — reference these in components */
--bg-page: var(--paper);
--bg-sunken: var(--gray-50);
--surface-card: var(--white);
--surface-raised: var(--white);
--surface-inverse:var(--ink);
--surface-code: var(--ink-900);
--border-subtle: rgba(18, 22, 28, 0.08);
--border-strong: rgba(18, 22, 28, 0.20);
--border-focus: var(--green-500);
--text-primary: var(--ink);
--text-secondary: var(--gray-700);
--text-muted: var(--gray-500);
--text-inverse: #F5F6F4;
--text-brand: var(--green-600);
--text-link: var(--green-600);
--brand: var(--green-500);
--brand-hover: var(--green-600);
--brand-active: var(--green-700);
--brand-tint: var(--green-100);
--brand-contrast: var(--white);
--brand-on-dark: var(--green-on-dark);
--success-tint: var(--success-x);
--warning-tint: var(--warning-x);
--danger-tint: var(--danger-x);
--info-tint: var(--info-x);
--ring-focus: 0 0 0 3px oklch(0.60 0.14 152 / 0.45);
--ring-danger: 0 0 0 3px oklch(0.60 0.17 25 / 0.35);
/* Flat, low-contrast elevation */
--sh-xs: 0 1px 2px rgba(18, 22, 28, 0.04);
--sh-sm: 0 1px 2px rgba(18, 22, 28, 0.06), 0 1px 1px rgba(18, 22, 28, 0.04);
--sh-md: 0 4px 12px rgba(18, 22, 28, 0.08);
--sh-lg: 0 8px 24px rgba(18, 22, 28, 0.14);
--sh-xl: 0 16px 40px rgba(18, 22, 28, 0.18);
--dur-instant: 100ms;
--dur-fast: 120ms;
--dur-base: 180ms;
--dur-slow: 260ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-entrance: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ============================================================
DARK THEME — only the semantic layer remaps.
Namespaced data-gsm-theme so it matches the design system.
============================================================ */
[data-gsm-theme="dark"] {
--bg-page: var(--ink-900);
--bg-sunken: #0E1216;
--surface-card: #14191F;
--surface-raised: #1A2027;
--surface-inverse:var(--white);
--surface-code: #05070A;
--border-subtle: rgba(255, 255, 255, 0.07);
--border-strong: rgba(255, 255, 255, 0.18);
--border-focus: var(--green-on-dark);
--text-primary: #F5F6F4;
--text-secondary: var(--gray-200);
--text-muted: var(--gray-400);
--text-inverse: var(--ink);
--text-brand: var(--green-on-dark);
--text-link: var(--green-on-dark);
--brand: var(--green-500);
--brand-hover: var(--green-on-dark);
--brand-active: var(--green-600);
--brand-tint: oklch(0.60 0.14 152 / 0.16);
--brand-contrast: var(--white);
--success: var(--green-300);
--warning: oklch(0.80 0.14 80);
--danger: oklch(0.72 0.16 25);
--info: oklch(0.72 0.12 245);
--success-tint: oklch(0.60 0.14 152 / 0.16);
--warning-tint: oklch(0.72 0.15 75 / 0.16);
--danger-tint: oklch(0.60 0.17 25 / 0.18);
--info-tint: oklch(0.62 0.13 245 / 0.16);
--ring-focus: 0 0 0 3px oklch(0.72 0.14 152 / 0.45);
--sh-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-sm: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-md: 0 4px 12px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4);
--sh-lg: 0 8px 24px rgba(0, 0, 0, 0.6), 0 4px 8px rgba(0, 0, 0, 0.4);
--sh-xl: 0 16px 40px rgba(0, 0, 0, 0.7);
color-scheme: dark;
}
/* ============================================================
TAILWIND THEME — utilities resolve to the semantic vars,
so everything flips automatically under data-gsm-theme="dark".
============================================================ */
@theme inline {
--color-*: initial;
--color-page: var(--bg-page);
--color-sunken: var(--bg-sunken);
--color-card: var(--surface-card);
--color-raised: var(--surface-raised);
--color-inverse: var(--surface-inverse);
--color-code: var(--surface-code);
--color-subtle: var(--border-subtle);
--color-strong: var(--border-strong);
--color-primary: var(--text-primary);
--color-secondary: var(--text-secondary);
--color-muted: var(--text-muted);
--color-on-brand: var(--brand-contrast);
--color-link: var(--text-link);
--color-brand: var(--brand);
--color-brand-hover: var(--brand-hover);
--color-brand-active: var(--brand-active);
--color-brand-tint: var(--brand-tint);
--color-brand-text: var(--text-brand);
--color-success: var(--success);
--color-success-tint: var(--success-tint);
--color-warning: var(--warning);
--color-warning-tint: var(--warning-tint);
--color-danger: var(--danger);
--color-danger-tint: var(--danger-tint);
--color-info: var(--info);
--color-info-tint: var(--info-tint);
--color-white: #FFFFFF;
--color-ink: var(--ink);
--color-green: var(--green-500);
--font-display: 'Space Grotesk', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-sans: 'IBM Plex Sans', ui-sans-serif, system-ui, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--radius-*: initial;
--radius-xs: 4px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 15px;
--radius-2xl: 18px;
--radius-full: 9999px;
--shadow-*: initial;
--shadow-xs: var(--sh-xs);
--shadow-sm: var(--sh-sm);
--shadow-md: var(--sh-md);
--shadow-lg: var(--sh-lg);
--shadow-xl: var(--sh-xl);
--shadow-ring: var(--ring-focus);
}
/* ============================================================
BASE
============================================================ */
html,
body,
#app {
height: 100%;
}
body {
font-family: var(--font-sans);
background: var(--bg-page);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* Display face for titles & metrics */
h1, h2, h3 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
/* Shared control styles (inputs, selects, textareas) */
@utility gn-input {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-card);
color: var(--text-primary);
padding: 0 12px;
height: 40px;
font-size: 0.875rem;
font-family: var(--font-sans);
transition: border-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
@utility gn-textarea {
width: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-card);
color: var(--text-primary);
padding: 10px 12px;
font-size: 0.875rem;
font-family: var(--font-sans);
resize: vertical;
transition: border-color var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
}
.gn-input:focus,
.gn-textarea:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: var(--ring-focus);
}
.gn-input::placeholder,
.gn-textarea::placeholder {
color: var(--text-muted);
}
/* Primary button — flat signal-green, no glow (infrastructure) */
@utility gn-btn-pri {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: var(--brand);
color: var(--brand-contrast);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.gn-btn-pri:hover:not(:disabled) { background: var(--brand-hover); }
.gn-btn-pri:active:not(:disabled) { background: var(--brand-active); transform: translateY(1px); }
.gn-btn-pri:focus-visible { outline: none; box-shadow: var(--ring-focus); }
.gn-btn-pri:disabled { opacity: 0.45; cursor: not-allowed; }
/* Secondary button */
@utility gn-btn-sec {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 40px;
padding: 0 16px;
border-radius: var(--radius-md);
border: 1px solid var(--border-strong);
background: var(--surface-card);
color: var(--text-primary);
font-family: var(--font-sans);
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard);
}
.gn-btn-sec:hover:not(:disabled) { background: var(--bg-sunken); }
.gn-btn-sec:active:not(:disabled) { transform: translateY(1px); }
.gn-btn-sec:focus-visible { outline: none; box-shadow: var(--ring-focus); }
.gn-btn-sec:disabled { opacity: 0.45; cursor: not-allowed; }
/* Small button size modifier */
@utility gn-btn-sm {
height: 32px;
padding: 0 12px;
font-size: 0.75rem;
border-radius: var(--radius-sm);
}
/* Mono eyebrow — uppercase, tracked out */
@utility gn-eyebrow {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-muted);
}
+30
View File
@@ -0,0 +1,30 @@
import { ref } from "vue";
// Persisted light/dark theme, shared key with the design-system kits.
const KEY = "gsmnode-theme";
function initial() {
try {
return localStorage.getItem(KEY) === "dark" ? "dark" : "light";
} catch {
return "light";
}
}
export const theme = ref(initial());
export function applyTheme(t) {
theme.value = t;
document.documentElement.setAttribute("data-gsm-theme", t);
try {
localStorage.setItem(KEY, t);
} catch {
/* private mode — theme just won't persist */
}
}
export function toggleTheme() {
applyTheme(theme.value === "dark" ? "light" : "dark");
}
applyTheme(theme.value);
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
// Builds into internal/api/dist, which the Go server embeds via go:embed
// and serves at the server root.
export default defineConfig({
plugins: [vue(), tailwindcss()],
build: {
outDir: "../internal/api/dist",
emptyOutDir: true,
},
server: {
port: 5174,
proxy: {
// Dev-mode proxy to a locally running API Server.
"/api": "http://localhost:8080",
},
},
});
+25
View File
@@ -0,0 +1,25 @@
# Runs the SMS Gateway API Server.
# Loads .env (if present), then starts the Go server.
#
# ./scripts/Run-ApiServer.ps1
$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$root = Split-Path -Parent $here # the "API Server" folder
Push-Location $root
try {
# Ensure Go is on PATH for this session.
$goBin = "C:\Program Files\Go\bin"
if (Test-Path $goBin) { $env:Path = "$goBin;$env:Path" }
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "Go is not installed or not on PATH."
}
Write-Host "Starting API Server (Ctrl+C to stop)..." -ForegroundColor Cyan
go run ./cmd/server
}
finally {
Pop-Location
}
+50
View File
@@ -0,0 +1,50 @@
// Creates a user in the PocketBase "users" collection to log in with.
//
// Usage (PowerShell):
// $env:POCKETBASE_URL="http://10.2.1.10:8028"
// $env:PB_ADMIN_EMAIL="admin@example.com"
// $env:PB_ADMIN_PASSWORD="admin-password"
// node scripts/create-user.mjs user@example.com "user-password" "Display Name"
const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
const [, , userEmail, userPassword, userName] = process.argv;
if (!EMAIL || !PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
process.exit(1);
}
if (!userEmail || !userPassword) {
console.error('Usage: node scripts/create-user.mjs <email> <password> ["name"]');
process.exit(1);
}
async function api(method, path, body, token) {
const res = await fetch(BASE + path, {
method,
headers: { "Content-Type": "application/json", ...(token ? { Authorization: token } : {}) },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${json?.message || text}`);
return json;
}
const auth = await api("POST", "/api/collections/_superusers/auth-with-password", {
identity: EMAIL,
password: PASSWORD,
});
const user = await api("POST", "/api/collections/users/records", {
email: userEmail,
password: userPassword,
passwordConfirm: userPassword,
name: userName || "",
emailVisibility: true,
verified: true,
}, auth.token);
console.log(`Created user ${user.email} (id: ${user.id}).`);
+194
View File
@@ -0,0 +1,194 @@
// Sets up the PocketBase collections the API Server expects.
//
// Usage (PowerShell):
// $env:POCKETBASE_URL="http://10.2.1.10:8028"
// $env:PB_ADMIN_EMAIL="you@example.com"
// $env:PB_ADMIN_PASSWORD="your-password"
// node scripts/setup-pocketbase.mjs
//
// It is idempotent: existing collections are updated, missing ones created.
// Requires Node 18+ (uses global fetch). No npm install needed.
const BASE = (process.env.POCKETBASE_URL || "http://10.2.1.10:8028").replace(/\/$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;
if (!EMAIL || !PASSWORD) {
console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
process.exit(1);
}
let token = "";
async function api(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: token } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = text ? JSON.parse(text) : null; } catch { /* non-JSON */ }
if (!res.ok) {
const msg = json?.message || text || res.statusText;
throw new Error(`${method} ${path} -> ${res.status}: ${msg}`);
}
return json;
}
async function authenticate() {
const res = await api("POST", "/api/collections/_superusers/auth-with-password", {
identity: EMAIL,
password: PASSWORD,
});
token = res.token;
console.log("Authenticated as superuser.");
}
async function getCollections() {
const res = await api("GET", "/api/collections?perPage=200");
const byName = {};
for (const c of res.items) byName[c.name] = c;
return byName;
}
// Field builders for PocketBase v0.23+ (collections use a `fields` array).
const f = {
text: (name, opts = {}) => ({ name, type: "text", required: false, ...opts }),
number: (name, opts = {}) => ({ name, type: "number", required: false, ...opts }),
date: (name, opts = {}) => ({ name, type: "date", required: false, ...opts }),
json: (name, opts = {}) => ({ name, type: "json", required: false, maxSize: 2000000, ...opts }),
url: (name, opts = {}) => ({ name, type: "url", required: false, ...opts }),
select: (name, values, opts = {}) => ({
name, type: "select", required: false, maxSelect: 1, values, ...opts,
}),
relation: (name, collectionId, opts = {}) => ({
name, type: "relation", required: false, collectionId,
cascadeDelete: false, maxSelect: 1, minSelect: 0, ...opts,
}),
autodate: (name, opts) => ({ name, type: "autodate", onCreate: true, onUpdate: false, ...opts }),
};
// Superuser-only API rules: only the API Server (acting as superuser) reads or
// writes these collections. Clients never touch PocketBase directly.
const LOCKED = { listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null };
function definitions(ids) {
return [
{
name: "devices",
type: "base",
...LOCKED,
fields: [
f.text("device_id", { required: true }),
f.text("name"),
f.text("platform"),
f.text("app_version"),
f.text("push_token"),
f.text("auth_token", { required: true }),
f.select("status", ["online", "offline"]),
f.date("last_seen_at"),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
f.autodate("updated", { onCreate: true, onUpdate: true }),
],
indexes: [
"CREATE UNIQUE INDEX idx_devices_auth_token ON devices (auth_token)",
"CREATE UNIQUE INDEX idx_devices_owner_device ON devices (owner, device_id)",
],
},
{
name: "messages",
type: "base",
...LOCKED,
fields: [
f.json("phone_numbers", { required: true }),
f.text("text_message"),
f.select("type", ["sms", "call"]),
f.number("sim_number"),
f.select("status", ["Pending", "Processed", "Sent", "Delivered", "Failed"]),
f.text("error"),
f.date("schedule_at"),
f.date("sent_at"),
f.date("delivered_at"),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
f.autodate("updated", { onCreate: true, onUpdate: true }),
],
indexes: [
"CREATE INDEX idx_messages_device_status ON messages (device, status)",
"CREATE INDEX idx_messages_owner ON messages (owner)",
],
},
{
name: "inbox",
type: "base",
...LOCKED,
fields: [
f.text("phone_number", { required: true }),
f.text("message"),
f.date("received_at"),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
],
indexes: ["CREATE INDEX idx_inbox_owner ON inbox (owner)"],
},
{
name: "webhooks",
type: "base",
...LOCKED,
fields: [
f.select("event", ["sms:received", "sms:sent", "sms:delivered", "sms:failed"]),
f.url("url", { required: true }),
f.relation("device", ids.devices, { cascadeDelete: false }),
f.relation("owner", ids.users, { required: true, cascadeDelete: true }),
f.autodate("created", { onCreate: true, onUpdate: false }),
],
indexes: ["CREATE INDEX idx_webhooks_owner_event ON webhooks (owner, event)"],
},
];
}
async function main() {
await authenticate();
let collections = await getCollections();
if (!collections.users) {
throw new Error('The default "users" auth collection was not found in PocketBase.');
}
// Resolve collection ids needed for relation fields. devices must exist before
// messages/inbox/webhooks reference it, so create in order, refreshing ids.
const order = ["devices", "messages", "inbox", "webhooks"];
for (const name of order) {
const ids = {
users: collections.users.id,
devices: collections.devices?.id,
};
const def = definitions(ids).find((d) => d.name === name);
if (collections[name]) {
await api("PATCH", `/api/collections/${collections[name].id}`, def);
console.log(`Updated collection: ${name}`);
} else {
await api("POST", "/api/collections", def);
console.log(`Created collection: ${name}`);
}
collections = await getCollections(); // refresh so later relations resolve
}
console.log("\nPocketBase setup complete.");
console.log("Collections: users (existing), devices, messages, inbox, webhooks.");
console.log("\nNext: create a user to log in with, e.g. via the PocketBase admin UI,");
console.log("or run scripts/create-user.mjs.");
}
main().catch((err) => {
console.error("\nSetup failed:", err.message);
process.exit(1);
});
+133
View File
@@ -0,0 +1,133 @@
# gsmnode — Home Assistant Integration
A custom Home Assistant integration that sends SMS **and places phone calls**
through the gsmnode **API Server**. It can be added and configured entirely
from the Home Assistant UI.
```
Home Assistant ──► API Server (/api/messages, /api/calls) ──► your phone ──► SMS / call
```
Like every other client, it talks **only** to the API Server (never PocketBase).
## What you get
- **UI setup** (config flow) — add it under *Settings → Devices & Services*.
- **Services** — `gsmnode.send_sms` and `gsmnode.call`, with field
pickers in the automation editor and *Developer Tools → Actions*.
- **Sensor** — `binary_sensor` "API Server" (connectivity) so you can see and
automate on the gateway being up/down.
## Install
1. Copy the integration folder into your Home Assistant config directory so it
lands at:
```
<config>/custom_components/gsmnode/
```
(Copy this repo's `custom_components/gsmnode/` next to your
`configuration.yaml`. Use the Samba / File editor / SSH add-on, or the
mapped volume for Docker.)
2. **Restart Home Assistant** (Settings → System → Restart).
## Add it from the UI
1. **Settings → Devices & Services → Add Integration**.
2. Search for **gsmnode**.
3. Fill in the form:
- **API Server URL** — e.g. `http://10.2.1.101:8080` (must be reachable from HA)
- **Email** / **Password** — a gateway user (create one with
`node "API Server/scripts/create-user.mjs" ha@local "pass" "Home Assistant"`)
- **Default device ID** *(optional)* — pin sends/calls to a specific phone
The form validates by logging in; you'll get *Invalid auth* or *Cannot
connect* if something's wrong.
A **gsmnode** device appears with an **API Server** connectivity sensor.
## Use it
### Send an SMS
```yaml
action: gsmnode.send_sms
data:
phone_numbers: ["+15551234567"]
message: "Hello from Home Assistant"
device_id: my-phone # optional
sim_number: 1 # optional (dual-SIM)
```
### Place a call
```yaml
action: gsmnode.call
data:
phone_number: "+15551234567"
device_id: my-phone # optional
```
### Example automation
```yaml
automation:
- alias: "Water leak — text then call"
trigger:
- platform: state
entity_id: binary_sensor.basement_leak
to: "on"
action:
- action: gsmnode.send_sms
data:
phone_numbers: ["+15551234567"]
message: "Water leak detected in the basement!"
- action: gsmnode.call
data:
phone_number: "+15551234567"
```
### React to the gateway going offline
```yaml
automation:
- alias: "Alert if SMS gateway API is down"
trigger:
- platform: state
entity_id: binary_sensor.gsmnode_api_server
to: "off"
for: "00:02:00"
action:
- action: persistent_notification.create
data:
title: "gsmnode"
message: "The API Server is unreachable."
```
## Receiving SMS in Home Assistant
To **receive** incoming texts, register the API Server's `sms:received` webhook
against a HA webhook trigger. A ready-to-use snippet is in
[`configuration.example.yaml`](configuration.example.yaml).
## Legacy YAML `notify` platform (optional)
A `notify.gsmnode` service is still available for those who prefer YAML
(`notify.py`). It's independent of the UI integration — see
[`configuration.example.yaml`](configuration.example.yaml). For new setups, the
UI integration above is recommended.
## How it works
- On first send it logs in (`POST /api/auth/login`) and caches the JWT; on a
`401` it re-logs in once and retries.
- `send_sms` → `POST /api/messages`; `call` → `POST /api/calls`.
- The sensor polls `GET /api/health` every 30s.
- All HTTP uses Home Assistant's shared aiohttp session (fully async).
## Notes / limitations
- The API Server must be reachable from the Home Assistant host.
- No external dependencies (`requirements: []`).
@@ -0,0 +1,49 @@
# RECOMMENDED: add this integration from the UI instead
# (Settings -> Devices & Services -> Add Integration -> gsmnode).
# The blocks below are only for the OPTIONAL legacy `notify.gsmnode`
# YAML service and for receiving incoming SMS via a webhook.
# --- Optional: legacy notify.gsmnode YAML service ---------------------
notify:
- platform: gsmnode
name: gsmnode # service becomes notify.gsmnode
api_base: http://10.2.1.10:8080 # the gsmnode API Server (reachable from HA)
email: !secret gsmnode_email
password: !secret gsmnode_password
device_id: my-phone # optional: pin to a specific device
# secrets.yaml:
# gsmnode_email: you@example.com
# gsmnode_password: your-password
# --- Usage ----------------------------------------------------------------
# Send an SMS:
# service: notify.gsmnode
# data: { message: "Hello", target: ["+15551234567"] }
#
# Place a phone call (gateway phone dials the number; message is ignored):
# service: notify.gsmnode
# data: { message: "", target: ["+15551234567"], data: { type: call } }
# --- Optional: receive incoming SMS as Home Assistant events ---------------
# The API Server can POST the `sms:received` webhook to a HA webhook trigger.
#
# 1. Create an automation with a webhook trigger (gives you a webhook_id):
#
# automation:
# - alias: "gsmnode - incoming SMS"
# trigger:
# - platform: webhook
# webhook_id: gsmnode_incoming
# allowed_methods: [POST]
# local_only: true
# action:
# - service: persistent_notification.create
# data:
# title: "SMS from {{ trigger.json.payload.phone_number }}"
# message: "{{ trigger.json.payload.message }}"
#
# 2. Register the webhook with the API Server (one-time), pointing at:
# http://<home-assistant>:8123/api/webhook/gsmnode_incoming
# e.g. via the Web App "Webhooks" page or:
# POST /api/webhooks {"event":"sms:received","url":"<that url>"}
@@ -0,0 +1,105 @@
"""The gsmnode integration.
Sends SMS and places phone calls through the gsmnode API Server. Configured
from the UI (Settings → Devices & Services → Add Integration → gsmnode).
Exposes two services — `gsmnode.send_sms` and `gsmnode.call` — and an
"API Server" connectivity binary sensor. A legacy `notify.gsmnode` platform
(YAML) is also available for backward compatibility (see notify.py / README).
"""
from __future__ import annotations
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from .client import GsmNodeClient, GsmNodeConnectionError
from .const import (
CONF_API_BASE,
CONF_DEVICE_ID,
DOMAIN,
SERVICE_CALL,
SERVICE_SEND_SMS,
)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
SEND_SMS_SCHEMA = vol.Schema(
{
vol.Required("phone_numbers"): vol.All(cv.ensure_list, [cv.string]),
vol.Required("message"): cv.string,
vol.Optional("device_id"): cv.string,
vol.Optional("sim_number"): vol.Coerce(int),
}
)
CALL_SCHEMA = vol.Schema(
{
vol.Required("phone_number"): cv.string,
vol.Optional("device_id"): cv.string,
}
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up gsmnode from a config entry."""
client = GsmNodeClient(
hass,
entry.data[CONF_API_BASE],
entry.data[CONF_EMAIL],
entry.data[CONF_PASSWORD],
entry.data.get(CONF_DEVICE_ID),
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
_async_register_services(hass)
return True
def _async_register_services(hass: HomeAssistant) -> None:
"""Register the send_sms / call services (once)."""
if hass.services.has_service(DOMAIN, SERVICE_SEND_SMS):
return
def _first_client() -> GsmNodeClient | None:
for value in hass.data.get(DOMAIN, {}).values():
if isinstance(value, GsmNodeClient):
return value
return None
async def handle_send_sms(call: ServiceCall) -> None:
client = _first_client()
if client is None:
raise GsmNodeConnectionError("no gsmnode configured")
await client.send_sms(
call.data["phone_numbers"],
call.data["message"],
call.data.get("device_id"),
call.data.get("sim_number"),
)
async def handle_call(call: ServiceCall) -> None:
client = _first_client()
if client is None:
raise GsmNodeConnectionError("no gsmnode configured")
await client.place_call(call.data["phone_number"], call.data.get("device_id"))
hass.services.async_register(
DOMAIN, SERVICE_SEND_SMS, handle_send_sms, schema=SEND_SMS_SCHEMA
)
hass.services.async_register(DOMAIN, SERVICE_CALL, handle_call, schema=CALL_SCHEMA)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
if not hass.data[DOMAIN]:
hass.services.async_remove(DOMAIN, SERVICE_SEND_SMS)
hass.services.async_remove(DOMAIN, SERVICE_CALL)
return unload_ok
@@ -0,0 +1,66 @@
"""API Server connectivity binary sensor."""
from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .client import GsmNodeClient
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the connectivity sensor for a config entry."""
client: GsmNodeClient = hass.data[DOMAIN][entry.entry_id]
coordinator: DataUpdateCoordinator[bool] = DataUpdateCoordinator(
hass,
_LOGGER,
name="gsmnode_health",
update_method=client.health,
update_interval=SCAN_INTERVAL,
)
await coordinator.async_config_entry_first_refresh()
async_add_entities([GsmNodeHealthSensor(coordinator, entry)])
class GsmNodeHealthSensor(CoordinatorEntity[DataUpdateCoordinator[bool]], BinarySensorEntity):
"""Reports whether the API Server is reachable."""
_attr_has_entity_name = True
_attr_name = "API Server"
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
def __init__(self, coordinator: DataUpdateCoordinator[bool], entry: ConfigEntry) -> None:
super().__init__(coordinator)
self._attr_unique_id = f"{entry.entry_id}_api_health"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name="gsmnode",
manufacturer="gsmnode",
)
@property
def is_on(self) -> bool:
"""True when the API Server responded OK on the last check."""
return bool(self.coordinator.data)
@@ -0,0 +1,105 @@
"""Async client for the gsmnode API Server (shared by the UI integration)."""
from __future__ import annotations
import aiohttp
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
class GsmNodeAuthError(Exception):
"""Raised when the API Server rejects the credentials."""
class GsmNodeConnectionError(Exception):
"""Raised when the API Server can't be reached or returns an error."""
class GsmNodeClient:
"""Talks to the API Server: login, send SMS, place calls, health."""
def __init__(
self,
hass: HomeAssistant,
api_base: str,
email: str,
password: str,
device_id: str | None = None,
) -> None:
self._session = async_get_clientsession(hass)
self._api_base = api_base.rstrip("/")
self._email = email
self._password = password
self.device_id = device_id
self._token: str | None = None
async def login(self) -> None:
"""Authenticate and cache the JWT."""
try:
async with self._session.post(
f"{self._api_base}/api/auth/login",
json={"email": self._email, "password": self._password},
) as resp:
if resp.status == 401:
raise GsmNodeAuthError("invalid credentials")
if resp.status != 200:
raise GsmNodeConnectionError(f"HTTP {resp.status}")
data = await resp.json()
self._token = data.get("access_token")
if not self._token:
raise GsmNodeAuthError("no token in response")
except aiohttp.ClientError as err:
raise GsmNodeConnectionError(str(err)) from err
async def _post(self, path: str, payload: dict) -> int:
headers = {"Authorization": f"Bearer {self._token}"} if self._token else {}
async with self._session.post(
f"{self._api_base}{path}", json=payload, headers=headers
) as resp:
return resp.status
async def _send(self, path: str, payload: dict) -> None:
"""POST with auth, re-logging in once on a 401."""
try:
if not self._token:
await self.login()
status = await self._post(path, payload)
if status == 401:
await self.login()
status = await self._post(path, payload)
except aiohttp.ClientError as err:
raise GsmNodeConnectionError(str(err)) from err
if status not in (200, 201, 202):
raise GsmNodeConnectionError(f"{path} -> HTTP {status}")
async def send_sms(
self,
phone_numbers: list[str],
message: str,
device_id: str | None = None,
sim_number: int | None = None,
) -> None:
"""Queue an outbound SMS."""
payload: dict = {"phone_numbers": phone_numbers, "text_message": message}
dev = device_id or self.device_id
if dev:
payload["device_id"] = dev
if sim_number is not None:
payload["sim_number"] = sim_number
await self._send("/api/messages", payload)
async def place_call(self, phone_number: str, device_id: str | None = None) -> None:
"""Queue an outbound phone call."""
payload: dict = {"phone_number": phone_number}
dev = device_id or self.device_id
if dev:
payload["device_id"] = dev
await self._send("/api/calls", payload)
async def health(self) -> bool:
"""Return True if the API Server's health endpoint responds OK."""
try:
async with self._session.get(f"{self._api_base}/api/health") as resp:
return resp.status == 200
except aiohttp.ClientError:
return False
@@ -0,0 +1,67 @@
"""Config flow for the gsmnode integration (UI setup)."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.helpers import selector
from .client import (
GsmNodeAuthError,
GsmNodeClient,
GsmNodeConnectionError,
)
from .const import CONF_API_BASE, CONF_DEVICE_ID, DEFAULT_API_BASE, DOMAIN
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_BASE, default=DEFAULT_API_BASE): str,
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): selector.TextSelector(
selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD)
),
vol.Optional(CONF_DEVICE_ID): str,
}
)
class GsmNodeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the UI configuration flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = GsmNodeClient(
self.hass,
user_input[CONF_API_BASE],
user_input[CONF_EMAIL],
user_input[CONF_PASSWORD],
user_input.get(CONF_DEVICE_ID),
)
try:
await client.login()
except GsmNodeAuthError:
errors["base"] = "invalid_auth"
except GsmNodeConnectionError:
errors["base"] = "cannot_connect"
else:
api_base = user_input[CONF_API_BASE].rstrip("/")
await self.async_set_unique_id(f"{api_base}::{user_input[CONF_EMAIL]}")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{user_input[CONF_EMAIL]} ({api_base})",
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_SCHEMA, errors=errors
)
@@ -0,0 +1,13 @@
"""Constants for the gsmnode integration."""
DOMAIN = "gsmnode"
CONF_API_BASE = "api_base"
CONF_DEVICE_ID = "device_id"
DEFAULT_API_BASE = "http://localhost:8080"
DEFAULT_NAME = "gsmnode"
# Service names registered by the integration.
SERVICE_SEND_SMS = "send_sms"
SERVICE_CALL = "call"
@@ -0,0 +1,13 @@
{
"domain": "gsmnode",
"name": "gsmnode",
"version": "1.2.0",
"config_flow": true,
"documentation": "https://github.com/your-org/sms-gateway",
"issue_tracker": "https://github.com/your-org/sms-gateway/issues",
"dependencies": [],
"codeowners": [],
"requirements": [],
"integration_type": "service",
"iot_class": "local_polling"
}
@@ -0,0 +1,150 @@
"""gsmnode notify platform.
Sends SMS through the gsmnode API Server's `/api/messages` endpoint. The
API Server is the only thing that talks to PocketBase, so this integration only
needs the API Server's URL and a user login.
"""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_TARGET,
PLATFORM_SCHEMA,
BaseNotificationService,
)
from homeassistant.const import CONF_EMAIL, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
CONF_API_BASE,
CONF_DEVICE_ID,
DEFAULT_API_BASE,
DEFAULT_NAME,
)
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_API_BASE, default=DEFAULT_API_BASE): cv.string,
vol.Required(CONF_EMAIL): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_DEVICE_ID): cv.string,
}
)
async def async_get_service(
hass: HomeAssistant,
config: ConfigType,
discovery_info: DiscoveryInfoType | None = None,
) -> "GsmNodeNotificationService":
"""Return the gsmnode notification service."""
return GsmNodeNotificationService(
hass,
config[CONF_API_BASE],
config[CONF_EMAIL],
config[CONF_PASSWORD],
config.get(CONF_DEVICE_ID),
)
class GsmNodeNotificationService(BaseNotificationService):
"""Implement the notification service for gsmnode."""
def __init__(
self,
hass: HomeAssistant,
api_base: str,
email: str,
password: str,
device_id: str | None,
) -> None:
"""Initialize the service."""
self._hass = hass
self._api_base = api_base.rstrip("/")
self._email = email
self._password = password
self._device_id = device_id
self._token: str | None = None
@property
def _session(self):
return async_get_clientsession(self._hass)
async def _login(self) -> None:
"""Authenticate against the API Server and cache the JWT."""
url = f"{self._api_base}/api/auth/login"
async with self._session.post(
url, json={"email": self._email, "password": self._password}
) as resp:
if resp.status != 200:
raise RuntimeError(f"login failed: HTTP {resp.status}")
data = await resp.json()
self._token = data.get("access_token")
if not self._token:
raise RuntimeError("login response did not contain a token")
async def _post(self, path: str, payload: dict[str, Any]) -> int:
"""POST to an API path; returns the HTTP status code."""
url = f"{self._api_base}{path}"
headers = {"Authorization": f"Bearer {self._token}"}
async with self._session.post(url, json=payload, headers=headers) as resp:
return resp.status
async def _send(self, path: str, payload: dict[str, Any]) -> None:
"""Authenticate (if needed) and POST, retrying once on a 401."""
try:
if not self._token:
await self._login()
status = await self._post(path, payload)
if status == 401: # token expired — re-login once and retry
await self._login()
status = await self._post(path, payload)
if status not in (200, 201, 202):
_LOGGER.error("gsmnode: %s failed with HTTP %s", path, status)
except Exception as err: # noqa: BLE001 - surface any transport error
_LOGGER.error("gsmnode: error calling %s: %s", path, err)
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send an SMS, or place a phone call when `data.type` is `call`.
Recipient numbers come from the `target` field. Optional data overrides:
`device_id` (which device), `sim_number` (SMS only), and `type: call` to
dial the target(s) instead of texting.
"""
targets = kwargs.get(ATTR_TARGET)
if not targets:
_LOGGER.error("gsmnode: no target phone number(s) provided")
return
data = kwargs.get(ATTR_DATA) or {}
device_id = data.get("device_id", self._device_id)
is_call = str(data.get("type", "")).lower() == "call"
if is_call:
# One call per target number (a call has a single recipient).
for number in targets:
payload: dict[str, Any] = {"phone_number": number}
if device_id:
payload["device_id"] = device_id
await self._send("/api/calls", payload)
return
payload = {"phone_numbers": list(targets), "text_message": message}
if device_id:
payload["device_id"] = device_id
if "sim_number" in data:
payload["sim_number"] = data["sim_number"]
await self._send("/api/messages", payload)
@@ -0,0 +1,53 @@
send_sms:
name: Send SMS
description: Send an SMS through the gateway.
fields:
phone_numbers:
name: Phone numbers
description: One or more recipient phone numbers.
required: true
example: '["+15551234567"]'
selector:
text:
multiple: true
message:
name: Message
description: The text to send.
required: true
example: Hello from Home Assistant
selector:
text:
multiline: true
device_id:
name: Device
description: Device to send from (defaults to the configured / most recent device).
example: my-phone
selector:
text:
sim_number:
name: SIM number
description: Which SIM to use on a dual-SIM phone.
example: 1
selector:
number:
min: 1
max: 4
mode: box
call:
name: Place call
description: Tell a gateway device to place an outbound phone call.
fields:
phone_number:
name: Phone number
description: The number to call.
required: true
example: "+15551234567"
selector:
text:
device_id:
name: Device
description: Device to call from (defaults to the configured / most recent device).
example: my-phone
selector:
text:
@@ -0,0 +1,43 @@
{
"config": {
"step": {
"user": {
"title": "gsmnode",
"description": "Connect to your gsmnode API Server.",
"data": {
"api_base": "API Server URL",
"email": "Email",
"password": "Password",
"device_id": "Default device ID (optional)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the API Server. Check the URL and that it is reachable.",
"invalid_auth": "Invalid email or password."
},
"abort": {
"already_configured": "This gateway is already configured."
}
},
"services": {
"send_sms": {
"name": "Send SMS",
"description": "Send an SMS through the gateway.",
"fields": {
"phone_numbers": { "name": "Phone numbers", "description": "One or more recipient phone numbers." },
"message": { "name": "Message", "description": "The text to send." },
"device_id": { "name": "Device", "description": "Device to send from." },
"sim_number": { "name": "SIM number", "description": "Which SIM to use on a dual-SIM phone." }
}
},
"call": {
"name": "Place call",
"description": "Tell a gateway device to place an outbound phone call.",
"fields": {
"phone_number": { "name": "Phone number", "description": "The number to call." },
"device_id": { "name": "Device", "description": "Device to call from." }
}
}
}
}
@@ -0,0 +1,43 @@
{
"config": {
"step": {
"user": {
"title": "gsmnode",
"description": "Connect to your gsmnode API Server.",
"data": {
"api_base": "API Server URL",
"email": "Email",
"password": "Password",
"device_id": "Default device ID (optional)"
}
}
},
"error": {
"cannot_connect": "Failed to connect to the API Server. Check the URL and that it is reachable.",
"invalid_auth": "Invalid email or password."
},
"abort": {
"already_configured": "This gateway is already configured."
}
},
"services": {
"send_sms": {
"name": "Send SMS",
"description": "Send an SMS through the gateway.",
"fields": {
"phone_numbers": { "name": "Phone numbers", "description": "One or more recipient phone numbers." },
"message": { "name": "Message", "description": "The text to send." },
"device_id": { "name": "Device", "description": "Device to send from." },
"sim_number": { "name": "SIM number", "description": "Which SIM to use on a dual-SIM phone." }
}
},
"call": {
"name": "Place call",
"description": "Tell a gateway device to place an outbound phone call.",
"fields": {
"phone_number": { "name": "Phone number", "description": "The number to call." },
"device_id": { "name": "Device", "description": "Device to call from." }
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
# Flutter / Dart
.dart_tool/
.packages
build/
.flutter-plugins
.flutter-plugins-dependencies
pubspec.lock
# Android
android/.gradle/
android/local.properties
android/key.properties
*.keystore
*.jks
# IDE
.idea/
*.iml
.vscode/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "d8a9f9a52e5af486f80d932e838ee93861ffd863"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
- platform: android
create_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
base_revision: d8a9f9a52e5af486f80d932e838ee93861ffd863
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+124
View File
@@ -0,0 +1,124 @@
# gsmnode — Phone App (Flutter / Android)
Turns an Android phone into the SMS gateway endpoint. It registers with the API
Server, polls for pending outbound messages, sends them over the radio, reports
delivery state, and forwards incoming SMS to the server's inbox.
```
API Server ──(pending messages)──► Phone App ──► SmsManager (send)
▲ │
└──(status reports / inbox)──────────┘ ◄── BroadcastReceiver (incoming)
```
> The Phone App talks **only** to the API Server (never to PocketBase directly),
> using the device token issued at registration.
## Status: source scaffold
This folder contains the complete Dart app and the native Android (Kotlin) SMS
bridge, but **not** the generated Gradle/platform scaffolding. You generate that
once with `flutter create` after installing the toolchain (below).
## Prerequisites
1. **Flutter SDK** (stable) — https://docs.flutter.dev/get-started/install/windows
2. **JDK 17** (this machine currently has only JDK 8 — Android Gradle needs 17+)
3. **Android SDK** (via Android Studio or `flutter doctor --android-licenses`)
4. A **physical Android phone** with a SIM (the emulator can't send real SMS)
Verify with `flutter doctor` — resolve anything it flags before continuing.
## Generate platform scaffolding & wire in the native code
From this `Phone App/` folder:
```powershell
# 1. Generate the android/ Gradle project (keeps lib/ and pubspec.yaml)
flutter create . --org app.smsgateway --project-name sms_gateway_phone --platforms=android
# 2. Overlay the SMS-enabled manifest + Kotlin (overwrites the generated stubs)
Copy-Item -Recurse -Force android_overlay/* android/
# 3. Fetch packages
flutter pub get
```
`android_overlay/` mirrors the real `android/` paths, so step 2 drops:
- `app/src/main/AndroidManifest.xml` — SMS permissions + the `SmsReceiver`
- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/MainActivity.kt` — send bridge
- `app/src/main/kotlin/app/smsgateway/sms_gateway_phone/SmsReceiver.kt` — incoming bridge
If Gradle complains about SDK levels, set `minSdkVersion 23` (or higher) in
`android/app/build.gradle`.
## Run
```powershell
flutter devices # confirm your phone is listed
flutter run # build & install on the connected phone
```
1. On first launch, enter the **API Server URL**, your **email/password**, and a
**device name**, then tap *Sign in & register device*.
- Emulator → host: use `http://10.0.2.2:8080`.
- Physical phone → use the host's LAN IP, e.g. `http://10.2.1.x:8080`
(the same network as the phone; make sure the API Server is reachable).
2. On the home screen, **grant SMS & phone permissions**, then **Start gateway**.
3. Send a test message from the Web App → it appears in the activity log and is
delivered via the phone. Texts received by the phone show up in the Web App
**Inbox**.
## How it maps to the API
| Action | Endpoint |
|---|---|
| Login | `POST /api/auth/login` (JWT) |
| Register device | `POST /api/mobile/v1/device` → device token |
| Poll pending | `GET /api/mobile/v1/messages` (marks them `Processed`) |
| Report state | `PATCH /api/mobile/v1/messages/{id}` (`Sent`/`Failed`) |
| Incoming SMS | `POST /api/mobile/v1/inbox` |
| Heartbeat | `POST /api/mobile/v1/ping` |
Pulled items carry a `type` of `sms` or `call`. For `call` the app places a
native phone call via `TelecomManager.placeCall` (needs `CALL_PHONE` — covered by
the phone permission) instead of sending SMS, then reports `Sent`.
`TelecomManager.placeCall` is used rather than `startActivity(ACTION_CALL)` so the
call still goes through when the screen is locked / the app is backgrounded
(Android blocks background activity starts, but not telecom-routed calls).
## Code layout
```
lib/
main.dart entry, bootstraps services, picks first screen
config.dart default API base + poll/ping intervals
models/message.dart outbound message model
services/
storage.dart persisted settings/tokens (shared_preferences)
api_client.dart API Server HTTP client
sms_service.dart platform-channel bridge (send + incoming stream)
gateway_service.dart the poll → send → report loop + inbox forwarding
screens/
login_screen.dart login + device registration
home_screen.dart start/stop, permissions, activity log
android_overlay/ native Android files to copy after `flutter create`
```
## Background & delivery reports (implemented)
- **Foreground service** (`GatewayForegroundService.kt`): starting the gateway
launches an ongoing-notification foreground service + partial wakelock, so the
poll/send loop keeps running while the screen is off or the app is backgrounded.
- **Delivery reports**: `MainActivity.sendSms` attaches `sent`/`delivered`
PendingIntents; `SmsStatusReceiver` forwards the outcome (tagged with the
message id) to Dart, which reports `Delivered`/`Failed` to the API Server.
## Known next steps (not yet implemented)
- **Survive full task removal / long Doze**: the foreground service covers
screen-lock, but surviving the user swiping the app away or hours of Doze would
need a dedicated background Dart isolate (e.g. `flutter_background_service`).
- **Push wake-up (FCM)**: register an FCM token at registration so the server can
wake the device instead of polling. Requires a Firebase project +
`google-services.json` + server-side FCM sending in the API Server.
- **MMS / data SMS**: only text SMS is implemented.
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
avoid_print: false
+13
View File
@@ -0,0 +1,13 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+43
View File
@@ -0,0 +1,43 @@
plugins {
id "com.android.application"
id "kotlin-android"
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "app.gsmnode.phone"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
defaultConfig {
applicationId = "app.gsmnode.phone"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Sending and receiving SMS -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- Placing outbound calls remotely -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!-- Optional: needed to choose a specific SIM on dual-SIM devices -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Keep the gateway alive in the background (foreground service path) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+ runtime permission for the ongoing service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="gsmnode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Incoming SMS broadcast receiver -->
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Receives send/delivery outcomes from PendingIntents (explicit) -->
<receiver
android:name=".SmsStatusReceiver"
android:exported="false" />
<!-- Foreground service that keeps the gateway loop alive -->
<service
android:name=".GatewayForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required so plugins (e.g. url launches) can query intents on Android 11+ -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,99 @@
package app.gsmnode.phone
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/// Foreground service that keeps the app process alive (and the CPU awake via a
/// partial wakelock) so the Dart gateway loop keeps polling/sending while the
/// screen is off or the app is in the background.
///
/// Note: this closes the "survives screen lock" gap. Surviving a full task
/// removal or Doze for hours would additionally need a background Dart isolate
/// (e.g. flutter_background_service) — documented as a further step.
class GatewayForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
companion object {
private const val CHANNEL_ID = "sms_gateway_service"
private const val NOTIFICATION_ID = 4711
fun start(context: Context) {
val intent = Intent(context, GatewayForegroundService::class.java)
ContextCompat.startForegroundService(context, intent)
}
fun stop(context: Context) {
context.stopService(Intent(context, GatewayForegroundService::class.java))
}
}
override fun onCreate() {
super.onCreate()
createChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
acquireWakeLock()
// Restart if the system kills the service.
return START_STICKY
}
private fun startForegroundCompat() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("gsmnode")
.setContentText("Gateway is running")
.setSmallIcon(android.R.drawable.stat_sys_upload)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "gsmnode:gateway"
).also { it.acquire() }
}
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID, "gsmnode",
NotificationManager.IMPORTANCE_LOW
).apply { description = "Keeps the SMS gateway running" }
getSystemService(NotificationManager::class.java)
.createNotificationChannel(channel)
}
}
override fun onDestroy() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,187 @@
package app.gsmnode.phone
import android.Manifest
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.telecom.TelecomManager
import android.telephony.SmsManager
import android.telephony.SubscriptionManager
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
companion object {
const val METHOD_CHANNEL = "app.gsmnode/sms"
const val EVENT_CHANNEL = "app.gsmnode/sms_incoming"
const val STATUS_CHANNEL = "app.gsmnode/sms_status"
const val STATUS_ACTION = "app.gsmnode.SMS_STATUS"
// Sinks used by the broadcast receivers to push events into Dart.
@Volatile var incomingSink: EventChannel.EventSink? = null
@Volatile var statusSink: EventChannel.EventSink? = null
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
val messenger = flutterEngine.dartExecutor.binaryMessenger
MethodChannel(messenger, METHOD_CHANNEL).setMethodCallHandler { call, result ->
when (call.method) {
"sendSms" -> {
val phone = call.argument<String>("phone")
val message = call.argument<String>("message")
val simSlot = call.argument<Int>("simSlot")
val messageId = call.argument<String>("messageId")
if (phone.isNullOrBlank() || message == null) {
result.error("BAD_ARGS", "phone and message are required", null)
return@setMethodCallHandler
}
try {
sendSms(phone, message, simSlot, messageId)
result.success(true)
} catch (e: Exception) {
result.error("SEND_FAILED", e.message, null)
}
}
"placeCall" -> {
val phone = call.argument<String>("phone")
if (phone.isNullOrBlank()) {
result.error("BAD_ARGS", "phone is required", null)
return@setMethodCallHandler
}
try {
placeCall(phone)
result.success(true)
} catch (e: Exception) {
result.error("CALL_FAILED", e.message, null)
}
}
"startService" -> {
GatewayForegroundService.start(this)
result.success(true)
}
"stopService" -> {
GatewayForegroundService.stop(this)
result.success(true)
}
else -> result.notImplemented()
}
}
EventChannel(messenger, EVENT_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
incomingSink = sink
}
override fun onCancel(args: Any?) {
incomingSink = null
}
})
EventChannel(messenger, STATUS_CHANNEL).setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(args: Any?, sink: EventChannel.EventSink?) {
statusSink = sink
}
override fun onCancel(args: Any?) {
statusSink = null
}
})
}
private fun smsManagerFor(simSlot: Int?): SmsManager {
if (simSlot == null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
else
@Suppress("DEPRECATION") SmsManager.getDefault()
}
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (hasPerm) {
val sm = getSystemService(SubscriptionManager::class.java)
val info = sm?.getActiveSubscriptionInfoForSimSlotIndex(simSlot)
if (info != null) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
getSystemService(SmsManager::class.java)
.createForSubscriptionId(info.subscriptionId)
else
@Suppress("DEPRECATION")
SmsManager.getSmsManagerForSubscriptionId(info.subscriptionId)
}
}
return smsManagerFor(null)
}
/// Builds a PendingIntent that, when fired by the radio, broadcasts the
/// send/delivery outcome to SmsStatusReceiver (tagged with the message id).
private fun statusPendingIntent(messageId: String?, phone: String, kind: String): PendingIntent {
val intent = Intent(this, SmsStatusReceiver::class.java).apply {
action = STATUS_ACTION
putExtra("messageId", messageId)
putExtra("phone", phone)
putExtra("kind", kind)
}
val requestCode = (messageId.orEmpty() + phone + kind).hashCode()
return PendingIntent.getBroadcast(
this, requestCode, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
/// Places a phone call. Requires the CALL_PHONE permission (requested in Dart
/// via the phone permission group).
///
/// Uses TelecomManager.placeCall, which routes through the system telecom
/// service rather than starting the dialer activity ourselves. This is what
/// lets the call go through when the screen is locked / the app is in the
/// background — a plain startActivity(ACTION_CALL) is blocked by Android's
/// background-activity-start restrictions in that state. Falls back to
/// ACTION_CALL only on very old devices without TelecomManager.placeCall.
private fun placeCall(phone: String) {
val uri = Uri.fromParts("tel", phone, null)
val hasPerm = ContextCompat.checkSelfPermission(
this, Manifest.permission.CALL_PHONE
) == PackageManager.PERMISSION_GRANTED
if (!hasPerm) {
throw SecurityException("CALL_PHONE permission not granted")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val telecom = getSystemService(TelecomManager::class.java)
if (telecom != null) {
telecom.placeCall(uri, Bundle())
return
}
}
val intent = Intent(Intent.ACTION_CALL, uri)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
}
private fun sendSms(phone: String, message: String, simSlot: Int?, messageId: String?) {
val sms = smsManagerFor(simSlot)
val parts = sms.divideMessage(message)
val sentPI = statusPendingIntent(messageId, phone, "sent")
val deliveredPI = statusPendingIntent(messageId, phone, "delivered")
if (parts.size > 1) {
val sentList = ArrayList<PendingIntent>(parts.size)
val deliveredList = ArrayList<PendingIntent>(parts.size)
for (i in parts.indices) {
sentList.add(sentPI)
deliveredList.add(deliveredPI)
}
sms.sendMultipartTextMessage(phone, null, parts, sentList, deliveredList)
} else {
sms.sendTextMessage(phone, null, message, sentPI, deliveredPI)
}
}
}
@@ -0,0 +1,40 @@
package app.gsmnode.phone
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.provider.Telephony
/// Receives incoming SMS and forwards each message to Dart via the EventChannel
/// sink held by MainActivity. Works while the app process is alive.
class SmsReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: return
if (messages.isEmpty()) return
// Multipart SMS arrive as several PDUs from the same sender; concatenate.
val from = messages[0].displayOriginatingAddress ?: ""
val body = StringBuilder()
var timestamp = System.currentTimeMillis()
for (m in messages) {
body.append(m.displayMessageBody ?: "")
timestamp = m.timestampMillis
}
val payload = mapOf(
"from" to from,
"body" to body.toString(),
"timestamp" to timestamp,
)
// EventSink must be touched on the main thread.
Handler(Looper.getMainLooper()).post {
MainActivity.incomingSink?.success(payload)
}
}
}
@@ -0,0 +1,33 @@
package app.gsmnode.phone
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
/// Receives SMS send/delivery outcomes from the PendingIntents created in
/// MainActivity.sendSms and forwards them to Dart, tagged with the message id
/// so the gateway loop can report Sent / Delivered / Failed to the API Server.
class SmsStatusReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val messageId = intent.getStringExtra("messageId")
val kind = intent.getStringExtra("kind") ?: return
// For "sent": RESULT_OK means the radio accepted the message.
// For "delivered": RESULT_OK means a positive delivery report arrived.
val success = resultCode == Activity.RESULT_OK
val payload = mapOf(
"messageId" to messageId,
"kind" to kind,
"success" to success,
"resultCode" to resultCode,
)
Handler(Looper.getMainLooper()).post {
MainActivity.statusSink?.success(payload)
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#2E9E6B</color>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+18
View File
@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
+25
View File
@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
}
include ":app"
@@ -0,0 +1,76 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Sending and receiving SMS -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- Placing outbound calls remotely -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<!-- Optional: needed to choose a specific SIM on dual-SIM devices -->
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Keep the gateway alive in the background (foreground service path) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android 13+ runtime permission for the ongoing service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="gsmnode"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Incoming SMS broadcast receiver -->
<receiver
android:name=".SmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter android:priority="999">
<action android:name="android.provider.Telephony.SMS_RECEIVED" />
</intent-filter>
</receiver>
<!-- Receives send/delivery outcomes from PendingIntents (explicit) -->
<receiver
android:name=".SmsStatusReceiver"
android:exported="false" />
<!-- Foreground service that keeps the gateway loop alive -->
<service
android:name=".GatewayForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required so plugins (e.g. url launches) can query intents on Android 11+ -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>

Some files were not shown because too many files have changed in this diff Show More