Files
Cyrene_v2/cmd/cyrene/channels.go
T
AskaEth 9253fef92a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(qq_obv11): OneBot v11 四端齐备(裁决 1.4 / 12.4.1)
- 四端=ws×client / ws×server(原有,补语义注释)+ **http×client**(新增 http_client.go:
  调 OneBot HTTP API、请求-响应、retcode 判成败、token 只走 Authorization 头)+
  **http×server**(新增 http_server.go:监听 listen_addr 收上报、fail-closed 鉴权、10MiB 体上限)
- 配置面:适配器新增 transport / api_base_url / report_path(json tag 沿用既有口径);
  env 兜底 CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH};**默认值=现状**(ws+server)
- 出站分流:session.go 发送路径按 transport 走 WS 写帧或 HTTP POST(不改它则 HTTP 端无法出站)
- 端口豁免边界:http+server 沿用 AGENTS §9 已登记的同一豁免(design/13 §0 原文即含 HTTP server),
  且 listen_addr 为空时报错、**绝不开端口**(与 ws 侧同名校验成对)
- 关键取舍(已上报):OneBot 的 HTTP 是「动作 + 上报」两半,mode 决定**哪一半必需**、
  另一半给齐字段就一并启用 ⇒ 单个适配器即可「收上报 + 回消息」,四端仍各自可独立选择与测试
- 复核:build/vet 绿 + qq_obv11/cmd 包测试绿 + `-race` 干净;10 条新用例(HTTP 两端全部用本地
  httptest 假 OneBot,未连真实 QQ);变异 3 条(伪造 retcode 成功 / 去掉上报鉴权门 / 默认值翻 http)已还原
- 未接:get_group_list(后续)、无 token 时放行(与既有 ws_server 一致,如需 fail-closed 请裁决)
2026-10-04 13:58:18 +08:00

324 lines
13 KiB
Go
Raw 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 main
// This file builds the four built-in IM channel adapters from the runtime-editable
// configuration. The effective source is channels.json when it is present and
// valid (design/04 §3.1); otherwise it falls back to the env channel block
// (CHANNELS_JSON multi-account + the single-account CHANNEL_<NAME>_* vars), which
// stays the final fallback so a bare install boots without any config file.
//
// Each account becomes its own adapter instance. A single-account env adapter is
// named after the bare platform (e.g. "kook"); a multi-account entry is named
// "kook:confID" and wrapped in a namedAdapter that surfaces its allow/deny lists
// through Config() for the channel bridge's black-or-white gate (design/16 §4.6
// §5-6). The channelFactory lets the bridge hot-replace a platform's adapters when
// the panel saves a new config (Bridge.Reload), so a token/address/rate change
// takes effect without a restart.
import (
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"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 every channel adapter from the effective
// account config (channels.json → env fallback) and returns the successfully
// built subset. Adapter construction never dials the network; actual connection
// happens in the channel bridge. f holds the path/fallback/logger so boot and the
// reload factory read the same source.
func buildChannelAdapters(f *channelFactory) []adapter.PlatformAdapter {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json load fell back to env", "error", warn)
}
out := make([]adapter.PlatformAdapter, 0)
for platform, accts := range accounts {
out = append(out, buildPlatformAdapters(platform, accts, f.log)...)
}
return out
}
// channelFactory is the channelbridge.PlatformFactory wired into the bridge. It
// rebuilds a platform's adapters from the CURRENT effective config (file → env),
// so a panel save followed by Bridge.Reload(platform) picks the new config up
// without a restart.
type channelFactory struct {
path string
fallback map[string][]config.ChannelAccount
log *slog.Logger
}
// Build returns the adapters for a platform from its effective account config.
func (f *channelFactory) Build(platform string) ([]adapter.PlatformAdapter, error) {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json reload fell back to env", "error", warn)
}
return buildPlatformAdapters(platform, accounts[platform], f.log), nil
}
// Persona returns the role bound to one channel account of a platform (design/22
// §2 方案 b). It reads the same effective config Build does — channels.json wins,
// env is the fallback — so the panel's save plus the bridge's hot reload stay the
// single update path, and it is called once per adapter connect rather than per
// message. An unknown platform/account, or an account without a personaId, returns
// "" (no channel-level binding: the session binding or the global default applies).
func (f *channelFactory) Persona(platform, accountID string) string {
accounts, warn := runtimeconfig.LoadChannels(f.path, f.fallback)
if warn != nil {
f.log.Warn("channels.json persona lookup fell back to env", "error", warn)
}
for _, acct := range accounts[platform] {
if strings.TrimSpace(acct.ID) == strings.TrimSpace(accountID) {
return strings.TrimSpace(acct.PersonaID)
}
}
return ""
}
// buildPlatformAdapters builds every account of one platform into an adapter
// instance. A failed account build is skipped with a warn (never fatal), matching
// the "no credentials → warn and continue" contract of the bridge.
func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *slog.Logger) []adapter.PlatformAdapter {
out := make([]adapter.PlatformAdapter, 0, len(accts))
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)
}
return out
}
// channelAccountsFromEnv synthesizes the env fallback: the single-account
// CHANNEL_<NAME>_* adapters plus any CHANNELS_JSON multi-account entries. It is
// the .env fallback passed to runtimeconfig.LoadChannels when channels.json is
// absent or invalid. .env only carries the necessary connection fallback
// (token/base_url/...); business and advanced per-account fields (receive_mode,
// webhook path/verify_token/encrypt_key, allow/deny lists, rate limits) are
// configured in the panel's channel management and live in channels.json, never
// in .env.
//
// A token-requiring platform (kook/telegram/weixin-ilink) is synthesized only
// when its token env var is set; with an unset token there is no account at all,
// so an empty-token placeholder never claims the bare platform name. qq-obv11 and
// webhook are always present (they do not need a token; the bridge's configured()
// gate decides whether to dial) so the channel list stays stable.
func channelAccountsFromEnv() map[string][]config.ChannelAccount {
out := map[string][]config.ChannelAccount{}
// KOOK: only the necessary connection fields. receive_mode / webhook_path /
// webhook_verify_token / webhook_encrypt_key are business·advanced fields that
// go to channels.json via the panel — .env does not fall back on them.
if tok := os.Getenv("CHANNEL_KOOK_TOKEN"); tok != "" {
out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok,
"base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"),
}})
}
// Telegram: token (necessary) + optional webhook_url (connection). Business
// tuning goes to channels.json.
if tok := os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"); tok != "" {
out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{
"bot_token": tok,
"webhook_url": os.Getenv("CHANNEL_TELEGRAM_WEBHOOK_URL"),
}})
}
// qq-obv11 的四个端(裁决 1.4):transport(ws|http) × mode(client|server)。
// 默认 transport=ws、mode=server,与改造前的行为完全一致(保守:不改变已部署
// 现状);listen_addr 是 server 端的显式监听地址,留空即不开端口(AGENTS §9)。
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
"transport": envString("CHANNEL_QQ_OBV11_TRANSPORT", "ws"),
"mode": envString("CHANNEL_QQ_OBV11_MODE", "server"),
"listen_addr": os.Getenv("CHANNEL_QQ_OBV11_LISTEN_ADDR"),
"remote_url": os.Getenv("CHANNEL_QQ_OBV11_REMOTE_URL"),
"api_base_url": os.Getenv("CHANNEL_QQ_OBV11_API_BASE_URL"),
"report_path": os.Getenv("CHANNEL_QQ_OBV11_REPORT_PATH"),
"access_token": os.Getenv("CHANNEL_QQ_OBV11_ACCESS_TOKEN"),
"self_id": os.Getenv("CHANNEL_QQ_OBV11_SELF_ID"),
}})
out["webhook"] = append(out["webhook"], config.ChannelAccount{Config: map[string]any{
"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"),
}})
if tok := os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_TOKEN"); tok != "" {
out["weixin-ilink"] = append(out["weixin-ilink"], config.ChannelAccount{Config: map[string]any{
"base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_BASE_URL"),
"cdn_base_url": os.Getenv("CHANNEL_WEIXIN_ILINK_CDN_BASE_URL"),
"bot_token": tok,
"bot_id": os.Getenv("CHANNEL_WEIXIN_ILINK_BOT_ID"),
"owner_id": os.Getenv("CHANNEL_WEIXIN_ILINK_OWNER_ID"),
}})
}
if raw := os.Getenv("CHANNELS_JSON"); raw != "" {
if accounts, err := config.ParseChannelAccounts(raw); err == nil {
for platform, accts := range accounts {
out[platform] = append(out[platform], accts...)
}
}
}
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
}
// InboundHandler forwards to the inner adapter when it exposes an inbound HTTP
// callback; the bridge type-asserts the wrapper and mounts the handler at
// WebhookPath. It returns nil for a non-webhook inner adapter.
func (n *namedAdapter) InboundHandler() http.Handler {
if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok {
return inner.InboundHandler()
}
return nil
}
// WebhookPath forwards the inner adapter's webhook route, returning "" when the
// inner adapter does not expose one (so the bridge skips it).
func (n *namedAdapter) WebhookPath() string {
if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok {
return inner.WebhookPath()
}
return ""
}
// 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)
}
if strings.TrimSpace(cfg.BotToken) == "" {
return nil, fmt.Errorf("%s: missing bot token", platform)
}
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)
}
// A weixin-ilink account may carry an empty bot_token: it is a pending
// scan-login account the panel drives via the login-qr/login-status admin
// endpoints (design/08 §4). The bridge's configured() gate skips it until
// the scan confirms and the token is persisted, so an empty token here is
// legitimate — build the adapter (disconnected) rather than dropping it.
w, err := weixin_ilink.New(cfg, nil, logger.WithSource(log, "plugins/channels/weixin_ilink"))
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)
}
if strings.TrimSpace(cfg.BotToken) == "" {
return nil, fmt.Errorf("%s: missing bot token", platform)
}
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
}