- 四端=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 请裁决)
324 lines
13 KiB
Go
324 lines
13 KiB
Go
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
|
||
}
|