事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。 - 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected - 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留 - 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期) - Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
395 lines
15 KiB
Go
395 lines
15 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/weixin_ilink"
|
||
)
|
||
|
||
// LLM-provider and channel-lifecycle admin writes (owner-only, audited). The LLM
|
||
// test performs a real model call; provider "add" records a process-lifetime
|
||
// entry. Channel open/close drive the real adapter lifecycle; the identity test
|
||
// is the structural actor × whitelist rule, never prompt text.
|
||
|
||
const (
|
||
// ownerProfilePath is the owner whitelist loaded for the structural identity
|
||
// test. It mirrors the path wire.go uses for owner recognition.
|
||
ownerProfilePath = "configs/users/owner.yaml"
|
||
// llmTestTimeout bounds a single LLM probe request.
|
||
llmTestTimeout = 30 * time.Second
|
||
// llmProbePrompt is the minimal prompt used to verify a model responds.
|
||
llmProbePrompt = "Reply with the single word: OK"
|
||
)
|
||
|
||
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
|
||
// provider group. Every field is a pointer so the handler can tell "absent"
|
||
// (preserve the stored value) from "explicitly zero" (overwrite): a partial edit
|
||
// from the panel must never wipe a field it did not touch. Name is a client-side
|
||
// display label (not persisted — the models.json schema carries id/provider/
|
||
// baseUrl/apiKey/model/fallback/multimodal/maxTokens).
|
||
type llmProviderRequest struct {
|
||
ID *string `json:"id"`
|
||
Name *string `json:"name"`
|
||
Provider *string `json:"provider"`
|
||
BaseURL *string `json:"baseUrl"`
|
||
APIKey *string `json:"apiKey"`
|
||
Model *string `json:"model"`
|
||
Multimodal *bool `json:"multimodal"`
|
||
Fallback *string `json:"fallback"`
|
||
// MaxTokens is the group's own token budget. Absent preserves the stored
|
||
// budget (0 = unset for a new group). Negative is rejected.
|
||
MaxTokens *int `json:"maxTokens"`
|
||
}
|
||
|
||
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
|
||
// promoting it to the active group) and hot-reloads the running LLM stack so the
|
||
// new provider/model takes effect without a restart (design/04 §3.1). A field the
|
||
// client omits keeps its stored value, so a partial edit never resets a budget or
|
||
// key. The API key is persisted to the gitignored file but never returned/logged.
|
||
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
|
||
var req llmProviderRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return
|
||
}
|
||
if req.MaxTokens != nil && *req.MaxTokens < 0 {
|
||
s.auditClaims(r, "llm.addProvider", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "maxTokens must be >= 0"))
|
||
return
|
||
}
|
||
if s.llm == nil {
|
||
s.auditClaims(r, "llm.addProvider", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
|
||
return
|
||
}
|
||
group, err := mergeLlmProvider(s.llm.Groups(), req)
|
||
if err != nil {
|
||
s.auditClaims(r, "llm.addProvider", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
|
||
return
|
||
}
|
||
// Promote the saved group to the active (index 0) slot, then persist and
|
||
// hot-reload so Current() reflects the new provider/model immediately.
|
||
groups := runtimeconfig.Activate(s.llm.Groups(), group)
|
||
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
|
||
s.auditClaims(r, "llm.addProvider", "error")
|
||
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "failed to save models.json"))
|
||
return
|
||
}
|
||
if err := s.llm.Reload(); err != nil {
|
||
s.auditClaims(r, "llm.addProvider", "error")
|
||
s.log.Error("llm reload", "error", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
|
||
return
|
||
}
|
||
saved := AdminLlmProvider{
|
||
ID: group.ID,
|
||
Name: providerDisplayName(group),
|
||
Provider: group.Provider,
|
||
BaseURL: group.BaseURL,
|
||
Model: group.Model,
|
||
Multimodal: group.Multimodal,
|
||
Enabled: group.ID == s.llm.Group().ID,
|
||
Fallback: group.Fallback,
|
||
MaxTokens: group.MaxTokens,
|
||
}
|
||
s.auditClaims(r, "llm.addProvider", "ok")
|
||
writeJSON(w, http.StatusCreated, map[string]any{
|
||
"provider": saved,
|
||
"persisted": true,
|
||
"path": s.llm.Path(),
|
||
})
|
||
}
|
||
|
||
// mergeLlmProvider applies a partial provider patch over the stored groups: a
|
||
// field present in the request wins, an absent field keeps the stored value (or
|
||
// its default for a new group). It returns a validated group.
|
||
func mergeLlmProvider(groups []runtimeconfig.Group, req llmProviderRequest) (runtimeconfig.Group, error) {
|
||
id := ""
|
||
if req.ID != nil {
|
||
id = strings.TrimSpace(*req.ID)
|
||
}
|
||
g, found := runtimeconfig.Find(groups, id)
|
||
if !found {
|
||
g = runtimeconfig.Group{}
|
||
}
|
||
g.ID = id
|
||
if g.ID == "" {
|
||
g.ID = runtimeconfig.NewID()
|
||
}
|
||
if req.Provider != nil {
|
||
g.Provider = strings.TrimSpace(*req.Provider)
|
||
}
|
||
if g.Provider == "" {
|
||
g.Provider = model.ProviderOpenAI
|
||
}
|
||
if g.Provider != model.ProviderOpenAI && g.Provider != model.ProviderAnthropic {
|
||
return runtimeconfig.Group{}, fmt.Errorf("provider must be openai or anthropic")
|
||
}
|
||
if req.BaseURL != nil {
|
||
g.BaseURL = strings.TrimSpace(*req.BaseURL)
|
||
}
|
||
if req.APIKey != nil {
|
||
g.APIKey = *req.APIKey
|
||
}
|
||
if req.Model != nil {
|
||
g.Model = strings.TrimSpace(*req.Model)
|
||
}
|
||
if req.Fallback != nil {
|
||
g.Fallback = strings.TrimSpace(*req.Fallback)
|
||
}
|
||
if req.Multimodal != nil {
|
||
g.Multimodal = *req.Multimodal
|
||
}
|
||
if req.MaxTokens != nil {
|
||
g.MaxTokens = *req.MaxTokens
|
||
}
|
||
if g.BaseURL == "" || g.Model == "" {
|
||
return runtimeconfig.Group{}, fmt.Errorf("baseUrl and model are required")
|
||
}
|
||
return g, nil
|
||
}
|
||
|
||
// llmTestRequest is the POST /api/v1/admin/llm/test body.
|
||
type llmTestRequest struct {
|
||
Provider string `json:"provider"`
|
||
BaseURL string `json:"baseUrl"`
|
||
APIKey string `json:"apiKey"`
|
||
Model string `json:"model"`
|
||
Prompt string `json:"prompt"`
|
||
}
|
||
|
||
// adminTestLlm performs a real completion against the supplied provider/model
|
||
// endpoint and reports the result — a genuine round-trip, not a placeholder.
|
||
func (s *Server) adminTestLlm(w http.ResponseWriter, r *http.Request) {
|
||
var req llmTestRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
|
||
s.auditClaims(r, "llm.test", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "model and baseUrl are required"))
|
||
return
|
||
}
|
||
provider := req.Provider
|
||
if provider == "" {
|
||
provider = model.ProviderOpenAI
|
||
}
|
||
adapter, err := model.NewChatAdapter(config.LLMConfig{Provider: provider, BaseURL: req.BaseURL, APIKey: req.APIKey, Model: req.Model})
|
||
if err != nil {
|
||
s.auditClaims(r, "llm.test", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
|
||
return
|
||
}
|
||
prompt := strings.TrimSpace(req.Prompt)
|
||
if prompt == "" {
|
||
prompt = llmProbePrompt
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), llmTestTimeout)
|
||
defer cancel()
|
||
resp, err := adapter.Chat(ctx, model.Request{
|
||
Messages: []model.Message{model.TextMessage(model.RoleUser, prompt)},
|
||
}, model.Options{Timeout: llmTestTimeout})
|
||
if err != nil {
|
||
s.auditClaims(r, "llm.test", "error")
|
||
s.log.Error("llm test", "provider", provider, "model", req.Model, "err", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_test_failed", "model call failed: %s", err.Error()))
|
||
return
|
||
}
|
||
s.auditClaims(r, "llm.test", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "provider": provider, "model": resp.Model, "reply": resp.Content, "tokens": resp.TotalTokens})
|
||
}
|
||
|
||
// channelActionRequest is the POST /api/v1/admin/channels/open|close body.
|
||
type channelActionRequest struct {
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// adminChannelOpen connects a registered channel adapter (real lifecycle call).
|
||
func (s *Server) adminChannelOpen(w http.ResponseWriter, r *http.Request) {
|
||
name, ok := s.channelName(r, w, "channel.open")
|
||
if !ok {
|
||
return
|
||
}
|
||
ch, found := findChannel(s.caps, name)
|
||
if !found {
|
||
s.auditClaims(r, "channel.open", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||
return
|
||
}
|
||
if err := ch.Connect(r.Context()); err != nil {
|
||
s.auditClaims(r, "channel.open", "error")
|
||
// A state a plain reconnect cannot fix must not read as a generic failure:
|
||
// the weixin-iLink bot token was rejected by iLink (errcode=-14), so the
|
||
// owner needs a fresh scan-code login. Answer 409 with that instruction
|
||
// instead of pretending the channel opened (the panel marks it connected).
|
||
if errors.Is(err, weixin_ilink.ErrSessionRejected) {
|
||
writeError(w, apperr.Wrap(apperr.ErrConflict, "session_rejected",
|
||
"该渠道会话已被 iLink 拒绝(errcode=-14):请用「扫码登录」重新绑定后再连接"))
|
||
return
|
||
}
|
||
// weixin-iLink with no token yet must not hang nor read as a credential
|
||
// error: the panel routes the owner to the out-of-band 扫码登录 flow.
|
||
if errors.Is(err, weixin_ilink.ErrNeedsLogin) {
|
||
writeError(w, apperr.Wrap(apperr.ErrConflict, "needs_login", "该渠道尚未登录:请先用「扫码登录」绑定,再连接"))
|
||
return
|
||
}
|
||
s.log.Error("channel open", "name", name, "err", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_connect_failed", "connect failed (check credentials): %s", err.Error()))
|
||
return
|
||
}
|
||
s.auditClaims(r, "channel.open", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": true})
|
||
}
|
||
|
||
// adminChannelClose disconnects a registered channel adapter (real lifecycle call).
|
||
func (s *Server) adminChannelClose(w http.ResponseWriter, r *http.Request) {
|
||
name, ok := s.channelName(r, w, "channel.close")
|
||
if !ok {
|
||
return
|
||
}
|
||
ch, found := findChannel(s.caps, name)
|
||
if !found {
|
||
s.auditClaims(r, "channel.close", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "channel %q not found", name))
|
||
return
|
||
}
|
||
if err := ch.Disconnect(r.Context()); err != nil {
|
||
s.auditClaims(r, "channel.close", "error")
|
||
s.log.Error("channel close", "name", name, "err", err)
|
||
writeError(w, apperr.Wrap(apperr.ErrInternal, "channel_disconnect_failed", "disconnect failed: %s", err.Error()))
|
||
return
|
||
}
|
||
s.auditClaims(r, "channel.close", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "connected": false})
|
||
}
|
||
|
||
// channelTestRequest is the POST /api/v1/admin/channels/test body: a channel
|
||
// account id plus the conversation shape, resolved by the structural actor rule.
|
||
type channelTestRequest struct {
|
||
Platform string `json:"platform"`
|
||
AccountID string `json:"accountId"`
|
||
IsGroup bool `json:"isGroup"`
|
||
}
|
||
|
||
// adminChannelTest runs the structural identity rule (actor metadata × owner
|
||
// whitelist), never prompt text. It loads the owner whitelist from disk so a
|
||
// mismatch degrades to the conservative default (member/stranger).
|
||
func (s *Server) adminChannelTest(w http.ResponseWriter, r *http.Request) {
|
||
var req channelTestRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return
|
||
}
|
||
if strings.TrimSpace(req.AccountID) == "" {
|
||
s.auditClaims(r, "channel.identity-test", "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "accountId is required"))
|
||
return
|
||
}
|
||
msg := adapter.UnifiedMessage{
|
||
Platform: req.Platform,
|
||
ChannelID: req.AccountID,
|
||
SenderID: req.AccountID,
|
||
Sender: adapter.Actor{UserID: req.AccountID, IsGroup: req.IsGroup},
|
||
}
|
||
if req.IsGroup {
|
||
msg.ChannelType = adapter.ChannelGroup
|
||
} else {
|
||
msg.ChannelType = adapter.ChannelDirect
|
||
}
|
||
owner := loadOwnerProfile()
|
||
actor := session.ResolveActor(msg, owner)
|
||
env := session.EnvFor(actor)
|
||
s.auditClaims(r, "channel.identity-test", "ok")
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"role": string(actor.Role),
|
||
"owner": actor.IsOwner(),
|
||
"isGroup": actor.IsGroupChat(),
|
||
"env": string(env),
|
||
"verdict": describeIdentity(actor, owner),
|
||
})
|
||
}
|
||
|
||
// channelName decodes the {name} body for open/close routes, writing a 400 on
|
||
// failure. It reports uniqueness of the decode via ok.
|
||
func (s *Server) channelName(r *http.Request, w http.ResponseWriter, op string) (string, bool) {
|
||
var req channelActionRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||
return "", false
|
||
}
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
s.auditClaims(r, op, "error")
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||
return "", false
|
||
}
|
||
return req.Name, true
|
||
}
|
||
|
||
// findChannel returns the registered channel adapter named name, if any. An exact
|
||
// adapter name ("kook:conf0") wins; a bare platform name ("kook") that is not an
|
||
// exact name still resolves to that platform's first registered account, so the
|
||
// panel/CLI "connect kook" reaches the token-bearing account even when it is named
|
||
// "kook:conf0" (the empty-token bare placeholder is never registered).
|
||
func findChannel(caps CapabilityStore, name string) (sdk.Channel, bool) {
|
||
if caps == nil {
|
||
return nil, false
|
||
}
|
||
var first sdk.Channel
|
||
for _, cap := range caps.List(capreg.KindChannel) {
|
||
ch, ok := cap.Value.(sdk.Channel)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if ch.Name() == name {
|
||
return ch, true
|
||
}
|
||
if first == nil && channelBasePlatform(ch.Name()) == name {
|
||
first = ch
|
||
}
|
||
}
|
||
if first != nil {
|
||
return first, true
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// loadOwnerProfile loads the owner whitelist from disk, returning nil on any
|
||
// failure so the identity test degrades to the conservative deny.
|
||
func loadOwnerProfile() *user.OwnerProfile {
|
||
p, err := user.LoadOwnerProfile(ownerProfilePath)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return p
|
||
}
|
||
|
||
// describeIdentity renders the structural verdict for the identity test.
|
||
func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
|
||
switch {
|
||
case a.IsGroupChat():
|
||
return "群聊上下文 → 隔离;记忆只读不写;仅可调用标记允许的工具"
|
||
case a.IsOwner():
|
||
return "匹配主人白名单 → owner(全量访问,记忆可读写)"
|
||
default:
|
||
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
|
||
}
|
||
}
|