diff --git a/CHANGELOG.md b/CHANGELOG.md index 5814e8120..6e3e04d63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - iMessage: add imsg JSON-RPC integration (stdio), chat_id routing, and group chat support. - Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI. +- UI: add Discord/Signal/iMessage connection panels in macOS + Control UI (thanks @thewilloftheshadow). - Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context. - Discord: revamp guild routing config with per-guild/channel rules and slugged display names; add optional group DM support (default off). - Discord: remove legacy guild/channel ignore lists in favor of per-guild allowlists (and proposed per-guild ignore lists). diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index 60efdd53b..97292bbdf 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -263,6 +263,12 @@ struct ConnectionsSettings: View { Divider().padding(.vertical, 2) Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Enabled") + Toggle("", isOn: self.$store.discordEnabled) + .labelsHidden() + .toggleStyle(.checkbox) + } GridRow { self.gridLabel("Bot token") if self.showDiscordToken { @@ -279,24 +285,19 @@ struct ConnectionsSettings: View { .disabled(self.isDiscordTokenLocked) } GridRow { - self.gridLabel("Require mention") - Toggle("", isOn: self.$store.discordRequireMention) + self.gridLabel("Allow DMs from") + TextField("123456789, username#1234", text: self.$store.discordAllowFrom) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Group DMs") + Toggle("", isOn: self.$store.discordGroupEnabled) .labelsHidden() .toggleStyle(.checkbox) } GridRow { - self.gridLabel("Allow from") - TextField("discord:123, user:456", text: self.$store.discordAllowFrom) - .textFieldStyle(.roundedBorder) - } - GridRow { - self.gridLabel("Allow guilds") - TextField("guildId1, guildId2", text: self.$store.discordGuildAllowFrom) - .textFieldStyle(.roundedBorder) - } - GridRow { - self.gridLabel("Allow guild users") - TextField("userId1, userId2", text: self.$store.discordGuildUsersAllowFrom) + self.gridLabel("Group channels") + TextField("channelId1, channelId2", text: self.$store.discordGroupChannels) .textFieldStyle(.roundedBorder) } GridRow { @@ -304,6 +305,39 @@ struct ConnectionsSettings: View { TextField("8", text: self.$store.discordMediaMaxMb) .textFieldStyle(.roundedBorder) } + GridRow { + self.gridLabel("History limit") + TextField("20", text: self.$store.discordHistoryLimit) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Reactions") + Toggle("", isOn: self.$store.discordEnableReactions) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Slash command") + Toggle("", isOn: self.$store.discordSlashEnabled) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Slash name") + TextField("clawd", text: self.$store.discordSlashName) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Slash session prefix") + TextField("discord:slash", text: self.$store.discordSlashSessionPrefix) + .textFieldStyle(.roundedBorder) + } + GridRow { + self.gridLabel("Slash ephemeral") + Toggle("", isOn: self.$store.discordSlashEphemeral) + .labelsHidden() + .toggleStyle(.checkbox) + } } if self.isDiscordTokenLocked { diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index cad06cde1..9269ee590 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -167,12 +167,18 @@ final class ConnectionsStore { var telegramWebhookSecret: String = "" var telegramWebhookPath: String = "" var telegramBusy = false + var discordEnabled = true var discordToken: String = "" - var discordRequireMention = true var discordAllowFrom: String = "" - var discordGuildAllowFrom: String = "" - var discordGuildUsersAllowFrom: String = "" + var discordGroupEnabled = false + var discordGroupChannels: String = "" var discordMediaMaxMb: String = "" + var discordHistoryLimit: String = "" + var discordEnableReactions = true + var discordSlashEnabled = false + var discordSlashName: String = "" + var discordSlashSessionPrefix: String = "" + var discordSlashEphemeral = true var signalEnabled = true var signalAccount: String = "" var signalHttpUrl: String = "" @@ -378,9 +384,10 @@ final class ConnectionsStore { self.telegramWebhookPath = telegram?["webhookPath"]?.stringValue ?? "" let discord = snap.config?["discord"]?.dictionaryValue + self.discordEnabled = discord?["enabled"]?.boolValue ?? true self.discordToken = discord?["token"]?.stringValue ?? "" - self.discordRequireMention = discord?["requireMention"]?.boolValue ?? true - if let allow = discord?["allowFrom"]?.arrayValue { + let discordDm = discord?["dm"]?.dictionaryValue + if let allow = discordDm?["allowFrom"]?.arrayValue { let strings = allow.compactMap { entry -> String? in if let str = entry.stringValue { return str } if let intVal = entry.intValue { return String(intVal) } @@ -391,38 +398,34 @@ final class ConnectionsStore { } else { self.discordAllowFrom = "" } - if let guildAllow = discord?["guildAllowFrom"]?.dictionaryValue { - if let guilds = guildAllow["guilds"]?.arrayValue { - let strings = guilds.compactMap { entry -> String? in - if let str = entry.stringValue { return str } - if let intVal = entry.intValue { return String(intVal) } - if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) } - return nil - } - self.discordGuildAllowFrom = strings.joined(separator: ", ") - } else { - self.discordGuildAllowFrom = "" - } - if let users = guildAllow["users"]?.arrayValue { - let strings = users.compactMap { entry -> String? in - if let str = entry.stringValue { return str } - if let intVal = entry.intValue { return String(intVal) } - if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) } - return nil - } - self.discordGuildUsersAllowFrom = strings.joined(separator: ", ") - } else { - self.discordGuildUsersAllowFrom = "" + self.discordGroupEnabled = discordDm?["groupEnabled"]?.boolValue ?? false + if let channels = discordDm?["groupChannels"]?.arrayValue { + let strings = channels.compactMap { entry -> String? in + if let str = entry.stringValue { return str } + if let intVal = entry.intValue { return String(intVal) } + if let doubleVal = entry.doubleValue { return String(Int(doubleVal)) } + return nil } + self.discordGroupChannels = strings.joined(separator: ", ") } else { - self.discordGuildAllowFrom = "" - self.discordGuildUsersAllowFrom = "" + self.discordGroupChannels = "" } if let media = discord?["mediaMaxMb"]?.doubleValue ?? discord?["mediaMaxMb"]?.intValue.map(Double.init) { self.discordMediaMaxMb = String(Int(media)) } else { self.discordMediaMaxMb = "" } + if let history = discord?["historyLimit"]?.doubleValue ?? discord?["historyLimit"]?.intValue.map(Double.init) { + self.discordHistoryLimit = String(Int(history)) + } else { + self.discordHistoryLimit = "" + } + self.discordEnableReactions = discord?["enableReactions"]?.boolValue ?? true + let slash = discord?["slashCommand"]?.dictionaryValue + self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false + self.discordSlashName = slash?["name"]?.stringValue ?? "" + self.discordSlashSessionPrefix = slash?["sessionPrefix"]?.stringValue ?? "" + self.discordSlashEphemeral = slash?["ephemeral"]?.boolValue ?? true let signal = snap.config?["signal"]?.dictionaryValue self.signalEnabled = signal?["enabled"]?.boolValue ?? true @@ -580,6 +583,11 @@ final class ConnectionsStore { } var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:] + if self.discordEnabled { + discord.removeValue(forKey: "enabled") + } else { + discord["enabled"] = false + } let token = self.discordToken.trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { discord.removeValue(forKey: "token") @@ -587,43 +595,37 @@ final class ConnectionsStore { discord["token"] = token } - discord["requireMention"] = self.discordRequireMention - + var dm: [String: Any] = (discord["dm"] as? [String: Any]) ?? [:] let allow = self.discordAllowFrom .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } if allow.isEmpty { - discord.removeValue(forKey: "allowFrom") + dm.removeValue(forKey: "allowFrom") } else { - discord["allowFrom"] = allow + dm["allowFrom"] = allow } - var guildAllow: [String: Any] = (discord["guildAllowFrom"] as? [String: Any]) ?? [:] - let guilds = self.discordGuildAllowFrom + if self.discordGroupEnabled { + dm["groupEnabled"] = true + } else { + dm.removeValue(forKey: "groupEnabled") + } + + let groupChannels = self.discordGroupChannels .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - if guilds.isEmpty { - guildAllow.removeValue(forKey: "guilds") + if groupChannels.isEmpty { + dm.removeValue(forKey: "groupChannels") } else { - guildAllow["guilds"] = guilds + dm["groupChannels"] = groupChannels } - let users = self.discordGuildUsersAllowFrom - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - if users.isEmpty { - guildAllow.removeValue(forKey: "users") + if dm.isEmpty { + discord.removeValue(forKey: "dm") } else { - guildAllow["users"] = users - } - - if guildAllow.isEmpty { - discord.removeValue(forKey: "guildAllowFrom") - } else { - discord["guildAllowFrom"] = guildAllow + discord["dm"] = dm } let media = self.discordMediaMaxMb.trimmingCharacters(in: .whitespacesAndNewlines) @@ -633,6 +635,50 @@ final class ConnectionsStore { discord["mediaMaxMb"] = value } + let history = self.discordHistoryLimit.trimmingCharacters(in: .whitespacesAndNewlines) + if history.isEmpty { + discord.removeValue(forKey: "historyLimit") + } else if let value = Int(history), value >= 0 { + discord["historyLimit"] = value + } else { + discord.removeValue(forKey: "historyLimit") + } + + if self.discordEnableReactions { + discord.removeValue(forKey: "enableReactions") + } else { + discord["enableReactions"] = false + } + + var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:] + if self.discordSlashEnabled { + slash["enabled"] = true + } else { + slash.removeValue(forKey: "enabled") + } + let slashName = self.discordSlashName.trimmingCharacters(in: .whitespacesAndNewlines) + if slashName.isEmpty { + slash.removeValue(forKey: "name") + } else { + slash["name"] = slashName + } + let slashPrefix = self.discordSlashSessionPrefix.trimmingCharacters(in: .whitespacesAndNewlines) + if slashPrefix.isEmpty { + slash.removeValue(forKey: "sessionPrefix") + } else { + slash["sessionPrefix"] = slashPrefix + } + if self.discordSlashEphemeral { + slash.removeValue(forKey: "ephemeral") + } else { + slash["ephemeral"] = false + } + if slash.isEmpty { + discord.removeValue(forKey: "slashCommand") + } else { + discord["slashCommand"] = slash + } + if discord.isEmpty { self.configRoot.removeValue(forKey: "discord") } else { diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index cc4eeb31e..d44cfae7c 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -69,7 +69,8 @@ export type DiscordChannelConfigResolved = { function summarizeAllowList(list?: Array) { if (!list || list.length === 0) return "any"; const sample = list.slice(0, 4).map((entry) => String(entry)); - const suffix = list.length > sample.length ? ` (+${list.length - sample.length})` : ""; + const suffix = + list.length > sample.length ? ` (+${list.length - sample.length})` : ""; return `${sample.join(", ")}${suffix}`; } @@ -77,7 +78,8 @@ function summarizeGuilds(entries?: Record) { if (!entries || Object.keys(entries).length === 0) return "any"; const keys = Object.keys(entries); const sample = keys.slice(0, 4); - const suffix = keys.length > sample.length ? ` (+${keys.length - sample.length})` : ""; + const suffix = + keys.length > sample.length ? ` (+${keys.length - sample.length})` : ""; return `${sample.join(", ")}${suffix}`; } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 24a7854f3..56e090504 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2096,35 +2096,39 @@ export async function startGatewayServer( }; }; - const startTelegramProvider = async () => { - if (telegramTask) return; - const cfg = loadConfig(); - if (cfg.telegram?.enabled === false) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "disabled", - }; - if (isVerbose()) { - logTelegram.debug("telegram provider disabled (telegram.enabled=false)"); - } - return; - } - const { token: telegramToken } = resolveTelegramToken(cfg, { - logMissingFile: (message) => logTelegram.warn(message), - }); - if (!telegramToken.trim()) { - telegramRuntime = { - ...telegramRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (isVerbose()) { - logTelegram.debug("telegram provider not configured (no TELEGRAM_BOT_TOKEN)"); - } - return; - } + const startTelegramProvider = async () => { + if (telegramTask) return; + const cfg = loadConfig(); + if (cfg.telegram?.enabled === false) { + telegramRuntime = { + ...telegramRuntime, + running: false, + lastError: "disabled", + }; + if (isVerbose()) { + logTelegram.debug( + "telegram provider disabled (telegram.enabled=false)", + ); + } + return; + } + const { token: telegramToken } = resolveTelegramToken(cfg, { + logMissingFile: (message) => logTelegram.warn(message), + }); + if (!telegramToken.trim()) { + telegramRuntime = { + ...telegramRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (isVerbose()) { + logTelegram.debug( + "telegram provider not configured (no TELEGRAM_BOT_TOKEN)", + ); + } + return; + } let telegramBotLabel = ""; try { const probe = await probeTelegram( @@ -2139,13 +2143,13 @@ export async function startGatewayServer( logTelegram.debug(`bot probe failed: ${String(err)}`); } } - logTelegram.info( - `starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`, - ); - telegramAbort = new AbortController(); - telegramRuntime = { - ...telegramRuntime, - running: true, + logTelegram.info( + `starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`, + ); + telegramAbort = new AbortController(); + telegramRuntime = { + ...telegramRuntime, + running: true, lastStartAt: Date.now(), lastError: null, mode: cfg.telegram?.webhookUrl ? "webhook" : "polling", @@ -2195,34 +2199,36 @@ export async function startGatewayServer( }; }; - const startDiscordProvider = async () => { - if (discordTask) return; - const cfg = loadConfig(); - if (cfg.discord?.enabled === false) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "disabled", - }; - if (isVerbose()) { - logDiscord.debug("discord provider disabled (discord.enabled=false)"); - } - return; - } - const discordToken = - process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; - if (!discordToken.trim()) { - discordRuntime = { - ...discordRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (isVerbose()) { - logDiscord.debug("discord provider not configured (no DISCORD_BOT_TOKEN)"); - } - return; - } + const startDiscordProvider = async () => { + if (discordTask) return; + const cfg = loadConfig(); + if (cfg.discord?.enabled === false) { + discordRuntime = { + ...discordRuntime, + running: false, + lastError: "disabled", + }; + if (isVerbose()) { + logDiscord.debug("discord provider disabled (discord.enabled=false)"); + } + return; + } + const discordToken = + process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; + if (!discordToken.trim()) { + discordRuntime = { + ...discordRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (isVerbose()) { + logDiscord.debug( + "discord provider not configured (no DISCORD_BOT_TOKEN)", + ); + } + return; + } let discordBotLabel = ""; try { const probe = await probeDiscord(discordToken.trim(), 2500); @@ -2233,10 +2239,10 @@ export async function startGatewayServer( logDiscord.debug(`bot probe failed: ${String(err)}`); } } - logDiscord.info( - `starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`, - ); - discordAbort = new AbortController(); + logDiscord.info( + `starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`, + ); + discordAbort = new AbortController(); discordRuntime = { ...discordRuntime, running: true, @@ -2287,32 +2293,32 @@ export async function startGatewayServer( }; }; - const startSignalProvider = async () => { - if (signalTask) return; - const cfg = loadConfig(); - if (!cfg.signal) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (isVerbose()) { - logSignal.debug("signal provider not configured (no signal config)"); - } - return; - } - if (cfg.signal?.enabled === false) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "disabled", - }; - if (isVerbose()) { - logSignal.debug("signal provider disabled (signal.enabled=false)"); - } - return; - } + const startSignalProvider = async () => { + if (signalTask) return; + const cfg = loadConfig(); + if (!cfg.signal) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (isVerbose()) { + logSignal.debug("signal provider not configured (no signal config)"); + } + return; + } + if (cfg.signal?.enabled === false) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "disabled", + }; + if (isVerbose()) { + logSignal.debug("signal provider disabled (signal.enabled=false)"); + } + return; + } const signalCfg = cfg.signal; const signalMeaningfullyConfigured = Boolean( signalCfg.account?.trim() || @@ -2322,20 +2328,20 @@ export async function startGatewayServer( typeof signalCfg.httpPort === "number" || typeof signalCfg.autoStart === "boolean", ); - if (!signalMeaningfullyConfigured) { - signalRuntime = { - ...signalRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (isVerbose()) { - logSignal.debug( - "signal provider not configured (signal config present but missing required fields)", - ); - } - return; - } + if (!signalMeaningfullyConfigured) { + signalRuntime = { + ...signalRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (isVerbose()) { + logSignal.debug( + "signal provider not configured (signal config present but missing required fields)", + ); + } + return; + } const host = cfg.signal?.httpHost?.trim() || "127.0.0.1"; const port = cfg.signal?.httpPort ?? 8080; const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`; @@ -2400,32 +2406,36 @@ export async function startGatewayServer( }; }; - const startIMessageProvider = async () => { - if (imessageTask) return; - const cfg = loadConfig(); - if (!cfg.imessage) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "not configured", - }; - // keep quiet by default; this is a normal state - if (isVerbose()) { - logIMessage.debug("imessage provider not configured (no imessage config)"); - } - return; - } - if (cfg.imessage?.enabled === false) { - imessageRuntime = { - ...imessageRuntime, - running: false, - lastError: "disabled", - }; - if (isVerbose()) { - logIMessage.debug("imessage provider disabled (imessage.enabled=false)"); - } - return; - } + const startIMessageProvider = async () => { + if (imessageTask) return; + const cfg = loadConfig(); + if (!cfg.imessage) { + imessageRuntime = { + ...imessageRuntime, + running: false, + lastError: "not configured", + }; + // keep quiet by default; this is a normal state + if (isVerbose()) { + logIMessage.debug( + "imessage provider not configured (no imessage config)", + ); + } + return; + } + if (cfg.imessage?.enabled === false) { + imessageRuntime = { + ...imessageRuntime, + running: false, + lastError: "disabled", + }; + if (isVerbose()) { + logIMessage.debug( + "imessage provider disabled (imessage.enabled=false)", + ); + } + return; + } const cliPath = cfg.imessage?.cliPath?.trim() || "imsg"; const dbPath = cfg.imessage?.dbPath?.trim(); logIMessage.info( diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index dd40617c5..a1ab69d81 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -217,11 +217,16 @@ export async function saveDiscordConfig(state: ConnectionsState) { delete discord.mediaMaxMb; } - const historyLimit = Number(form.historyLimit); - if (Number.isFinite(historyLimit) && historyLimit >= 0) { - discord.historyLimit = historyLimit; - } else { + const historyLimitRaw = form.historyLimit.trim(); + if (historyLimitRaw.length === 0) { delete discord.historyLimit; + } else { + const historyLimit = Number(historyLimitRaw); + if (Number.isFinite(historyLimit) && historyLimit >= 0) { + discord.historyLimit = historyLimit; + } else { + delete discord.historyLimit; + } } if (form.enableReactions) {