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
678 lines
21 KiB
Go
678 lines
21 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
|
"github.com/1Panel-dev/1Panel/agent/app/model"
|
|
"github.com/1Panel-dev/1Panel/agent/app/task"
|
|
"github.com/1Panel-dev/1Panel/agent/buserr"
|
|
"github.com/1Panel-dev/1Panel/agent/i18n"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/common"
|
|
"github.com/1Panel-dev/1Panel/agent/utils/compose"
|
|
)
|
|
|
|
// Keep panel configuration writes out of the backup/activation/recovery window.
|
|
var feishuSwitches sync.Map
|
|
|
|
func isOpenclawBundledFeishuVersion(version string) bool {
|
|
return common.CompareAppVersion(version, "2026.10.7")
|
|
}
|
|
|
|
func hasBundledFeishu(container string) bool {
|
|
image, err := feishuContainerImage(container)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, err = bundledFeishuImage(image)
|
|
return err == nil
|
|
}
|
|
|
|
func usesBundledFeishu(conf map[string]interface{}) bool {
|
|
f := getChannelConfig(conf, "feishu")
|
|
for _, key := range []string{"threadSession", "replyMode"} {
|
|
if _, old := f[key]; old {
|
|
return false
|
|
}
|
|
}
|
|
if _, old := f["streaming"].(bool); old {
|
|
return false
|
|
}
|
|
entries := childMap(childMap(conf, "plugins"), "entries")
|
|
if extractBoolValue(childMap(entries, "openclaw-lark")["enabled"], false) {
|
|
return false
|
|
}
|
|
_, streaming := f["streaming"].(map[string]interface{})
|
|
_, selected := entries["feishu"]
|
|
return selected && (extractBoolValue(childMap(entries, "feishu")["enabled"], false) || f["groupSessionScope"] != nil || streaming)
|
|
}
|
|
|
|
func hasLegacyFeishu(conf map[string]interface{}, installPath string) bool {
|
|
legacy, _, err := classifyLegacyFeishu(conf, installPath)
|
|
return err != nil || legacy
|
|
}
|
|
|
|
func selectBundledFeishu(conf map[string]interface{}, enabled bool) {
|
|
plugins := ensureChildMap(conf, "plugins")
|
|
entries := ensureChildMap(plugins, "entries")
|
|
if legacy, ok := entries["openclaw-lark"]; ok {
|
|
if entry, ok := legacy.(map[string]interface{}); ok {
|
|
entry["enabled"] = false
|
|
}
|
|
}
|
|
ensureChildMap(entries, "feishu")["enabled"] = enabled
|
|
// Preserve an absent allowlist; creating one would disable unrelated plugins.
|
|
if allow, ok := plugins["allow"]; ok && len(extractStringList(allow)) > 0 {
|
|
ids := slices.DeleteFunc(extractStringList(allow), func(id string) bool { return id == "openclaw-lark" })
|
|
if !slices.Contains(ids, "feishu") {
|
|
ids = append(ids, "feishu")
|
|
}
|
|
plugins["allow"] = ids
|
|
}
|
|
if deny, ok := plugins["deny"]; ok {
|
|
plugins["deny"] = slices.DeleteFunc(extractStringList(deny), func(id string) bool { return id == "feishu" })
|
|
}
|
|
}
|
|
|
|
func feishuConfigError(field string) error { return buserr.WithDetail("ErrFeishuConfig", field, nil) }
|
|
|
|
func convertLegacyFeishu(conf map[string]interface{}, req dto.AgentFeishuSwitchReq) error {
|
|
f := getChannelConfig(conf, "feishu")
|
|
if len(f) == 0 {
|
|
if raw, exists := childMap(conf, "channels")["feishu"]; exists && raw != nil {
|
|
if _, ok := raw.(map[string]interface{}); !ok {
|
|
return feishuConfigError("channels.feishu")
|
|
}
|
|
}
|
|
f = ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
|
|
f["enabled"], f["dmPolicy"], f["groupPolicy"] = false, "pairing", "allowlist"
|
|
}
|
|
if err := checkLegacyFeishuFields(f, "channels.feishu", false); err != nil {
|
|
return err
|
|
}
|
|
if err := convertFeishuAccess(f, "channels.feishu"); err != nil {
|
|
return err
|
|
}
|
|
delete(f, "threadSession")
|
|
delete(f, "replyMode")
|
|
f["streaming"] = map[string]interface{}{"mode": "off"}
|
|
if req.Streaming {
|
|
f["streaming"] = map[string]interface{}{"mode": "partial"}
|
|
}
|
|
f["groupSessionScope"] = req.GroupSessionScope
|
|
f["requireMention"] = req.RequireMention == "true"
|
|
delete(f, "topicSessionMode")
|
|
// Preserve legacy defaults explicitly; bundled defaults are more restrictive.
|
|
if _, ok := f["groupPolicy"]; !ok {
|
|
f["groupPolicy"] = "open"
|
|
}
|
|
entries := ensureChildMap(ensureChildMap(conf, "plugins"), "entries")
|
|
ensureChildMap(entries, "openclaw-lark")["enabled"] = false
|
|
selectBundledFeishu(conf, extractBoolValue(f["enabled"], true))
|
|
return nil
|
|
}
|
|
|
|
// Limit automatic conversion to the shared channel fields; custom plugin options require review.
|
|
func checkLegacyFeishuFields(f map[string]interface{}, field string, group bool) error {
|
|
allowed := strings.Fields("enabled name appId appSecret encryptKey verificationToken domain connectionMode webhookPath defaultAccount dmPolicy allowFrom groupPolicy groupAllowFrom groupSenderAllowFrom requireMention accounts groups streaming threadSession replyMode groupSessionScope topicSessionMode replyInThread")
|
|
if group {
|
|
allowed = strings.Fields("enabled requireMention allowFrom groupSessionScope topicSessionMode replyInThread")
|
|
}
|
|
for _, key := range sortedChildKeys(f) {
|
|
if !slices.Contains(allowed, key) {
|
|
return feishuConfigError(field + "." + key)
|
|
}
|
|
}
|
|
if enabled, exists := f["enabled"]; exists {
|
|
if _, ok := enabled.(bool); !ok {
|
|
return feishuConfigError(field + ".enabled")
|
|
}
|
|
}
|
|
for _, key := range []string{"dmPolicy", "groupPolicy"} {
|
|
if raw, exists := f[key]; exists {
|
|
value, ok := raw.(string)
|
|
options := []string{"open", "allowlist", "disabled"}
|
|
if key == "dmPolicy" {
|
|
options = append(options, "pairing")
|
|
}
|
|
if !ok || !slices.Contains(options, value) {
|
|
return feishuConfigError(field + "." + key)
|
|
}
|
|
}
|
|
}
|
|
for _, key := range []string{"allowFrom", "groupSenderAllowFrom"} {
|
|
if raw, exists := f[key]; exists {
|
|
values, ok := raw.([]interface{})
|
|
if !ok {
|
|
return feishuConfigError(field + "." + key)
|
|
}
|
|
for _, value := range values {
|
|
if _, ok := value.(string); !ok {
|
|
return feishuConfigError(field + "." + key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, section := range []string{"accounts", "groups"} {
|
|
raw, exists := f[section]
|
|
if !exists {
|
|
continue
|
|
}
|
|
entries, ok := raw.(map[string]interface{})
|
|
if !ok {
|
|
return feishuConfigError(field + "." + section)
|
|
}
|
|
for _, id := range sortedChildKeys(entries) {
|
|
entry, ok := entries[id].(map[string]interface{})
|
|
name := field + "." + section + "." + id
|
|
if !ok {
|
|
return feishuConfigError(name)
|
|
}
|
|
if err := checkLegacyFeishuFields(entry, name, section == "groups"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func convertFeishuAccess(f map[string]interface{}, field string) error {
|
|
if raw, ok := f["groupAllowFrom"]; ok {
|
|
values, ok := raw.([]interface{})
|
|
if !ok {
|
|
return feishuConfigError(field + ".groupAllowFrom")
|
|
}
|
|
groups, senders := []string{}, []string{}
|
|
for _, value := range values {
|
|
id, ok := value.(string)
|
|
if !ok {
|
|
return feishuConfigError(field + ".groupAllowFrom")
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(id, "oc_"):
|
|
groups = append(groups, id)
|
|
case strings.HasPrefix(id, "ou_"):
|
|
senders = append(senders, id)
|
|
default:
|
|
return feishuConfigError(field + ".groupAllowFrom")
|
|
}
|
|
}
|
|
// A sender-only legacy allowlist has different group admission semantics.
|
|
if len(senders) > 0 && (len(groups) == 0 || f["groupSenderAllowFrom"] != nil) {
|
|
return feishuConfigError(field + ".groupAllowFrom")
|
|
}
|
|
f["groupAllowFrom"] = groups
|
|
if len(senders) > 0 {
|
|
f["groupSenderAllowFrom"] = senders
|
|
}
|
|
}
|
|
for _, section := range []string{"accounts", "groups"} {
|
|
for id, value := range childMap(f, section) {
|
|
entry, ok := value.(map[string]interface{})
|
|
name := field + "." + section + "." + id
|
|
if !ok {
|
|
return feishuConfigError(name)
|
|
}
|
|
// Account/group overrides must be resolved explicitly, not flattened.
|
|
for _, key := range []string{"threadSession", "replyMode"} {
|
|
if _, ok := entry[key]; ok {
|
|
return feishuConfigError(name + "." + key)
|
|
}
|
|
}
|
|
if mention, ok := entry["requireMention"]; ok {
|
|
if _, ok := mention.(bool); !ok {
|
|
return feishuConfigError(name + ".requireMention")
|
|
}
|
|
}
|
|
if raw, exists := entry["streaming"]; exists {
|
|
streaming, ok := raw.(bool)
|
|
if !ok || streaming {
|
|
return feishuConfigError(name + ".streaming")
|
|
}
|
|
entry["streaming"] = map[string]interface{}{"mode": "off"}
|
|
}
|
|
if err := convertFeishuAccess(entry, name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func extractBundledFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig {
|
|
result := extractFeishuConfig(conf)
|
|
f := getChannelConfig(conf, "feishu")
|
|
result.Enabled = extractBoolValue(f["enabled"], true) && extractBoolValue(childMap(childMap(childMap(conf, "plugins"), "entries"), "feishu")["enabled"], true)
|
|
result.GroupSessionScope = extractStringValue(f["groupSessionScope"])
|
|
if result.GroupSessionScope == "" {
|
|
result.GroupSessionScope = "group"
|
|
}
|
|
result.Streaming = extractStringValue(childMap(f, "streaming")["mode"]) != "off"
|
|
result.GroupPolicy = extractStringValue(f["groupPolicy"])
|
|
if result.GroupPolicy == "" {
|
|
result.GroupPolicy = "allowlist"
|
|
}
|
|
if len(f) == 0 {
|
|
result.Streaming = false
|
|
result.RequireMention = "true"
|
|
} else if _, ok := f["requireMention"]; !ok {
|
|
result.RequireMention = "open"
|
|
}
|
|
accounts := childMap(f, "accounts")
|
|
ids := sortedChildKeys(accounts)
|
|
if (f["appId"] != nil || f["appSecret"] != nil) && !slices.Contains(ids, "default") {
|
|
ids = append([]string{"default"}, ids...)
|
|
}
|
|
result.Bots = []dto.AgentFeishuBot{}
|
|
for _, id := range ids {
|
|
effective := make(map[string]interface{}, len(f))
|
|
for k, v := range f {
|
|
effective[k] = v
|
|
}
|
|
for k, v := range childMap(accounts, id) {
|
|
effective[k] = v
|
|
}
|
|
bot := defaultFeishuBot()
|
|
bot.AccountID, bot.IsDefault = id, id == "default"
|
|
bot.Name = extractDisplayName(childMap(accounts, id), "", id)
|
|
bot.Enabled = extractBoolValue(childMap(accounts, id)["enabled"], true)
|
|
bot.AppID, bot.AppSecret = extractStringValue(effective["appId"]), extractStringValue(effective["appSecret"])
|
|
bot.DmPolicy = extractStringValue(effective["dmPolicy"])
|
|
if bot.DmPolicy == "" {
|
|
bot.DmPolicy = "pairing"
|
|
}
|
|
bot.AllowFrom = extractStringList(effective["allowFrom"])
|
|
result.Bots = append(result.Bots, bot)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func setBundledFeishuConfig(conf map[string]interface{}, req dto.AgentFeishuConfigUpdateReq) error {
|
|
if !slices.Contains([]string{"group", "group_sender", "group_topic", "group_topic_sender"}, req.GroupSessionScope) {
|
|
return feishuConfigError("groupSessionScope")
|
|
}
|
|
f := ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
|
|
accounts := childMap(f, "accounts")
|
|
seen := map[string]bool{}
|
|
appIDs := map[string]bool{}
|
|
for _, bot := range req.Bots {
|
|
if bot.AccountID == "" || bot.AccountID != strings.ToLower(strings.TrimSpace(bot.AccountID)) || seen[bot.AccountID] || appIDs[bot.AppID] || bot.AppID == "" || !slices.Contains([]string{"pairing", "open", "allowlist", "disabled"}, bot.DmPolicy) || (bot.DmPolicy == "allowlist" && len(bot.AllowFrom) == 0) {
|
|
return feishuConfigError("accounts")
|
|
}
|
|
seen[bot.AccountID] = true
|
|
appIDs[bot.AppID] = true
|
|
entry := childMap(accounts, bot.AccountID)
|
|
if _, exists := entry["appSecret"]; !exists && bot.AppSecret == "" {
|
|
entry["appSecret"] = f["appSecret"]
|
|
}
|
|
// SecretRef credentials cannot be represented by the form: keep them intact.
|
|
if _, secretRef := entry["appSecret"].(map[string]interface{}); !secretRef && bot.AppSecret == "" {
|
|
return feishuConfigError("accounts." + bot.AccountID + ".appSecret")
|
|
}
|
|
entry["appId"], entry["name"], entry["enabled"], entry["dmPolicy"] = bot.AppID, bot.Name, bot.Enabled, bot.DmPolicy
|
|
if bot.AppSecret != "" {
|
|
entry["appSecret"] = bot.AppSecret
|
|
}
|
|
switch bot.DmPolicy {
|
|
case "open":
|
|
entry["allowFrom"] = []string{"*"}
|
|
case "allowlist":
|
|
entry["allowFrom"] = bot.AllowFrom
|
|
default:
|
|
if len(bot.AllowFrom) > 0 {
|
|
entry["allowFrom"] = bot.AllowFrom
|
|
} else {
|
|
entry["allowFrom"] = []string{}
|
|
}
|
|
}
|
|
accounts[bot.AccountID] = entry
|
|
}
|
|
for id := range accounts {
|
|
if !seen[id] {
|
|
delete(accounts, id)
|
|
}
|
|
}
|
|
if !seen["default"] {
|
|
delete(f, "appId")
|
|
delete(f, "appSecret")
|
|
}
|
|
if id := extractStringValue(f["defaultAccount"]); id != "" && !seen[id] {
|
|
return feishuConfigError("defaultAccount")
|
|
}
|
|
if len(accounts) > 0 {
|
|
f["accounts"] = accounts
|
|
} else {
|
|
delete(f, "accounts")
|
|
}
|
|
f["enabled"], f["groupSessionScope"], f["groupPolicy"] = req.Enabled, req.GroupSessionScope, req.GroupPolicy
|
|
if req.RequireMention == "open" {
|
|
delete(f, "requireMention")
|
|
} else {
|
|
f["requireMention"] = req.RequireMention == "true"
|
|
}
|
|
if req.GroupPolicy == "allowlist" {
|
|
f["groupAllowFrom"] = req.GroupAllowFrom
|
|
} else {
|
|
delete(f, "groupAllowFrom")
|
|
}
|
|
streaming := ensureChildMap(f, "streaming")
|
|
streaming["mode"] = "off"
|
|
if req.Streaming {
|
|
streaming["mode"] = "partial"
|
|
}
|
|
selectBundledFeishu(conf, req.Enabled && hasEnabledBots(req.Bots))
|
|
return nil
|
|
}
|
|
|
|
// Write beside the original so validation and rename preserve the mounted filesystem and owner.
|
|
func stageFeishuConfig(configPath string, payload []byte) (string, error) {
|
|
info, err := os.Lstat(configPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return "", feishuConfigError("openclaw.json")
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-*.json")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
name := f.Name()
|
|
defer func() {
|
|
f.Close()
|
|
if err != nil {
|
|
os.Remove(name)
|
|
}
|
|
}()
|
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
err = f.Chown(int(stat.Uid), int(stat.Gid))
|
|
}
|
|
if err == nil {
|
|
err = f.Chmod(info.Mode().Perm())
|
|
}
|
|
if err == nil {
|
|
_, err = f.Write(payload)
|
|
}
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
if err == nil {
|
|
err = f.Close()
|
|
}
|
|
return name, err
|
|
}
|
|
|
|
func validateFeishuConfig(container, configPath string) error {
|
|
out, err := cmd.RunDockerExecWithStdout(time.Minute, container, "env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath), "openclaw", "config", "validate", "--json")
|
|
return feishuValidationResult(out, err)
|
|
}
|
|
|
|
func replaceFeishuConfig(configPath string, expected, payload []byte) error {
|
|
tmp, err := stageFeishuConfig(configPath, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp)
|
|
current, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytes.Equal(current, expected) {
|
|
return buserr.New("ErrFeishuConfigChanged")
|
|
}
|
|
return os.Rename(tmp, configPath)
|
|
}
|
|
|
|
func (a AgentService) SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error {
|
|
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !isOpenclawBundledFeishuVersion(install.Version) {
|
|
return buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
return buserr.New("TaskIsExecuting")
|
|
}
|
|
started := false
|
|
defer func() {
|
|
if !started {
|
|
feishuSwitches.Delete(agent.ConfigPath)
|
|
}
|
|
}()
|
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
|
return err
|
|
}
|
|
if !hasBundledFeishu(install.ContainerName) {
|
|
return buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
conf, err := readOpenclawConfig(agent.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !legacy {
|
|
return buserr.New("ErrFeishuAlreadySwitched")
|
|
}
|
|
if err := prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
|
|
return err
|
|
}
|
|
switchTask, err := task.NewTaskWithOps("Feishu", task.TaskUpdate, task.TaskScopeAI, req.TaskID, req.AgentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switchTask.AddSubTaskWithAlias("AgentFeishuSwitch", func(t *task.Task) error { return a.runFeishuSwitch(agent, install, req, t) }, nil)
|
|
started = true
|
|
go func() { defer feishuSwitches.Delete(agent.ConfigPath); executeAgentPluginTask(switchTask) }()
|
|
return nil
|
|
}
|
|
|
|
func (a AgentService) runFeishuSwitch(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuSwitchReq, t *task.Task) (result error) {
|
|
original, conf, err := readFeishuConfigBytes(agent.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !legacy {
|
|
return buserr.New("ErrFeishuAlreadySwitched")
|
|
}
|
|
image, err := feishuContainerImage(install.ContainerName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
image, err = bundledFeishuImage(image)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err = prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.MarshalIndent(conf, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
recovery, err := feishuRecoveryPayload(payload, configured)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Validate both candidates without invoking Doctor or mutating retained state.
|
|
for _, candidate := range [][]byte{payload, recovery} {
|
|
staged, stageErr := stageFeishuConfig(agent.ConfigPath, candidate)
|
|
if stageErr != nil {
|
|
return stageErr
|
|
}
|
|
validateErr := validateFeishuImageConfig(image, staged)
|
|
os.Remove(staged)
|
|
if validateErr != nil {
|
|
return validateErr
|
|
}
|
|
}
|
|
if _, err = compose.Stop(install.GetComposePath()); err != nil {
|
|
return err
|
|
}
|
|
replaced := false
|
|
defer func() {
|
|
if !replaced {
|
|
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
|
|
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
|
|
}
|
|
return
|
|
}
|
|
if result == nil {
|
|
return
|
|
}
|
|
if _, stopErr := compose.Stop(install.GetComposePath()); stopErr != nil {
|
|
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", stopErr))
|
|
return
|
|
}
|
|
if restoreErr := replaceFeishuConfig(agent.ConfigPath, payload, recovery); restoreErr != nil {
|
|
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", restoreErr))
|
|
return
|
|
}
|
|
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
|
|
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
|
|
return
|
|
}
|
|
if healthErr := verifyFeishuActivation(install.ContainerName, false); healthErr != nil {
|
|
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", healthErr))
|
|
return
|
|
}
|
|
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
|
|
}()
|
|
current, err := os.ReadFile(agent.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !bytes.Equal(current, original) {
|
|
return buserr.New("ErrFeishuConfigChanged")
|
|
}
|
|
backup, err := backupFeishuConfig(agent.ConfigPath, original)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
t.Log(i18n.GetWithName("AgentFeishuBackup", filepath.Base(backup)))
|
|
if err = replaceFeishuConfig(agent.ConfigPath, original, payload); err != nil {
|
|
return err
|
|
}
|
|
replaced = true
|
|
if _, err = compose.Operate(install.GetComposePath(), "start"); err != nil {
|
|
return err
|
|
}
|
|
enabled := configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
|
|
if err = verifyFeishuActivation(install.ContainerName, enabled); err != nil {
|
|
return err
|
|
}
|
|
if enabled {
|
|
probeFeishuConnection(install.ContainerName, t)
|
|
}
|
|
if configured {
|
|
t.Log(i18n.GetMsgByKey("AgentFeishuVerifyMessages"))
|
|
} else {
|
|
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func saveBundledFeishuConfig(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuConfigUpdateReq) error {
|
|
if !isOpenclawBundledFeishuVersion(install.Version) {
|
|
return buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
|
|
return buserr.New("TaskIsExecuting")
|
|
}
|
|
defer feishuSwitches.Delete(agent.ConfigPath)
|
|
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
|
|
return err
|
|
}
|
|
if !hasBundledFeishu(install.ContainerName) {
|
|
return buserr.New("ErrFeishuImageRequired")
|
|
}
|
|
original, err := os.ReadFile(agent.ConfigPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var conf map[string]interface{}
|
|
if err = json.Unmarshal(original, &conf); err != nil {
|
|
return err
|
|
}
|
|
if hasLegacyFeishu(conf, install.GetPath()) {
|
|
return buserr.New("ErrFeishuSwitchRequired")
|
|
}
|
|
if err = setBundledFeishuConfig(conf, req); err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.MarshalIndent(conf, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
staged, err := stageFeishuConfig(agent.ConfigPath, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(staged)
|
|
if err = validateFeishuConfig(install.ContainerName, staged); err != nil {
|
|
return err
|
|
}
|
|
return replaceFeishuConfig(agent.ConfigPath, original, payload)
|
|
}
|
|
|
|
func feishuProbeOK(output string) bool {
|
|
raw, err := extractEmbeddedJSON(output)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var status struct {
|
|
ChannelAccounts map[string][]struct {
|
|
Enabled bool `json:"enabled"`
|
|
Configured bool `json:"configured"`
|
|
Running bool `json:"running"`
|
|
Probe struct {
|
|
OK bool `json:"ok"`
|
|
} `json:"probe"`
|
|
} `json:"channelAccounts"`
|
|
}
|
|
if json.Unmarshal(raw, &status) != nil {
|
|
return false
|
|
}
|
|
accounts, ok := status.ChannelAccounts["feishu"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, account := range accounts {
|
|
if account.Enabled && (!account.Configured || !account.Running || !account.Probe.OK) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func feishuGatewayReady(output string, enabled bool) bool {
|
|
raw, err := extractEmbeddedJSON(output)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
var health struct {
|
|
OK bool `json:"ok"`
|
|
Plugins struct {
|
|
Loaded []string `json:"loaded"`
|
|
} `json:"plugins"`
|
|
}
|
|
return json.Unmarshal(raw, &health) == nil && health.OK && !slices.Contains(health.Plugins.Loaded, "openclaw-lark") && (!enabled || slices.Contains(health.Plugins.Loaded, "feishu"))
|
|
}
|