managarten/services/mana-notify/internal/channel/push.go
Till JS 7e931b1c6d refactor(services): rename Go services, remove -go suffix
mana-search-go → mana-search
mana-notify-go → mana-notify
mana-crawler-go → mana-crawler
mana-api-gateway-go → mana-api-gateway

Legacy NestJS versions are deleted, suffix no longer needed.
Updated all references in docker-compose, CLAUDE.md, package.json,
Forgejo workflows, and service package.json files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:18:40 +01:00

169 lines
4 KiB
Go

package channel
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/manacore/mana-notify/internal/config"
)
const expoPushURL = "https://exp.host/--/api/v2/push/send"
type PushService struct {
accessToken string
client *http.Client
}
func NewPushService(cfg *config.Config) *PushService {
return &PushService{
accessToken: cfg.ExpoAccessToken,
client: &http.Client{Timeout: 30 * time.Second},
}
}
type PushMessage struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Data map[string]any `json:"data,omitempty"`
Sound string `json:"sound,omitempty"`
Badge *int `json:"badge,omitempty"`
ChannelID string `json:"channelId,omitempty"`
}
type PushResult struct {
Success bool
TicketID string
Error string
}
func (s *PushService) IsConfigured() bool {
return s.accessToken != ""
}
// IsExpoPushToken validates Expo token format.
func IsExpoPushToken(token string) bool {
return strings.HasPrefix(token, "ExponentPushToken[") || strings.HasPrefix(token, "ExpoPushToken[")
}
// SendToTokens sends push notifications in batches of 100 (Expo limit).
func (s *PushService) SendToTokens(ctx context.Context, tokens []string, title, body string, data map[string]any, sound string, badge *int) map[string]PushResult {
results := make(map[string]PushResult, len(tokens))
if !s.IsConfigured() {
for _, t := range tokens {
results[t] = PushResult{Success: false, Error: "Expo not configured"}
}
return results
}
// Build messages
messages := make([]PushMessage, 0, len(tokens))
for _, token := range tokens {
messages = append(messages, PushMessage{
To: token,
Title: title,
Body: body,
Data: data,
Sound: sound,
Badge: badge,
})
}
// Chunk into batches of 100
for i := 0; i < len(messages); i += 100 {
end := i + 100
if end > len(messages) {
end = len(messages)
}
chunk := messages[i:end]
batchResults := s.sendBatch(ctx, chunk)
for token, result := range batchResults {
results[token] = result
}
}
return results
}
func (s *PushService) sendBatch(ctx context.Context, messages []PushMessage) map[string]PushResult {
results := make(map[string]PushResult, len(messages))
body, err := json.Marshal(messages)
if err != nil {
for _, m := range messages {
results[m.To] = PushResult{Success: false, Error: "marshal error"}
}
return results
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, expoPushURL, bytes.NewReader(body))
if err != nil {
for _, m := range messages {
results[m.To] = PushResult{Success: false, Error: "request error"}
}
return results
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if s.accessToken != "" {
req.Header.Set("Authorization", "Bearer "+s.accessToken)
}
resp, err := s.client.Do(req)
if err != nil {
slog.Error("expo push failed", "error", err)
for _, m := range messages {
results[m.To] = PushResult{Success: false, Error: err.Error()}
}
return results
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var expoResp struct {
Data []struct {
Status string `json:"status"`
ID string `json:"id"`
Message string `json:"message"`
Details struct {
Error string `json:"error"`
} `json:"details"`
} `json:"data"`
}
if err := json.Unmarshal(respBody, &expoResp); err != nil {
for _, m := range messages {
results[m.To] = PushResult{Success: false, Error: fmt.Sprintf("decode error: %s", err)}
}
return results
}
for i, ticket := range expoResp.Data {
if i >= len(messages) {
break
}
token := messages[i].To
if ticket.Status == "ok" {
results[token] = PushResult{Success: true, TicketID: ticket.ID}
} else {
errMsg := ticket.Message
if ticket.Details.Error != "" {
errMsg = ticket.Details.Error
}
results[token] = PushResult{Success: false, Error: errMsg}
}
}
return results
}