@chrysb/alphaclaw 0.6.2-beta.4 → 0.6.2-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/lib/public/assets/icons/slack.svg +17 -0
  2. package/lib/public/css/cron.css +91 -39
  3. package/lib/public/js/components/add-channel-menu.js +59 -0
  4. package/lib/public/js/components/agents-tab/agent-bindings-section/index.js +14 -38
  5. package/lib/public/js/components/agents-tab/agent-bindings-section/use-channel-items.js +0 -6
  6. package/lib/public/js/components/agents-tab/create-channel-modal.js +185 -47
  7. package/lib/public/js/components/channels.js +15 -44
  8. package/lib/public/js/components/cron-tab/cron-calendar.js +287 -164
  9. package/lib/public/js/components/cron-tab/cron-insights-panel.js +325 -0
  10. package/lib/public/js/components/cron-tab/cron-job-detail.js +38 -363
  11. package/lib/public/js/components/cron-tab/cron-job-settings-card.js +233 -0
  12. package/lib/public/js/components/cron-tab/cron-overview.js +40 -19
  13. package/lib/public/js/components/cron-tab/cron-prompt-editor.js +173 -0
  14. package/lib/public/js/components/cron-tab/cron-run-history-panel.js +69 -56
  15. package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +20 -2
  16. package/lib/public/js/components/cron-tab/index.js +170 -78
  17. package/lib/public/js/components/envars.js +4 -3
  18. package/lib/public/js/components/file-viewer/editor-surface.js +5 -1
  19. package/lib/public/js/components/file-viewer/use-editor-line-number-sync.js +36 -0
  20. package/lib/public/js/components/file-viewer/use-file-viewer.js +7 -23
  21. package/lib/public/js/components/file-viewer/utils.js +1 -5
  22. package/lib/public/js/components/onboarding/pairing-utils.js +1 -0
  23. package/lib/public/js/components/onboarding/welcome-config.js +31 -1
  24. package/lib/public/js/components/onboarding/welcome-form-step.js +145 -67
  25. package/lib/public/js/components/onboarding/welcome-pairing-step.js +89 -50
  26. package/lib/public/js/components/pairings.js +1 -1
  27. package/lib/public/js/components/welcome/index.js +1 -0
  28. package/lib/public/js/lib/channel-provider-availability.js +23 -0
  29. package/lib/server/agents/channels.js +110 -6
  30. package/lib/server/agents/shared.js +70 -1
  31. package/lib/server/constants.js +13 -0
  32. package/lib/server/gateway.js +28 -11
  33. package/lib/server/onboarding/openclaw.js +30 -0
  34. package/lib/server/onboarding/validation.js +1 -1
  35. package/lib/server/routes/pairings.js +2 -2
  36. package/lib/server/routes/system.js +9 -2
  37. package/lib/server/slack-api.js +38 -0
  38. package/lib/server/watchdog-notify.js +20 -3
  39. package/lib/server.js +3 -1
  40. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
+ import { useEffect, useMemo, useRef, useState } from "https://esm.sh/preact/hooks";
2
3
  import htm from "https://esm.sh/htm";
3
4
  import { ActionButton } from "../action-button.js";
4
5
  import { PageHeader } from "../page-header.js";
@@ -11,94 +12,185 @@ import { useCronTab } from "./use-cron-tab.js";
11
12
  const html = htm.bind(h);
12
13
 
13
14
  export const CronTab = ({ jobId = "", onSetLocation = () => {} }) => {
14
- const { refs, state, actions } = useCronTab({ jobId, onSetLocation });
15
+ const { state, actions } = useCronTab({ jobId, onSetLocation });
16
+ const [showJobSelector, setShowJobSelector] = useState(false);
17
+ const selectorShellRef = useRef(null);
15
18
  const isAllJobsSelected = state.selectedRouteKey === kAllCronJobsRouteKey;
16
19
  const noJobs = state.jobs.length === 0;
20
+ const selectedJob = state.selectedJob;
21
+ const selectedJobLabel = useMemo(() => {
22
+ if (isAllJobsSelected) return "All jobs";
23
+ const selectedJob = state.jobs.find(
24
+ (job) => String(job?.id || "") === String(state.selectedRouteKey || ""),
25
+ );
26
+ return String(selectedJob?.name || selectedJob?.id || "All jobs");
27
+ }, [isAllJobsSelected, state.jobs, state.selectedRouteKey]);
28
+ const hasUnsavedDetailChanges = useMemo(() => {
29
+ if (isAllJobsSelected || !selectedJob) return false;
30
+ const sessionTarget = String(
31
+ state.routingDraft?.sessionTarget || selectedJob?.sessionTarget || "main",
32
+ );
33
+ const wakeMode = String(
34
+ state.routingDraft?.wakeMode || selectedJob?.wakeMode || "now",
35
+ );
36
+ const deliveryMode = String(
37
+ state.routingDraft?.deliveryMode || selectedJob?.delivery?.mode || "none",
38
+ );
39
+ const currentSessionTarget = String(selectedJob?.sessionTarget || "main");
40
+ const currentWakeMode = String(selectedJob?.wakeMode || "now");
41
+ const currentDeliveryMode = String(selectedJob?.delivery?.mode || "none");
42
+ const isRoutingDirty =
43
+ sessionTarget !== currentSessionTarget ||
44
+ wakeMode !== currentWakeMode ||
45
+ deliveryMode !== currentDeliveryMode;
46
+ const isPromptDirty = state.promptValue !== state.savedPromptValue;
47
+ return isRoutingDirty || isPromptDirty;
48
+ }, [
49
+ isAllJobsSelected,
50
+ selectedJob,
51
+ state.promptValue,
52
+ state.routingDraft?.deliveryMode,
53
+ state.routingDraft?.sessionTarget,
54
+ state.routingDraft?.wakeMode,
55
+ state.savedPromptValue,
56
+ ]);
57
+
58
+ useEffect(() => {
59
+ if (!showJobSelector) return () => {};
60
+ const handlePointerDown = (event) => {
61
+ if (selectorShellRef.current?.contains(event.target)) return;
62
+ setShowJobSelector(false);
63
+ };
64
+ const handleKeyDown = (event) => {
65
+ if (event.key !== "Escape") return;
66
+ setShowJobSelector(false);
67
+ };
68
+ window.addEventListener("pointerdown", handlePointerDown);
69
+ window.addEventListener("keydown", handleKeyDown);
70
+ return () => {
71
+ window.removeEventListener("pointerdown", handlePointerDown);
72
+ window.removeEventListener("keydown", handleKeyDown);
73
+ };
74
+ }, [showJobSelector]);
75
+
76
+ const handleSelectAllJobs = () => {
77
+ actions.selectAllJobs();
78
+ setShowJobSelector(false);
79
+ };
80
+
81
+ const handleSelectJob = (nextJobId) => {
82
+ actions.selectJob(nextJobId);
83
+ setShowJobSelector(false);
84
+ };
17
85
 
18
86
  return html`
19
87
  <div class="cron-tab-shell">
20
88
  <div class="cron-tab-header">
21
- <${PageHeader}
22
- title="Cron Jobs"
23
- actions=${html`
24
- <${ActionButton}
25
- onClick=${actions.refreshAll}
26
- tone="secondary"
27
- size="sm"
28
- idleLabel="Refresh"
29
- />
30
- `}
31
- />
89
+ <div class="cron-tab-header-content">
90
+ <${PageHeader}
91
+ leading=${html`
92
+ <div class="cron-tab-selector-shell" ref=${selectorShellRef}>
93
+ <button
94
+ type="button"
95
+ class=${`cron-tab-selector-toggle ${showJobSelector ? "is-open" : ""}`}
96
+ onClick=${() => setShowJobSelector((value) => !value)}
97
+ aria-expanded=${showJobSelector}
98
+ aria-haspopup="listbox"
99
+ >
100
+ <span class="cron-tab-selector-title">${selectedJobLabel}</span>
101
+ <span class="cron-tab-selector-caret">▾</span>
102
+ </button>
103
+ ${showJobSelector
104
+ ? html`
105
+ <div class="cron-tab-selector-dropdown">
106
+ <${CronJobList}
107
+ jobs=${state.jobs}
108
+ selectedRouteKey=${state.selectedRouteKey}
109
+ onSelectAllJobs=${handleSelectAllJobs}
110
+ onSelectJob=${handleSelectJob}
111
+ />
112
+ </div>
113
+ `
114
+ : null}
115
+ </div>
116
+ `}
117
+ actions=${html`
118
+ ${isAllJobsSelected || noJobs
119
+ ? html`
120
+ <${ActionButton}
121
+ onClick=${actions.refreshAll}
122
+ tone="secondary"
123
+ size="sm"
124
+ idleLabel="Refresh"
125
+ />
126
+ `
127
+ : html`
128
+ <${ActionButton}
129
+ onClick=${actions.saveChanges}
130
+ loading=${state.savingChanges}
131
+ disabled=${!hasUnsavedDetailChanges}
132
+ tone="primary"
133
+ size="sm"
134
+ idleLabel="Save changes"
135
+ loadingLabel="Saving..."
136
+ />
137
+ `}
138
+ `}
139
+ />
140
+ </div>
32
141
  </div>
33
142
  <div class="cron-tab-main">
34
- <aside
35
- ref=${refs.listPanelRef}
36
- class="cron-list-panel"
37
- style=${{ width: `${state.listPanelWidthPx}px` }}
38
- >
39
- <${CronJobList}
40
- jobs=${state.jobs}
41
- selectedRouteKey=${state.selectedRouteKey}
42
- onSelectAllJobs=${actions.selectAllJobs}
43
- onSelectJob=${actions.selectJob}
44
- />
45
- </aside>
46
- <div
47
- class=${`cron-list-resizer ${state.isResizingListPanel ? "is-resizing" : ""}`}
48
- onpointerdown=${actions.onListResizerPointerDown}
49
- role="separator"
50
- aria-orientation="vertical"
51
- aria-label="Resize cron jobs list"
52
- ></div>
53
- <main class="cron-detail-panel">
54
- ${noJobs
55
- ? html`
56
- <div class="h-full flex items-center justify-center text-sm text-gray-500">
57
- No cron jobs configured. Cron jobs are managed via the OpenClaw CLI.
58
- </div>
59
- `
60
- : isAllJobsSelected
143
+ <div class="cron-tab-main-content">
144
+ <main class="cron-detail-panel">
145
+ ${noJobs
61
146
  ? html`
62
- <${CronOverview}
63
- jobs=${state.jobs}
64
- status=${state.status}
65
- bulkUsageByJobId=${state.bulkUsageByJobId}
66
- bulkRunsByJobId=${state.bulkRunsByJobId}
67
- onSelectJob=${actions.selectJob}
68
- />
147
+ <div class="h-full flex items-center justify-center text-sm text-gray-500">
148
+ No cron jobs configured. Cron jobs are managed via the OpenClaw CLI.
149
+ </div>
69
150
  `
70
- : html`
71
- <${CronJobDetail}
72
- job=${state.selectedJob}
73
- runEntries=${state.runEntries}
74
- runTotal=${state.runTotal}
75
- runHasMore=${state.runHasMore}
76
- loadingMoreRuns=${state.loadingMoreRuns}
77
- runStatusFilter=${state.runStatusFilter}
78
- onSetRunStatusFilter=${actions.setRunStatusFilter}
79
- onLoadMoreRuns=${actions.loadMoreRuns}
80
- onRunNow=${actions.runSelectedJobNow}
81
- runningJob=${state.runningJob}
82
- onToggleEnabled=${actions.setSelectedJobEnabled}
83
- togglingJobEnabled=${state.togglingJobEnabled}
84
- usage=${state.usage}
85
- usageDays=${state.usageDays}
86
- onSetUsageDays=${actions.setUsageDays}
87
- promptValue=${state.promptValue}
88
- savedPromptValue=${state.savedPromptValue}
89
- onChangePrompt=${actions.setPromptValue}
90
- onSaveChanges=${actions.saveChanges}
91
- savingChanges=${state.savingChanges}
92
- routingDraft=${state.routingDraft}
93
- onChangeRoutingDraft=${actions.setRoutingDraft}
94
- deliverySessions=${state.deliverySessions}
95
- loadingDeliverySessions=${state.loadingDeliverySessions}
96
- deliverySessionsError=${state.deliverySessionsError}
97
- destinationSessionKey=${state.destinationSessionKey}
98
- onChangeDestinationSessionKey=${actions.setDestinationSessionKey}
99
- />
100
- `}
101
- </main>
151
+ : isAllJobsSelected
152
+ ? html`
153
+ <${CronOverview}
154
+ jobs=${state.jobs}
155
+ status=${state.status}
156
+ bulkUsageByJobId=${state.bulkUsageByJobId}
157
+ bulkRunsByJobId=${state.bulkRunsByJobId}
158
+ onSelectJob=${handleSelectJob}
159
+ />
160
+ `
161
+ : html`
162
+ <${CronJobDetail}
163
+ job=${state.selectedJob}
164
+ runEntries=${state.runEntries}
165
+ runTotal=${state.runTotal}
166
+ runHasMore=${state.runHasMore}
167
+ loadingMoreRuns=${state.loadingMoreRuns}
168
+ runStatusFilter=${state.runStatusFilter}
169
+ onSetRunStatusFilter=${actions.setRunStatusFilter}
170
+ onLoadMoreRuns=${actions.loadMoreRuns}
171
+ onRunNow=${actions.runSelectedJobNow}
172
+ runningJob=${state.runningJob}
173
+ onToggleEnabled=${actions.setSelectedJobEnabled}
174
+ togglingJobEnabled=${state.togglingJobEnabled}
175
+ usage=${state.usage}
176
+ usageDays=${state.usageDays}
177
+ onSetUsageDays=${actions.setUsageDays}
178
+ promptValue=${state.promptValue}
179
+ savedPromptValue=${state.savedPromptValue}
180
+ onChangePrompt=${actions.setPromptValue}
181
+ onSaveChanges=${actions.saveChanges}
182
+ savingChanges=${state.savingChanges}
183
+ routingDraft=${state.routingDraft}
184
+ onChangeRoutingDraft=${actions.setRoutingDraft}
185
+ deliverySessions=${state.deliverySessions}
186
+ loadingDeliverySessions=${state.loadingDeliverySessions}
187
+ deliverySessionsError=${state.deliverySessionsError}
188
+ destinationSessionKey=${state.destinationSessionKey}
189
+ onChangeDestinationSessionKey=${actions.setDestinationSessionKey}
190
+ />
191
+ `}
192
+ </main>
193
+ </div>
102
194
  </div>
103
195
  </div>
104
196
  `;
@@ -45,7 +45,8 @@ const kFeatureIconByName = {
45
45
  },
46
46
  };
47
47
  const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
48
- const kManagedChannelTokenPattern = /^(TELEGRAM|DISCORD)_BOT_TOKEN(?:_[A-Z0-9_]+)?$/;
48
+ const kManagedChannelTokenPattern =
49
+ /^(?:TELEGRAM_BOT_TOKEN|DISCORD_BOT_TOKEN|SLACK_BOT_TOKEN|SLACK_APP_TOKEN)(?:_[A-Z0-9_]+)?$/;
49
50
  const stripSurroundingQuotes = (raw) => {
50
51
  const value = String(raw || "").trim();
51
52
  if (value.length < 2) return value;
@@ -342,7 +343,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
342
343
  if (managedChannelKeys.length) {
343
344
  const uniqueManagedKeys = Array.from(new Set(managedChannelKeys));
344
345
  showToast(
345
- `Channel bot tokens are managed from Channels: ${uniqueManagedKeys.join(", ")}`,
346
+ `Channel tokens are managed from Channels: ${uniqueManagedKeys.join(", ")}`,
346
347
  "error",
347
348
  );
348
349
  }
@@ -383,7 +384,7 @@ export const Envars = ({ onRestartRequired = () => {} }) => {
383
384
  const key = normalizeEnvVarKey(newKey);
384
385
  if (!key) return;
385
386
  if (isManagedChannelTokenKey(key)) {
386
- showToast(`Channel bot tokens are managed from Channels: ${key}`, "error");
387
+ showToast(`Channel tokens are managed from Channels: ${key}`, "error");
387
388
  return;
388
389
  }
389
390
  if (reservedKeys.has(key)) {
@@ -13,6 +13,7 @@ const EditorTextarea = ({
13
13
  handleEditorSelectionChange,
14
14
  isEditBlocked,
15
15
  isPreviewOnly,
16
+ textareaWrap = "soft",
16
17
  }) => html`
17
18
  <textarea
18
19
  class=${overlay ? "file-viewer-editor file-viewer-editor-overlay" : "file-viewer-editor"}
@@ -33,7 +34,7 @@ const EditorTextarea = ({
33
34
  data-gramm="false"
34
35
  data-gramm_editor="false"
35
36
  data-enable-grammarly="false"
36
- wrap="soft"
37
+ wrap=${textareaWrap}
37
38
  ></textarea>
38
39
  `;
39
40
 
@@ -55,6 +56,7 @@ export const EditorSurface = ({
55
56
  handleEditorSelectionChange,
56
57
  isEditBlocked,
57
58
  isPreviewOnly,
59
+ textareaWrap = "soft",
58
60
  }) => html`
59
61
  <div class=${editorShellClassName} aria-hidden=${editorShellAriaHidden}>
60
62
  <div class="file-viewer-editor-line-num-col" ref=${editorLineNumbersRef}>
@@ -106,6 +108,7 @@ export const EditorSurface = ({
106
108
  handleEditorSelectionChange=${handleEditorSelectionChange}
107
109
  isEditBlocked=${isEditBlocked}
108
110
  isPreviewOnly=${isPreviewOnly}
111
+ textareaWrap=${textareaWrap}
109
112
  />
110
113
  </div>
111
114
  `
@@ -120,6 +123,7 @@ export const EditorSurface = ({
120
123
  handleEditorSelectionChange=${handleEditorSelectionChange}
121
124
  isEditBlocked=${isEditBlocked}
122
125
  isPreviewOnly=${isPreviewOnly}
126
+ textareaWrap=${textareaWrap}
123
127
  />
124
128
  `}
125
129
  </div>
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect } from "https://esm.sh/preact/hooks";
2
+
3
+ export const useEditorLineNumberSync = ({
4
+ enabled = false,
5
+ syncKey = "",
6
+ editorLineNumberRowRefs,
7
+ editorHighlightLineRefs,
8
+ }) => {
9
+ const syncEditorLineNumberHeights = useCallback(() => {
10
+ if (!enabled) return;
11
+ const numberRows = editorLineNumberRowRefs?.current || [];
12
+ const highlightRows = editorHighlightLineRefs?.current || [];
13
+ const rowCount = Math.min(numberRows.length, highlightRows.length);
14
+ for (let index = 0; index < rowCount; index += 1) {
15
+ const numberRow = numberRows[index];
16
+ const highlightRow = highlightRows[index];
17
+ if (!numberRow || !highlightRow) continue;
18
+ numberRow.style.height = `${highlightRow.offsetHeight}px`;
19
+ }
20
+ }, [editorHighlightLineRefs, editorLineNumberRowRefs, enabled]);
21
+
22
+ useEffect(() => {
23
+ syncEditorLineNumberHeights();
24
+ }, [syncEditorLineNumberHeights, syncKey]);
25
+
26
+ useEffect(() => {
27
+ if (!enabled) return () => {};
28
+ const onResize = () => syncEditorLineNumberHeights();
29
+ window.addEventListener("resize", onResize);
30
+ return () => window.removeEventListener("resize", onResize);
31
+ }, [enabled, syncEditorLineNumberHeights]);
32
+
33
+ return {
34
+ syncEditorLineNumberHeights,
35
+ };
36
+ };
@@ -32,6 +32,7 @@ import { useFileDiff } from "./use-file-diff.js";
32
32
  import { useFileViewerDraftSync } from "./use-file-viewer-draft-sync.js";
33
33
  import { useFileViewerHotkeys } from "./use-file-viewer-hotkeys.js";
34
34
  import { useEditorSelectionRestore } from "./use-editor-selection-restore.js";
35
+ import { useEditorLineNumberSync } from "./use-editor-line-number-sync.js";
35
36
 
36
37
  export const useFileViewer = ({
37
38
  filePath = "",
@@ -190,29 +191,12 @@ export const useFileViewer = ({
190
191
  [parsedFrontmatter.body, isMarkdownFile],
191
192
  );
192
193
 
193
- const syncEditorLineNumberHeights = useCallback(() => {
194
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return;
195
- const numberRows = editorLineNumberRowRefs.current;
196
- const highlightRows = editorHighlightLineRefs.current;
197
- const rowCount = Math.min(numberRows.length, highlightRows.length);
198
- for (let index = 0; index < rowCount; index += 1) {
199
- const numberRow = numberRows[index];
200
- const highlightRow = highlightRows[index];
201
- if (!numberRow || !highlightRow) continue;
202
- numberRow.style.height = `${highlightRow.offsetHeight}px`;
203
- }
204
- }, [shouldUseHighlightedEditor, viewMode]);
205
-
206
- useEffect(() => {
207
- syncEditorLineNumberHeights();
208
- }, [content, syncEditorLineNumberHeights]);
209
-
210
- useEffect(() => {
211
- if (!shouldUseHighlightedEditor || viewMode !== "edit") return () => {};
212
- const onResize = () => syncEditorLineNumberHeights();
213
- window.addEventListener("resize", onResize);
214
- return () => window.removeEventListener("resize", onResize);
215
- }, [shouldUseHighlightedEditor, viewMode, syncEditorLineNumberHeights]);
194
+ useEditorLineNumberSync({
195
+ enabled: shouldUseHighlightedEditor && viewMode === "edit",
196
+ syncKey: `${normalizedPath}:${renderContent.length}:${highlightedEditorLines.length}`,
197
+ editorLineNumberRowRefs,
198
+ editorHighlightLineRefs,
199
+ });
216
200
 
217
201
  useEffect(() => {
218
202
  if (!isMarkdownFile && viewMode !== "edit") {
@@ -13,11 +13,7 @@ export const clampSelectionIndex = (value, maxValue) => {
13
13
  export const countTextLines = (content) => {
14
14
  const text = String(content || "");
15
15
  if (!text) return 1;
16
- let lineCount = 1;
17
- for (let index = 0; index < text.length; index += 1) {
18
- if (text.charCodeAt(index) === 10) lineCount += 1;
19
- }
20
- return lineCount;
16
+ return text.split(/\r\n|\r|\n/).length;
21
17
  };
22
18
 
23
19
  export const shouldUseSimpleEditorMode = ({
@@ -1,6 +1,7 @@
1
1
  export const getPreferredPairingChannel = (vals = {}) => {
2
2
  if (vals.TELEGRAM_BOT_TOKEN) return "telegram";
3
3
  if (vals.DISCORD_BOT_TOKEN) return "discord";
4
+ if (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN) return "slack";
4
5
  return "";
5
6
  };
6
7
 
@@ -121,8 +121,38 @@ export const kWelcomeGroups = [
121
121
  >`,
122
122
  placeholder: "MTQ3...",
123
123
  },
124
+ {
125
+ key: "SLACK_BOT_TOKEN",
126
+ label: "Slack Bot Token",
127
+ hint: html`From your Slack app's${" "}<a
128
+ href="https://api.slack.com/apps"
129
+ target="_blank"
130
+ class="hover:underline"
131
+ style="color: var(--accent-link)"
132
+ >OAuth & Permissions</a
133
+ >${" "}page${" "}·${" "}<a
134
+ href="https://docs.openclaw.ai/channels/slack"
135
+ target="_blank"
136
+ class="hover:underline"
137
+ style="color: var(--accent-link)"
138
+ >full guide</a
139
+ >`,
140
+ placeholder: "xoxb-...",
141
+ },
142
+ {
143
+ key: "SLACK_APP_TOKEN",
144
+ label: "Slack App Token (Socket Mode)",
145
+ hint: html`From${" "}<a
146
+ href="https://api.slack.com/apps"
147
+ target="_blank"
148
+ class="hover:underline"
149
+ style="color: var(--accent-link)"
150
+ >Basic Information</a
151
+ >${" "}→ App-Level Tokens (needs${" "}<code>connections:write</code>${" "}scope)`,
152
+ placeholder: "xapp-...",
153
+ },
124
154
  ],
125
- validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN),
155
+ validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)),
126
156
  },
127
157
  {
128
158
  id: "tools",