Slack: add some fixes and connect it all up

This commit is contained in:
Shadow
2026-01-04 01:53:15 -06:00
parent 02d7e286ea
commit 8c38a7fee8
45 changed files with 1568 additions and 89 deletions

View File

@@ -27,6 +27,7 @@ import type {
CronFormState,
DiscordForm,
IMessageForm,
SlackForm,
SignalForm,
TelegramForm,
} from "./ui-types";
@@ -44,6 +45,7 @@ import {
loadProviders,
updateDiscordForm,
updateIMessageForm,
updateSlackForm,
updateSignalForm,
updateTelegramForm,
} from "./controllers/connections";
@@ -117,6 +119,11 @@ export type AppViewState = {
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
@@ -269,6 +276,11 @@ export function renderApp(state: AppViewState) {
discordTokenLocked: state.discordTokenLocked,
discordSaving: state.discordSaving,
discordStatus: state.discordConfigStatus,
slackForm: state.slackForm,
slackTokenLocked: state.slackTokenLocked,
slackAppTokenLocked: state.slackAppTokenLocked,
slackSaving: state.slackSaving,
slackStatus: state.slackConfigStatus,
signalForm: state.signalForm,
signalSaving: state.signalSaving,
signalStatus: state.signalConfigStatus,
@@ -283,6 +295,8 @@ export function renderApp(state: AppViewState) {
onTelegramSave: () => state.handleTelegramSave(),
onDiscordChange: (patch) => updateDiscordForm(state, patch),
onDiscordSave: () => state.handleDiscordSave(),
onSlackChange: (patch) => updateSlackForm(state, patch),
onSlackSave: () => state.handleSlackSave(),
onSignalChange: (patch) => updateSignalForm(state, patch),
onSignalSave: () => state.handleSignalSave(),
onIMessageChange: (patch) => updateIMessageForm(state, patch),

View File

@@ -36,9 +36,11 @@ import type {
} from "./types";
import {
defaultDiscordActions,
defaultSlackActions,
type CronFormState,
type DiscordForm,
type IMessageForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "./ui-types";
@@ -59,6 +61,7 @@ import {
logoutWhatsApp,
saveDiscordConfig,
saveIMessageConfig,
saveSlackConfig,
saveSignalConfig,
saveTelegramConfig,
startWhatsAppLogin,
@@ -233,7 +236,6 @@ export class ClawdisApp extends LitElement {
mediaMaxMb: "",
historyLimit: "",
textChunkLimit: "",
replyToMode: "off",
guilds: [],
actions: { ...defaultDiscordActions },
slashEnabled: false,
@@ -244,6 +246,29 @@ export class ClawdisApp extends LitElement {
@state() discordSaving = false;
@state() discordTokenLocked = false;
@state() discordConfigStatus: string | null = null;
@state() slackForm: SlackForm = {
enabled: true,
botToken: "",
appToken: "",
dmEnabled: true,
allowFrom: "",
groupEnabled: false,
groupChannels: "",
mediaMaxMb: "",
textChunkLimit: "",
reactionNotifications: "own",
reactionAllowlist: "",
slashEnabled: false,
slashName: "",
slashSessionPrefix: "",
slashEphemeral: true,
actions: { ...defaultSlackActions },
channels: [],
};
@state() slackSaving = false;
@state() slackTokenLocked = false;
@state() slackAppTokenLocked = false;
@state() slackConfigStatus: string | null = null;
@state() signalForm: SignalForm = {
enabled: true,
account: "",
@@ -774,6 +799,12 @@ export class ClawdisApp extends LitElement {
await loadProviders(this, true);
}
async handleSlackSave() {
await saveSlackConfig(this);
await loadConfig(this);
await loadProviders(this, true);
}
async handleSignalSave() {
await saveSignalConfig(this);
await loadConfig(this);

View File

@@ -6,11 +6,14 @@ import type {
} from "../types";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SlackChannelForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
@@ -34,10 +37,12 @@ export type ConfigState = {
lastError: string | null;
telegramForm: TelegramForm;
discordForm: DiscordForm;
slackForm: SlackForm;
signalForm: SignalForm;
imessageForm: IMessageForm;
telegramConfigStatus: string | null;
discordConfigStatus: string | null;
slackConfigStatus: string | null;
signalConfigStatus: string | null;
imessageConfigStatus: string | null;
};
@@ -255,10 +260,6 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof slack.textChunkLimit === "number"
? String(slack.textChunkLimit)
: "",
replyToMode:
slack.replyToMode === "first" || slack.replyToMode === "all"
? slack.replyToMode
: "off",
reactionNotifications:
slack.reactionNotifications === "off" ||
slack.reactionNotifications === "all" ||
@@ -492,4 +493,3 @@ function removePathValue(
delete (current as Record<string, unknown>)[lastKey];
}
}

View File

@@ -3,11 +3,14 @@ import { parseList } from "../format";
import type { ConfigSnapshot, ProvidersStatusSnapshot } from "../types";
import {
defaultDiscordActions,
defaultSlackActions,
type DiscordActionForm,
type DiscordForm,
type DiscordGuildChannelForm,
type DiscordGuildForm,
type IMessageForm,
type SlackActionForm,
type SlackForm,
type SignalForm,
type TelegramForm,
} from "../ui-types";
@@ -31,6 +34,11 @@ export type ConnectionsState = {
discordSaving: boolean;
discordTokenLocked: boolean;
discordConfigStatus: string | null;
slackForm: SlackForm;
slackSaving: boolean;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackConfigStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalConfigStatus: string | null;
@@ -54,6 +62,8 @@ export async function loadProviders(state: ConnectionsState, probe: boolean) {
state.providersLastSuccess = Date.now();
state.telegramTokenLocked = res.telegram.tokenSource === "env";
state.discordTokenLocked = res.discord?.tokenSource === "env";
state.slackTokenLocked = res.slack?.botTokenSource === "env";
state.slackAppTokenLocked = res.slack?.appTokenSource === "env";
} catch (err) {
state.providersError = String(err);
} finally {
@@ -136,6 +146,21 @@ export function updateDiscordForm(
state.discordForm = { ...state.discordForm, ...patch };
}
export function updateSlackForm(
state: ConnectionsState,
patch: Partial<SlackForm>,
) {
if (patch.actions) {
state.slackForm = {
...state.slackForm,
...patch,
actions: { ...state.slackForm.actions, ...patch.actions },
};
return;
}
state.slackForm = { ...state.slackForm, ...patch };
}
export function updateSignalForm(
state: ConnectionsState,
patch: Partial<SignalForm>,
@@ -437,9 +462,6 @@ export async function saveSlackConfig(state: ConnectionsState) {
delete slack.textChunkLimit;
}
if (form.replyToMode === "off") delete slack.replyToMode;
else slack.replyToMode = form.replyToMode;
if (form.reactionNotifications === "own") {
delete slack.reactionNotifications;
} else {
@@ -670,4 +692,3 @@ export async function saveIMessageConfig(state: ConnectionsState) {
state.imessageSaving = false;
}
}

View File

@@ -3,6 +3,7 @@ export type ProvidersStatusSnapshot = {
whatsapp: WhatsAppStatus;
telegram: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
};
@@ -89,6 +90,37 @@ export type DiscordStatus = {
lastProbeAt?: number | null;
};
export type SlackBot = {
id?: string | null;
name?: string | null;
};
export type SlackTeam = {
id?: string | null;
name?: string | null;
};
export type SlackProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: SlackBot | null;
team?: SlackTeam | null;
};
export type SlackStatus = {
configured: boolean;
botTokenSource?: string | null;
appTokenSource?: string | null;
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
probe?: SlackProbe | null;
lastProbeAt?: number | null;
};
export type SignalProbe = {
ok: boolean;
status?: number | null;

View File

@@ -84,7 +84,6 @@ export type SlackForm = {
groupChannels: string;
mediaMaxMb: string;
textChunkLimit: string;
replyToMode: "off" | "first" | "all";
reactionNotifications: "off" | "own" | "all" | "allowlist";
reactionAllowlist: string;
slashEnabled: boolean;
@@ -168,4 +167,3 @@ export type CronFormState = {
timeoutSeconds: string;
postToMainPrefix: string;
};

View File

@@ -6,6 +6,8 @@ import type {
DiscordActionForm,
DiscordForm,
IMessageForm,
SlackActionForm,
SlackForm,
SignalForm,
TelegramForm,
} from "../ui-types";
@@ -54,6 +56,11 @@ export type ConnectionsProps = {
discordTokenLocked: boolean;
discordSaving: boolean;
discordStatus: string | null;
slackForm: SlackForm;
slackTokenLocked: boolean;
slackAppTokenLocked: boolean;
slackSaving: boolean;
slackStatus: string | null;
signalForm: SignalForm;
signalSaving: boolean;
signalStatus: string | null;
@@ -68,6 +75,8 @@ export type ConnectionsProps = {
onTelegramSave: () => void;
onDiscordChange: (patch: Partial<DiscordForm>) => void;
onDiscordSave: () => void;
onSlackChange: (patch: Partial<SlackForm>) => void;
onSlackSave: () => void;
onSignalChange: (patch: Partial<SignalForm>) => void;
onSignalSave: () => void;
onIMessageChange: (patch: Partial<IMessageForm>) => void;
@@ -78,12 +87,14 @@ export function renderConnections(props: ConnectionsProps) {
const whatsapp = props.snapshot?.whatsapp;
const telegram = props.snapshot?.telegram;
const discord = props.snapshot?.discord ?? null;
const slack = props.snapshot?.slack ?? null;
const signal = props.snapshot?.signal ?? null;
const imessage = props.snapshot?.imessage ?? null;
const providerOrder: ProviderKey[] = [
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
];
@@ -101,7 +112,14 @@ export function renderConnections(props: ConnectionsProps) {
return html`
<section class="grid grid-cols-2">
${orderedProviders.map((provider) =>
renderProvider(provider.key, props, { whatsapp, telegram, discord, signal, imessage }),
renderProvider(provider.key, props, {
whatsapp,
telegram,
discord,
slack,
signal,
imessage,
}),
)}
</section>
@@ -135,7 +153,13 @@ function formatDuration(ms?: number | null) {
return `${hr}h`;
}
type ProviderKey = "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
type ProviderKey =
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
function providerEnabled(key: ProviderKey, props: ConnectionsProps) {
const snapshot = props.snapshot;
@@ -151,6 +175,8 @@ function providerEnabled(key: ProviderKey, props: ConnectionsProps) {
return snapshot.telegram.configured || snapshot.telegram.running;
case "discord":
return Boolean(snapshot.discord?.configured || snapshot.discord?.running);
case "slack":
return Boolean(snapshot.slack?.configured || snapshot.slack?.running);
case "signal":
return Boolean(snapshot.signal?.configured || snapshot.signal?.running);
case "imessage":
@@ -167,6 +193,7 @@ function renderProvider(
whatsapp?: ProvidersStatusSnapshot["whatsapp"];
telegram?: ProvidersStatusSnapshot["telegram"];
discord?: ProvidersStatusSnapshot["discord"] | null;
slack?: ProvidersStatusSnapshot["slack"] | null;
signal?: ProvidersStatusSnapshot["signal"] | null;
imessage?: ProvidersStatusSnapshot["imessage"] | null;
},
@@ -949,6 +976,389 @@ function renderProvider(
</div>
`;
}
case "slack": {
const slack = data.slack;
const botName = slack?.probe?.bot?.name;
const teamName = slack?.probe?.team?.name;
return html`
<div class="card">
<div class="card-title">Slack</div>
<div class="card-sub">Socket mode status and bot details.</div>
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${slack?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${slack?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Bot</span>
<span>${botName ? botName : "n/a"}</span>
</div>
<div>
<span class="label">Team</span>
<span>${teamName ? teamName : "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${slack?.lastStartAt ? formatAgo(slack.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${slack?.lastProbeAt ? formatAgo(slack.lastProbeAt) : "n/a"}</span>
</div>
</div>
${slack?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${slack.lastError}
</div>`
: nothing}
${slack?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${slack.probe.ok ? "ok" : "failed"} ·
${slack.probe.status ?? ""}
${slack.probe.error ?? ""}
</div>`
: nothing}
<div class="form-grid" style="margin-top: 16px;">
<label class="field">
<span>Enabled</span>
<select
.value=${props.slackForm.enabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
enabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Bot token</span>
<input
type="password"
.value=${props.slackForm.botToken}
?disabled=${props.slackTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
botToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>App token</span>
<input
type="password"
.value=${props.slackForm.appToken}
?disabled=${props.slackAppTokenLocked}
@input=${(e: Event) =>
props.onSlackChange({
appToken: (e.target as HTMLInputElement).value,
})}
/>
</label>
<label class="field">
<span>DMs enabled</span>
<select
.value=${props.slackForm.dmEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
dmEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Allow DMs from</span>
<input
.value=${props.slackForm.allowFrom}
@input=${(e: Event) =>
props.onSlackChange({
allowFrom: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456, *"
/>
</label>
<label class="field">
<span>Group DMs enabled</span>
<select
.value=${props.slackForm.groupEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
groupEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Group DM channels</span>
<input
.value=${props.slackForm.groupChannels}
@input=${(e: Event) =>
props.onSlackChange({
groupChannels: (e.target as HTMLInputElement).value,
})}
placeholder="G123, #team"
/>
</label>
<label class="field">
<span>Reaction notifications</span>
<select
.value=${props.slackForm.reactionNotifications}
@change=${(e: Event) =>
props.onSlackChange({
reactionNotifications: (e.target as HTMLSelectElement)
.value as "off" | "own" | "all" | "allowlist",
})}
>
<option value="off">Off</option>
<option value="own">Own</option>
<option value="all">All</option>
<option value="allowlist">Allowlist</option>
</select>
</label>
<label class="field">
<span>Reaction allowlist</span>
<input
.value=${props.slackForm.reactionAllowlist}
@input=${(e: Event) =>
props.onSlackChange({
reactionAllowlist: (e.target as HTMLInputElement).value,
})}
placeholder="U123, U456"
/>
</label>
<label class="field">
<span>Text chunk limit</span>
<input
.value=${props.slackForm.textChunkLimit}
@input=${(e: Event) =>
props.onSlackChange({
textChunkLimit: (e.target as HTMLInputElement).value,
})}
placeholder="4000"
/>
</label>
<label class="field">
<span>Media max (MB)</span>
<input
.value=${props.slackForm.mediaMaxMb}
@input=${(e: Event) =>
props.onSlackChange({
mediaMaxMb: (e.target as HTMLInputElement).value,
})}
placeholder="20"
/>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Slash command</div>
<div class="form-grid" style="margin-top: 8px;">
<label class="field">
<span>Slash enabled</span>
<select
.value=${props.slackForm.slashEnabled ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEnabled: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>
<label class="field">
<span>Slash name</span>
<input
.value=${props.slackForm.slashName}
@input=${(e: Event) =>
props.onSlackChange({
slashName: (e.target as HTMLInputElement).value,
})}
placeholder="clawd"
/>
</label>
<label class="field">
<span>Slash session prefix</span>
<input
.value=${props.slackForm.slashSessionPrefix}
@input=${(e: Event) =>
props.onSlackChange({
slashSessionPrefix: (e.target as HTMLInputElement).value,
})}
placeholder="slack:slash"
/>
</label>
<label class="field">
<span>Slash ephemeral</span>
<select
.value=${props.slackForm.slashEphemeral ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
slashEphemeral: (e.target as HTMLSelectElement).value === "yes",
})}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
</div>
<div class="card-sub" style="margin-top: 16px;">Channels</div>
<div class="card-sub">
Add channel ids or #names and optionally require mentions.
</div>
<div class="list">
${props.slackForm.channels.map(
(channel, channelIndex) => html`
<div class="list-item">
<div class="list-main">
<div class="form-grid">
<label class="field">
<span>Channel id / name</span>
<input
.value=${channel.key}
@input=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
key: (e.target as HTMLInputElement).value,
};
props.onSlackChange({ channels: next });
}}
/>
</label>
<label class="field">
<span>Allow</span>
<select
.value=${channel.allow ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
allow:
(e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>Require mention</span>
<select
.value=${channel.requireMention ? "yes" : "no"}
@change=${(e: Event) => {
const next = [...props.slackForm.channels];
next[channelIndex] = {
...next[channelIndex],
requireMention:
(e.target as HTMLSelectElement).value === "yes",
};
props.onSlackChange({ channels: next });
}}
>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</label>
<label class="field">
<span>&nbsp;</span>
<button
class="btn"
@click=${() => {
const next = [...props.slackForm.channels];
next.splice(channelIndex, 1);
props.onSlackChange({ channels: next });
}}
>
Remove
</button>
</label>
</div>
</div>
</div>
`,
)}
</div>
<button
class="btn"
style="margin-top: 8px;"
@click=${() =>
props.onSlackChange({
channels: [
...props.slackForm.channels,
{ key: "", allow: true, requireMention: false },
],
})}
>
Add channel
</button>
<div class="card-sub" style="margin-top: 16px;">Tool actions</div>
<div class="form-grid" style="margin-top: 8px;">
${slackActionOptions.map(
(action) => html`<label class="field">
<span>${action.label}</span>
<select
.value=${props.slackForm.actions[action.key] ? "yes" : "no"}
@change=${(e: Event) =>
props.onSlackChange({
actions: {
...props.slackForm.actions,
[action.key]: (e.target as HTMLSelectElement).value === "yes",
},
})}
>
<option value="yes">Enabled</option>
<option value="no">Disabled</option>
</select>
</label>`,
)}
</div>
${props.slackTokenLocked || props.slackAppTokenLocked
? html`<div class="callout" style="margin-top: 12px;">
${props.slackTokenLocked ? "SLACK_BOT_TOKEN " : ""}
${props.slackAppTokenLocked ? "SLACK_APP_TOKEN " : ""}
is set in the environment. Config edits will not override it.
</div>`
: nothing}
${props.slackStatus
? html`<div class="callout" style="margin-top: 12px;">
${props.slackStatus}
</div>`
: nothing}
<div class="row" style="margin-top: 14px;">
<button
class="btn primary"
?disabled=${props.slackSaving}
@click=${() => props.onSlackSave()}
>
${props.slackSaving ? "Saving…" : "Save"}
</button>
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}
case "signal": {
const signal = data.signal;
return html`
@@ -1355,4 +1765,3 @@ function renderProvider(
return nothing;
}
}