Files
Cyrene_v2/internal/services/backthink/push.go
T
AskaEth 2dee372fb2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink/admin): ③b — rag/delete 真实现 + D1 推送出口钩子(自建优先→个推兜底) + api-reference 补 D2/D3/D5 补丁
- B5: POST /admin/rag/delete 真做(DeleteDocument→DeleteProject 级联删章节+索引清理, owner+审计, 200/404/500, 500降级兜底); users/iot scenarios+rules 诚实501(无用户表/IoT场景规则模型, 消息写明缺什么, 不伪造), 锁住 501+not_implemented
- D1: backthink/push_outlet.go PushExporter{Export}; pushOutlet 自建HTTP(2xx=送达, token仅Authorization头+error脱敏)优先→Getui兜底(未接入, 诚实日志'getui not integrated'+(false,nil)); backthink.Deps.Exporter → pusher.withExporter, Push 在WS/IM后 exportAsync(detached+5s超时, 失败仅日志, 不破坏现有投递, 限流不上报); config PushOutletConfig(PUSH_EXPORTER_* + GETUI_*占位)
- 前端 Rag.tsx/api.ts 删除后 reload + 类型; Docs/api-client v0.31 + api-reference(#23 owner/200-404, §8.4, 端点表PATCH/DELETE messages + pinned + messages?q=)
- 测试(push_outlet/admin_rag_delete/admin_honest501/knowledge store), 后端+前端build全绿+3平台交叉编译
- 刻意: Getui SDK 未接入(无SDK不硬造), 主人自建服务填 PUSH_EXPORTER_URL/TOKEN 即可
2026-09-09 00:08:29 +08:00

407 lines
16 KiB
Go

package backthink
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// Route identifies how a proactive message was actually delivered, for the
// monitor panel and the push result record.
type Route string
const (
// RouteNone means no push was attempted or no target was available.
RouteNone Route = "none"
// RouteRateLimited means the push was refused by the rate limiter.
RouteRateLimited Route = "rate_limited"
// RouteActiveClient means the message went to an active web/native client.
RouteActiveClient Route = "active_client"
// RouteIMBridge means the message fell back to the IM bridge.
RouteIMBridge Route = "im_bridge"
)
// Channel-hint tokens recognized by the push router. They come from the
// conclusion's [渠道:...] marker (parseProactive) and select where a proactive
// message is delivered. An empty/unknown hint is treated as auto. A concrete
// platform id (kook/telegram/...) is a value other than these three.
const (
channelAuto = "auto"
channelIM = "im"
channelWeb = "web"
// defaultProactivePlatform is the Platform the outbound proactive message
// carries when the conclusion names no specific channel.
defaultProactivePlatform = "cyrene-backthink"
)
// String returns the route as a plain label.
func (r Route) String() string { return string(r) }
// PushResult is the outcome of one proactive push attempt, recorded with each
// thinking pass (design/12 §4).
type PushResult struct {
// Pushed reports whether the message was delivered.
Pushed bool
// Route is the delivery route taken.
Route Route
// Bypassed reports whether this push was event-driven and skipped the PushGate
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
// bypassed.
Bypassed bool
// Detail is a short human-readable note (e.g. client count, decision reason).
Detail string
// Err is the underlying error text when delivery failed, or that of the
// provider that decided not to push.
Err string
}
// String returns a concise summary for logs.
func (p PushResult) String() string {
if p.Err != "" {
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
}
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
}
// ActiveClient delivers a proactive message to every currently active web/native
// client and reports how many received it. It is the preferred push target. The
// gateway WebSocket hub implements this in production; tests inject a mock.
type ActiveClient interface {
// SendProactive pushes msg to each active client and returns the number of
// clients that accepted it.
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
}
// IMAdapter is the IM bridge fallback: it pushes a proactive message over an IM
// channel. Every adapter.PlatformAdapter satisfies this subset, so the service
// depends on the narrow interface rather than the concrete adapter type.
type IMAdapter interface {
// SendProactive pushes msg to the owner's IM channel.
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error
}
// PushGate decides whether a proactive push may proceed right now, applying the
// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It
// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can
// be tested with a mock.
type PushGate interface {
// Allow reports whether a proactive push of the given kind may be sent now.
Allow(ctx context.Context, kind PushKind) bool
// RecordPush notes a successful delivery so the cooldown/per-kind windows
// start from a real successful push, not a merely-attempted one.
RecordPush(kind PushKind)
}
// PushRequest carries one proactive push attempt: the outbound message plus the
// channel target the autonomous conclusion asked for (design/20 §2 P2).
// ChannelHint is the lowercased [渠道:...] token: ""/auto pick the default (the
// owner mandate: ONE channel, chosen by her / her most recent platform / the
// whitelist default); "web" targets the active client; "im" targets the IM bridge;
// a concrete platform id (kook/telegram/weixin-ilink/...) targets the IM bridge
// with the message tagged for that platform.
type PushRequest struct {
// Message is the normalized outbound proactive message.
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
ChannelHint string
// TriggerKind records what woke this pass. An event-driven push bypasses the
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
TriggerKind TriggerKind
}
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
// rate gate and honors the channel hint the autonomous conclusion selected: "web"
// goes to the active client, everything else goes over the IM bridge a single time,
// where the bridge picks the one channel (her choice → her most recent platform →
// whitelist default, WeChat included).
type Pusher interface {
// Push delivers req to a single target channel, per the channel hint. It never
// blocks on a slow target.
Push(ctx context.Context, req PushRequest) PushResult
}
// pusher is the concrete Pusher: it consults the gate, then routes the message to
// exactly one target — the active client for a "web" hint, otherwise the IM bridge.
// After the local delivery it mirrors the message to the optional push outlet
// (self-hosted service → Getui fallback) without blocking that delivery.
type pusher struct {
active ActiveClient
im IMAdapter
gate PushGate
exporter PushExporter
log *slog.Logger
// exportDone, when non-nil, receives one value after each asynchronous export
// completes. It exists so tests can deterministically observe the hook; the
// send is non-blocking, so a full channel never stalls the exporter.
exportDone chan struct{}
}
// newPusher builds a Pusher from the optional active-client sink, IM sink and
// rate gate. Any of them may be nil; a nil target simply makes that route
// unavailable. The push outlet is attached separately with withExporter.
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
return &pusher{active: active, im: im, gate: gate, log: log}
}
// withExporter attaches the push outlet hook and returns the pusher for fluent
// use. A nil exporter leaves the hook disabled.
func (p *pusher) withExporter(exporter PushExporter) *pusher {
p.exporter = exporter
return p
}
// Push implements Pusher: it applies the rate gate, then routes the message to a
// single target. A "web" hint reaches the active web/native client; every other
// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where
// the bridge selects one owner channel (her [渠道:...] choice, then the platform she
// used most recently, then the whitelist default). The IM bridge is primary, so a
// push never fans out to several channels. When no IM sink is wired the active
// client is the honest fallback, so the owner is still reached rather than the
// message being dropped.
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if err := ctx.Err(); err != nil {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
kind := classifyPush(req.Message.Content)
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
// by an empty reflection. Same-event dedup is applied upstream in think().
eventDriven := req.TriggerKind == TriggerEvent
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
p.log.Warn("backthink push rate limited", "kind", kind.String())
return PushResult{Pushed: false, Route: RouteRateLimited, Detail: "proactive push rate limited"}
}
hint := normalizeChannelHint(req.ChannelHint)
msg := req.Message
var res PushResult
if hint == channelWeb {
res = p.pushActive(ctx, msg)
} else {
// "im"/"auto"/a concrete platform all travel over the IM bridge once. A
// generic hint hands the sentinel platform so the bridge falls through to its
// recent-active / default selection; a concrete hint is honored when the
// channel is whitelisted and connected.
if hint == channelIM || hint == channelAuto {
msg.Platform = defaultProactivePlatform
} else {
msg.Platform = hint
}
res = p.pushIM(ctx, msg)
if !res.Pushed {
res = p.pushActive(ctx, msg)
}
}
// Only a real delivery advances the cooldown/per-kind windows (and only for a
// gate-governed push), so an attempted-but-failed push does not suppress a later
// genuine one, and an event push never contributes to the heartbeat throttle.
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
// D1: mirror the proactive message to the external push outlet (self-hosted
// unified push service → Getui fallback). Fire-and-forget: a slow or failing
// outlet never delays or breaks the local delivery above, and a rate-limited
// push returned before reaching here.
p.exportAsync(msg)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
res.Bypassed = true
res = annotateBypass(res)
}
return res
}
// exportAsync reports msg to the push outlet on a detached, bounded goroutine so
// the proactive path never blocks on the outlet. A nil exporter (hook disabled)
// or a nil outlet result is a no-op; a delivery miss or error is only logged.
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
if p.exporter == nil {
return
}
timeout := defaultExportTimeout
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
delivered, err := p.exporter.Export(ctx, msg)
switch {
case err != nil:
p.log.Warn("push outlet export failed", "error", err)
case !delivered:
p.log.Info("push outlet did not deliver", "hint", "getui fallback not integrated")
}
if p.exportDone != nil {
select {
case p.exportDone <- struct{}{}:
default:
}
}
}()
}
// pushActive delivers only to the active web/native client.
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {
n, err := p.active.SendProactive(ctx, msg)
if err != nil {
p.log.Warn("backthink active client push failed", "error", err)
} else if n > 0 {
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
}
}
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
}
// pushIM delivers only to the IM bridge.
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
}
if err := p.im.SendProactive(ctx, msg); err != nil {
p.log.Warn("backthink IM push failed", "error", err)
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}
}
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
}
// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to
// the canonical route tokens (auto/im/web); anything else is a concrete platform
// id (kook/telegram/...). Unknown/blank maps to auto.
func normalizeChannelHint(hint string) string {
switch strings.ToLower(strings.TrimSpace(hint)) {
case "", "auto":
return channelAuto
case "im", "bridge":
return channelIM
case "web", "frontend", "active":
return channelWeb
default:
return strings.ToLower(strings.TrimSpace(hint))
}
}
// isSpecificChannel reports whether hint names a concrete platform rather than a
// routing keyword (auto/im/web).
func isSpecificChannel(hint string) bool {
return hint != channelAuto && hint != channelIM && hint != channelWeb
}
// annotateHint appends the requested channel hint to the push result detail so
// the record/panel surfaces which channel she chose, even when the route fell
// back. Blank/auto hints add nothing.
func annotateHint(res PushResult, hint string) PushResult {
h := strings.ToLower(strings.TrimSpace(hint))
if h == "" || h == channelAuto {
return res
}
tag := "channel_hint=" + h
if res.Detail == "" {
res.Detail = tag
} else {
res.Detail = res.Detail + ", " + tag
}
return res
}
// annotateBypass tags an event-driven push result so the record/panel shows it
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
} else {
res.Detail = res.Detail + ", event_bypass"
}
return res
}
// rateGate is the production PushGate: a sliding-window counter over
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never
// spammed with the same style (design/12 §6 策略二).
type rateGate struct {
log *slog.Logger
limiter *session.RateLimiter
cooldown time.Duration
kindWindow time.Duration
now func() time.Time
mu sync.Mutex
lastPushTime time.Time
lastKind map[PushKind]time.Time
}
// newRateGate builds a gate that allows at most limit pushes within window, keeps
// pushes cooldown apart after a success, and limits each PushKind to one push per
// kindWindow. A non-positive limit or window yields an always-allow limiter (no
// rate rule); a non-positive cooldown or kindWindow disables that specific guard.
func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate {
return &rateGate{
log: log,
limiter: session.NewRateLimiter(limit, window),
cooldown: cooldown,
kindWindow: kindWindow,
now: time.Now,
lastKind: make(map[PushKind]time.Time),
}
}
// Allow implements PushGate. A canceled context denies (fail closed). It refuses
// when the last successful push is inside the cooldown, or when the same flavor
// was delivered within kindWindow, before consulting the sliding-window limiter.
func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
if err := ctx.Err(); err != nil {
return false
}
now := g.now()
g.mu.Lock()
defer g.mu.Unlock()
if !g.lastPushTime.IsZero() && now.Sub(g.lastPushTime) < g.cooldown {
return false
}
if lt, ok := g.lastKind[kind]; ok && now.Sub(lt) < g.kindWindow {
return false
}
return g.limiter.Allow()
}
// RecordPush implements PushGate: it stamps the last successful push time and the
// per-flavor last time so the cooldown and kind window start from a real delivery.
func (g *rateGate) RecordPush(kind PushKind) {
now := g.now()
g.mu.Lock()
defer g.mu.Unlock()
g.lastPushTime = now
g.lastKind[kind] = now
}
// buildProactiveMessage assembles the normalized outbound message carrying a
// think conclusion's clean body. It tags the platform with the requested channel
// when the conclusion names a concrete platform, otherwise the default backthink
// platform. The concrete active-client and IM adapters apply their own
// routing/targeting to the owner (red line: owner only).
//
// The body is run through stripInternalMarkers as a final guard so a structured
// decision/channel marker that survived parsing can never reach the user-facing
// message (only our logs/records may carry it).
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
platform := defaultProactivePlatform
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
platform = h
}
return adapter.UnifiedMessage{
Platform: platform,
ChannelType: adapter.ChannelDirect,
Content: stripInternalMarkers(message),
ContentType: adapter.ContentText,
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
Timestamp: b.now(),
}
}