降噪A(策略一/二/五): - channelbridge/light.go: 非owner分级门——小事件当系统消息登记(不进loop)、非owner轻响应(她判断回/不回,规则优先mention/关键词+轻LLM兜底fail-closed静默) - backthink 触发降噪: SubjectSystemEvent 低价值不唤醒thinking(仅高价值/owner才think), iot/reminder不受影响 - quiet hours: 安静时段非owner无信号静默,被@才回(非硬性静默); config QUIET_START/END_HOUR 单源共用 限流B(策略三/四): - adapter/flowgate.go: per-adapter 滑窗限流器+总上限+审查剔噪闸门(Admit两连:速率+上限/审查), owner私聊绕过 - channelbridge/flowgate.go: onMessage入站最前挂闸, 超限/被剔不占worker槽; 审查模型NewLLMReviewer复用model.Adapter(仅>80%阈值触发+跨分钟节流,失败回退FIFO,绝不判身份) - config: ChannelAccount 加 GroupMsgPerMin(默认6)/MsgPerMin/MaxMessages(默认50), CHANNELS_JSON每账号, 向后兼容 - 被剔消息走OnRejected审计不静默丢 main wire接线: 加modelAdapter字段+Review注入(reviewFor nil-safe,无LLM回退FIFO) 全仓 go build/vet/test 绿(53包)+交叉编译, 零新依赖, 前端无涉
218 lines
7.8 KiB
Go
218 lines
7.8 KiB
Go
package main
|
|
|
|
// This file builds the four built-in IM channel adapters from environment
|
|
// configuration. The config package carries no channel block, so credentials are
|
|
// read from CHANNEL_<NAME>_* env vars here and injected into each adapter's
|
|
// concrete Config. Adapters whose construction fails are skipped rather than
|
|
// aborting boot, matching the "no credentials → warn and continue" contract of
|
|
// the channel bridge.
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/qq_obv11"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/telegram"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
|
)
|
|
|
|
// buildChannelAdapters constructs the four channel adapters from environment
|
|
// configuration and returns the successfully built subset. Adapter construction
|
|
// never dials the network; actual connection happens in the channel bridge.
|
|
func buildChannelAdapters(log *slog.Logger) []adapter.PlatformAdapter {
|
|
qq := qq_obv11.NewAdapter(qq_obv11.Config{
|
|
Mode: envString("CHANNEL_QQ_OBV11_MODE", "server"),
|
|
ListenAddr: os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
|
|
RemoteURL: os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
|
|
AccessToken: os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
|
|
SelfID: os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
|
|
Logger: log,
|
|
})
|
|
|
|
weixin, werr := weixin_ilink.New(weixin_ilink.Config{
|
|
BaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
|
|
CDNBaseURL: os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
|
|
BotToken: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"),
|
|
BotID: os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
|
|
OwnerID: os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
|
|
}, nil, log)
|
|
if werr != nil {
|
|
// A blank config defaults to a valid base URL, so this only fires on an
|
|
// explicitly invalid one; skip the channel rather than fail startup.
|
|
log.Warn("weixin-ilink adapter build skipped", "error", werr)
|
|
weixin = nil
|
|
}
|
|
|
|
tg := telegram.New(telegram.Config{
|
|
BotToken: os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"),
|
|
WebhookURL: os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
|
|
}, telegram.WithLogger(log))
|
|
|
|
kook := kook.New(kook.Config{
|
|
BotToken: os.Getenv("CHANNEL_KOOK_TOKEN"),
|
|
BaseURL: os.Getenv("CHANNEL_KOOK_BASE_URL"),
|
|
}, kook.WithLogger(log))
|
|
|
|
// webhook.WithLogger does not tag provenance, so wrap base with the module
|
|
// source here to satisfy the logging rule.
|
|
wh := webhook.New(
|
|
webhook.WithConfig(webhook.Config{
|
|
EndpointPath: os.Getenv("CHANNEL_WEBHOOK_ENDPOINT_PATH"),
|
|
ChannelID: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_ID"),
|
|
ChannelName: os.Getenv("CHANNEL_WEBHOOK_CHANNEL_NAME"),
|
|
Secret: os.Getenv("CHANNEL_WEBHOOK_SECRET"),
|
|
SignatureHeader: os.Getenv("CHANNEL_WEBHOOK_SIGNATURE_HEADER"),
|
|
}),
|
|
webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")),
|
|
)
|
|
|
|
out := []adapter.PlatformAdapter{qq, tg, wh, kook}
|
|
if weixin != nil {
|
|
out = append(out, weixin)
|
|
}
|
|
|
|
// design/16 §4.6 §5: a platform may carry multiple access configs (multiple
|
|
// accounts) via CHANNELS_JSON. Each account becomes its own adapter instance
|
|
// (named platform:confID) with independent session/account and its own allow/deny
|
|
// black-or-white lists. An empty CHANNELS_JSON adds nothing, so the single-account
|
|
// env path above is unchanged.
|
|
if accounts, err := config.ParseChannelAccounts(os.Getenv("CHANNELS_JSON")); err == nil {
|
|
for platform, accts := range accounts {
|
|
for _, acct := range accts {
|
|
built, berr := buildAccountAdapter(platform, acct, log)
|
|
if berr != nil {
|
|
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
|
continue
|
|
}
|
|
out = append(out, built)
|
|
}
|
|
}
|
|
} else {
|
|
log.Warn("CHANNELS_JSON parse failed, ignoring multi-account config", "error", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// namedAdapter wraps a platform adapter so a multi-account instance gets a unique
|
|
// name (platform:confID) and surfaces its allow/deny lists through Config(), which
|
|
// the channel bridge reads to enforce the platform's black-or-white lists per
|
|
// account (design/16 §4.6 §5-6). Everything else delegates to the inner adapter.
|
|
type namedAdapter struct {
|
|
adapter.PlatformAdapter
|
|
name string
|
|
allowGroups []string
|
|
denyGroups []string
|
|
allowUsers []string
|
|
denyUsers []string
|
|
// flow limits (design/12 §6 策略三/四). They are always surfaced through
|
|
// Config() so the bridge can build a per-account ingress gate; the bridge maps
|
|
// the zero default to the protective values and a negative value to "off".
|
|
groupMsgPerMin int
|
|
msgPerMin int
|
|
maxMessages int
|
|
}
|
|
|
|
func (n *namedAdapter) Name() string { return n.name }
|
|
|
|
func (n *namedAdapter) Config() map[string]any {
|
|
cfg := n.PlatformAdapter.Config()
|
|
out := make(map[string]any, len(cfg)+7)
|
|
for k, v := range cfg {
|
|
out[k] = v
|
|
}
|
|
out["allowGroups"] = n.allowGroups
|
|
out["denyGroups"] = n.denyGroups
|
|
out["allowUsers"] = n.allowUsers
|
|
out["denyUsers"] = n.denyUsers
|
|
out["groupMsgPerMin"] = n.groupMsgPerMin
|
|
out["msgPerMin"] = n.msgPerMin
|
|
out["maxMessages"] = n.maxMessages
|
|
return out
|
|
}
|
|
|
|
// buildAccountAdapter builds one account's adapter for a platform, decoding the
|
|
// account's generic Config map into the platform's typed config and wrapping it so
|
|
// the bridge sees a distinct account name and its allow/deny lists.
|
|
func buildAccountAdapter(platform string, acct config.ChannelAccount, log *slog.Logger) (adapter.PlatformAdapter, error) {
|
|
name := platform
|
|
if acct.ID != "" {
|
|
name = platform + ":" + acct.ID
|
|
}
|
|
|
|
var inner adapter.PlatformAdapter
|
|
switch barePlatform(platform) {
|
|
case "telegram":
|
|
var cfg telegram.Config
|
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode telegram config: %w", err)
|
|
}
|
|
inner = telegram.New(cfg, telegram.WithLogger(log))
|
|
case "weixin-ilink":
|
|
var cfg weixin_ilink.Config
|
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode weixin-ilink config: %w", err)
|
|
}
|
|
w, err := weixin_ilink.New(cfg, nil, log)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build weixin-ilink: %w", err)
|
|
}
|
|
inner = w
|
|
case "qq-obv11":
|
|
var cfg qq_obv11.Config
|
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode qq-obv11 config: %w", err)
|
|
}
|
|
inner = qq_obv11.NewAdapter(cfg)
|
|
case "kook":
|
|
var cfg kook.Config
|
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode kook config: %w", err)
|
|
}
|
|
inner = kook.New(cfg, kook.WithLogger(log))
|
|
case "webhook":
|
|
var cfg webhook.Config
|
|
if err := adapter.DecodeConfig(acct.Config, &cfg); err != nil {
|
|
return nil, fmt.Errorf("decode webhook config: %w", err)
|
|
}
|
|
inner = webhook.New(webhook.WithConfig(cfg), webhook.WithLogger(logger.WithSource(log, "plugins/channels/webhook")))
|
|
default:
|
|
return nil, fmt.Errorf("unknown platform %q", platform)
|
|
}
|
|
return &namedAdapter{
|
|
PlatformAdapter: inner,
|
|
name: name,
|
|
allowGroups: acct.AllowGroups,
|
|
denyGroups: acct.DenyGroups,
|
|
allowUsers: acct.AllowUsers,
|
|
denyUsers: acct.DenyUsers,
|
|
groupMsgPerMin: acct.GroupMsgPerMin,
|
|
msgPerMin: acct.MsgPerMin,
|
|
maxMessages: acct.MaxMessages,
|
|
}, nil
|
|
}
|
|
|
|
// barePlatform returns the platform name for a possibly-suffixed "platform:confID".
|
|
func barePlatform(name string) string {
|
|
for i := 0; i < len(name); i++ {
|
|
if name[i] == ':' {
|
|
return name[:i]
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
|
|
// envString returns the value of key, or fallback when the variable is unset or
|
|
// blank.
|
|
func envString(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|