Files

213 lines
5.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package serverchan
import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
"unicode/utf8"
)
// Server酱对标题/正文的长度上限
const (
maxTitleRunes = 32 // 标题按字符计
maxDespBytes = 32 * 1024 // 正文按字节计
maxShortRunes = 64 // 消息卡片内容按字符计
)
// clampTitle 按字符数截断标题
func clampTitle(title string) string {
return truncateRunes(title, maxTitleRunes)
}
// truncateRunes 按字符数上限截断,结果恒为合法 UTF-8。
//
// 用 []rune 转换而不是 s[:n]:Go 的字符串切片按字节走,切中文会留下半个
// UTF-8 字符。ToValidUTF8 先净化,标题可能来自用户自配的订阅模板。
func truncateRunes(s string, max int) string {
s = strings.ToValidUTF8(s, string(utf8.RuneError))
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
// truncateBytes 按字节上限截断,回退到最近的字符边界,绝不切断多字节字符。
//
// 正文按字节计是接口实际口径,只能按字节切;但 s[:max] 合法当且仅当 s[max]
// 不是后续字节(后续字节 top two bits 恒为 10),据此往回退即是最大合法切点。
func truncateBytes(s string, max int) string {
s = strings.ToValidUTF8(s, string(utf8.RuneError))
if len(s) <= max {
return s
}
for max > 0 && !utf8.RuneStart(s[max]) {
max--
}
return s[:max]
}
// ServerChanNotifier Server酱通知器
type ServerChanNotifier struct {
SendKey string
ApiURL string
}
// NewServerChanNotifier 创建Server酱通知器
// sendKey: Server酱的SendKey
func NewServerChanNotifier(sendKey string) (*ServerChanNotifier, error) {
if sendKey == "" {
return nil, fmt.Errorf("SendKey不能为空")
}
// 根据 sendkey 是否以 "sctp" 开头决定 API 的 URL
var apiUrl string
if strings.HasPrefix(sendKey, "sctp") {
// 使用正则表达式提取数字部分
re := regexp.MustCompile(`sctp(\d+)t`)
matches := re.FindStringSubmatch(sendKey)
if len(matches) > 1 {
num := matches[1]
apiUrl = fmt.Sprintf("https://%s.push.ft07.com/send/%s.send", num, sendKey)
} else {
return nil, fmt.Errorf("SendKey格式错误: %s", sendKey)
}
} else {
apiUrl = fmt.Sprintf("https://sctapi.ftqq.com/%s.send", sendKey)
}
return &ServerChanNotifier{
SendKey: sendKey,
ApiURL: apiUrl,
}, nil
}
// SendMarkdown 发送Markdown格式消息
func (s *ServerChanNotifier) SendMarkdown(title, content string) error {
return s.send(title, content)
}
// SendText 发送文本消息
func (s *ServerChanNotifier) SendText(title, content string) error {
return s.send(title, content)
}
// send 内部发送方法
func (s *ServerChanNotifier) send(title, content string) error {
if title == "" {
return fmt.Errorf("消息标题不能为空")
}
title = clampTitle(title)
content = truncateBytes(content, maxDespBytes)
// 构建POST表单数据
data := url.Values{}
data.Set("title", title)
data.Set("desp", content)
// 创建HTTP客户端
client := &http.Client{}
req, err := http.NewRequest("POST", s.ApiURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// 发送请求
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %v", err)
}
// 检查HTTP状态码
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Server酱返回错误状态码: %d, 响应: %s", resp.StatusCode, string(body))
}
// 这里可以进一步解析响应JSON,检查具体的返回状态
// Server酱的返回格式类似:{"code":0,"message":"success","data":{"pushid":"xxxx","readkey":"yyyy"}}
// 如果需要严格验证,可以解析JSON检查code是否为0
return nil
}
// SendWithOptions 发送带额外选项的消息
// options支持的参数:
// - short: 消息卡片内容,最大长度64
// - noip: 是否隐藏调用IP,"1"表示隐藏
// - channel: 动态指定消息通道,如 "9|66"
// - openid: 消息抄送的openid
func (s *ServerChanNotifier) SendWithOptions(title, content string, options map[string]string) error {
if title == "" {
return fmt.Errorf("消息标题不能为空")
}
title = clampTitle(title)
content = truncateBytes(content, maxDespBytes)
// 构建POST表单数据
data := url.Values{}
data.Set("title", title)
data.Set("desp", content)
// 添加可选参数
if short, ok := options["short"]; ok && short != "" {
data.Set("short", truncateRunes(short, maxShortRunes))
}
if noip, ok := options["noip"]; ok && noip != "" {
data.Set("noip", noip)
}
if channel, ok := options["channel"]; ok && channel != "" {
data.Set("channel", channel)
}
if openid, ok := options["openid"]; ok && openid != "" {
data.Set("openid", openid)
}
// 创建HTTP客户端
client := &http.Client{}
req, err := http.NewRequest("POST", s.ApiURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
// 设置请求头
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// 发送请求
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %v", err)
}
// 检查HTTP状态码
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Server酱返回错误状态码: %d, 响应: %s", resp.StatusCode, string(body))
}
return nil
}