diff --git a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift index 0b95c849c..a69379c26 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsSettings.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsSettings.swift @@ -334,6 +334,105 @@ struct ConnectionsSettings: View { } } + Divider().padding(.vertical, 2) + + Text("Tool actions") + .font(.caption) + .foregroundStyle(.secondary) + + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) { + GridRow { + self.gridLabel("Reactions") + Toggle("", isOn: self.$store.discordActionReactions) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Stickers") + Toggle("", isOn: self.$store.discordActionStickers) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Polls") + Toggle("", isOn: self.$store.discordActionPolls) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Permissions") + Toggle("", isOn: self.$store.discordActionPermissions) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Messages") + Toggle("", isOn: self.$store.discordActionMessages) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Threads") + Toggle("", isOn: self.$store.discordActionThreads) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Pins") + Toggle("", isOn: self.$store.discordActionPins) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Search") + Toggle("", isOn: self.$store.discordActionSearch) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Member info") + Toggle("", isOn: self.$store.discordActionMemberInfo) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Role info") + Toggle("", isOn: self.$store.discordActionRoleInfo) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Channel info") + Toggle("", isOn: self.$store.discordActionChannelInfo) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Voice status") + Toggle("", isOn: self.$store.discordActionVoiceStatus) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Events") + Toggle("", isOn: self.$store.discordActionEvents) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Role changes") + Toggle("", isOn: self.$store.discordActionRoles) + .labelsHidden() + .toggleStyle(.checkbox) + } + GridRow { + self.gridLabel("Moderation") + Toggle("", isOn: self.$store.discordActionModeration) + .labelsHidden() + .toggleStyle(.checkbox) + } + } + if self.isDiscordTokenLocked { Text("Token set via DISCORD_BOT_TOKEN env; config edits won’t override it.") .font(.caption) diff --git a/apps/macos/Sources/Clawdis/ConnectionsStore.swift b/apps/macos/Sources/Clawdis/ConnectionsStore.swift index ef676179b..fd1bafecf 100644 --- a/apps/macos/Sources/Clawdis/ConnectionsStore.swift +++ b/apps/macos/Sources/Clawdis/ConnectionsStore.swift @@ -174,6 +174,21 @@ final class ConnectionsStore { var discordGroupChannels: String = "" var discordMediaMaxMb: String = "" var discordHistoryLimit: String = "" + var discordActionReactions = true + var discordActionStickers = true + var discordActionPolls = true + var discordActionPermissions = true + var discordActionMessages = true + var discordActionThreads = true + var discordActionPins = true + var discordActionSearch = true + var discordActionMemberInfo = true + var discordActionRoleInfo = true + var discordActionChannelInfo = true + var discordActionVoiceStatus = true + var discordActionEvents = true + var discordActionRoles = false + var discordActionModeration = false var discordSlashEnabled = false var discordSlashName: String = "" var discordSlashSessionPrefix: String = "" @@ -419,6 +434,22 @@ final class ConnectionsStore { } else { self.discordHistoryLimit = "" } + let discordActions = discord?["actions"]?.dictionaryValue + self.discordActionReactions = discordActions?["reactions"]?.boolValue ?? true + self.discordActionStickers = discordActions?["stickers"]?.boolValue ?? true + self.discordActionPolls = discordActions?["polls"]?.boolValue ?? true + self.discordActionPermissions = discordActions?["permissions"]?.boolValue ?? true + self.discordActionMessages = discordActions?["messages"]?.boolValue ?? true + self.discordActionThreads = discordActions?["threads"]?.boolValue ?? true + self.discordActionPins = discordActions?["pins"]?.boolValue ?? true + self.discordActionSearch = discordActions?["search"]?.boolValue ?? true + self.discordActionMemberInfo = discordActions?["memberInfo"]?.boolValue ?? true + self.discordActionRoleInfo = discordActions?["roleInfo"]?.boolValue ?? true + self.discordActionChannelInfo = discordActions?["channelInfo"]?.boolValue ?? true + self.discordActionVoiceStatus = discordActions?["voiceStatus"]?.boolValue ?? true + self.discordActionEvents = discordActions?["events"]?.boolValue ?? true + self.discordActionRoles = discordActions?["roles"]?.boolValue ?? false + self.discordActionModeration = discordActions?["moderation"]?.boolValue ?? false let slash = discord?["slashCommand"]?.dictionaryValue self.discordSlashEnabled = slash?["enabled"]?.boolValue ?? false self.discordSlashName = slash?["name"]?.stringValue ?? "" @@ -642,6 +673,35 @@ final class ConnectionsStore { discord.removeValue(forKey: "historyLimit") } + var actions: [String: Any] = (discord["actions"] as? [String: Any]) ?? [:] + func setAction(_ key: String, value: Bool, defaultValue: Bool) { + if value == defaultValue { + actions.removeValue(forKey: key) + } else { + actions[key] = value + } + } + setAction("reactions", value: self.discordActionReactions, defaultValue: true) + setAction("stickers", value: self.discordActionStickers, defaultValue: true) + setAction("polls", value: self.discordActionPolls, defaultValue: true) + setAction("permissions", value: self.discordActionPermissions, defaultValue: true) + setAction("messages", value: self.discordActionMessages, defaultValue: true) + setAction("threads", value: self.discordActionThreads, defaultValue: true) + setAction("pins", value: self.discordActionPins, defaultValue: true) + setAction("search", value: self.discordActionSearch, defaultValue: true) + setAction("memberInfo", value: self.discordActionMemberInfo, defaultValue: true) + setAction("roleInfo", value: self.discordActionRoleInfo, defaultValue: true) + setAction("channelInfo", value: self.discordActionChannelInfo, defaultValue: true) + setAction("voiceStatus", value: self.discordActionVoiceStatus, defaultValue: true) + setAction("events", value: self.discordActionEvents, defaultValue: true) + setAction("roles", value: self.discordActionRoles, defaultValue: false) + setAction("moderation", value: self.discordActionModeration, defaultValue: false) + if actions.isEmpty { + discord.removeValue(forKey: "actions") + } else { + discord["actions"] = actions + } + var slash: [String: Any] = (discord["slashCommand"] as? [String: Any]) ?? [:] if self.discordSlashEnabled { slash["enabled"] = true diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 1bcd91be4..fe88468a2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -26,12 +26,13 @@ import type { SkillStatusReport, StatusSummary, } from "./types"; -import type { - CronFormState, - DiscordForm, - IMessageForm, - SignalForm, - TelegramForm, +import { + defaultDiscordActions, + type CronFormState, + type DiscordForm, + type IMessageForm, + type SignalForm, + type TelegramForm, } from "./ui-types"; import { loadChatHistory, sendChat, handleChatEvent } from "./controllers/chat"; import { loadNodes } from "./controllers/nodes"; @@ -143,6 +144,7 @@ export class ClawdisApp extends LitElement { groupChannels: "", mediaMaxMb: "", historyLimit: "", + actions: { ...defaultDiscordActions }, slashEnabled: false, slashName: "", slashSessionPrefix: "", diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 5582900f3..fb2248002 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -1,6 +1,13 @@ import type { GatewayBrowserClient } from "../gateway"; import type { ConfigSnapshot } from "../types"; -import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; +import { + defaultDiscordActions, + type DiscordActionForm, + type DiscordForm, + type IMessageForm, + type SignalForm, + type TelegramForm, +} from "../ui-types"; export type ConfigState = { client: GatewayBrowserClient | null; @@ -88,6 +95,11 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot const discordDm = (discord.dm ?? {}) as Record; const slash = (discord.slashCommand ?? {}) as Record; + const discordActions = (discord.actions ?? {}) as Record; + const readAction = (key: keyof DiscordActionForm) => + typeof discordActions[key] === "boolean" + ? (discordActions[key] as boolean) + : defaultDiscordActions[key]; state.discordForm = { enabled: typeof discord.enabled === "boolean" ? discord.enabled : true, token: typeof discord.token === "string" ? discord.token : "", @@ -99,6 +111,23 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot typeof discord.mediaMaxMb === "number" ? String(discord.mediaMaxMb) : "", historyLimit: typeof discord.historyLimit === "number" ? String(discord.historyLimit) : "", + actions: { + reactions: readAction("reactions"), + stickers: readAction("stickers"), + polls: readAction("polls"), + permissions: readAction("permissions"), + messages: readAction("messages"), + threads: readAction("threads"), + pins: readAction("pins"), + search: readAction("search"), + memberInfo: readAction("memberInfo"), + roleInfo: readAction("roleInfo"), + channelInfo: readAction("channelInfo"), + voiceStatus: readAction("voiceStatus"), + events: readAction("events"), + roles: readAction("roles"), + moderation: readAction("moderation"), + }, slashEnabled: typeof slash.enabled === "boolean" ? slash.enabled : false, slashName: typeof slash.name === "string" ? slash.name : "", slashSessionPrefix: diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index afd977058..f056db87d 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -1,7 +1,14 @@ import type { GatewayBrowserClient } from "../gateway"; import { parseList } from "../format"; import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types"; -import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; +import { + defaultDiscordActions, + type DiscordActionForm, + type DiscordForm, + type IMessageForm, + type SignalForm, + type TelegramForm, +} from "../ui-types"; export type ConnectionsState = { client: GatewayBrowserClient | null; @@ -116,6 +123,14 @@ export function updateDiscordForm( state: ConnectionsState, patch: Partial, ) { + if (patch.actions) { + state.discordForm = { + ...state.discordForm, + ...patch, + actions: { ...state.discordForm.actions, ...patch.actions }, + }; + return; + } state.discordForm = { ...state.discordForm, ...patch }; } @@ -246,6 +261,32 @@ export async function saveDiscordConfig(state: ConnectionsState) { } } + const actions: Partial = {}; + const applyAction = (key: keyof DiscordActionForm) => { + const value = form.actions[key]; + if (value !== defaultDiscordActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("stickers"); + applyAction("polls"); + applyAction("permissions"); + applyAction("messages"); + applyAction("threads"); + applyAction("pins"); + applyAction("search"); + applyAction("memberInfo"); + applyAction("roleInfo"); + applyAction("channelInfo"); + applyAction("voiceStatus"); + applyAction("events"); + applyAction("roles"); + applyAction("moderation"); + if (Object.keys(actions).length > 0) { + discord.actions = actions; + } else { + delete discord.actions; + } + const slash = { ...(discord.slashCommand ?? {}) } as Record; if (form.slashEnabled) { slash.enabled = true; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 08f8edf9c..231b932af 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -16,12 +16,49 @@ export type DiscordForm = { groupChannels: string; mediaMaxMb: string; historyLimit: string; + actions: DiscordActionForm; slashEnabled: boolean; slashName: string; slashSessionPrefix: string; slashEphemeral: boolean; }; +export type DiscordActionForm = { + reactions: boolean; + stickers: boolean; + polls: boolean; + permissions: boolean; + messages: boolean; + threads: boolean; + pins: boolean; + search: boolean; + memberInfo: boolean; + roleInfo: boolean; + channelInfo: boolean; + voiceStatus: boolean; + events: boolean; + roles: boolean; + moderation: boolean; +}; + +export const defaultDiscordActions: DiscordActionForm = { + reactions: true, + stickers: true, + polls: true, + permissions: true, + messages: true, + threads: true, + pins: true, + search: true, + memberInfo: true, + roleInfo: true, + channelInfo: true, + voiceStatus: true, + events: true, + roles: false, + moderation: false, +}; + export type SignalForm = { enabled: boolean; account: string; diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index 6a627b67b..3438dbaac 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -2,7 +2,13 @@ import { html, nothing } from "lit"; import { formatAgo } from "../format"; import type { ProvidersStatusSnapshot } from "../types"; -import type { DiscordForm, IMessageForm, SignalForm, TelegramForm } from "../ui-types"; +import type { + DiscordActionForm, + DiscordForm, + IMessageForm, + SignalForm, + TelegramForm, +} from "../ui-types"; export type ConnectionsProps = { connected: boolean; @@ -48,6 +54,24 @@ export function renderConnections(props: ConnectionsProps) { const discord = props.snapshot?.discord ?? null; const signal = props.snapshot?.signal ?? null; const imessage = props.snapshot?.imessage ?? null; + const discordActionOptions: Array<{ key: keyof DiscordActionForm; label: string }> = + [ + { key: "reactions", label: "Reactions" }, + { key: "stickers", label: "Stickers" }, + { key: "polls", label: "Polls" }, + { key: "permissions", label: "Permissions" }, + { key: "messages", label: "Messages" }, + { key: "threads", label: "Threads" }, + { key: "pins", label: "Pins" }, + { key: "search", label: "Search" }, + { key: "memberInfo", label: "Member info" }, + { key: "roleInfo", label: "Role info" }, + { key: "channelInfo", label: "Channel info" }, + { key: "voiceStatus", label: "Voice status" }, + { key: "events", label: "Events" }, + { key: "roles", label: "Role changes" }, + { key: "moderation", label: "Moderation" }, + ]; const providerOrder: ProviderKey[] = [ "whatsapp", "telegram", @@ -572,6 +596,28 @@ function renderProvider( +
Tool actions
+
+ ${discordActionOptions.map( + (action) => html``, + )} +
+ ${props.discordTokenLocked ? html`
DISCORD_BOT_TOKEN is set in the environment. Config edits will not override it.