Files
aiaa-notification-server/internal/adapter/safew.go
T
ryan e369f398d8 feat: enhance Safew error handling and polling mechanism
- Added error_msg field to safewAPIResponse for better error descriptions.
- Updated safewErrorDescription function to include error_msg in the output.
- Introduced a new test to verify that error messages are included in Safew error descriptions.
- Improved the polling mechanism in the Watcher to handle conflicts and ensure no overlapping polls occur.
2026-08-15 01:07:01 +08:00

333 lines
8.0 KiB
Go

package adapter
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const safewAPIBase = "https://api.safew.bot"
type SafeWSender struct {
apiBase string
}
type safewConfig struct {
Token string `json:"token"`
ChatID json.RawMessage `json:"chat_id"`
}
type safewMessage struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
}
type safewAPIResponse struct {
OK bool `json:"ok"`
Description string `json:"description"`
ErrorMsg string `json:"error_msg"`
}
func (s *SafeWSender) Type() string { return "safew" }
func (s *SafeWSender) Send(title, content string, config json.RawMessage) error {
var cfg safewConfig
if err := json.Unmarshal(config, &cfg); err != nil {
return fmt.Errorf("parse safew config: %w", err)
}
token := strings.TrimSpace(cfg.Token)
if token == "" {
return fmt.Errorf("safew: token is required")
}
chatID, err := parseSafewChatID(cfg.ChatID)
if err != nil {
return err
}
payload := safewMessage{
ChatID: chatID,
Text: "*" + escapeMarkdownV2(title) + "*\n" + escapeMarkdownV2(content),
ParseMode: "MarkdownV2",
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("safew marshal: %w", err)
}
resp, err := http.Post(s.endpoint(token), "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("safew send: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("safew read: %w", err)
}
if resp.StatusCode >= 400 {
desc := safewErrorDescription(respBody)
if desc != "" {
return fmt.Errorf("safew returned status %d: %s", resp.StatusCode, desc)
}
return fmt.Errorf("safew returned status %d", resp.StatusCode)
}
var api safewAPIResponse
if err := json.Unmarshal(respBody, &api); err != nil {
return fmt.Errorf("safew decode: %w", err)
}
if !api.OK {
desc := api.Description
if desc == "" {
desc = "ok=false"
}
return fmt.Errorf("safew: %s", desc)
}
return nil
}
func (s *SafeWSender) endpoint(token string) string {
return s.methodURL(token, "sendMessage")
}
func (s *SafeWSender) methodURL(token, method string) string {
base := s.apiBase
if base == "" {
base = safewAPIBase
}
return strings.TrimRight(base, "/") + "/bot" + token + "/" + method
}
type SafewAuthError struct {
Description string
}
func (e *SafewAuthError) Error() string {
if e.Description == "" {
return "safew unauthorized"
}
return e.Description
}
var safewLongPollClient = &http.Client{Timeout: 45 * time.Second}
func (s *SafeWSender) PollGroupChats(token string, offset int64, timeout int) ([]SafewChat, int64, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, offset, fmt.Errorf("safew: token is required")
}
payload := map[string]any{
"timeout": timeout,
"limit": 100,
}
if offset > 0 {
payload["offset"] = offset
}
reqBody, _ := json.Marshal(payload)
resp, err := safewLongPollClient.Post(s.methodURL(token, "getUpdates"), "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, offset, fmt.Errorf("safew getUpdates: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, offset, fmt.Errorf("safew getUpdates read: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, offset, &SafewAuthError{Description: safewErrorDescription(respBody)}
}
if resp.StatusCode >= 400 {
desc := safewErrorDescription(respBody)
if desc != "" {
return nil, offset, fmt.Errorf("safew getUpdates status %d: %s", resp.StatusCode, desc)
}
return nil, offset, fmt.Errorf("safew getUpdates status %d", resp.StatusCode)
}
var api safewAPIResponse
if err := json.Unmarshal(respBody, &api); err == nil && !api.OK {
if resp.StatusCode == 401 || strings.Contains(strings.ToLower(api.Description), "token") {
return nil, offset, &SafewAuthError{Description: api.Description}
}
return nil, offset, fmt.Errorf("safew: %s", api.Description)
}
chats, maxID, err := GroupsFromUpdates(respBody)
if err != nil {
return nil, offset, err
}
next := offset
if maxID > 0 {
next = maxID + 1
}
return chats, next, nil
}
func parseSafewChatID(raw json.RawMessage) (string, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return "", fmt.Errorf("safew: chat_id is required")
}
if raw[0] == '"' {
var id string
if err := json.Unmarshal(raw, &id); err != nil {
return "", fmt.Errorf("safew: invalid chat_id: %w", err)
}
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("safew: chat_id is required")
}
return id, nil
}
return string(raw), nil
}
func safewErrorDescription(body []byte) string {
var api safewAPIResponse
if err := json.Unmarshal(body, &api); err != nil {
return strings.TrimSpace(string(body))
}
switch {
case api.Description != "" && api.ErrorMsg != "":
return api.Description + ": " + api.ErrorMsg
case api.ErrorMsg != "":
return api.ErrorMsg
default:
return api.Description
}
}
func escapeMarkdownV2(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!', '\\':
b.WriteByte('\\')
}
b.WriteRune(r)
}
return b.String()
}
type SafewChat struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username *string `json:"username"`
}
func GroupsFromUpdates(body []byte) ([]SafewChat, int64, error) {
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var wrap struct {
OK bool `json:"ok"`
Result []json.RawMessage `json:"result"`
}
if err := dec.Decode(&wrap); err != nil {
return nil, 0, fmt.Errorf("safew updates decode: %w", err)
}
seen := map[string]SafewChat{}
var maxID int64
for _, item := range wrap.Result {
id, chat, err := parseUpdateItem(item)
if err != nil {
return nil, 0, err
}
if id > maxID {
maxID = id
}
if chat == nil {
continue
}
if chat.Type != "group" && chat.Type != "supergroup" {
continue
}
seen[chat.ID] = *chat
}
out := make([]SafewChat, 0, len(seen))
for _, c := range seen {
out = append(out, c)
}
return out, maxID, nil
}
func parseUpdateItem(item json.RawMessage) (int64, *SafewChat, error) {
dec := json.NewDecoder(bytes.NewReader(item))
dec.UseNumber()
var u map[string]json.RawMessage
if err := dec.Decode(&u); err != nil {
return 0, nil, err
}
var updateID int64
if raw, ok := u["update_id"]; ok {
var n json.Number
if err := json.Unmarshal(raw, &n); err == nil {
updateID, _ = n.Int64()
}
}
for _, key := range []string{"message", "edited_message", "channel_post", "edited_channel_post", "my_chat_member", "chat_member"} {
raw, ok := u[key]
if !ok {
continue
}
chat := chatFromNested(raw)
if chat != nil {
return updateID, chat, nil
}
}
return updateID, nil, nil
}
func chatFromNested(raw json.RawMessage) *SafewChat {
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var obj map[string]json.RawMessage
if err := dec.Decode(&obj); err != nil {
return nil
}
chatRaw, ok := obj["chat"]
if !ok {
return nil
}
dec = json.NewDecoder(bytes.NewReader(chatRaw))
dec.UseNumber()
var c struct {
ID json.Number `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Username *string `json:"username"`
}
if err := dec.Decode(&c); err != nil {
return nil
}
id := strings.TrimSpace(c.ID.String())
if id == "" {
return nil
}
return &SafewChat{ID: id, Type: c.Type, Title: c.Title, Username: c.Username}
}
func FilterSafewChats(chats []SafewChat, q string) []SafewChat {
q = strings.TrimSpace(strings.ToLower(q))
if q == "" {
return chats
}
var out []SafewChat
for _, c := range chats {
uname := ""
if c.Username != nil {
uname = *c.Username
}
if strings.Contains(strings.ToLower(c.Title), q) ||
strings.Contains(strings.ToLower(uname), q) ||
strings.Contains(strings.ToLower(c.ID), q) {
out = append(out, c)
}
}
return out
}