fix: prevent config clobbering

This commit is contained in:
Peter Steinberger
2026-01-15 04:05:01 +00:00
parent bd467ff765
commit 31d3aef8d6
29 changed files with 975 additions and 380 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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`)

View File

@@ -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",
}),
);

View File

@@ -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,

View File

@@ -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
View 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;
}

View File

@@ -93,6 +93,7 @@ export type ConfigFileSnapshot = {
parsed: unknown;
valid: boolean;
config: ClawdbotConfig;
hash?: string;
issues: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[];
};

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -10,6 +10,7 @@ const BASE_METHODS = [
"config.get",
"config.set",
"config.apply",
"config.patch",
"config.schema",
"wizard.start",
"wizard.next",

View File

@@ -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(

View 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();
});
});

View File

@@ -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",
});
});

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;