mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 08:19:41 +02:00
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>
169 lines
4 KiB
Go
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
|
|
}
|