mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-07 00:05:14 +00:00
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades * fix(ai): override inherited Feishu allowlists when cleared
337 lines
10 KiB
Go
337 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/constant"
|
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/docker"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func classifyLegacyFeishu(conf map[string]interface{}, installPath string) (legacy, configured bool, err error) {
|
|
for _, field := range []string{"channels", "channels.feishu", "plugins", "plugins.entries", "plugins.entries.openclaw-lark", "plugins.entries.feishu"} {
|
|
parts := strings.Split(field, ".")
|
|
parent := conf
|
|
for _, part := range parts[:len(parts)-1] {
|
|
parent = childMap(parent, part)
|
|
}
|
|
if raw, exists := parent[parts[len(parts)-1]]; exists {
|
|
if _, ok := raw.(map[string]interface{}); !ok {
|
|
return false, false, feishuConfigError(field)
|
|
}
|
|
}
|
|
}
|
|
plugins := childMap(conf, "plugins")
|
|
for _, key := range []string{"allow", "deny"} {
|
|
if raw, exists := plugins[key]; exists {
|
|
if _, ok := raw.([]string); ok {
|
|
continue
|
|
}
|
|
ids, ok := raw.([]interface{})
|
|
if !ok {
|
|
return false, false, feishuConfigError("plugins." + key)
|
|
}
|
|
for _, id := range ids {
|
|
if _, ok := id.(string); !ok {
|
|
return false, false, feishuConfigError("plugins." + key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, id := range []string{"openclaw-lark", "feishu"} {
|
|
if raw, exists := childMap(childMap(plugins, "entries"), id)["enabled"]; exists {
|
|
if _, ok := raw.(bool); !ok {
|
|
return false, false, feishuConfigError("plugins.entries." + id + ".enabled")
|
|
}
|
|
}
|
|
}
|
|
if usesBundledFeishu(conf) {
|
|
return false, false, nil
|
|
}
|
|
f := getChannelConfig(conf, "feishu")
|
|
configured = len(f) > 0
|
|
if raw, exists := conf["bindings"]; exists {
|
|
bindings, ok := raw.([]interface{})
|
|
if !ok {
|
|
return false, false, feishuConfigError("bindings")
|
|
}
|
|
for _, raw := range bindings {
|
|
binding, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return false, false, feishuConfigError("bindings")
|
|
}
|
|
if match, exists := binding["match"]; exists {
|
|
if _, ok := match.(map[string]interface{}); !ok {
|
|
return false, false, feishuConfigError("bindings.match")
|
|
}
|
|
}
|
|
if extractStringValue(childMap(binding, "match")["channel"]) == "feishu" {
|
|
configured = true
|
|
}
|
|
}
|
|
}
|
|
entries := childMap(childMap(conf, "plugins"), "entries")
|
|
entry, selected := entries["openclaw-lark"]
|
|
if selected {
|
|
old, ok := entry.(map[string]interface{})
|
|
if !ok {
|
|
return false, configured, feishuConfigError("plugins.entries.openclaw-lark")
|
|
}
|
|
// A completed disable-only repair must not repeat on every upgrade.
|
|
if enabled, ok := old["enabled"].(bool); ok && !enabled && !configured {
|
|
return false, false, nil
|
|
}
|
|
}
|
|
installed, err := checkPluginInstalled(installPath, "feishu")
|
|
if err != nil {
|
|
return false, configured, err
|
|
}
|
|
_, oldThread := f["threadSession"]
|
|
_, oldReply := f["replyMode"]
|
|
_, oldStreaming := f["streaming"].(bool)
|
|
return selected || installed || oldThread || oldReply || oldStreaming, configured, nil
|
|
}
|
|
|
|
func prepareLegacyFeishu(conf map[string]interface{}, configured bool, transition *dto.AgentFeishuTransition) error {
|
|
if !configured {
|
|
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
|
|
return nil
|
|
}
|
|
if transition == nil || !transition.Confirmed {
|
|
return buserr.New("ErrFeishuUpgradeConfirm")
|
|
}
|
|
if !validFeishuTransition(transition.GroupSessionScope, transition.RequireMention) {
|
|
return feishuConfigError("feishuTransition")
|
|
}
|
|
return convertLegacyFeishu(conf, dto.AgentFeishuSwitchReq{Streaming: transition.Streaming, GroupSessionScope: transition.GroupSessionScope, RequireMention: transition.RequireMention})
|
|
}
|
|
|
|
func validFeishuTransition(scope, mention string) bool {
|
|
return (scope == "group" || scope == "group_sender" || scope == "group_topic" || scope == "group_topic_sender") && (mention == "true" || mention == "false")
|
|
}
|
|
|
|
func feishuRecoveryPayload(candidate []byte, configured bool) ([]byte, error) {
|
|
var conf map[string]interface{}
|
|
if err := json.Unmarshal(candidate, &conf); err != nil {
|
|
return nil, err
|
|
}
|
|
if configured {
|
|
ensureChildMap(ensureChildMap(conf, "channels"), "feishu")["enabled"] = false
|
|
selectBundledFeishu(conf, false)
|
|
}
|
|
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
|
|
return json.MarshalIndent(conf, "", " ")
|
|
}
|
|
|
|
func (u *appUpgradeContext) prepareFeishuUpgrade() error {
|
|
if u.original.App.Key != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(u.candidate.Version) {
|
|
return nil
|
|
}
|
|
source := filepath.Join(u.original.GetPath(), "data/conf/openclaw.json")
|
|
staged := filepath.Join(u.stageDir, "data/conf/openclaw.json")
|
|
// The staged copy predates protocol migration and is the exact cutover comparison source.
|
|
original := u.openclawOriginal
|
|
current, err := os.ReadFile(source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytes.Equal(original, current) {
|
|
return buserr.New("ErrFeishuConfigChanged")
|
|
}
|
|
_, conf, err := readFeishuConfigBytes(staged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
legacy, configured, err := classifyLegacyFeishu(conf, u.original.GetPath())
|
|
if err != nil || !legacy {
|
|
return err
|
|
}
|
|
if !u.req.Backup {
|
|
return buserr.New("ErrFeishuUpgradeBackup")
|
|
}
|
|
if err = prepareLegacyFeishu(conf, configured, u.req.FeishuTransition); err != nil {
|
|
return err
|
|
}
|
|
project, err := docker.GetComposeProject(u.original.Name, u.original.GetPath(), []byte(u.candidate.DockerCompose), u.envContent, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(project.Services) != 1 {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
for _, service := range project.Services {
|
|
if service.Build != nil {
|
|
return buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
u.feishuImage, err = bundledFeishuImage(service.Image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
configMounted := false
|
|
for _, v := range service.Volumes {
|
|
if v.Target == "/etc/localtime" {
|
|
continue
|
|
}
|
|
if v.Type != "bind" {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
if err = feishuBackupPath(u.original.GetPath(), v.Source); err != nil {
|
|
return err
|
|
}
|
|
if v.Target == "/home/node/.openclaw" {
|
|
configMounted = filepath.Clean(v.Source) == filepath.Dir(source)
|
|
}
|
|
}
|
|
if !configMounted {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
}
|
|
cli, err := docker.NewDockerClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cli.Close()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
info, err := cli.ContainerInspect(ctx, u.original.ContainerName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.feishuOldImage = info.Image
|
|
for _, m := range info.Mounts {
|
|
if m.Destination == "/etc/localtime" {
|
|
continue
|
|
}
|
|
if string(m.Type) != "bind" {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
if err = feishuBackupPath(u.original.GetPath(), m.Source); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Tar preserves symlinks: an external target would not be in the application backup.
|
|
if err = filepath.WalkDir(filepath.Join(u.original.GetPath(), "data"), func(file string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return feishuBackupPath(u.original.GetPath(), file)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
u.feishuOriginal = original
|
|
u.feishuEnabled = configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
|
|
payload, err := json.MarshalIndent(conf, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
before, err := os.ReadFile(staged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = replaceFeishuConfig(staged, before, payload); err != nil {
|
|
return err
|
|
}
|
|
targetCompose := filepath.Join(u.stageDir, "docker-compose.yml")
|
|
if err = pinFeishuRollbackImage(targetCompose, u.feishuImage); err != nil {
|
|
return err
|
|
}
|
|
pinned, err := os.ReadFile(targetCompose)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u.candidate.DockerCompose = string(pinned)
|
|
return nil
|
|
}
|
|
|
|
func feishuBackupPath(root, source string) error {
|
|
root, err := filepath.EvalSymlinks(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(source)
|
|
if err != nil {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
rel, err := filepath.Rel(root, resolved)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *appUpgradeContext) checkFeishuUpgradeSource() error {
|
|
if u.feishuOriginal == nil {
|
|
return nil
|
|
}
|
|
current, err := os.ReadFile(filepath.Join(u.original.GetPath(), "data/conf/openclaw.json"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytes.Equal(current, u.feishuOriginal) {
|
|
return buserr.New("ErrFeishuConfigChanged")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (u *appUpgradeContext) verifyFeishuUpgrade(t *task.Task) error {
|
|
if u.feishuOriginal == nil {
|
|
return nil
|
|
}
|
|
if err := verifyFeishuActivation(u.candidate.ContainerName, u.feishuEnabled); err != nil {
|
|
return err
|
|
}
|
|
if u.feishuEnabled {
|
|
probeFeishuConnection(u.candidate.ContainerName, t)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func probeFeishuConnection(name string, t *task.Task) {
|
|
output, err := cmd.RunDockerExecWithStdout(30*time.Second, name, "openclaw", "channels", "status", "--channel", "feishu", "--probe", "--json")
|
|
if err != nil || !feishuProbeOK(output) {
|
|
t.Log(i18n.GetMsgByKey("AgentFeishuProbeWarning"))
|
|
}
|
|
}
|
|
|
|
// Pin the restored service to the actual old image, even if its registry tag moved.
|
|
func pinFeishuRollbackImage(composeFile string, image string) error {
|
|
raw, err := os.ReadFile(composeFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var conf map[string]interface{}
|
|
if err = yaml.Unmarshal(raw, &conf); err != nil {
|
|
return err
|
|
}
|
|
services := childMap(conf, "services")
|
|
if len(services) != 1 {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
for _, raw := range services {
|
|
service, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return buserr.New("ErrFeishuUpgradeMounts")
|
|
}
|
|
service["image"] = image
|
|
}
|
|
payload, err := yaml.Marshal(conf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeUpgradeFile(composeFile, payload, constant.FilePerm)
|
|
}
|