package snowluma
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"sync"
"time"
)
// HTTPReport HTTP POST 上报连接器:框架起一个 HTTP 服务,
// SnowLuma 把事件 POST 过来;处理函数里调用的 Reply/Approve 会以
// OneBot「快速操作」的形式随 HTTP 响应返回。
type HTTPReport struct {
addr string
srv *http.Server
}
// NewHTTPReport 创建 HTTP 上报连接器,监听 addr(如 ":8081"),
// SnowLuma 侧上报地址填 http://<本机IP>:8081/(任意路径均可)。
func NewHTTPReport(addr string) *HTTPReport { return &HTTPReport{addr: addr} }
func (h *HTTPReport) Name() string { return "http-report(" + h.addr + ")" }
func (h *HTTPReport) Serve(ctx context.Context, bot *Client) error {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
body, err := readAllLimited(r.Body)
if err != nil {
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
return
}
if bot.secret != "" && !verifySignature(bot.secret, body, r.Header.Get("X-Signature")) {
http.Error(w, "invalid signature", http.StatusForbidden)
return
}
ev, err := decodeEvent(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
quick := &quickBox{params: map[string]any{}}
bot.dispatch(ev, bot, quick)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(quick.params)
})
srv := &http.Server{
Addr: h.addr,
Handler: mux,
BaseContext: func(net.Listener) context.Context { return ctx },
}
h.srv = srv
err := srv.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Shutdown 关闭监听(Run 的退出流程会调用)。
func (h *HTTPReport) Shutdown(ctx context.Context) error {
if h.srv == nil {
return nil
}
return h.srv.Shutdown(ctx)
}
// quickBox 收集 HTTP 上报模式下随响应返回的快速操作参数。
type quickBox struct {
params map[string]any
}
func (q *quickBox) set(key string, value any) {
if q.params == nil {
q.params = map[string]any{}
}
q.params[key] = value
}
// verifySignature 校验 SnowLuma HTTP 上报的 X-Signature(HMAC-SHA1)。
func verifySignature(secret string, body []byte, got string) bool {
mac := hmac.New(sha1.New, []byte(secret))
mac.Write(body)
want := "sha1=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(got), []byte(want))
}
func readAllLimited(r io.Reader) ([]byte, error) {
const maxBody = 32 << 20
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, io.LimitReader(r, maxBody+1)); err != nil {
return nil, err
}
if buf.Len() > maxBody {
return nil, errors.New("request body too large")
}
return buf.Bytes(), nil
}
// HTTPAPI HTTP API 连接器:框架作为客户端,POST 到 SnowLuma 的 HTTP 服务执行 action。
// 仅配置了 HTTPAPI 而没有 WS 会话时,Client.Call 走这条通道。
type HTTPAPI struct {
base string
token atomicString
hc *http.Client
}
type atomicString struct {
mu sync.RWMutex
v string
}
func (a *atomicString) Store(v string) {
a.mu.Lock()
a.v = v
a.mu.Unlock()
}
func (a *atomicString) Load() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.v
}
// NewHTTPAPI 创建 HTTP API 连接器,base 形如 http://127.0.0.1:3000。
func NewHTTPAPI(base string) *HTTPAPI {
return &HTTPAPI{base: base, hc: &http.Client{Timeout: 30 * time.Second}}
}
func (h *HTTPAPI) Name() string { return "http-api(" + h.base + ")" }
// Serve 对 HTTP API 连接器只是完成鉴权配置并阻塞到框架退出。
func (h *HTTPAPI) Serve(ctx context.Context, bot *Client) error {
h.token.Store(bot.token)
<-ctx.Done()
return nil
}
// Call 实现 Caller:POST /,body 为 params 对象。
func (h *HTTPAPI) Call(ctx context.Context, action string, params any, out any) error {
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
}
body := []byte("{}")
if params != nil {
var err error
if body, err = json.Marshal(params); err != nil {
return fmt.Errorf("snowluma: marshal params of %s: %w", action, err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.base+"/"+action, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if token := h.token.Load(); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := h.hc.Do(req)
if err != nil {
return fmt.Errorf("snowluma: http api %s: %w", action, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return fmt.Errorf("snowluma: action %s not found (404)", action)
}
var envelope Response
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
return fmt.Errorf("snowluma: http api %s: bad response (http %d): %w", action, resp.StatusCode, err)
}
if err := envelope.Err(); err != nil {
return err
}
if err := envelope.Decode(out); err != nil {
return fmt.Errorf("snowluma: decode data of %s: %w", action, err)
}
return nil
}