fix: prevent config clobbering
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
|
||||
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
|
||||
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
|
||||
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ enum ConfigStore {
|
||||
}
|
||||
|
||||
private static let overrideStore = OverrideStore()
|
||||
@MainActor private static var lastHash: String?
|
||||
|
||||
private static func isRemoteMode() async -> Bool {
|
||||
let overrides = await self.overrideStore.overrides
|
||||
@@ -75,6 +76,7 @@ enum ConfigStore {
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
self.lastHash = snap.hash
|
||||
return snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
} catch {
|
||||
return nil
|
||||
@@ -83,17 +85,24 @@ enum ConfigStore {
|
||||
|
||||
@MainActor
|
||||
private static func saveToGateway(_ root: [String: Any]) async throws {
|
||||
if self.lastHash == nil {
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to encode config.",
|
||||
])
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
if let baseHash = self.lastHash {
|
||||
params["baseHash"] = AnyCodable(baseHash)
|
||||
}
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
_ = await self.loadFromGateway()
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -12,6 +12,7 @@ extension ConnectionsStore {
|
||||
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
|
||||
: nil
|
||||
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
|
||||
self.configHash = snap.hash
|
||||
self.configLoaded = true
|
||||
|
||||
self.applyUIConfig(snap)
|
||||
@@ -34,10 +35,22 @@ extension ConnectionsStore {
|
||||
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
|
||||
}
|
||||
|
||||
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
|
||||
if let channels = snap.config?["channels"]?.dictionaryValue,
|
||||
let entry = channels[key]?.dictionaryValue {
|
||||
return entry
|
||||
}
|
||||
return snap.config?[key]?.dictionaryValue
|
||||
}
|
||||
|
||||
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
|
||||
let telegram = snap.config?["telegram"]?.dictionaryValue
|
||||
let telegram = self.resolveChannelConfig(snap, key: "telegram")
|
||||
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
|
||||
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
|
||||
let groups = telegram?["groups"]?.dictionaryValue
|
||||
let defaultGroup = groups?["*"]?.dictionaryValue
|
||||
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
|
||||
?? telegram?["requireMention"]?.boolValue
|
||||
?? true
|
||||
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
|
||||
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
|
||||
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
|
||||
@@ -46,7 +59,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
|
||||
let discord = snap.config?["discord"]?.dictionaryValue
|
||||
let discord = self.resolveChannelConfig(snap, key: "discord")
|
||||
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
|
||||
self.discordToken = discord?["token"]?.stringValue ?? ""
|
||||
|
||||
@@ -122,7 +135,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applySignalConfig(_ snap: ConfigSnapshot) {
|
||||
let signal = snap.config?["signal"]?.dictionaryValue
|
||||
let signal = self.resolveChannelConfig(snap, key: "signal")
|
||||
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
|
||||
self.signalAccount = signal?["account"]?.stringValue ?? ""
|
||||
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
|
||||
@@ -139,7 +152,7 @@ extension ConnectionsStore {
|
||||
}
|
||||
|
||||
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
|
||||
let imessage = snap.config?["imessage"]?.dictionaryValue
|
||||
let imessage = self.resolveChannelConfig(snap, key: "imessage")
|
||||
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
|
||||
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
|
||||
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
|
||||
@@ -150,6 +163,15 @@ extension ConnectionsStore {
|
||||
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
|
||||
}
|
||||
|
||||
private func channelConfigRoot(for key: String) -> [String: Any] {
|
||||
if let channels = self.configRoot["channels"] as? [String: Any],
|
||||
let entry = channels[key] as? [String: Any]
|
||||
{
|
||||
return entry
|
||||
}
|
||||
return self.configRoot[key] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
func saveTelegramConfig() async {
|
||||
guard !self.isSavingConfig else { return }
|
||||
self.isSavingConfig = true
|
||||
@@ -158,30 +180,24 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
|
||||
let token = self.trimmed(self.telegramToken)
|
||||
if token.isEmpty {
|
||||
telegram.removeValue(forKey: "botToken")
|
||||
} else {
|
||||
telegram["botToken"] = token
|
||||
var telegram: [String: Any] = [:]
|
||||
if !self.isTelegramTokenLocked {
|
||||
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
|
||||
}
|
||||
|
||||
telegram["requireMention"] = self.telegramRequireMention
|
||||
|
||||
telegram["requireMention"] = NSNull()
|
||||
telegram["groups"] = [
|
||||
"*": [
|
||||
"requireMention": self.telegramRequireMention,
|
||||
],
|
||||
]
|
||||
let allow = self.splitCsv(self.telegramAllowFrom)
|
||||
if allow.isEmpty {
|
||||
telegram.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
telegram["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&telegram, key: "allowFrom", values: allow)
|
||||
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
|
||||
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
|
||||
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
|
||||
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
|
||||
|
||||
self.setSection("telegram", payload: telegram)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("telegram", payload: telegram)
|
||||
}
|
||||
|
||||
func saveDiscordConfig() async {
|
||||
@@ -192,9 +208,9 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
let discord = self.buildDiscordConfig()
|
||||
self.setSection("discord", payload: discord)
|
||||
await self.persistConfig()
|
||||
let base = self.channelConfigRoot(for: "discord")
|
||||
let discord = self.buildDiscordPatch(base: base)
|
||||
await self.persistChannelPatch("discord", payload: discord)
|
||||
}
|
||||
|
||||
func saveSignalConfig() async {
|
||||
@@ -205,42 +221,23 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
|
||||
if self.signalEnabled {
|
||||
signal.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
signal["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
|
||||
if self.signalAutoStart {
|
||||
signal.removeValue(forKey: "autoStart")
|
||||
} else {
|
||||
signal["autoStart"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
|
||||
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
|
||||
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
|
||||
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
|
||||
|
||||
var signal: [String: Any] = [:]
|
||||
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "account", value: self.signalAccount)
|
||||
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
|
||||
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
|
||||
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
|
||||
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
|
||||
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
|
||||
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
|
||||
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
|
||||
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
|
||||
let allow = self.splitCsv(self.signalAllowFrom)
|
||||
if allow.isEmpty {
|
||||
signal.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
signal["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&signal, key: "allowFrom", values: allow)
|
||||
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
|
||||
|
||||
self.setSection("signal", payload: signal)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("signal", payload: signal)
|
||||
}
|
||||
|
||||
func saveIMessageConfig() async {
|
||||
@@ -251,147 +248,168 @@ extension ConnectionsStore {
|
||||
await self.loadConfig()
|
||||
}
|
||||
|
||||
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
|
||||
if self.imessageEnabled {
|
||||
imessage.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
imessage["enabled"] = false
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
var imessage: [String: Any] = [:]
|
||||
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
|
||||
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
|
||||
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
|
||||
|
||||
let service = self.trimmed(self.imessageService)
|
||||
if service.isEmpty || service == "auto" {
|
||||
imessage.removeValue(forKey: "service")
|
||||
imessage["service"] = NSNull()
|
||||
} else {
|
||||
imessage["service"] = service
|
||||
}
|
||||
|
||||
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
|
||||
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
|
||||
|
||||
let allow = self.splitCsv(self.imessageAllowFrom)
|
||||
if allow.isEmpty {
|
||||
imessage.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
imessage["allowFrom"] = allow
|
||||
}
|
||||
self.setPatchList(&imessage, key: "allowFrom", values: allow)
|
||||
|
||||
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
|
||||
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
self.setPatchBool(
|
||||
&imessage,
|
||||
key: "includeAttachments",
|
||||
value: self.imessageIncludeAttachments,
|
||||
defaultValue: false)
|
||||
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
|
||||
|
||||
self.setSection("imessage", payload: imessage)
|
||||
await self.persistConfig()
|
||||
await self.persistChannelPatch("imessage", payload: imessage)
|
||||
}
|
||||
|
||||
private func buildDiscordConfig() -> [String: Any] {
|
||||
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
|
||||
if self.discordEnabled {
|
||||
discord.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
discord["enabled"] = false
|
||||
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
|
||||
var discord: [String: Any] = [:]
|
||||
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
|
||||
if !self.isDiscordTokenLocked {
|
||||
self.setPatchString(&discord, key: "token", value: self.discordToken)
|
||||
}
|
||||
self.setOptionalString(&discord, key: "token", value: self.discordToken)
|
||||
|
||||
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
|
||||
if let dm = self.buildDiscordDmPatch() {
|
||||
discord["dm"] = dm
|
||||
} else {
|
||||
discord.removeValue(forKey: "dm")
|
||||
discord["dm"] = NSNull()
|
||||
}
|
||||
|
||||
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
|
||||
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
|
||||
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
|
||||
|
||||
let replyToMode = self.trimmed(self.discordReplyToMode)
|
||||
if replyToMode.isEmpty || replyToMode == "off" {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
} else if ["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = replyToMode
|
||||
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
|
||||
discord["replyToMode"] = NSNull()
|
||||
} else {
|
||||
discord.removeValue(forKey: "replyToMode")
|
||||
discord["replyToMode"] = replyToMode
|
||||
}
|
||||
|
||||
if let guilds = self.buildDiscordGuildsConfig() {
|
||||
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
|
||||
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
|
||||
discord["guilds"] = guilds
|
||||
} else {
|
||||
discord.removeValue(forKey: "guilds")
|
||||
discord["guilds"] = NSNull()
|
||||
}
|
||||
|
||||
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
|
||||
if let actions = self.buildDiscordActionsPatch() {
|
||||
discord["actions"] = actions
|
||||
} else {
|
||||
discord.removeValue(forKey: "actions")
|
||||
discord["actions"] = NSNull()
|
||||
}
|
||||
|
||||
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
|
||||
if let slash = self.buildDiscordSlashPatch() {
|
||||
discord["slashCommand"] = slash
|
||||
} else {
|
||||
discord.removeValue(forKey: "slashCommand")
|
||||
discord["slashCommand"] = NSNull()
|
||||
}
|
||||
|
||||
return discord
|
||||
}
|
||||
|
||||
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var dm = base
|
||||
if self.discordDmEnabled {
|
||||
dm.removeValue(forKey: "enabled")
|
||||
} else {
|
||||
dm["enabled"] = false
|
||||
}
|
||||
private func buildDiscordDmPatch() -> [String: Any]? {
|
||||
var dm: [String: Any] = [:]
|
||||
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
|
||||
let allow = self.splitCsv(self.discordAllowFrom)
|
||||
if allow.isEmpty {
|
||||
dm.removeValue(forKey: "allowFrom")
|
||||
} else {
|
||||
dm["allowFrom"] = allow
|
||||
}
|
||||
|
||||
if self.discordGroupEnabled {
|
||||
dm["groupEnabled"] = true
|
||||
} else {
|
||||
dm.removeValue(forKey: "groupEnabled")
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "allowFrom", values: allow)
|
||||
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
|
||||
let groupChannels = self.splitCsv(self.discordGroupChannels)
|
||||
if groupChannels.isEmpty {
|
||||
dm.removeValue(forKey: "groupChannels")
|
||||
} else {
|
||||
dm["groupChannels"] = groupChannels
|
||||
}
|
||||
|
||||
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
|
||||
return dm.isEmpty ? nil : dm
|
||||
}
|
||||
|
||||
private func buildDiscordGuildsConfig() -> [String: Any]? {
|
||||
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { return }
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if !slug.isEmpty { payload["slug"] = slug }
|
||||
if entry.requireMention { payload["requireMention"] = true }
|
||||
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
if !users.isEmpty { payload["users"] = users }
|
||||
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { return }
|
||||
var channelPayload: [String: Any] = [:]
|
||||
if !channel.allow { channelPayload["allow"] = false }
|
||||
if channel.requireMention { channelPayload["requireMention"] = true }
|
||||
channelsResult[channelKey] = channelPayload
|
||||
}
|
||||
if !channels.isEmpty { payload["channels"] = channels }
|
||||
result[key] = payload
|
||||
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
|
||||
if self.discordGuilds.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
return guilds.isEmpty ? nil : guilds
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for entry in self.discordGuilds {
|
||||
let key = self.trimmed(entry.key)
|
||||
guard !key.isEmpty else { continue }
|
||||
formKeys.insert(key)
|
||||
let baseGuild = base[key] as? [String: Any] ?? [:]
|
||||
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var actions = base
|
||||
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
|
||||
var payload: [String: Any] = [:]
|
||||
let slug = self.trimmed(entry.slug)
|
||||
if slug.isEmpty {
|
||||
payload["slug"] = NSNull()
|
||||
} else {
|
||||
payload["slug"] = slug
|
||||
}
|
||||
if entry.requireMention {
|
||||
payload["requireMention"] = true
|
||||
} else {
|
||||
payload["requireMention"] = NSNull()
|
||||
}
|
||||
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
|
||||
payload["reactionNotifications"] = entry.reactionNotifications
|
||||
} else {
|
||||
payload["reactionNotifications"] = NSNull()
|
||||
}
|
||||
let users = self.splitCsv(entry.users)
|
||||
self.setPatchList(&payload, key: "users", values: users)
|
||||
|
||||
let baseChannels = base["channels"] as? [String: Any] ?? [:]
|
||||
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
|
||||
payload["channels"] = channels
|
||||
} else {
|
||||
payload["channels"] = NSNull()
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
|
||||
if forms.isEmpty {
|
||||
return NSNull()
|
||||
}
|
||||
var patch: [String: Any] = [:]
|
||||
let baseKeys = Set(base.keys)
|
||||
var formKeys = Set<String>()
|
||||
for channel in forms {
|
||||
let channelKey = self.trimmed(channel.key)
|
||||
guard !channelKey.isEmpty else { continue }
|
||||
formKeys.insert(channelKey)
|
||||
var channelPayload: [String: Any] = [:]
|
||||
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
|
||||
self.setPatchBool(
|
||||
&channelPayload,
|
||||
key: "requireMention",
|
||||
value: channel.requireMention,
|
||||
defaultValue: false)
|
||||
patch[channelKey] = channelPayload
|
||||
}
|
||||
for key in baseKeys.subtracting(formKeys) {
|
||||
patch[key] = NSNull()
|
||||
}
|
||||
return patch.isEmpty ? NSNull() : patch
|
||||
}
|
||||
|
||||
private func buildDiscordActionsPatch() -> [String: Any]? {
|
||||
var actions: [String: Any] = [:]
|
||||
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
|
||||
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
|
||||
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
|
||||
@@ -410,52 +428,44 @@ extension ConnectionsStore {
|
||||
return actions.isEmpty ? nil : actions
|
||||
}
|
||||
|
||||
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
|
||||
var slash = base
|
||||
if self.discordSlashEnabled {
|
||||
slash["enabled"] = true
|
||||
} else {
|
||||
slash.removeValue(forKey: "enabled")
|
||||
}
|
||||
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
if self.discordSlashEphemeral {
|
||||
slash.removeValue(forKey: "ephemeral")
|
||||
} else {
|
||||
slash["ephemeral"] = false
|
||||
}
|
||||
private func buildDiscordSlashPatch() -> [String: Any]? {
|
||||
var slash: [String: Any] = [:]
|
||||
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
|
||||
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
|
||||
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
|
||||
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
|
||||
return slash.isEmpty ? nil : slash
|
||||
}
|
||||
|
||||
private func persistConfig() async {
|
||||
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
|
||||
do {
|
||||
guard let baseHash = self.configHash else {
|
||||
self.configStatus = "Config hash missing; reload and retry."
|
||||
return
|
||||
}
|
||||
let data = try JSONSerialization.data(
|
||||
withJSONObject: self.configRoot,
|
||||
withJSONObject: ["channels": [channelId: payload]],
|
||||
options: [.prettyPrinted, .sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
self.configStatus = "Failed to encode config."
|
||||
return
|
||||
}
|
||||
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
|
||||
let params: [String: AnyCodable] = [
|
||||
"raw": AnyCodable(raw),
|
||||
"baseHash": AnyCodable(baseHash),
|
||||
]
|
||||
_ = try await GatewayConnection.shared.requestRaw(
|
||||
method: .configSet,
|
||||
method: .configPatch,
|
||||
params: params,
|
||||
timeoutMs: 10000)
|
||||
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
|
||||
await self.loadConfig()
|
||||
await self.refresh(probe: true)
|
||||
} catch {
|
||||
self.configStatus = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func setSection(_ key: String, payload: [String: Any]) {
|
||||
if payload.isEmpty {
|
||||
self.configRoot.removeValue(forKey: key)
|
||||
} else {
|
||||
self.configRoot[key] = payload
|
||||
}
|
||||
}
|
||||
|
||||
private func stringList(from values: [AnyCodable]?) -> String {
|
||||
guard let values else { return "" }
|
||||
let strings = values.compactMap { entry -> String? in
|
||||
@@ -492,25 +502,29 @@ extension ConnectionsStore {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
} else if let number = Double(trimmed) {
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
if let number = Double(trimmed) {
|
||||
target[key] = number
|
||||
} else {
|
||||
target[key] = NSNull()
|
||||
}
|
||||
}
|
||||
|
||||
private func setOptionalInt(
|
||||
private func setPatchInt(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: String,
|
||||
@@ -518,26 +532,39 @@ extension ConnectionsStore {
|
||||
{
|
||||
let trimmed = self.trimmed(value)
|
||||
if trimmed.isEmpty {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
guard let number = Int(trimmed) else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
let isValid = allowZero ? number >= 0 : number > 0
|
||||
guard isValid else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = NSNull()
|
||||
return
|
||||
}
|
||||
target[key] = number
|
||||
}
|
||||
|
||||
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
|
||||
if value {
|
||||
target[key] = true
|
||||
private func setPatchBool(
|
||||
_ target: inout [String: Any],
|
||||
key: String,
|
||||
value: Bool,
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target.removeValue(forKey: key)
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
|
||||
if values.isEmpty {
|
||||
target[key] = NSNull()
|
||||
} else {
|
||||
target[key] = values
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +575,7 @@ extension ConnectionsStore {
|
||||
defaultValue: Bool)
|
||||
{
|
||||
if value == defaultValue {
|
||||
actions.removeValue(forKey: key)
|
||||
actions[key] = NSNull()
|
||||
} else {
|
||||
actions[key] = value
|
||||
}
|
||||
|
||||
@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
|
||||
let path: String?
|
||||
let exists: Bool?
|
||||
let raw: String?
|
||||
let hash: String?
|
||||
let parsed: AnyCodable?
|
||||
let valid: Bool?
|
||||
let config: [String: AnyCodable]?
|
||||
@@ -307,6 +308,7 @@ final class ConnectionsStore {
|
||||
var pollTask: Task<Void, Never>?
|
||||
var configRoot: [String: Any] = [:]
|
||||
var configLoaded = false
|
||||
var configHash: String?
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
|
||||
@@ -55,6 +55,7 @@ actor GatewayConnection {
|
||||
case channelsStatus = "channels.status"
|
||||
case configGet = "config.get"
|
||||
case configSet = "config.set"
|
||||
case configPatch = "config.patch"
|
||||
case wizardStart = "wizard.start"
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
|
||||
@@ -579,8 +579,12 @@ Subcommands:
|
||||
|
||||
Common RPCs:
|
||||
- `config.apply` (validate + write config + restart + wake)
|
||||
- `config.patch` (merge a partial update without clobbering unrelated keys)
|
||||
- `update.run` (run update + restart + wake)
|
||||
|
||||
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
|
||||
`config.get` if a config already exists.
|
||||
|
||||
## Models
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
@@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload for the entire config
|
||||
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
|
||||
- `sessionKey` (optional) — last active session key for the wake-up ping
|
||||
- `restartDelayMs` (optional) — delay before restart (default 2000)
|
||||
|
||||
Example (via `gateway call`):
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.apply --params '{
|
||||
"raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
|
||||
"restartDelayMs": 1000
|
||||
}'
|
||||
```
|
||||
|
||||
## Partial updates (RPC)
|
||||
|
||||
Use `config.patch` to merge a partial update into the existing config without clobbering
|
||||
unrelated keys. It applies JSON merge patch semantics:
|
||||
- objects merge recursively
|
||||
- `null` deletes a key
|
||||
- arrays replace
|
||||
|
||||
Params:
|
||||
- `raw` (string) — JSON5 payload containing just the keys to change
|
||||
- `baseHash` (required) — config hash from `config.get`
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
clawdbot gateway call config.get --params '{}' # capture payload.hash
|
||||
clawdbot gateway call config.patch --params '{
|
||||
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
|
||||
"baseHash": "<hash-from-config.get>"
|
||||
}'
|
||||
```
|
||||
|
||||
## Minimal config (recommended starting point)
|
||||
|
||||
```json5
|
||||
|
||||
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
## What it can do (today)
|
||||
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
|
||||
- Stream tool calls + live tool output cards in Chat (agent events)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.set`)
|
||||
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
|
||||
- Instances: presence list + refresh (`system-presence`)
|
||||
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
@@ -38,6 +38,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
|
||||
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
|
||||
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
|
||||
|
||||
@@ -6,7 +6,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createClawdbotTools } from "./clawdbot-tools.js";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(async () => ({ ok: true })),
|
||||
callGatewayTool: vi.fn(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return { hash: "hash-1" };
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("gateway tool", () => {
|
||||
@@ -71,11 +76,13 @@ describe("gateway tool", () => {
|
||||
raw,
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"config.apply",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
raw: raw.trim(),
|
||||
baseHash: "hash-1",
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
@@ -33,6 +35,7 @@ const GatewayToolSchema = Type.Object({
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
// config.apply
|
||||
raw: Type.Optional(Type.String()),
|
||||
baseHash: Type.Optional(Type.String()),
|
||||
// config.apply, update.run
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
@@ -125,6 +128,24 @@ export function createGatewayTool(opts?: {
|
||||
}
|
||||
if (action === "config.apply") {
|
||||
const raw = readStringParam(params, "raw", { required: true });
|
||||
let baseHash = readStringParam(params, "baseHash");
|
||||
if (!baseHash) {
|
||||
const snapshot = await callGatewayTool("config.get", gatewayOpts, {});
|
||||
if (snapshot && typeof snapshot === "object") {
|
||||
const hash = (snapshot as { hash?: unknown }).hash;
|
||||
if (typeof hash === "string" && hash.trim()) {
|
||||
baseHash = hash.trim();
|
||||
} else {
|
||||
const rawSnapshot = (snapshot as { raw?: unknown }).raw;
|
||||
if (typeof rawSnapshot === "string") {
|
||||
baseHash = crypto
|
||||
.createHash("sha256")
|
||||
.update(rawSnapshot)
|
||||
.digest("hex");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const sessionKey =
|
||||
typeof params.sessionKey === "string" && params.sessionKey.trim()
|
||||
? params.sessionKey.trim()
|
||||
@@ -137,6 +158,7 @@ export function createGatewayTool(opts?: {
|
||||
: undefined;
|
||||
const result = await callGatewayTool("config.apply", gatewayOpts, {
|
||||
raw,
|
||||
baseHash,
|
||||
sessionKey,
|
||||
note,
|
||||
restartDelayMs,
|
||||
|
||||
@@ -51,6 +51,10 @@ const SHELL_ENV_EXPECTED_KEYS = [
|
||||
|
||||
export type ParseConfigJson5Result = { ok: true; parsed: unknown } | { ok: false; error: string };
|
||||
|
||||
function hashConfigRaw(raw: string | null): string {
|
||||
return crypto.createHash("sha256").update(raw ?? "").digest("hex");
|
||||
}
|
||||
|
||||
export type ConfigIoDeps = {
|
||||
fs?: typeof fs;
|
||||
json5?: typeof JSON5;
|
||||
@@ -263,6 +267,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const exists = deps.fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const hash = hashConfigRaw(null);
|
||||
const config = applyTalkApiKey(
|
||||
applyModelDefaults(
|
||||
applyContextPruningDefaults(applySessionDefaults(applyMessageDefaults({}))),
|
||||
@@ -276,6 +281,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config,
|
||||
hash,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
@@ -283,6 +289,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
|
||||
try {
|
||||
const raw = deps.fs.readFileSync(configPath, "utf-8");
|
||||
const hash = hashConfigRaw(raw);
|
||||
const parsedRes = parseConfigJson5(raw, deps.json5);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
@@ -292,6 +299,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
hash,
|
||||
issues: [{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
@@ -316,6 +324,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: parsedRes.parsed,
|
||||
valid: false,
|
||||
config: {},
|
||||
hash,
|
||||
issues: [{ path: "", message }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
@@ -338,6 +347,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: parsedRes.parsed,
|
||||
valid: false,
|
||||
config: resolvedConfig,
|
||||
hash,
|
||||
issues: validated.issues,
|
||||
legacyIssues,
|
||||
};
|
||||
@@ -363,6 +373,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
),
|
||||
),
|
||||
),
|
||||
hash,
|
||||
issues: [],
|
||||
legacyIssues,
|
||||
};
|
||||
@@ -374,6 +385,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
parsed: {},
|
||||
valid: false,
|
||||
config: {},
|
||||
hash: hashConfigRaw(null),
|
||||
issues: [{ path: "", message: `read failed: ${String(err)}` }],
|
||||
legacyIssues: [],
|
||||
};
|
||||
|
||||
28
src/config/merge-patch.ts
Normal file
28
src/config/merge-patch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
type PlainObject = Record<string, unknown>;
|
||||
|
||||
function isPlainObject(value: unknown): value is PlainObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function applyMergePatch(base: unknown, patch: unknown): unknown {
|
||||
if (!isPlainObject(patch)) {
|
||||
return patch;
|
||||
}
|
||||
|
||||
const result: PlainObject = isPlainObject(base) ? { ...base } : {};
|
||||
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete result[key];
|
||||
continue;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const baseValue = result[key];
|
||||
result[key] = applyMergePatch(isPlainObject(baseValue) ? baseValue : {}, value);
|
||||
continue;
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export type ConfigFileSnapshot = {
|
||||
parsed: unknown;
|
||||
valid: boolean;
|
||||
config: ClawdbotConfig;
|
||||
hash?: string;
|
||||
issues: ConfigValidationIssue[];
|
||||
legacyIssues: LegacyConfigIssue[];
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
ConfigApplyParamsSchema,
|
||||
type ConfigGetParams,
|
||||
ConfigGetParamsSchema,
|
||||
type ConfigPatchParams,
|
||||
ConfigPatchParamsSchema,
|
||||
type ConfigSchemaParams,
|
||||
ConfigSchemaParamsSchema,
|
||||
type ConfigSchemaResponse,
|
||||
@@ -201,6 +203,7 @@ export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
|
||||
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
|
||||
export const validateConfigPatchParams = ajv.compile<ConfigPatchParams>(ConfigPatchParamsSchema);
|
||||
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(ConfigSchemaParamsSchema);
|
||||
export const validateWizardStartParams = ajv.compile<WizardStartParams>(WizardStartParamsSchema);
|
||||
export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNextParamsSchema);
|
||||
@@ -272,6 +275,7 @@ export {
|
||||
ConfigGetParamsSchema,
|
||||
ConfigSetParamsSchema,
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigPatchParamsSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
WizardStartParamsSchema,
|
||||
@@ -338,6 +342,7 @@ export type {
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
ConfigApplyParams,
|
||||
ConfigPatchParams,
|
||||
ConfigSchemaParams,
|
||||
ConfigSchemaResponse,
|
||||
WizardStartParams,
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: fal
|
||||
export const ConfigSetParamsSchema = Type.Object(
|
||||
{
|
||||
raw: NonEmptyString,
|
||||
baseHash: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
@@ -14,6 +15,7 @@ export const ConfigSetParamsSchema = Type.Object(
|
||||
export const ConfigApplyParamsSchema = Type.Object(
|
||||
{
|
||||
raw: NonEmptyString,
|
||||
baseHash: Type.Optional(NonEmptyString),
|
||||
sessionKey: Type.Optional(Type.String()),
|
||||
note: Type.Optional(Type.String()),
|
||||
restartDelayMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
@@ -21,6 +23,14 @@ export const ConfigApplyParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigPatchParamsSchema = Type.Object(
|
||||
{
|
||||
raw: NonEmptyString,
|
||||
baseHash: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
export const UpdateRunParamsSchema = Type.Object(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import {
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigPatchParamsSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
ConfigSetParamsSchema,
|
||||
@@ -131,6 +132,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
ConfigApplyParams: ConfigApplyParamsSchema,
|
||||
ConfigPatchParams: ConfigPatchParamsSchema,
|
||||
ConfigSchemaParams: ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponse: ConfigSchemaResponseSchema,
|
||||
WizardStartParams: WizardStartParamsSchema,
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
import type {
|
||||
ConfigApplyParamsSchema,
|
||||
ConfigGetParamsSchema,
|
||||
ConfigPatchParamsSchema,
|
||||
ConfigSchemaParamsSchema,
|
||||
ConfigSchemaResponseSchema,
|
||||
ConfigSetParamsSchema,
|
||||
@@ -124,6 +125,7 @@ export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type ConfigApplyParams = Static<typeof ConfigApplyParamsSchema>;
|
||||
export type ConfigPatchParams = Static<typeof ConfigPatchParamsSchema>;
|
||||
export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>;
|
||||
export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>;
|
||||
export type WizardStartParams = Static<typeof WizardStartParamsSchema>;
|
||||
|
||||
@@ -7,17 +7,63 @@ import {
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { applyLegacyMigrations } from "../config/legacy.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import { buildConfigSchema } from "../config/schema.js";
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
formatValidationErrors,
|
||||
validateConfigGetParams,
|
||||
validateConfigPatchParams,
|
||||
validateConfigSchemaParams,
|
||||
validateConfigSetParams,
|
||||
} from "./protocol/index.js";
|
||||
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
const raw = (params as { baseHash?: unknown })?.baseHash;
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function requireConfigBaseHash(
|
||||
params: unknown,
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
||||
): { ok: true } | { ok: false; error: { code: string; message: string } } {
|
||||
if (!snapshot.exists) return { ok: true };
|
||||
if (typeof snapshot.raw !== "string" || !snapshot.hash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config base hash unavailable; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
const baseHash = resolveBaseHash(params);
|
||||
if (!baseHash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config base hash required; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (baseHash !== snapshot.hash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
_ctx,
|
||||
_nodeId,
|
||||
@@ -85,6 +131,11 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const guard = requireConfigBaseHash(params, snapshot);
|
||||
if (!guard.ok) {
|
||||
return { ok: false, error: guard.error };
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
return {
|
||||
@@ -126,6 +177,87 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "config.patch": {
|
||||
if (!validateConfigPatchParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const guard = requireConfigBaseHash(params, snapshot);
|
||||
if (!guard.ok) {
|
||||
return { ok: false, error: guard.error };
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config; fix before patching",
|
||||
},
|
||||
};
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config.patch params: raw (string) required",
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedRes = parseConfigJson5(rawValue);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: parsedRes.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
!parsedRes.parsed ||
|
||||
typeof parsedRes.parsed !== "object" ||
|
||||
Array.isArray(parsedRes.parsed)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config.patch raw must be an object",
|
||||
},
|
||||
};
|
||||
}
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
const validated = validateConfigObject(resolved);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config",
|
||||
details: { issues: validated.issues },
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
path: CONFIG_PATH_CLAWDBOT,
|
||||
config: validated.config,
|
||||
}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const BASE_METHODS = [
|
||||
"config.get",
|
||||
"config.set",
|
||||
"config.apply",
|
||||
"config.patch",
|
||||
"config.schema",
|
||||
"wizard.start",
|
||||
"wizard.next",
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { applyLegacyMigrations } from "../../config/legacy.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import { buildConfigSchema } from "../../config/schema.js";
|
||||
import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js";
|
||||
import {
|
||||
@@ -21,11 +23,62 @@ import {
|
||||
formatValidationErrors,
|
||||
validateConfigApplyParams,
|
||||
validateConfigGetParams,
|
||||
validateConfigPatchParams,
|
||||
validateConfigSchemaParams,
|
||||
validateConfigSetParams,
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
const raw = (params as { baseHash?: unknown })?.baseHash;
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function requireConfigBaseHash(
|
||||
params: unknown,
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
||||
respond: (ok: boolean, payload?: unknown, error?: unknown) => void,
|
||||
): boolean {
|
||||
if (!snapshot.exists) return true;
|
||||
if (typeof snapshot.raw !== "string" || !snapshot.hash) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"config base hash unavailable; re-run config.get and retry",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const baseHash = resolveBaseHash(params);
|
||||
if (!baseHash) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"config base hash required; re-run config.get and retry",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (baseHash !== snapshot.hash) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"config changed since last load; re-run config.get and retry",
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const configHandlers: GatewayRequestHandlers = {
|
||||
"config.get": async ({ params, respond }) => {
|
||||
if (!validateConfigGetParams(params)) {
|
||||
@@ -93,6 +146,10 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!requireConfigBaseHash(params, snapshot, respond)) {
|
||||
return;
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
respond(
|
||||
@@ -129,6 +186,80 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"config.patch": async ({ params, respond }) => {
|
||||
if (!validateConfigPatchParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!requireConfigBaseHash(params, snapshot, respond)) {
|
||||
return;
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config; fix before patching"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"invalid config.patch params: raw (string) required",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const parsedRes = parseConfigJson5(rawValue);
|
||||
if (!parsedRes.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
|
||||
return;
|
||||
}
|
||||
if (!parsedRes.parsed || typeof parsedRes.parsed !== "object" || Array.isArray(parsedRes.parsed)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "config.patch raw must be an object"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
const validated = validateConfigObject(resolved);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
|
||||
details: { issues: validated.issues },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH_CLAWDBOT,
|
||||
config: validated.config,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"config.apply": async ({ params, respond }) => {
|
||||
if (!validateConfigApplyParams(params)) {
|
||||
respond(
|
||||
@@ -141,6 +272,10 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!requireConfigBaseHash(params, snapshot, respond)) {
|
||||
return;
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
respond(
|
||||
|
||||
176
src/gateway/server.config-patch.test.ts
Normal file
176
src/gateway/server.config-patch.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { connectOk, onceMessage, startServerWithClient } from "./test-helpers.js";
|
||||
|
||||
describe("gateway config.patch", () => {
|
||||
it("merges patches without clobbering unrelated config", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: setId,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { botToken: "token-1" } },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId);
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const getId = "req-get";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: getId,
|
||||
method: "config.get",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string } }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === getId,
|
||||
);
|
||||
expect(getRes.ok).toBe(true);
|
||||
const baseHash = getRes.payload?.hash;
|
||||
expect(typeof baseHash).toBe("string");
|
||||
|
||||
const patchId = "req-patch";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: patchId,
|
||||
method: "config.patch",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
baseHash,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const patchRes = await onceMessage<{ ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === patchId,
|
||||
);
|
||||
expect(patchRes.ok).toBe(true);
|
||||
|
||||
const get2Id = "req-get-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: get2Id,
|
||||
method: "config.get",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
const get2Res = await onceMessage<{
|
||||
ok: boolean;
|
||||
payload?: { config?: { gateway?: { mode?: string }; channels?: { telegram?: { botToken?: string } } } };
|
||||
}>(ws, (o) => o.type === "res" && o.id === get2Id);
|
||||
expect(get2Res.ok).toBe(true);
|
||||
expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("requires base hash when config exists", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: setId,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "local" },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId);
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const patchId = "req-patch-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: patchId,
|
||||
method: "config.patch",
|
||||
params: {
|
||||
raw: JSON.stringify({ gateway: { mode: "remote" } }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const patchRes = await onceMessage<{ ok: boolean; error?: { message?: string } }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === patchId,
|
||||
);
|
||||
expect(patchRes.ok).toBe(false);
|
||||
expect(patchRes.error?.message).toContain("base hash");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it("requires base hash for config.set when config exists", async () => {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const setId = "req-set-3";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: setId,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "local" },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setRes = await onceMessage<{ ok: boolean }>(ws, (o) => o.type === "res" && o.id === setId);
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const set2Id = "req-set-4";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: set2Id,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "remote" },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const set2Res = await onceMessage<{ ok: boolean; error?: { message?: string } }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === set2Id,
|
||||
);
|
||||
expect(set2Res.ok).toBe(false);
|
||||
expect(set2Res.error?.message).toContain("base hash");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -133,10 +133,12 @@ describe("applyConfigSnapshot", () => {
|
||||
const state = createState();
|
||||
applyConfigSnapshot(state, {
|
||||
config: {
|
||||
telegram: {},
|
||||
discord: {},
|
||||
signal: {},
|
||||
imessage: {},
|
||||
channels: {
|
||||
telegram: {},
|
||||
discord: {},
|
||||
signal: {},
|
||||
imessage: {},
|
||||
},
|
||||
},
|
||||
valid: true,
|
||||
issues: [],
|
||||
@@ -171,7 +173,7 @@ describe("updateConfigFormValue", () => {
|
||||
it("seeds from snapshot when form is null", () => {
|
||||
const state = createState();
|
||||
state.configSnapshot = {
|
||||
config: { telegram: { botToken: "t" }, gateway: { mode: "local" } },
|
||||
config: { channels: { telegram: { botToken: "t" } }, gateway: { mode: "local" } },
|
||||
valid: true,
|
||||
issues: [],
|
||||
raw: "{}",
|
||||
@@ -181,7 +183,7 @@ describe("updateConfigFormValue", () => {
|
||||
|
||||
expect(state.configFormDirty).toBe(true);
|
||||
expect(state.configForm).toEqual({
|
||||
telegram: { botToken: "t" },
|
||||
channels: { telegram: { botToken: "t" } },
|
||||
gateway: { mode: "local", port: 18789 },
|
||||
});
|
||||
});
|
||||
@@ -212,11 +214,15 @@ describe("applyConfig", () => {
|
||||
state.applySessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
state.configFormMode = "raw";
|
||||
state.configRaw = "{\n agent: { workspace: \"~/clawd\" }\n}\n";
|
||||
state.configSnapshot = {
|
||||
hash: "hash-123",
|
||||
};
|
||||
|
||||
await applyConfig(state);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.apply", {
|
||||
raw: "{\n agent: { workspace: \"~/clawd\" }\n}\n",
|
||||
baseHash: "hash-123",
|
||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,11 +115,12 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
||||
state.configIssues = Array.isArray(snapshot.issues) ? snapshot.issues : [];
|
||||
|
||||
const config = snapshot.config ?? {};
|
||||
const telegram = (config.telegram ?? {}) as Record<string, unknown>;
|
||||
const discord = (config.discord ?? {}) as Record<string, unknown>;
|
||||
const slack = (config.slack ?? {}) as Record<string, unknown>;
|
||||
const signal = (config.signal ?? {}) as Record<string, unknown>;
|
||||
const imessage = (config.imessage ?? {}) as Record<string, unknown>;
|
||||
const channels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const telegram = (channels.telegram ?? config.telegram ?? {}) as Record<string, unknown>;
|
||||
const discord = (channels.discord ?? config.discord ?? {}) as Record<string, unknown>;
|
||||
const slack = (channels.slack ?? config.slack ?? {}) as Record<string, unknown>;
|
||||
const signal = (channels.signal ?? config.signal ?? {}) as Record<string, unknown>;
|
||||
const imessage = (channels.imessage ?? config.imessage ?? {}) as Record<string, unknown>;
|
||||
const toList = (value: unknown) =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
@@ -406,7 +407,12 @@ export async function saveConfig(state: ConfigState) {
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
await state.client.request("config.set", { raw });
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.set", { raw, baseHash });
|
||||
state.configFormDirty = false;
|
||||
await loadConfig(state);
|
||||
} catch (err) {
|
||||
@@ -425,8 +431,14 @@ export async function applyConfig(state: ConfigState) {
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
await state.client.request("config.apply", {
|
||||
raw,
|
||||
baseHash,
|
||||
sessionKey: state.applySessionKey,
|
||||
});
|
||||
state.configFormDirty = false;
|
||||
|
||||
@@ -13,70 +13,68 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
state.discordSaving = true;
|
||||
state.discordConfigStatus = null;
|
||||
try {
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const config = { ...base } as Record<string, unknown>;
|
||||
const discord = { ...(config.discord ?? {}) } as Record<string, unknown>;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.discordConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const discord: Record<string, unknown> = {};
|
||||
const form = state.discordForm;
|
||||
|
||||
if (form.enabled) {
|
||||
delete discord.enabled;
|
||||
discord.enabled = null;
|
||||
} else {
|
||||
discord.enabled = false;
|
||||
}
|
||||
|
||||
if (!state.discordTokenLocked) {
|
||||
const token = form.token.trim();
|
||||
if (token) discord.token = token;
|
||||
else delete discord.token;
|
||||
discord.token = token || null;
|
||||
}
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
const dm = { ...(discord.dm ?? {}) } as Record<string, unknown>;
|
||||
if (form.dmEnabled) delete dm.enabled;
|
||||
else dm.enabled = false;
|
||||
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
|
||||
else delete dm.allowFrom;
|
||||
if (form.groupEnabled) dm.groupEnabled = true;
|
||||
else delete dm.groupEnabled;
|
||||
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
|
||||
else delete dm.groupChannels;
|
||||
if (Object.keys(dm).length > 0) discord.dm = dm;
|
||||
else delete discord.dm;
|
||||
const dm: Record<string, unknown> = {
|
||||
enabled: form.dmEnabled ? null : false,
|
||||
allowFrom: allowFrom.length > 0 ? allowFrom : null,
|
||||
groupEnabled: form.groupEnabled ? true : null,
|
||||
groupChannels: groupChannels.length > 0 ? groupChannels : null,
|
||||
};
|
||||
discord.dm = dm;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
discord.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
delete discord.mediaMaxMb;
|
||||
discord.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const historyLimitRaw = form.historyLimit.trim();
|
||||
if (historyLimitRaw.length === 0) {
|
||||
delete discord.historyLimit;
|
||||
discord.historyLimit = null;
|
||||
} else {
|
||||
const historyLimit = Number(historyLimitRaw);
|
||||
if (Number.isFinite(historyLimit) && historyLimit >= 0) {
|
||||
discord.historyLimit = historyLimit;
|
||||
} else {
|
||||
delete discord.historyLimit;
|
||||
discord.historyLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
const chunkLimitRaw = form.textChunkLimit.trim();
|
||||
if (chunkLimitRaw.length === 0) {
|
||||
delete discord.textChunkLimit;
|
||||
discord.textChunkLimit = null;
|
||||
} else {
|
||||
const chunkLimit = Number(chunkLimitRaw);
|
||||
if (Number.isFinite(chunkLimit) && chunkLimit > 0) {
|
||||
discord.textChunkLimit = chunkLimit;
|
||||
} else {
|
||||
delete discord.textChunkLimit;
|
||||
discord.textChunkLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (form.replyToMode === "off") {
|
||||
delete discord.replyToMode;
|
||||
discord.replyToMode = null;
|
||||
} else {
|
||||
discord.replyToMode = form.replyToMode;
|
||||
}
|
||||
@@ -114,7 +112,7 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
guilds[key] = entry;
|
||||
});
|
||||
if (Object.keys(guilds).length > 0) discord.guilds = guilds;
|
||||
else delete discord.guilds;
|
||||
else discord.guilds = null;
|
||||
|
||||
const actions: Partial<DiscordActionForm> = {};
|
||||
const applyAction = (key: keyof DiscordActionForm) => {
|
||||
@@ -139,36 +137,33 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
if (Object.keys(actions).length > 0) {
|
||||
discord.actions = actions;
|
||||
} else {
|
||||
delete discord.actions;
|
||||
discord.actions = null;
|
||||
}
|
||||
|
||||
const slash = { ...(discord.slashCommand ?? {}) } as Record<string, unknown>;
|
||||
if (form.slashEnabled) {
|
||||
slash.enabled = true;
|
||||
} else {
|
||||
delete slash.enabled;
|
||||
slash.enabled = null;
|
||||
}
|
||||
if (form.slashName.trim()) slash.name = form.slashName.trim();
|
||||
else delete slash.name;
|
||||
else slash.name = null;
|
||||
if (form.slashSessionPrefix.trim())
|
||||
slash.sessionPrefix = form.slashSessionPrefix.trim();
|
||||
else delete slash.sessionPrefix;
|
||||
else slash.sessionPrefix = null;
|
||||
if (form.slashEphemeral) {
|
||||
delete slash.ephemeral;
|
||||
slash.ephemeral = null;
|
||||
} else {
|
||||
slash.ephemeral = false;
|
||||
}
|
||||
if (Object.keys(slash).length > 0) discord.slashCommand = slash;
|
||||
else delete discord.slashCommand;
|
||||
discord.slashCommand = Object.keys(slash).length > 0 ? slash : null;
|
||||
|
||||
if (Object.keys(discord).length > 0) {
|
||||
config.discord = discord;
|
||||
} else {
|
||||
delete config.discord;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
|
||||
await state.client.request("config.set", { raw });
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { discord } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.discordConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.discordConfigStatus = String(err);
|
||||
@@ -176,4 +171,3 @@ export async function saveDiscordConfig(state: ConnectionsState) {
|
||||
state.discordSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,57 +7,53 @@ export async function saveIMessageConfig(state: ConnectionsState) {
|
||||
state.imessageSaving = true;
|
||||
state.imessageConfigStatus = null;
|
||||
try {
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const config = { ...base } as Record<string, unknown>;
|
||||
const imessage = { ...(config.imessage ?? {}) } as Record<string, unknown>;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.imessageConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const imessage: Record<string, unknown> = {};
|
||||
const form = state.imessageForm;
|
||||
|
||||
if (form.enabled) {
|
||||
delete imessage.enabled;
|
||||
imessage.enabled = null;
|
||||
} else {
|
||||
imessage.enabled = false;
|
||||
}
|
||||
|
||||
const cliPath = form.cliPath.trim();
|
||||
if (cliPath) imessage.cliPath = cliPath;
|
||||
else delete imessage.cliPath;
|
||||
imessage.cliPath = cliPath || null;
|
||||
|
||||
const dbPath = form.dbPath.trim();
|
||||
if (dbPath) imessage.dbPath = dbPath;
|
||||
else delete imessage.dbPath;
|
||||
imessage.dbPath = dbPath || null;
|
||||
|
||||
if (form.service === "auto") {
|
||||
delete imessage.service;
|
||||
imessage.service = null;
|
||||
} else {
|
||||
imessage.service = form.service;
|
||||
}
|
||||
|
||||
const region = form.region.trim();
|
||||
if (region) imessage.region = region;
|
||||
else delete imessage.region;
|
||||
imessage.region = region || null;
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
if (allowFrom.length > 0) imessage.allowFrom = allowFrom;
|
||||
else delete imessage.allowFrom;
|
||||
imessage.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
|
||||
if (form.includeAttachments) imessage.includeAttachments = true;
|
||||
else delete imessage.includeAttachments;
|
||||
imessage.includeAttachments = form.includeAttachments ? true : null;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
imessage.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
delete imessage.mediaMaxMb;
|
||||
imessage.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
if (Object.keys(imessage).length > 0) {
|
||||
config.imessage = imessage;
|
||||
} else {
|
||||
delete config.imessage;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
|
||||
await state.client.request("config.set", { raw });
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { imessage } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.imessageConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.imessageConfigStatus = String(err);
|
||||
@@ -65,4 +61,3 @@ export async function saveIMessageConfig(state: ConnectionsState) {
|
||||
state.imessageSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,42 +7,41 @@ export async function saveSignalConfig(state: ConnectionsState) {
|
||||
state.signalSaving = true;
|
||||
state.signalConfigStatus = null;
|
||||
try {
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const config = { ...base } as Record<string, unknown>;
|
||||
const signal = { ...(config.signal ?? {}) } as Record<string, unknown>;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.signalConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const signal: Record<string, unknown> = {};
|
||||
const form = state.signalForm;
|
||||
|
||||
if (form.enabled) {
|
||||
delete signal.enabled;
|
||||
signal.enabled = null;
|
||||
} else {
|
||||
signal.enabled = false;
|
||||
}
|
||||
|
||||
const account = form.account.trim();
|
||||
if (account) signal.account = account;
|
||||
else delete signal.account;
|
||||
signal.account = account || null;
|
||||
|
||||
const httpUrl = form.httpUrl.trim();
|
||||
if (httpUrl) signal.httpUrl = httpUrl;
|
||||
else delete signal.httpUrl;
|
||||
signal.httpUrl = httpUrl || null;
|
||||
|
||||
const httpHost = form.httpHost.trim();
|
||||
if (httpHost) signal.httpHost = httpHost;
|
||||
else delete signal.httpHost;
|
||||
signal.httpHost = httpHost || null;
|
||||
|
||||
const httpPort = Number(form.httpPort);
|
||||
if (Number.isFinite(httpPort) && httpPort > 0) {
|
||||
signal.httpPort = httpPort;
|
||||
} else {
|
||||
delete signal.httpPort;
|
||||
signal.httpPort = null;
|
||||
}
|
||||
|
||||
const cliPath = form.cliPath.trim();
|
||||
if (cliPath) signal.cliPath = cliPath;
|
||||
else delete signal.cliPath;
|
||||
signal.cliPath = cliPath || null;
|
||||
|
||||
if (form.autoStart) {
|
||||
delete signal.autoStart;
|
||||
signal.autoStart = null;
|
||||
} else {
|
||||
signal.autoStart = false;
|
||||
}
|
||||
@@ -50,35 +49,29 @@ export async function saveSignalConfig(state: ConnectionsState) {
|
||||
if (form.receiveMode === "on-start" || form.receiveMode === "manual") {
|
||||
signal.receiveMode = form.receiveMode;
|
||||
} else {
|
||||
delete signal.receiveMode;
|
||||
signal.receiveMode = null;
|
||||
}
|
||||
|
||||
if (form.ignoreAttachments) signal.ignoreAttachments = true;
|
||||
else delete signal.ignoreAttachments;
|
||||
if (form.ignoreStories) signal.ignoreStories = true;
|
||||
else delete signal.ignoreStories;
|
||||
if (form.sendReadReceipts) signal.sendReadReceipts = true;
|
||||
else delete signal.sendReadReceipts;
|
||||
signal.ignoreAttachments = form.ignoreAttachments ? true : null;
|
||||
signal.ignoreStories = form.ignoreStories ? true : null;
|
||||
signal.sendReadReceipts = form.sendReadReceipts ? true : null;
|
||||
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
if (allowFrom.length > 0) signal.allowFrom = allowFrom;
|
||||
else delete signal.allowFrom;
|
||||
signal.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
|
||||
const mediaMaxMb = Number(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
signal.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
delete signal.mediaMaxMb;
|
||||
signal.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
if (Object.keys(signal).length > 0) {
|
||||
config.signal = signal;
|
||||
} else {
|
||||
delete config.signal;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
|
||||
await state.client.request("config.set", { raw });
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { signal } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.signalConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.signalConfigStatus = String(err);
|
||||
@@ -86,4 +79,3 @@ export async function saveSignalConfig(state: ConnectionsState) {
|
||||
state.signalSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,60 +8,58 @@ export async function saveSlackConfig(state: ConnectionsState) {
|
||||
state.slackSaving = true;
|
||||
state.slackConfigStatus = null;
|
||||
try {
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const config = { ...base } as Record<string, unknown>;
|
||||
const slack = { ...(config.slack ?? {}) } as Record<string, unknown>;
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.slackConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const slack: Record<string, unknown> = {};
|
||||
const form = state.slackForm;
|
||||
|
||||
if (form.enabled) {
|
||||
delete slack.enabled;
|
||||
slack.enabled = null;
|
||||
} else {
|
||||
slack.enabled = false;
|
||||
}
|
||||
|
||||
if (!state.slackTokenLocked) {
|
||||
const token = form.botToken.trim();
|
||||
if (token) slack.botToken = token;
|
||||
else delete slack.botToken;
|
||||
slack.botToken = token || null;
|
||||
}
|
||||
if (!state.slackAppTokenLocked) {
|
||||
const token = form.appToken.trim();
|
||||
if (token) slack.appToken = token;
|
||||
else delete slack.appToken;
|
||||
slack.appToken = token || null;
|
||||
}
|
||||
|
||||
const dm = { ...(slack.dm ?? {}) } as Record<string, unknown>;
|
||||
const dm: Record<string, unknown> = {};
|
||||
dm.enabled = form.dmEnabled;
|
||||
const allowFrom = parseList(form.allowFrom);
|
||||
if (allowFrom.length > 0) dm.allowFrom = allowFrom;
|
||||
else delete dm.allowFrom;
|
||||
dm.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
if (form.groupEnabled) {
|
||||
dm.groupEnabled = true;
|
||||
} else {
|
||||
delete dm.groupEnabled;
|
||||
dm.groupEnabled = null;
|
||||
}
|
||||
const groupChannels = parseList(form.groupChannels);
|
||||
if (groupChannels.length > 0) dm.groupChannels = groupChannels;
|
||||
else delete dm.groupChannels;
|
||||
if (Object.keys(dm).length > 0) slack.dm = dm;
|
||||
else delete slack.dm;
|
||||
dm.groupChannels = groupChannels.length > 0 ? groupChannels : null;
|
||||
slack.dm = dm;
|
||||
|
||||
const mediaMaxMb = Number.parseFloat(form.mediaMaxMb);
|
||||
if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) {
|
||||
slack.mediaMaxMb = mediaMaxMb;
|
||||
} else {
|
||||
delete slack.mediaMaxMb;
|
||||
slack.mediaMaxMb = null;
|
||||
}
|
||||
|
||||
const textChunkLimit = Number.parseInt(form.textChunkLimit, 10);
|
||||
if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) {
|
||||
slack.textChunkLimit = textChunkLimit;
|
||||
} else {
|
||||
delete slack.textChunkLimit;
|
||||
slack.textChunkLimit = null;
|
||||
}
|
||||
|
||||
if (form.reactionNotifications === "own") {
|
||||
delete slack.reactionNotifications;
|
||||
slack.reactionNotifications = null;
|
||||
} else {
|
||||
slack.reactionNotifications = form.reactionNotifications;
|
||||
}
|
||||
@@ -69,27 +67,26 @@ export async function saveSlackConfig(state: ConnectionsState) {
|
||||
if (reactionAllowlist.length > 0) {
|
||||
slack.reactionAllowlist = reactionAllowlist;
|
||||
} else {
|
||||
delete slack.reactionAllowlist;
|
||||
slack.reactionAllowlist = null;
|
||||
}
|
||||
|
||||
const slash = { ...(slack.slashCommand ?? {}) } as Record<string, unknown>;
|
||||
const slash: Record<string, unknown> = {};
|
||||
if (form.slashEnabled) {
|
||||
slash.enabled = true;
|
||||
} else {
|
||||
delete slash.enabled;
|
||||
slash.enabled = null;
|
||||
}
|
||||
if (form.slashName.trim()) slash.name = form.slashName.trim();
|
||||
else delete slash.name;
|
||||
else slash.name = null;
|
||||
if (form.slashSessionPrefix.trim())
|
||||
slash.sessionPrefix = form.slashSessionPrefix.trim();
|
||||
else delete slash.sessionPrefix;
|
||||
else slash.sessionPrefix = null;
|
||||
if (form.slashEphemeral) {
|
||||
delete slash.ephemeral;
|
||||
slash.ephemeral = null;
|
||||
} else {
|
||||
slash.ephemeral = false;
|
||||
}
|
||||
if (Object.keys(slash).length > 0) slack.slashCommand = slash;
|
||||
else delete slack.slashCommand;
|
||||
slack.slashCommand = slash;
|
||||
|
||||
const actions: Partial<SlackActionForm> = {};
|
||||
const applyAction = (key: keyof SlackActionForm) => {
|
||||
@@ -104,7 +101,7 @@ export async function saveSlackConfig(state: ConnectionsState) {
|
||||
if (Object.keys(actions).length > 0) {
|
||||
slack.actions = actions;
|
||||
} else {
|
||||
delete slack.actions;
|
||||
slack.actions = null;
|
||||
}
|
||||
|
||||
const channels = form.channels
|
||||
@@ -123,17 +120,15 @@ export async function saveSlackConfig(state: ConnectionsState) {
|
||||
if (channels.length > 0) {
|
||||
slack.channels = Object.fromEntries(channels);
|
||||
} else {
|
||||
delete slack.channels;
|
||||
slack.channels = null;
|
||||
}
|
||||
|
||||
if (Object.keys(slack).length > 0) {
|
||||
config.slack = slack;
|
||||
} else {
|
||||
delete config.slack;
|
||||
}
|
||||
|
||||
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
|
||||
await state.client.request("config.set", { raw });
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { slack } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.slackConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.slackConfigStatus = String(err);
|
||||
|
||||
@@ -165,56 +165,53 @@ export async function saveTelegramConfig(state: ConnectionsState) {
|
||||
}
|
||||
}
|
||||
const base = state.configSnapshot?.config ?? {};
|
||||
const config = { ...base } as Record<string, unknown>;
|
||||
const telegram = { ...(config.telegram ?? {}) } as Record<string, unknown>;
|
||||
const channels = (base.channels ?? {}) as Record<string, unknown>;
|
||||
const telegram = {
|
||||
...(channels.telegram ?? base.telegram ?? {}),
|
||||
} as Record<string, unknown>;
|
||||
if (!state.telegramTokenLocked) {
|
||||
const token = state.telegramForm.token.trim();
|
||||
if (token) telegram.botToken = token;
|
||||
else delete telegram.botToken;
|
||||
telegram.botToken = token || null;
|
||||
}
|
||||
const groups =
|
||||
telegram.groups && typeof telegram.groups === "object"
|
||||
? ({ ...(telegram.groups as Record<string, unknown>) } as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
const groupsPatch: Record<string, unknown> = {};
|
||||
if (state.telegramForm.groupsWildcardEnabled) {
|
||||
const existingGroups = telegram.groups as Record<string, unknown> | undefined;
|
||||
const defaultGroup =
|
||||
groups["*"] && typeof groups["*"] === "object"
|
||||
? ({ ...(groups["*"] as Record<string, unknown>) } as Record<
|
||||
existingGroups?.["*"] && typeof existingGroups["*"] === "object"
|
||||
? ({ ...(existingGroups["*"] as Record<string, unknown>) } as Record<
|
||||
string,
|
||||
unknown
|
||||
>)
|
||||
: {};
|
||||
defaultGroup.requireMention = state.telegramForm.requireMention;
|
||||
groups["*"] = defaultGroup;
|
||||
telegram.groups = groups;
|
||||
} else if (groups["*"]) {
|
||||
delete groups["*"];
|
||||
if (Object.keys(groups).length > 0) telegram.groups = groups;
|
||||
else delete telegram.groups;
|
||||
groupsPatch["*"] = defaultGroup;
|
||||
} else {
|
||||
groupsPatch["*"] = null;
|
||||
}
|
||||
delete telegram.requireMention;
|
||||
telegram.groups = groupsPatch;
|
||||
telegram.requireMention = null;
|
||||
const allowFrom = parseList(state.telegramForm.allowFrom);
|
||||
if (allowFrom.length > 0) telegram.allowFrom = allowFrom;
|
||||
else delete telegram.allowFrom;
|
||||
telegram.allowFrom = allowFrom.length > 0 ? allowFrom : null;
|
||||
const proxy = state.telegramForm.proxy.trim();
|
||||
if (proxy) telegram.proxy = proxy;
|
||||
else delete telegram.proxy;
|
||||
telegram.proxy = proxy || null;
|
||||
const webhookUrl = state.telegramForm.webhookUrl.trim();
|
||||
if (webhookUrl) telegram.webhookUrl = webhookUrl;
|
||||
else delete telegram.webhookUrl;
|
||||
telegram.webhookUrl = webhookUrl || null;
|
||||
const webhookSecret = state.telegramForm.webhookSecret.trim();
|
||||
if (webhookSecret) telegram.webhookSecret = webhookSecret;
|
||||
else delete telegram.webhookSecret;
|
||||
telegram.webhookSecret = webhookSecret || null;
|
||||
const webhookPath = state.telegramForm.webhookPath.trim();
|
||||
if (webhookPath) telegram.webhookPath = webhookPath;
|
||||
else delete telegram.webhookPath;
|
||||
telegram.webhookPath = webhookPath || null;
|
||||
|
||||
config.telegram = telegram;
|
||||
const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`;
|
||||
await state.client.request("config.set", { raw });
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.telegramConfigStatus = "Config hash missing; reload and retry.";
|
||||
return;
|
||||
}
|
||||
const raw = `${JSON.stringify(
|
||||
{ channels: { telegram } },
|
||||
null,
|
||||
2,
|
||||
).trimEnd()}\n`;
|
||||
await state.client.request("config.patch", { raw, baseHash });
|
||||
state.telegramConfigStatus = "Saved. Restart gateway if needed.";
|
||||
} catch (err) {
|
||||
state.telegramConfigStatus = String(err);
|
||||
|
||||
@@ -214,6 +214,7 @@ export type ConfigSnapshot = {
|
||||
path?: string | null;
|
||||
exists?: boolean | null;
|
||||
raw?: string | null;
|
||||
hash?: string | null;
|
||||
parsed?: unknown;
|
||||
valid?: boolean | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
|
||||
Reference in New Issue
Block a user