Files
Cyrene_v2/cmd/cyrene/channels.go
T
AskaEth 5969bba3bd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channel,runtime): 批次2 后端 — 消息级角色归属进上下文 + IM 私聊随主会话角色 + 渠道级绑定
按 design/22 §2/§3/§4.4/§7(3/4/5)。
- ①「消息级角色归属」进入模型上下文(批次 1 只做存储+DTO):历史消息的发送角色现在对模型可见,且**不把系统元数据塞进用户正文**(design/11 §10 红线)
- ② IM owner 私聊镜像解析出的角色 = **主会话绑定的角色**(§7-3)
- ③ 渠道级绑定:ChannelAccount 加 personaId(channels.json 读写/校验/脱敏沿用既有约定)+ 角色解析优先级 **会话 > 渠道 > 默认** + **群聊/陌生人强制默认角色**(§7-4);admin 渠道配置端点支持 personaId
- Docs/api-client 版本 +1
- 验收:go build/vet/test 全绿 + linux/darwin 交叉编译
2026-10-03 21:01:33 +08:00

318 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"),
}})
}
out["qq-obv11"] = append(out["qq-obv11"], config.ChannelAccount{Config: map[string]any{
"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"),
"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
}