a9b6234208
Motivation: 让通知模板能够将 action、event 等原始枚举值映射为可读中文文案,并支持按事件名后缀匹配;同时简化 SafeW 消息发送格式,避免 Markdown 转义引入的显示问题。 Changes: * 新增 `case` 模板函数,按值匹配 key/文案并支持末尾奇数参数作为默认值 * `case` 匹配兼容事件名后缀(如 trade.close 匹配 .close) * `line` 前缀自动去除末尾冒号,避免重复冒号 * 模板渲染前自动注入 event 字段,便于模板按事件名取值 * SafeW 消息改为纯文本发送,移除 MarkdownV2 转义
359 lines
8.8 KiB
Go
359 lines
8.8 KiB
Go
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,omitempty"`
|
|
}
|
|
|
|
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: title + "\n" + content,
|
|
}
|
|
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
|
|
}
|
|
if n > 0 {
|
|
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
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|