package adapter import ( "bytes" "encoding/json" "fmt" "io" "log/slog" "net/http" "strings" "sync" "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} var safewAllowedUpdates = []string{ "message", "edited_message", "my_chat_member", "chat_member", "chat_join_request", "callback_query", } var safewWebhookCleared sync.Map 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") } s.ensureWebhookCleared(token) payload := map[string]any{ "timeout": timeout, "limit": 100, "allowed_updates": safewAllowedUpdates, } 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, n, err := GroupsFromUpdates(respBody) if err != nil { return nil, offset, err } next := offset if maxID > 0 { next = maxID + 1 } slog.Info("safew getUpdates", "timeout", timeout, "updates", n, "groups", len(chats), "offset", next) return chats, next, nil } func (s *SafeWSender) ensureWebhookCleared(token string) { if _, loaded := safewWebhookCleared.LoadOrStore(token, true); loaded { return } reqBody, _ := json.Marshal(map[string]any{"drop_pending_updates": false}) resp, err := http.Post(s.methodURL(token, "deleteWebhook"), "application/json", bytes.NewReader(reqBody)) if err != nil { slog.Warn("safew deleteWebhook", "error", err) return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { slog.Warn("safew deleteWebhook", "status", resp.StatusCode, "error", safewErrorDescription(body)) } } 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, int, 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, 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, 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, len(wrap.Result), 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", "chat_join_request", "callback_query", } { 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 } if chatRaw, ok := obj["chat"]; ok { 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} } if msgRaw, ok := obj["message"]; ok { return chatFromNested(msgRaw) } return nil } 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 }