适配器: login_admin.go(StartLogin 幂等取二维码/LoginStatus idle-waiting-confirmed-failed);
login.go 重写 Connect 委托 StartLogin+等待,后台 runLogin 轮询,completeLogin/failLogin
(锁序 loginMu->a.mu 不嵌套反向无死锁); adapter.go 加登录会话字段
网关: admin_channels_weixin.go(2 端点 login-qr/login-status owner+审计, confirmed 合并写回
channels.json 保留 base_url/cdn/黑白名单 + Reload 热重载连接; qrImage best-effort Base64,
token 掩码) + server.go 路由
前端: WeixinLogin.tsx(token空→扫码按钮→二维码→1.5s轮询→confirmed提示已登录+显示bot_id),
AdapterDetailPage 渲染, api/types 补类型
必需偏离(最小化): 允许空token的weixin账号被构建/持久化(channels.go+runtimeconfig移出
tokenRequiringPlatforms),否则空token账号无法注册调用StartLogin; 空token保持断开(configured
仍要求token非空),确认后才持久化连接——守红线。
测试: login_admin/admin端点用例; 后端 build/vet/test 全绿 + 前端 build 101模块绿.
299 lines
12 KiB
Go
299 lines
12 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
|
|
}
|
|
|
|
// 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
|
|
}
|