按 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 交叉编译
318 lines
13 KiB
Go
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
|
|
}
|