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>
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=*
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
/server
|
||||
/server.exe
|
||||
/tmp/
|
||||
*.log
|
||||
@@ -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
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module smsgateway/apiserver
|
||||
|
||||
go 1.26
|
||||
|
||||
require github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
@@ -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=
|
||||
@@ -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"]),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -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 |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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>
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, `"`, `\"`) + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 759 B |
@@ -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 |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import "./theme";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}).`);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
avoid_print: false
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||