fix: align discord config ui

This commit is contained in:
Peter Steinberger
2026-01-02 21:15:59 +01:00
parent 4541bb2716
commit 118a6d7421
6 changed files with 303 additions and 205 deletions

View File

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

View File

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

View File

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

View File

@@ -69,7 +69,8 @@ export type DiscordChannelConfigResolved = {
function summarizeAllowList(list?: Array<string | number>) {
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<string, DiscordGuildEntryResolved>) {
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}`;
}

View File

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

View File

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