Rebrand CommGate to gsmnode and adopt new design system

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-06 08:53:14 +02:00
commit d6956395c8
166 changed files with 12605 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
package api
import (
"net/http"
"strings"
"time"
"smsgateway/apiserver/internal/pb"
)
// Message lifecycle states.
const (
statusPending = "Pending" // enqueued, not yet picked up by a device
statusProcessed = "Processed" // delivered to a device for sending
statusSent = "Sent" // device handed it to the SIM/radio
statusDelivered = "Delivered" // delivery report received
statusFailed = "Failed" // sending failed
)
var validReportStates = map[string]bool{
statusSent: true, statusDelivered: true, statusFailed: true, statusProcessed: true,
}
// Message kinds.
const (
msgTypeSMS = "sms"
msgTypeCall = "call"
)
type messageDTO struct {
ID string `json:"id"`
Type string `json:"type"`
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id,omitempty"`
SimNumber int `json:"sim_number,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
ScheduleAt string `json:"schedule_at,omitempty"`
CreatedAt string `json:"created_at"`
}
func recordToMessage(rec pb.Record) messageDTO {
msgType := asString(rec["type"])
if msgType == "" {
msgType = msgTypeSMS
}
return messageDTO{
ID: asString(rec["id"]),
Type: msgType,
PhoneNumbers: asStringSlice(rec["phone_numbers"]),
TextMessage: asString(rec["text_message"]),
DeviceID: asString(rec["device"]),
SimNumber: asInt(rec["sim_number"]),
Status: asString(rec["status"]),
Error: asString(rec["error"]),
ScheduleAt: asString(rec["schedule_at"]),
CreatedAt: asString(rec["created"]),
}
}
type enqueueRequest struct {
PhoneNumbers []string `json:"phone_numbers"`
TextMessage string `json:"text_message"`
DeviceID string `json:"device_id"`
SimNumber int `json:"sim_number"`
ScheduleAt string `json:"schedule_at"`
}
// handleEnqueueMessage queues an outbound SMS for one of the user's devices.
func (s *Server) handleEnqueueMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
var req enqueueRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
phones := cleanPhones(req.PhoneNumbers)
if len(phones) == 0 {
writeError(w, http.StatusBadRequest, "at least one phone number is required")
return
}
if strings.TrimSpace(req.TextMessage) == "" {
writeError(w, http.StatusBadRequest, "text_message is required")
return
}
// Resolve the target device: explicit device_id, else the user's first
// online device.
deviceRecID, err := s.resolveDevice(r, uid, req.DeviceID)
if err != nil {
writeUpstreamError(w, err)
return
}
if deviceRecID == "" {
writeError(w, http.StatusBadRequest, "no device available; register a device first")
return
}
fields := pb.Record{
"phone_numbers": phones,
"text_message": req.TextMessage,
"type": msgTypeSMS,
"device": deviceRecID,
"owner": uid,
"status": statusPending,
}
if req.SimNumber > 0 {
fields["sim_number"] = req.SimNumber
}
if req.ScheduleAt != "" {
fields["schedule_at"] = req.ScheduleAt
}
rec, err := s.pb.Create(r.Context(), colMessages, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
writeJSON(w, http.StatusAccepted, recordToMessage(rec))
}
// resolveDevice returns the PocketBase device record id to target. requested may
// be either the internal record id or the client-facing device_id.
func (s *Server) resolveDevice(r *http.Request, uid, requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested != "" {
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid)+" && (id = "+pbQuote(requested)+" || device_id = "+pbQuote(requested)+")", "")
if err != nil {
return "", err
}
if dev == nil {
return "", nil
}
return asString(dev["id"]), nil
}
dev, err := s.pb.FindFirst(r.Context(), colDevices,
"owner = "+pbQuote(uid), "-last_seen_at")
if err != nil || dev == nil {
return "", err
}
return asString(dev["id"]), nil
}
// handleListMessages lists the user's messages with optional filters.
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
filters := []string{"owner = " + pbQuote(uid)}
if st := r.URL.Query().Get("status"); st != "" {
filters = append(filters, "status = "+pbQuote(st))
}
if dev := r.URL.Query().Get("device_id"); dev != "" {
filters = append(filters, "device = "+pbQuote(dev))
}
if t := r.URL.Query().Get("type"); t != "" {
filters = append(filters, "type = "+pbQuote(t))
}
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: strings.Join(filters, " && "),
Sort: "-created",
Page: queryInt(r, "page", 1),
PerPage: clampPerPage(queryInt(r, "per_page", 50)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
out = append(out, recordToMessage(rec))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "total": res.TotalItems, "page": res.Page,
})
}
// handleGetMessage returns a single message's state.
func (s *Server) handleGetMessage(w http.ResponseWriter, r *http.Request) {
uid, _ := userFromCtx(r.Context())
rec, err := s.pb.GetOne(r.Context(), colMessages, r.PathValue("id"))
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["owner"]) != uid {
writeError(w, http.StatusForbidden, "not your message")
return
}
writeJSON(w, http.StatusOK, recordToMessage(rec))
}
// handlePullMessages returns pending messages for the calling device and marks
// them Processed so they are not handed out twice.
func (s *Server) handlePullMessages(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
deviceID := asString(device["id"])
res, err := s.pb.List(r.Context(), colMessages, pb.ListOptions{
Filter: "device = " + pbQuote(deviceID) + " && status = " + pbQuote(statusPending),
Sort: "created",
PerPage: clampPerPage(queryInt(r, "limit", 20)),
})
if err != nil {
writeUpstreamError(w, err)
return
}
out := make([]messageDTO, 0, len(res.Items))
for _, rec := range res.Items {
id := asString(rec["id"])
updated, uerr := s.pb.Update(r.Context(), colMessages, id, pb.Record{"status": statusProcessed})
if uerr != nil {
// Skip a message we couldn't claim rather than failing the pull.
continue
}
out = append(out, recordToMessage(updated))
}
writeJSON(w, http.StatusOK, map[string]any{"items": out})
}
type reportRequest struct {
Status string `json:"status"`
Error string `json:"error"`
}
// handleReportMessage records a delivery state reported by the device and fires
// the matching webhooks.
func (s *Server) handleReportMessage(w http.ResponseWriter, r *http.Request) {
device := deviceFromCtx(r.Context())
id := r.PathValue("id")
var req reportRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if !validReportStates[req.Status] {
writeError(w, http.StatusBadRequest, "invalid status")
return
}
rec, err := s.pb.GetOne(r.Context(), colMessages, id)
if err != nil {
writeUpstreamError(w, err)
return
}
if asString(rec["device"]) != asString(device["id"]) {
writeError(w, http.StatusForbidden, "message not assigned to this device")
return
}
fields := pb.Record{"status": req.Status, "error": req.Error}
now := time.Now().UTC().Format("2006-01-02 15:04:05.000Z")
switch req.Status {
case statusSent:
fields["sent_at"] = now
case statusDelivered:
fields["delivered_at"] = now
}
updated, err := s.pb.Update(r.Context(), colMessages, id, fields)
if err != nil {
writeUpstreamError(w, err)
return
}
// Dispatch webhooks for terminal/notable states.
if event := eventForStatus(req.Status); event != "" {
s.dispatchWebhooks(asString(device["owner"]), asString(device["id"]), event, map[string]any{
"message_id": id,
"phone_numbers": asStringSlice(updated["phone_numbers"]),
"status": req.Status,
"error": req.Error,
})
}
writeJSON(w, http.StatusOK, recordToMessage(updated))
}
func eventForStatus(status string) string {
switch status {
case statusSent:
return "sms:sent"
case statusDelivered:
return "sms:delivered"
case statusFailed:
return "sms:failed"
default:
return ""
}
}
func cleanPhones(in []string) []string {
out := make([]string, 0, len(in))
for _, p := range in {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func clampPerPage(n int) int {
if n <= 0 {
return 50
}
if n > 200 {
return 200
}
return n
}