@pinet/slack-bridge 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +570 -0
- package/dist/activity-log.d.ts +62 -0
- package/dist/activity-log.js +293 -0
- package/dist/agent-completion-runtime.d.ts +17 -0
- package/dist/agent-completion-runtime.js +26 -0
- package/dist/agent-event-runtime.d.ts +13 -0
- package/dist/agent-event-runtime.js +27 -0
- package/dist/agent-prompt-guidance.d.ts +22 -0
- package/dist/agent-prompt-guidance.js +46 -0
- package/dist/broker/adapters/slack.d.ts +74 -0
- package/dist/broker/adapters/slack.js +563 -0
- package/dist/broker/adapters/types.d.ts +1 -0
- package/dist/broker/adapters/types.js +1 -0
- package/dist/broker/agent-messaging.d.ts +1 -0
- package/dist/broker/agent-messaging.js +1 -0
- package/dist/broker/auth.d.ts +1 -0
- package/dist/broker/auth.js +1 -0
- package/dist/broker/client.d.ts +167 -0
- package/dist/broker/client.js +624 -0
- package/dist/broker/control-plane-dashboard.d.ts +98 -0
- package/dist/broker/control-plane-dashboard.js +213 -0
- package/dist/broker/ghost-reaper.d.ts +49 -0
- package/dist/broker/ghost-reaper.js +209 -0
- package/dist/broker/index.d.ts +36 -0
- package/dist/broker/index.js +90 -0
- package/dist/broker/leader.d.ts +1 -0
- package/dist/broker/leader.js +1 -0
- package/dist/broker/maintenance.d.ts +1 -0
- package/dist/broker/maintenance.js +1 -0
- package/dist/broker/message-send.d.ts +1 -0
- package/dist/broker/message-send.js +1 -0
- package/dist/broker/paths.d.ts +1 -0
- package/dist/broker/paths.js +1 -0
- package/dist/broker/raw-tcp-loopback.d.ts +1 -0
- package/dist/broker/raw-tcp-loopback.js +1 -0
- package/dist/broker/router.d.ts +1 -0
- package/dist/broker/router.js +1 -0
- package/dist/broker/schema.d.ts +9 -0
- package/dist/broker/schema.js +63 -0
- package/dist/broker/socket-server.d.ts +120 -0
- package/dist/broker/socket-server.js +1087 -0
- package/dist/broker/types.d.ts +1 -0
- package/dist/broker/types.js +1 -0
- package/dist/broker-delivery.d.ts +10 -0
- package/dist/broker-delivery.js +26 -0
- package/dist/broker-inbound-persistence.d.ts +11 -0
- package/dist/broker-inbound-persistence.js +22 -0
- package/dist/broker-prompt-loader.d.ts +34 -0
- package/dist/broker-prompt-loader.js +185 -0
- package/dist/broker-runtime-access.d.ts +14 -0
- package/dist/broker-runtime-access.js +20 -0
- package/dist/broker-runtime.d.ts +100 -0
- package/dist/broker-runtime.js +533 -0
- package/dist/broker-thread-owner-hints.d.ts +10 -0
- package/dist/broker-thread-owner-hints.js +14 -0
- package/dist/canvases.d.ts +80 -0
- package/dist/canvases.js +221 -0
- package/dist/command-registration-runtime.d.ts +9 -0
- package/dist/command-registration-runtime.js +9 -0
- package/dist/core-tool-guardrails.d.ts +24 -0
- package/dist/core-tool-guardrails.js +66 -0
- package/dist/deploy-manifest.d.ts +27 -0
- package/dist/deploy-manifest.js +197 -0
- package/dist/follower-delivery.d.ts +16 -0
- package/dist/follower-delivery.js +70 -0
- package/dist/follower-runtime.d.ts +61 -0
- package/dist/follower-runtime.js +350 -0
- package/dist/git-metadata.d.ts +35 -0
- package/dist/git-metadata.js +88 -0
- package/dist/guardrails.d.ts +69 -0
- package/dist/guardrails.js +283 -0
- package/dist/helpers.d.ts +550 -0
- package/dist/helpers.js +2711 -0
- package/dist/home-tab.d.ts +27 -0
- package/dist/home-tab.js +242 -0
- package/dist/imessage-tools.d.ts +33 -0
- package/dist/imessage-tools.js +96 -0
- package/dist/inbox-drain-runtime.d.ts +30 -0
- package/dist/inbox-drain-runtime.js +71 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1330 -0
- package/dist/persisted-runtime-state.d.ts +52 -0
- package/dist/persisted-runtime-state.js +98 -0
- package/dist/pinet-activity-formatting.d.ts +20 -0
- package/dist/pinet-activity-formatting.js +43 -0
- package/dist/pinet-agent-status.d.ts +36 -0
- package/dist/pinet-agent-status.js +48 -0
- package/dist/pinet-commands.d.ts +70 -0
- package/dist/pinet-commands.js +402 -0
- package/dist/pinet-confirmation-replies.d.ts +7 -0
- package/dist/pinet-confirmation-replies.js +30 -0
- package/dist/pinet-control-plane-dashboard.d.ts +34 -0
- package/dist/pinet-control-plane-dashboard.js +86 -0
- package/dist/pinet-home-tabs.d.ts +36 -0
- package/dist/pinet-home-tabs.js +101 -0
- package/dist/pinet-maintenance-delivery.d.ts +22 -0
- package/dist/pinet-maintenance-delivery.js +45 -0
- package/dist/pinet-mesh-ops.d.ts +97 -0
- package/dist/pinet-mesh-ops.js +266 -0
- package/dist/pinet-registration-gate.d.ts +14 -0
- package/dist/pinet-registration-gate.js +42 -0
- package/dist/pinet-remote-control-acks.d.ts +16 -0
- package/dist/pinet-remote-control-acks.js +46 -0
- package/dist/pinet-remote-control.d.ts +17 -0
- package/dist/pinet-remote-control.js +74 -0
- package/dist/pinet-runtime-composition.d.ts +27 -0
- package/dist/pinet-runtime-composition.js +20 -0
- package/dist/pinet-tools.d.ts +67 -0
- package/dist/pinet-tools.js +1211 -0
- package/dist/prompts/broker/default.md +41 -0
- package/dist/prompts/broker/tmux.md +49 -0
- package/dist/ralph-loop.d.ts +76 -0
- package/dist/ralph-loop.js +524 -0
- package/dist/reaction-triggers.d.ts +25 -0
- package/dist/reaction-triggers.js +171 -0
- package/dist/repo-tool-guardrails.d.ts +20 -0
- package/dist/repo-tool-guardrails.js +81 -0
- package/dist/runtime-agent-context.d.ts +93 -0
- package/dist/runtime-agent-context.js +302 -0
- package/dist/runtime-mode.d.ts +10 -0
- package/dist/runtime-mode.js +39 -0
- package/dist/scheduled-wakeups.d.ts +1 -0
- package/dist/scheduled-wakeups.js +1 -0
- package/dist/session-ui-runtime.d.ts +20 -0
- package/dist/session-ui-runtime.js +163 -0
- package/dist/single-player-runtime.d.ts +71 -0
- package/dist/single-player-runtime.js +436 -0
- package/dist/skins/cosmere.json +1571 -0
- package/dist/skins/foundation.json +1304 -0
- package/dist/slack-access.d.ts +186 -0
- package/dist/slack-access.js +462 -0
- package/dist/slack-api.d.ts +2 -0
- package/dist/slack-api.js +1 -0
- package/dist/slack-block-kit.d.ts +36 -0
- package/dist/slack-block-kit.js +242 -0
- package/dist/slack-export.d.ts +31 -0
- package/dist/slack-export.js +206 -0
- package/dist/slack-message-context.d.ts +15 -0
- package/dist/slack-message-context.js +207 -0
- package/dist/slack-modals.d.ts +13 -0
- package/dist/slack-modals.js +79 -0
- package/dist/slack-pinet-runtime-adapter.d.ts +16 -0
- package/dist/slack-pinet-runtime-adapter.js +72 -0
- package/dist/slack-presence.d.ts +36 -0
- package/dist/slack-presence.js +89 -0
- package/dist/slack-request-runtime.d.ts +8 -0
- package/dist/slack-request-runtime.js +19 -0
- package/dist/slack-runtime-access.d.ts +28 -0
- package/dist/slack-runtime-access.js +107 -0
- package/dist/slack-scope-diagnostics.d.ts +31 -0
- package/dist/slack-scope-diagnostics.js +235 -0
- package/dist/slack-socket-dedup.d.ts +7 -0
- package/dist/slack-socket-dedup.js +135 -0
- package/dist/slack-thread-status.d.ts +41 -0
- package/dist/slack-thread-status.js +156 -0
- package/dist/slack-tool-policy-runtime.d.ts +36 -0
- package/dist/slack-tool-policy-runtime.js +96 -0
- package/dist/slack-tools.d.ts +52 -0
- package/dist/slack-tools.js +2688 -0
- package/dist/slack-turn-guardrails.d.ts +19 -0
- package/dist/slack-turn-guardrails.js +36 -0
- package/dist/slack-upload.d.ts +49 -0
- package/dist/slack-upload.js +224 -0
- package/dist/task-assignments.d.ts +45 -0
- package/dist/task-assignments.js +527 -0
- package/dist/thread-confirmations.d.ts +22 -0
- package/dist/thread-confirmations.js +139 -0
- package/dist/tool-registration-runtime.d.ts +13 -0
- package/dist/tool-registration-runtime.js +13 -0
- package/dist/ttl-cache.d.ts +47 -0
- package/dist/ttl-cache.js +105 -0
- package/manifest.yaml +57 -0
- package/package.json +60 -0
- package/skills/pinet-skin-creator/SKILL.md +109 -0
- package/skills/pinet-skin-creator/references/descriptor-format.md +99 -0
- package/skills/pinet-skin-creator/references/safety-checklist.md +56 -0
- package/skills/pinet-skin-creator/templates/pinet-skin-descriptor.json +77 -0
- package/skills/slack-bridge/SKILL.md +360 -0
|
@@ -0,0 +1,2688 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { buildSlackCanvasCreateRequest, buildSlackCanvasEditRequest, buildSlackCanvasSectionsLookupRequest, extractSlackCanvasCommentsPage, extractSlackChannelCanvasId, normalizeSlackCanvasCommentsLimit, normalizeSlackCanvasCreateKind, normalizeSlackCanvasUpdateMode, pickSlackCanvasSectionId, } from "./canvases.js";
|
|
4
|
+
import { normalizeSlackBlocksInput, summarizeSlackBlocksForPolicy } from "./slack-block-kit.js";
|
|
5
|
+
import { encodeSlackModalPrivateMetadata, normalizeSlackModalViewInput } from "./slack-modals.js";
|
|
6
|
+
import { findSlackPresenceDirectoryUser, formatSlackPresenceLine, formatSlackPresenceTimestamp, getBestSlackPresenceUserName, isSlackUserId, resolveSlackPresenceDndEndTs, stripSlackUserReference, } from "./slack-presence.js";
|
|
7
|
+
import { buildSlackThreadExport, filterSlackExportMessagesByRange, parseSlackExportBoundaryTs, } from "./slack-export.js";
|
|
8
|
+
import { normalizeReactionName } from "./reaction-triggers.js";
|
|
9
|
+
import { resolveScheduledWakeupFireAt } from "./scheduled-wakeups.js";
|
|
10
|
+
import { performSlackUpload, prepareSlackUpload } from "./slack-upload.js";
|
|
11
|
+
import { TtlCache } from "./ttl-cache.js";
|
|
12
|
+
import { DEFAULT_SLACK_THREAD_STATUS, normalizeSlackThreadStatus, setSlackThreadStatus, SLACK_THREAD_LOADING_MESSAGES, } from "./slack-thread-status.js";
|
|
13
|
+
const SLACK_OUTPUT_OPTION_PARAMETERS = {
|
|
14
|
+
format: Type.Optional(Type.String({ description: 'Response presentation format: "cli" (default) or "json".' })),
|
|
15
|
+
response_format: Type.Optional(Type.String({
|
|
16
|
+
description: "Alias for response presentation format. Use this when an action already owns a format argument, such as export.",
|
|
17
|
+
})),
|
|
18
|
+
full: Type.Optional(Type.Boolean({
|
|
19
|
+
description: "Include full structured details instead of compact default details.",
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
const SLACK_ACTIONS_WITH_FORMAT_ARG = new Set(["export"]);
|
|
23
|
+
const SLACK_DISPATCHER_EXAMPLES = {
|
|
24
|
+
react: [{ action: "react", args: { emoji: "👀", thread_ts: "1712345678.000100" } }],
|
|
25
|
+
read: [{ action: "read", args: { thread_ts: "1712345678.000100", limit: 20 } }],
|
|
26
|
+
upload: [
|
|
27
|
+
{
|
|
28
|
+
action: "upload",
|
|
29
|
+
args: {
|
|
30
|
+
content: "diff --git a/example b/example",
|
|
31
|
+
filename: "changes.diff",
|
|
32
|
+
filetype: "diff",
|
|
33
|
+
thread_ts: "1712345678.000100",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
schedule: [
|
|
38
|
+
{
|
|
39
|
+
action: "schedule",
|
|
40
|
+
args: { text: "Follow-up reminder", thread_ts: "1712345678.000100", delay: "30m" },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
presence: [{ action: "presence", args: { users: ["@alice", "U123456"] } }],
|
|
44
|
+
export: [{ action: "export", args: { thread_ts: "1712345678.000100", format: "markdown" } }],
|
|
45
|
+
status: [
|
|
46
|
+
{ action: "status", args: { thread_ts: "1712345678.000100", status: "Reading context…" } },
|
|
47
|
+
{ action: "status", args: { thread_ts: "1712345678.000100", clear: true } },
|
|
48
|
+
],
|
|
49
|
+
post_channel: [
|
|
50
|
+
{
|
|
51
|
+
action: "post_channel",
|
|
52
|
+
args: { channel: "#deployments", text: "Deploy complete" },
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
read_channel: [{ action: "read_channel", args: { channel: "#deployments", limit: 20 } }],
|
|
56
|
+
confirm_action: [
|
|
57
|
+
{
|
|
58
|
+
action: "confirm_action",
|
|
59
|
+
args: {
|
|
60
|
+
thread_ts: "1712345678.000100",
|
|
61
|
+
tool: "slack:delete",
|
|
62
|
+
action: "channel=#deployments | thread_ts=1712345678.000100 | ts=1712345678.000200 | thread=false",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
delete: [
|
|
67
|
+
{
|
|
68
|
+
action: "delete",
|
|
69
|
+
args: { ts: "1712345678.000200", thread_ts: "1712345678.000100", confirm: true },
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
pin: [
|
|
73
|
+
{
|
|
74
|
+
action: "pin",
|
|
75
|
+
args: { action: "pin", message_ts: "1712345678.000100", thread_ts: "1712345678.000100" },
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
bookmark: [
|
|
79
|
+
{
|
|
80
|
+
action: "bookmark",
|
|
81
|
+
args: { action: "add", channel: "#project", title: "Runbook", url: "https://example.com" },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
create_channel: [{ action: "create_channel", args: { name: "proj-alpha" } }],
|
|
85
|
+
project_create: [{ action: "project_create", args: { name: "proj-alpha", topic: "Alpha" } }],
|
|
86
|
+
canvas_comments_read: [
|
|
87
|
+
{ action: "canvas_comments_read", args: { canvas_id: "F0123", limit: 20 } },
|
|
88
|
+
],
|
|
89
|
+
canvas_create: [
|
|
90
|
+
{ action: "canvas_create", args: { title: "Launch notes", markdown: "# Launch" } },
|
|
91
|
+
{
|
|
92
|
+
action: "canvas_create",
|
|
93
|
+
args: { kind: "channel", channel: "#project", title: "Runbook", markdown: "# Runbook" },
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
canvas_update: [
|
|
97
|
+
{
|
|
98
|
+
action: "canvas_update",
|
|
99
|
+
args: { canvas_id: "F0123", markdown: "## Update", mode: "append" },
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
modal_open: [
|
|
103
|
+
{
|
|
104
|
+
action: "modal_open",
|
|
105
|
+
args: { trigger_id: "fresh-trigger-id", view: { type: "modal", blocks: [] } },
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
modal_push: [
|
|
109
|
+
{
|
|
110
|
+
action: "modal_push",
|
|
111
|
+
args: { trigger_id: "fresh-trigger-id", view: { type: "modal", blocks: [] } },
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
modal_update: [
|
|
115
|
+
{
|
|
116
|
+
action: "modal_update",
|
|
117
|
+
args: { view_id: "V123", view: { type: "modal", blocks: [] } },
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
function isRecord(value) {
|
|
122
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
123
|
+
}
|
|
124
|
+
function normalizeSlackDispatcherActionName(value) {
|
|
125
|
+
if (typeof value !== "string") {
|
|
126
|
+
throw new Error("slack action must be a string. Use action='help' to list available actions.");
|
|
127
|
+
}
|
|
128
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
129
|
+
if (!normalized) {
|
|
130
|
+
throw new Error("slack action is required. Use action='help' to list available actions.");
|
|
131
|
+
}
|
|
132
|
+
if (normalized.startsWith("slack:"))
|
|
133
|
+
return normalized.slice("slack:".length);
|
|
134
|
+
if (normalized.startsWith("slack_"))
|
|
135
|
+
return normalized.slice("slack_".length);
|
|
136
|
+
return normalized;
|
|
137
|
+
}
|
|
138
|
+
function normalizeSlackGuardrailToolName(toolName) {
|
|
139
|
+
const normalized = toolName.trim().toLowerCase().replace(/-/g, "_");
|
|
140
|
+
if (normalized.startsWith("slack:"))
|
|
141
|
+
return normalized;
|
|
142
|
+
if (normalized.startsWith("slack_") &&
|
|
143
|
+
normalized !== "slack_inbox" &&
|
|
144
|
+
normalized !== "slack_send") {
|
|
145
|
+
return `slack:${normalized.slice("slack_".length)}`;
|
|
146
|
+
}
|
|
147
|
+
return toolName;
|
|
148
|
+
}
|
|
149
|
+
function isAbortError(error) {
|
|
150
|
+
return error instanceof Error && error.name === "AbortError";
|
|
151
|
+
}
|
|
152
|
+
function getErrorMessage(error) {
|
|
153
|
+
return error instanceof Error ? error.message : String(error);
|
|
154
|
+
}
|
|
155
|
+
function isPinetDeliveryFallbackError(error) {
|
|
156
|
+
const lower = getErrorMessage(error).toLowerCase();
|
|
157
|
+
if (lower.includes("already owned"))
|
|
158
|
+
return false;
|
|
159
|
+
return (lower.includes("not running") ||
|
|
160
|
+
lower.includes("unexpected state") ||
|
|
161
|
+
lower.includes("unavailable") ||
|
|
162
|
+
lower.includes("not connected") ||
|
|
163
|
+
lower.includes("disconnected") ||
|
|
164
|
+
lower.includes("timeout") ||
|
|
165
|
+
lower.includes("timed out") ||
|
|
166
|
+
lower.includes("econn") ||
|
|
167
|
+
lower.includes("socket") ||
|
|
168
|
+
lower.includes("no transport source") ||
|
|
169
|
+
lower.includes("no transport channel") ||
|
|
170
|
+
lower.includes("no adapter") ||
|
|
171
|
+
lower.includes("identity is unavailable"));
|
|
172
|
+
}
|
|
173
|
+
function classifySlackDispatcherError(error) {
|
|
174
|
+
const message = getErrorMessage(error);
|
|
175
|
+
const lower = message.toLowerCase();
|
|
176
|
+
if (lower.includes("not_authed") ||
|
|
177
|
+
lower.includes("invalid_auth") ||
|
|
178
|
+
lower.includes("token_revoked") ||
|
|
179
|
+
lower.includes("missing_scope") ||
|
|
180
|
+
lower.includes("access_denied")) {
|
|
181
|
+
return {
|
|
182
|
+
class: "auth",
|
|
183
|
+
message,
|
|
184
|
+
retryable: false,
|
|
185
|
+
hint: "Check Slack token configuration, app installation, channel membership, and required OAuth scopes.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (lower.includes("rate_limited") || lower.includes("rate limit")) {
|
|
189
|
+
return {
|
|
190
|
+
class: "rate-limit",
|
|
191
|
+
message,
|
|
192
|
+
retryable: true,
|
|
193
|
+
hint: "Wait for Slack rate limits to clear, then retry the same action if it is idempotent.",
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (lower.includes("not_found") ||
|
|
197
|
+
lower.includes("not found") ||
|
|
198
|
+
lower.includes("channel_not_found") ||
|
|
199
|
+
lower.includes("user_not_found") ||
|
|
200
|
+
lower.includes("canvas_not_found") ||
|
|
201
|
+
lower.includes("file_not_found")) {
|
|
202
|
+
return {
|
|
203
|
+
class: "not-found",
|
|
204
|
+
message,
|
|
205
|
+
retryable: false,
|
|
206
|
+
hint: "Verify Slack IDs, channel names, canvas/file IDs, and whether the bot can access the target.",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
if (lower.includes("files.getuploadurlexternal") && lower.includes("invalid_arguments")) {
|
|
210
|
+
return {
|
|
211
|
+
class: "input",
|
|
212
|
+
message,
|
|
213
|
+
retryable: false,
|
|
214
|
+
hint: "Slack rejected upload metadata for files.getUploadURLExternal (invalid_arguments). Check filename and snippet_type; remove unsupported snippet extensions for inline content and retry.",
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (lower.includes("slack raw upload failed")) {
|
|
218
|
+
const rawStatusMatch = message.match(/Slack raw upload failed \(http\s*(\d{3})/i);
|
|
219
|
+
const rawStatus = rawStatusMatch?.[1];
|
|
220
|
+
if (rawStatus === "429") {
|
|
221
|
+
return {
|
|
222
|
+
class: "rate-limit",
|
|
223
|
+
message,
|
|
224
|
+
retryable: true,
|
|
225
|
+
hint: "Wait for Slack rate limits to clear, then retry the same action if it is idempotent.",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (lower.includes("transport") ||
|
|
229
|
+
lower.includes("network") ||
|
|
230
|
+
lower.includes("fetch") ||
|
|
231
|
+
lower.includes("timeout") ||
|
|
232
|
+
lower.includes("econnreset") ||
|
|
233
|
+
lower.includes("etimedout") ||
|
|
234
|
+
lower.includes("possible outbound proxy/firewall block")) {
|
|
235
|
+
return {
|
|
236
|
+
class: "network",
|
|
237
|
+
message,
|
|
238
|
+
retryable: true,
|
|
239
|
+
hint: "Upload data-plane host is unavailable. Verify egress rules/proxy allowlist for files.slack.com and uploads.slack.com, then retry.",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (rawStatus && rawStatus.startsWith("5")) {
|
|
243
|
+
return {
|
|
244
|
+
class: "network",
|
|
245
|
+
message,
|
|
246
|
+
retryable: true,
|
|
247
|
+
hint: "Retry after checking network connectivity. For write actions, verify whether Slack already applied the request before retrying.",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (rawStatus && rawStatus.startsWith("4")) {
|
|
251
|
+
return {
|
|
252
|
+
class: "input",
|
|
253
|
+
message,
|
|
254
|
+
retryable: false,
|
|
255
|
+
hint: "Slack rejected the upload payload. Verify the upload URL/request metadata and retry; if this persists, classify as a Slack-side upload failure.",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
class: "network",
|
|
260
|
+
message,
|
|
261
|
+
retryable: true,
|
|
262
|
+
hint: "Retry after checking network connectivity. For write actions, verify whether Slack already applied the request before retrying.",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
if (lower.includes("network") ||
|
|
266
|
+
lower.includes("fetch") ||
|
|
267
|
+
lower.includes("timeout") ||
|
|
268
|
+
lower.includes("econnreset") ||
|
|
269
|
+
lower.includes("etimedout")) {
|
|
270
|
+
return {
|
|
271
|
+
class: "network",
|
|
272
|
+
message,
|
|
273
|
+
retryable: true,
|
|
274
|
+
hint: "Retry after checking network connectivity. For write actions, verify whether Slack already applied the request before retrying.",
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
class: "input",
|
|
279
|
+
message,
|
|
280
|
+
retryable: false,
|
|
281
|
+
hint: "Call slack with action='help' and args.topic set to the action name for the expected schema and examples.",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function extractToolResponseText(response) {
|
|
285
|
+
if (!isRecord(response) || !Array.isArray(response.content))
|
|
286
|
+
return undefined;
|
|
287
|
+
const lines = response.content
|
|
288
|
+
.map((item) => {
|
|
289
|
+
if (!isRecord(item))
|
|
290
|
+
return undefined;
|
|
291
|
+
return typeof item.text === "string" ? item.text : undefined;
|
|
292
|
+
})
|
|
293
|
+
.filter((line) => line != null && line.length > 0);
|
|
294
|
+
return lines.length > 0 ? lines.join("\n") : undefined;
|
|
295
|
+
}
|
|
296
|
+
function extractToolResponseDetails(response) {
|
|
297
|
+
return isRecord(response) && "details" in response ? response.details : undefined;
|
|
298
|
+
}
|
|
299
|
+
function extractToolResponseFullDetails(response) {
|
|
300
|
+
return isRecord(response) && "fullDetails" in response ? response.fullDetails : undefined;
|
|
301
|
+
}
|
|
302
|
+
function normalizeSlackOutputOptions(action, args) {
|
|
303
|
+
if (args != null && !isRecord(args)) {
|
|
304
|
+
throw new Error("slack args must be an object.");
|
|
305
|
+
}
|
|
306
|
+
const ownsFormatArg = SLACK_ACTIONS_WITH_FORMAT_ARG.has(action);
|
|
307
|
+
const rawResponseFormat = isRecord(args) ? args.response_format : undefined;
|
|
308
|
+
const rawFormat = rawResponseFormat ?? (isRecord(args) && !ownsFormatArg ? args.format : undefined);
|
|
309
|
+
const normalizedFormat = rawFormat == null ? "cli" : String(rawFormat).trim().toLowerCase();
|
|
310
|
+
if (normalizedFormat !== "cli" && normalizedFormat !== "json") {
|
|
311
|
+
throw new Error(ownsFormatArg
|
|
312
|
+
? 'response_format must be "cli" or "json" when provided.'
|
|
313
|
+
: 'format must be "cli" or "json" when provided.');
|
|
314
|
+
}
|
|
315
|
+
const format = normalizedFormat;
|
|
316
|
+
const rawFull = isRecord(args) ? args.full : undefined;
|
|
317
|
+
if (rawFull != null && typeof rawFull !== "boolean") {
|
|
318
|
+
throw new Error("full must be a boolean when provided.");
|
|
319
|
+
}
|
|
320
|
+
return { format, full: rawFull === true };
|
|
321
|
+
}
|
|
322
|
+
function getSlackEnvelopeCliText(envelope) {
|
|
323
|
+
if (envelope.status === "succeeded" && isRecord(envelope.data)) {
|
|
324
|
+
const text = envelope.data.text;
|
|
325
|
+
if (typeof text === "string" && text.length > 0)
|
|
326
|
+
return text;
|
|
327
|
+
}
|
|
328
|
+
return JSON.stringify(envelope, null, 2);
|
|
329
|
+
}
|
|
330
|
+
function buildSlackDispatcherResponse(envelope, output = { format: "json", full: true }) {
|
|
331
|
+
return {
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: "text",
|
|
335
|
+
text: output.format === "json"
|
|
336
|
+
? JSON.stringify(envelope, null, 2)
|
|
337
|
+
: getSlackEnvelopeCliText(envelope),
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
details: envelope,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function buildSlackActionSuccessEnvelope(response, output = { format: "json", full: true }) {
|
|
344
|
+
return {
|
|
345
|
+
status: "succeeded",
|
|
346
|
+
data: {
|
|
347
|
+
text: extractToolResponseText(response),
|
|
348
|
+
details: output.full
|
|
349
|
+
? (extractToolResponseFullDetails(response) ?? extractToolResponseDetails(response))
|
|
350
|
+
: extractToolResponseDetails(response),
|
|
351
|
+
},
|
|
352
|
+
errors: [],
|
|
353
|
+
warnings: [],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
function buildSlackActionFailureEnvelope(error) {
|
|
357
|
+
return {
|
|
358
|
+
status: "failed",
|
|
359
|
+
data: null,
|
|
360
|
+
errors: [classifySlackDispatcherError(error)],
|
|
361
|
+
warnings: [],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function getSlackDispatcherExamples(action) {
|
|
365
|
+
return SLACK_DISPATCHER_EXAMPLES[action] ?? [{ action, args: {} }];
|
|
366
|
+
}
|
|
367
|
+
function buildSlackOutputControlsHelp(action) {
|
|
368
|
+
const ownsFormatArg = action != null && SLACK_ACTIONS_WITH_FORMAT_ARG.has(action);
|
|
369
|
+
return {
|
|
370
|
+
format: ownsFormatArg
|
|
371
|
+
? "Reserved for this action's own payload format; use response_format for dispatcher output."
|
|
372
|
+
: 'Optional response presentation: "cli" (default) or "json".',
|
|
373
|
+
response_format: 'Optional response presentation alias: "cli" (default) or "json". Prefer this when an action already owns a format argument.',
|
|
374
|
+
full: "Optional boolean. Include full structured details instead of compact default details.",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function truncateSlackText(value, maxLength = 180) {
|
|
378
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
379
|
+
if (collapsed.length <= maxLength)
|
|
380
|
+
return collapsed;
|
|
381
|
+
return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
382
|
+
}
|
|
383
|
+
function buildSlackInboxPromptGuidelines() {
|
|
384
|
+
return [
|
|
385
|
+
"You are connected to Slack via the slack-bridge extension.",
|
|
386
|
+
"When Slack messages arrive: ACK briefly, do the work, report blockers immediately, and report the outcome when done.",
|
|
387
|
+
"Use slack_send for direct assistant-thread replies. Use the slack dispatcher for non-hot Slack actions such as reactions, reads, uploads, schedules, channel posts, pins, bookmarks, canvases, modals, presence, exports, and confirmations.",
|
|
388
|
+
"Call slack with action='help' for the cold-action catalogue, or action='help' with args.topic for a specific action schema and examples.",
|
|
389
|
+
"Security guardrails may be active for Slack-triggered actions. Cold Slack actions are checked with slack:<action> guardrail names.",
|
|
390
|
+
"Reaction-triggered requests may arrive as structured 'Reaction trigger from Slack:' messages — treat them as explicit user instructions attached to the referenced Slack message or thread.",
|
|
391
|
+
];
|
|
392
|
+
}
|
|
393
|
+
function buildSlackSendPromptGuidelines() {
|
|
394
|
+
return [
|
|
395
|
+
"Use slack_send for replies in the current Slack assistant thread; always reply where the task came from.",
|
|
396
|
+
"For rich Block Kit JSON examples or modal/canvas patterns, load the slack-bridge skill instead of relying on tool schemas.",
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
function getSlackCanvasSummary(markdown) {
|
|
400
|
+
if (markdown == null || markdown.length === 0)
|
|
401
|
+
return "(empty canvas)";
|
|
402
|
+
const collapsed = markdown.replace(/\s+/g, " ").trim();
|
|
403
|
+
if (collapsed.length <= 80)
|
|
404
|
+
return collapsed;
|
|
405
|
+
return `${collapsed.slice(0, 77)}...`;
|
|
406
|
+
}
|
|
407
|
+
function normalizeOptionalSlackCursor(cursor) {
|
|
408
|
+
const trimmed = cursor?.trim();
|
|
409
|
+
return trimmed && trimmed.length > 0 ? trimmed : undefined;
|
|
410
|
+
}
|
|
411
|
+
function buildSlackCanvasCommentsSummaryText(input) {
|
|
412
|
+
const label = input.title ? `${input.title} (${input.canvasId})` : input.canvasId;
|
|
413
|
+
const lines = [
|
|
414
|
+
`Canvas comments for ${label}`,
|
|
415
|
+
`Returned ${input.returnedCount} of ${input.commentsCount} comment(s).`,
|
|
416
|
+
];
|
|
417
|
+
if (input.comments.length === 0) {
|
|
418
|
+
lines.push("(no comments)");
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
for (const comment of input.comments) {
|
|
422
|
+
const author = comment.userName ?? comment.userId ?? "unknown";
|
|
423
|
+
const created = comment.createdTs ?? "unknown-ts";
|
|
424
|
+
lines.push(`[${created}] ${author}: ${comment.text}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (input.nextCursor) {
|
|
428
|
+
lines.push(`More comments are available. Re-run with cursor=${input.nextCursor}.`);
|
|
429
|
+
}
|
|
430
|
+
return lines.join("\n");
|
|
431
|
+
}
|
|
432
|
+
function isSlackMethodError(err, method, ...codes) {
|
|
433
|
+
if (!(err instanceof Error)) {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
return codes.some((code) => err.message.includes(`Slack ${method}: ${code}`));
|
|
437
|
+
}
|
|
438
|
+
function normalizeSlackPinAction(action) {
|
|
439
|
+
const normalized = action.trim().toLowerCase();
|
|
440
|
+
if (normalized === "pin" || normalized === "unpin") {
|
|
441
|
+
return normalized;
|
|
442
|
+
}
|
|
443
|
+
throw new Error("action must be 'pin' or 'unpin'.");
|
|
444
|
+
}
|
|
445
|
+
function normalizeSlackBookmarkAction(action) {
|
|
446
|
+
const normalized = action.trim().toLowerCase();
|
|
447
|
+
if (normalized === "add" || normalized === "remove" || normalized === "list") {
|
|
448
|
+
return normalized;
|
|
449
|
+
}
|
|
450
|
+
throw new Error("action must be 'add', 'remove', or 'list'.");
|
|
451
|
+
}
|
|
452
|
+
function normalizeSlackBookmarkUrl(url) {
|
|
453
|
+
const trimmed = url.trim();
|
|
454
|
+
if (!trimmed) {
|
|
455
|
+
throw new Error("url is required when action='add'.");
|
|
456
|
+
}
|
|
457
|
+
let parsed;
|
|
458
|
+
try {
|
|
459
|
+
parsed = new URL(trimmed);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
throw new Error("url must be an absolute http(s) URL.");
|
|
463
|
+
}
|
|
464
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
465
|
+
throw new Error("url must use http or https.");
|
|
466
|
+
}
|
|
467
|
+
return parsed.toString();
|
|
468
|
+
}
|
|
469
|
+
function asSlackObject(value) {
|
|
470
|
+
return typeof value === "object" && value !== null
|
|
471
|
+
? value
|
|
472
|
+
: undefined;
|
|
473
|
+
}
|
|
474
|
+
function asTrimmedSlackString(value) {
|
|
475
|
+
if (typeof value !== "string")
|
|
476
|
+
return undefined;
|
|
477
|
+
const trimmed = value.trim();
|
|
478
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
479
|
+
}
|
|
480
|
+
function extractSlackCanvasPermalink(response) {
|
|
481
|
+
const direct = asTrimmedSlackString(response.permalink) ??
|
|
482
|
+
asTrimmedSlackString(response.url) ??
|
|
483
|
+
asTrimmedSlackString(response.link);
|
|
484
|
+
if (direct)
|
|
485
|
+
return direct;
|
|
486
|
+
const canvas = asSlackObject(response.canvas);
|
|
487
|
+
const canvasLink = asTrimmedSlackString(canvas?.permalink) ??
|
|
488
|
+
asTrimmedSlackString(canvas?.url) ??
|
|
489
|
+
asTrimmedSlackString(canvas?.link);
|
|
490
|
+
if (canvasLink)
|
|
491
|
+
return canvasLink;
|
|
492
|
+
const file = asSlackObject(response.file);
|
|
493
|
+
return asTrimmedSlackString(file?.permalink);
|
|
494
|
+
}
|
|
495
|
+
function isPiAgentSlackMessage(message, botUserId, agentOwnerToken) {
|
|
496
|
+
const messageUser = typeof message.user === "string" ? message.user : undefined;
|
|
497
|
+
if (messageUser) {
|
|
498
|
+
return botUserId != null && messageUser === botUserId;
|
|
499
|
+
}
|
|
500
|
+
const metadata = message.metadata;
|
|
501
|
+
if (!metadata || typeof metadata !== "object") {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
if (metadata.event_type !== "pi_agent_msg") {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
const eventPayload = metadata.event_payload;
|
|
508
|
+
if (!eventPayload || typeof eventPayload !== "object") {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
return eventPayload.agent_owner === agentOwnerToken;
|
|
512
|
+
}
|
|
513
|
+
function summarizeSlackDeleteAction(input) {
|
|
514
|
+
return `channel=${input.channel ?? input.defaultChannel ?? ""} | thread_ts=${input.threadTs ?? ""} | ts=${input.ts} | thread=${input.thread ?? false}`;
|
|
515
|
+
}
|
|
516
|
+
export function registerSlackTools(pi, deps) {
|
|
517
|
+
const { getBotToken, getDefaultChannel, getSecurityPrompt, inbox, slack, getAgentName, getAgentEmoji, getAgentOwnerToken, getLastDmChannel, updateBadge, resolveUser, threadContext, resolveChannel, rememberChannel, requireToolPolicy: requireToolPolicyForName, registerConfirmationRequest, getBotUserId, pinetDelivery, } = deps;
|
|
518
|
+
async function resolveTrackedThreadChannel(threadTs) {
|
|
519
|
+
return threadContext.resolveThreadChannel(threadTs);
|
|
520
|
+
}
|
|
521
|
+
function noteThreadReply(threadTs, channelId) {
|
|
522
|
+
threadContext.noteThreadReply(threadTs, channelId);
|
|
523
|
+
}
|
|
524
|
+
function clearPendingThreadAttention(threadTs) {
|
|
525
|
+
threadContext.clearPendingAttention(threadTs);
|
|
526
|
+
}
|
|
527
|
+
function requireToolPolicy(toolName, threadTs, action) {
|
|
528
|
+
requireToolPolicyForName(normalizeSlackGuardrailToolName(toolName), threadTs, action);
|
|
529
|
+
}
|
|
530
|
+
async function resolveSlackSendChannel(threadTs) {
|
|
531
|
+
const trackedThreadChannel = await resolveTrackedThreadChannel(threadTs);
|
|
532
|
+
if (trackedThreadChannel)
|
|
533
|
+
return trackedThreadChannel;
|
|
534
|
+
if (!threadTs) {
|
|
535
|
+
const defaultChannel = getDefaultChannel();
|
|
536
|
+
if (defaultChannel)
|
|
537
|
+
return resolveChannel(defaultChannel);
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
const lastDmChannel = getLastDmChannel();
|
|
541
|
+
if (lastDmChannel)
|
|
542
|
+
return lastDmChannel;
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
async function deliverSlackMessage(input) {
|
|
546
|
+
const blocks = input.blocks ? normalizeSlackBlocksInput(input.blocks) : undefined;
|
|
547
|
+
let fallbackReason;
|
|
548
|
+
if (input.threadTs && pinetDelivery) {
|
|
549
|
+
try {
|
|
550
|
+
if (pinetDelivery.isAvailable()) {
|
|
551
|
+
const result = await pinetDelivery.sendSlackMessage({
|
|
552
|
+
threadId: input.threadTs,
|
|
553
|
+
channel: input.channel,
|
|
554
|
+
text: input.text,
|
|
555
|
+
...(blocks ? { blocks } : {}),
|
|
556
|
+
});
|
|
557
|
+
return {
|
|
558
|
+
threadTs: input.threadTs,
|
|
559
|
+
channel: result.channel,
|
|
560
|
+
blocksCount: blocks?.length ?? 0,
|
|
561
|
+
delivery: "pinet",
|
|
562
|
+
adapter: result.adapter,
|
|
563
|
+
messageId: result.messageId,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
if (!isPinetDeliveryFallbackError(error))
|
|
569
|
+
throw error;
|
|
570
|
+
fallbackReason = getErrorMessage(error);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const body = {
|
|
574
|
+
channel: input.channel,
|
|
575
|
+
text: input.text,
|
|
576
|
+
metadata: {
|
|
577
|
+
event_type: "pi_agent_msg",
|
|
578
|
+
event_payload: { agent: getAgentName(), agent_owner: getAgentOwnerToken() },
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
if (blocks) {
|
|
582
|
+
body.blocks = blocks;
|
|
583
|
+
}
|
|
584
|
+
if (input.threadTs)
|
|
585
|
+
body.thread_ts = input.threadTs;
|
|
586
|
+
const response = await slack("chat.postMessage", getBotToken(), body);
|
|
587
|
+
const ts = response.message.ts;
|
|
588
|
+
return {
|
|
589
|
+
ts,
|
|
590
|
+
channel: input.channel,
|
|
591
|
+
blocksCount: blocks?.length ?? 0,
|
|
592
|
+
delivery: "slack",
|
|
593
|
+
...(fallbackReason ? { fallbackReason } : {}),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const slackActionRegistry = new Map();
|
|
597
|
+
function registerSlackAction(definition) {
|
|
598
|
+
const action = normalizeSlackDispatcherActionName(definition.name);
|
|
599
|
+
if (action === "help") {
|
|
600
|
+
throw new Error("slack help is reserved for dispatcher schema discovery.");
|
|
601
|
+
}
|
|
602
|
+
slackActionRegistry.set(action, definition);
|
|
603
|
+
}
|
|
604
|
+
function buildSlackDispatcherHelpEnvelope(args) {
|
|
605
|
+
if (args != null && !isRecord(args)) {
|
|
606
|
+
return buildSlackActionFailureEnvelope(new Error("slack help args must be an object."));
|
|
607
|
+
}
|
|
608
|
+
const topic = isRecord(args) && typeof args.topic === "string" ? args.topic : undefined;
|
|
609
|
+
if (topic) {
|
|
610
|
+
const action = normalizeSlackDispatcherActionName(topic);
|
|
611
|
+
const definition = slackActionRegistry.get(action);
|
|
612
|
+
if (!definition) {
|
|
613
|
+
return buildSlackActionFailureEnvelope(new Error(`Unknown Slack action topic '${topic}'. Use action='help' to list actions.`));
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
status: "succeeded",
|
|
617
|
+
data: {
|
|
618
|
+
action,
|
|
619
|
+
summary: definition.description ?? "",
|
|
620
|
+
guardrail_tool: `slack:${action}`,
|
|
621
|
+
args_schema: definition.parameters ?? {},
|
|
622
|
+
examples: getSlackDispatcherExamples(action),
|
|
623
|
+
output_controls: buildSlackOutputControlsHelp(action),
|
|
624
|
+
},
|
|
625
|
+
errors: [],
|
|
626
|
+
warnings: [],
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
return {
|
|
630
|
+
status: "succeeded",
|
|
631
|
+
data: {
|
|
632
|
+
actions: [
|
|
633
|
+
{
|
|
634
|
+
action: "help",
|
|
635
|
+
summary: "List Slack dispatcher actions or return a specific action argument schema.",
|
|
636
|
+
guardrail_tool: null,
|
|
637
|
+
},
|
|
638
|
+
...[...slackActionRegistry.entries()].map(([action, definition]) => ({
|
|
639
|
+
action,
|
|
640
|
+
summary: definition.description ?? "",
|
|
641
|
+
guardrail_tool: `slack:${action}`,
|
|
642
|
+
})),
|
|
643
|
+
],
|
|
644
|
+
output_controls: buildSlackOutputControlsHelp(),
|
|
645
|
+
},
|
|
646
|
+
errors: [],
|
|
647
|
+
warnings: [],
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
pi.registerTool({
|
|
651
|
+
name: "slack",
|
|
652
|
+
label: "Slack",
|
|
653
|
+
description: "Dispatcher for non-hot Slack actions: react, read, upload, schedule, presence, export, post_channel, read_channel, confirm_action, delete, pin, bookmark, create_channel, project_create, canvas_comments_read, canvas_create, canvas_update, modal_open, modal_push, modal_update, and help. Use slack_inbox and slack_send for hot-path inbox/reply work.",
|
|
654
|
+
promptSnippet: "Run non-hot Slack actions through a compact dispatcher. Use action='help' for the action catalogue or args.topic for a specific schema. Defaults to compact cli output; pass args.format='json' (or args.response_format='json' when the action owns format) or args.full=true for structured/full details.",
|
|
655
|
+
promptGuidelines: [
|
|
656
|
+
"Use slack_inbox and slack_send for the hot path. Use this dispatcher for every other Slack action.",
|
|
657
|
+
"Cold actions are guarded as slack:<action>, for example slack:upload or slack:canvas_update.",
|
|
658
|
+
"The dispatcher returns {status,data,errors,warnings}. On input errors, call action='help' with args.topic for the action schema.",
|
|
659
|
+
"For Block Kit templates, modal patterns, and canvas examples, load the slack-bridge skill lazily.",
|
|
660
|
+
],
|
|
661
|
+
parameters: Type.Object({
|
|
662
|
+
action: Type.String({
|
|
663
|
+
description: "Action name: help | react | read | upload | schedule | presence | export | post_channel | read_channel | confirm_action | delete | pin | bookmark | create_channel | project_create | canvas_comments_read | canvas_create | canvas_update | modal_open | modal_push | modal_update",
|
|
664
|
+
}),
|
|
665
|
+
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
666
|
+
description: "Action arguments. Call slack with action='help' and args.topic='<action>' for the exact JSON schema and examples. Add format='cli'|'json' and full=true for explicit presentation control; use response_format when the action already has a format field.",
|
|
667
|
+
})),
|
|
668
|
+
}),
|
|
669
|
+
async execute(_id, params) {
|
|
670
|
+
try {
|
|
671
|
+
const action = normalizeSlackDispatcherActionName(params.action);
|
|
672
|
+
const args = params.args ?? {};
|
|
673
|
+
let output;
|
|
674
|
+
try {
|
|
675
|
+
output = normalizeSlackOutputOptions(action, args);
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
return buildSlackDispatcherResponse(buildSlackActionFailureEnvelope(error));
|
|
679
|
+
}
|
|
680
|
+
if (action === "help") {
|
|
681
|
+
return buildSlackDispatcherResponse(buildSlackDispatcherHelpEnvelope(args), output);
|
|
682
|
+
}
|
|
683
|
+
if (!isRecord(args)) {
|
|
684
|
+
return buildSlackDispatcherResponse(buildSlackActionFailureEnvelope(new Error("slack args must be an object.")), output);
|
|
685
|
+
}
|
|
686
|
+
const definition = slackActionRegistry.get(action);
|
|
687
|
+
if (!definition) {
|
|
688
|
+
return buildSlackDispatcherResponse(buildSlackActionFailureEnvelope(new Error(`Unknown Slack action '${action}'. Use action='help' to list actions.`)), output);
|
|
689
|
+
}
|
|
690
|
+
const response = await definition.execute(`slack:${action}`, args, undefined, undefined, undefined);
|
|
691
|
+
return buildSlackDispatcherResponse(buildSlackActionSuccessEnvelope(response, output), output);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (isAbortError(error)) {
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
return buildSlackDispatcherResponse(buildSlackActionFailureEnvelope(error), {
|
|
698
|
+
format: "json",
|
|
699
|
+
full: true,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
async function createStandaloneCanvasFallback(input) {
|
|
705
|
+
const fallbackRequest = buildSlackCanvasCreateRequest({
|
|
706
|
+
kind: "standalone",
|
|
707
|
+
title: input.title,
|
|
708
|
+
markdown: input.markdown,
|
|
709
|
+
channelId: input.channelId,
|
|
710
|
+
});
|
|
711
|
+
let fallbackResponse;
|
|
712
|
+
try {
|
|
713
|
+
fallbackResponse = await slack(fallbackRequest.method, getBotToken(), fallbackRequest.body);
|
|
714
|
+
}
|
|
715
|
+
catch (fallbackErr) {
|
|
716
|
+
const fallbackMessage = fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
717
|
+
throw new Error(`Slack channel canvas creation failed with canvas_tab_creation_failed, and standalone fallback creation also failed: ${fallbackMessage}. To recover manually, create a standalone canvas with channel=${input.channelLabel} and update/share it by canvas_id.`);
|
|
718
|
+
}
|
|
719
|
+
const fallbackCanvasId = asTrimmedSlackString(fallbackResponse.canvas_id);
|
|
720
|
+
if (!fallbackCanvasId) {
|
|
721
|
+
throw new Error(`Slack channel canvas creation failed with canvas_tab_creation_failed, and standalone fallback creation did not return a canvas_id. To recover manually, create a standalone canvas with channel=${input.channelLabel} and update/share it by canvas_id.`);
|
|
722
|
+
}
|
|
723
|
+
let permalink = extractSlackCanvasPermalink(fallbackResponse);
|
|
724
|
+
let permalinkLookupError;
|
|
725
|
+
if (!permalink) {
|
|
726
|
+
try {
|
|
727
|
+
const fileInfo = await slack("files.info", getBotToken(), { file: fallbackCanvasId });
|
|
728
|
+
permalink = extractSlackCanvasPermalink(fileInfo);
|
|
729
|
+
}
|
|
730
|
+
catch (permalinkErr) {
|
|
731
|
+
permalinkLookupError =
|
|
732
|
+
permalinkErr instanceof Error ? permalinkErr.message : String(permalinkErr);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
let bookmarkId;
|
|
736
|
+
let bookmarkStatus = "skipped";
|
|
737
|
+
let bookmarkError;
|
|
738
|
+
if (permalink) {
|
|
739
|
+
try {
|
|
740
|
+
const title = input.title && input.title.trim().length > 0
|
|
741
|
+
? input.title.trim()
|
|
742
|
+
: `Canvas ${fallbackCanvasId}`;
|
|
743
|
+
const bookmarkResponse = await slack("bookmarks.add", getBotToken(), {
|
|
744
|
+
channel_id: input.channelId,
|
|
745
|
+
title,
|
|
746
|
+
type: "link",
|
|
747
|
+
link: normalizeSlackBookmarkUrl(permalink),
|
|
748
|
+
emoji: ":memo:",
|
|
749
|
+
});
|
|
750
|
+
const bookmark = asSlackObject(bookmarkResponse.bookmark);
|
|
751
|
+
bookmarkId = asTrimmedSlackString(bookmark?.id);
|
|
752
|
+
bookmarkStatus = "added";
|
|
753
|
+
}
|
|
754
|
+
catch (bookmarkErr) {
|
|
755
|
+
bookmarkStatus = "failed";
|
|
756
|
+
bookmarkError = bookmarkErr instanceof Error ? bookmarkErr.message : String(bookmarkErr);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
canvasId: fallbackCanvasId,
|
|
761
|
+
bookmarkStatus,
|
|
762
|
+
...(permalink ? { permalink } : {}),
|
|
763
|
+
...(permalinkLookupError ? { permalinkLookupError } : {}),
|
|
764
|
+
...(bookmarkId ? { bookmarkId } : {}),
|
|
765
|
+
...(bookmarkError ? { bookmarkError } : {}),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
function buildStandaloneCanvasFallbackBookmarkSummary(input) {
|
|
769
|
+
if (input.bookmarkStatus === "added") {
|
|
770
|
+
return ` Bookmarked it in ${input.channelLabel}${input.bookmarkId ? ` as ${input.bookmarkId}` : ""}.`;
|
|
771
|
+
}
|
|
772
|
+
if (input.permalink) {
|
|
773
|
+
return ` Could not bookmark it automatically${input.bookmarkError ? ` (${input.bookmarkError})` : ""}; add ${input.permalink} as a channel bookmark if you need a durable channel link.`;
|
|
774
|
+
}
|
|
775
|
+
return ` Slack did not expose a permalink${input.permalinkLookupError ? ` (${input.permalinkLookupError})` : ""}; use canvas_id=${input.canvasId} directly or add a bookmark manually once you have the canvas URL.`;
|
|
776
|
+
}
|
|
777
|
+
async function resolveCanvasTarget(canvasId, channel) {
|
|
778
|
+
const trimmedCanvasId = canvasId?.trim();
|
|
779
|
+
if (trimmedCanvasId) {
|
|
780
|
+
return { canvasId: trimmedCanvasId };
|
|
781
|
+
}
|
|
782
|
+
const channelInput = channel?.trim();
|
|
783
|
+
if (!channelInput) {
|
|
784
|
+
throw new Error("Provide either canvas_id or channel.");
|
|
785
|
+
}
|
|
786
|
+
const channelId = await resolveChannel(channelInput);
|
|
787
|
+
const info = await slack("conversations.info", getBotToken(), { channel: channelId });
|
|
788
|
+
const resolvedCanvasId = extractSlackChannelCanvasId(info);
|
|
789
|
+
if (!resolvedCanvasId) {
|
|
790
|
+
throw new Error(`Slack did not expose a channel canvas ID in conversations.info for ${channelInput}. Provide canvas_id directly. If channel canvas tab creation failed earlier, use the standalone fallback canvas_id returned by canvas_create. Otherwise run canvas_create with kind='channel' and channel='${channelInput}'; if Slack rejects channel tab creation, canvas_create will auto-create and bookmark a standalone fallback. Use kind='standalone' only when you intentionally want to skip the channel-tab attempt.`);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
canvasId: resolvedCanvasId,
|
|
794
|
+
channelId,
|
|
795
|
+
channelLabel: channelInput,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
async function assertCanvasCommentsTargetIsCanvas(canvasId) {
|
|
799
|
+
try {
|
|
800
|
+
await slack("canvases.sections.lookup", getBotToken(), {
|
|
801
|
+
canvas_id: canvasId,
|
|
802
|
+
criteria: { section_types: ["any_header"] },
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
if (isSlackMethodError(err, "canvases.sections.lookup", "canvas_deleted")) {
|
|
807
|
+
throw new Error(`Canvas ${canvasId} is no longer available.`);
|
|
808
|
+
}
|
|
809
|
+
if (isSlackMethodError(err, "canvases.sections.lookup", "access_denied")) {
|
|
810
|
+
throw new Error(`Canvas ${canvasId} is not accessible with the current bot token.`);
|
|
811
|
+
}
|
|
812
|
+
if (isSlackMethodError(err, "canvases.sections.lookup", "canvas_not_found")) {
|
|
813
|
+
throw new Error(`Canvas ${canvasId} is unavailable, inaccessible, or not a canvas. This tool only reads comments for Slack canvases.`);
|
|
814
|
+
}
|
|
815
|
+
throw err;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async function resolveSlackTargetChannel(threadTs, channel) {
|
|
819
|
+
const trackedThreadChannel = threadTs ? await resolveTrackedThreadChannel(threadTs) : null;
|
|
820
|
+
if (trackedThreadChannel) {
|
|
821
|
+
return trackedThreadChannel;
|
|
822
|
+
}
|
|
823
|
+
const channelInput = channel?.trim();
|
|
824
|
+
if (channelInput) {
|
|
825
|
+
return resolveChannel(channelInput);
|
|
826
|
+
}
|
|
827
|
+
if (!threadTs) {
|
|
828
|
+
const dmChannel = getLastDmChannel();
|
|
829
|
+
if (dmChannel) {
|
|
830
|
+
return dmChannel;
|
|
831
|
+
}
|
|
832
|
+
const defaultChannel = getDefaultChannel();
|
|
833
|
+
if (defaultChannel) {
|
|
834
|
+
return resolveChannel(defaultChannel);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
throw new Error(threadTs
|
|
838
|
+
? "Unknown Slack thread. If you know the destination channel, pass channel explicitly."
|
|
839
|
+
: "No active Slack thread. Provide channel or configure defaultChannel in settings.json.");
|
|
840
|
+
}
|
|
841
|
+
async function fetchSlackThreadMessages(channelId, threadTs, oldest, latest, includeAllMetadata = false) {
|
|
842
|
+
const messages = [];
|
|
843
|
+
let cursor;
|
|
844
|
+
do {
|
|
845
|
+
const response = await slack("conversations.replies", getBotToken(), {
|
|
846
|
+
channel: channelId,
|
|
847
|
+
ts: threadTs,
|
|
848
|
+
limit: 1000,
|
|
849
|
+
...(cursor ? { cursor } : {}),
|
|
850
|
+
...(oldest ? { oldest } : {}),
|
|
851
|
+
...(latest ? { latest } : {}),
|
|
852
|
+
...(includeAllMetadata ? { include_all_metadata: true } : {}),
|
|
853
|
+
});
|
|
854
|
+
const batch = Array.isArray(response.messages)
|
|
855
|
+
? response.messages
|
|
856
|
+
: [];
|
|
857
|
+
messages.push(...batch);
|
|
858
|
+
const nextCursor = response.response_metadata
|
|
859
|
+
?.next_cursor;
|
|
860
|
+
cursor = typeof nextCursor === "string" && nextCursor.length > 0 ? nextCursor : undefined;
|
|
861
|
+
} while (cursor);
|
|
862
|
+
return messages;
|
|
863
|
+
}
|
|
864
|
+
async function resolveSlackDeleteTargets(input) {
|
|
865
|
+
const channelId = await resolveSlackTargetChannel(input.threadTs, input.channel);
|
|
866
|
+
const targetTs = input.ts.trim();
|
|
867
|
+
if (!targetTs) {
|
|
868
|
+
throw new Error("ts is required.");
|
|
869
|
+
}
|
|
870
|
+
if (!input.thread) {
|
|
871
|
+
return { channelId, messageTsList: [targetTs] };
|
|
872
|
+
}
|
|
873
|
+
const messages = await fetchSlackThreadMessages(channelId, targetTs, undefined, undefined, true);
|
|
874
|
+
const threadRootTs = messages.length > 0 && typeof messages[0]?.ts === "string"
|
|
875
|
+
? messages[0].ts
|
|
876
|
+
: undefined;
|
|
877
|
+
if (!threadRootTs) {
|
|
878
|
+
throw new Error(`Slack did not return a thread rooted at ${targetTs} in channel ${input.channel ?? channelId}.`);
|
|
879
|
+
}
|
|
880
|
+
if (threadRootTs !== targetTs) {
|
|
881
|
+
throw new Error("When thread=true, ts must be the thread root timestamp.");
|
|
882
|
+
}
|
|
883
|
+
const undeletableMessages = messages
|
|
884
|
+
.filter((message) => !isPiAgentSlackMessage(message, getBotUserId(), getAgentOwnerToken()))
|
|
885
|
+
.map((message) => (typeof message.ts === "string" ? message.ts : "unknown-ts"));
|
|
886
|
+
if (undeletableMessages.length > 0) {
|
|
887
|
+
throw new Error(`Cannot delete thread ${targetTs} because it includes message(s) not posted by the current bot: ${undeletableMessages.join(", ")}. Delete those messages individually instead.`);
|
|
888
|
+
}
|
|
889
|
+
const messageTsList = messages
|
|
890
|
+
.map((message) => (typeof message.ts === "string" ? message.ts : undefined))
|
|
891
|
+
.filter((messageTs) => messageTs != null && messageTs.length > 0);
|
|
892
|
+
if (messageTsList.length === 0) {
|
|
893
|
+
throw new Error(`Slack did not return any deletable messages for thread ${targetTs}.`);
|
|
894
|
+
}
|
|
895
|
+
return {
|
|
896
|
+
channelId,
|
|
897
|
+
messageTsList: [...messageTsList.slice(1), messageTsList[0]],
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
async function buildSlackExportPayload(messages) {
|
|
901
|
+
const userIds = new Set();
|
|
902
|
+
for (const message of messages) {
|
|
903
|
+
const userId = typeof message.user === "string" ? message.user : undefined;
|
|
904
|
+
if (userId) {
|
|
905
|
+
userIds.add(userId);
|
|
906
|
+
}
|
|
907
|
+
const text = typeof message.text === "string" ? message.text : "";
|
|
908
|
+
for (const match of text.matchAll(/<@([A-Z0-9]+)>/g)) {
|
|
909
|
+
if (match[1]) {
|
|
910
|
+
userIds.add(match[1]);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const mentionNames = Object.fromEntries(await Promise.all([...userIds].map(async (userId) => [userId, await resolveUser(userId)])));
|
|
915
|
+
const exportedMessages = messages.map((message) => {
|
|
916
|
+
const userId = typeof message.user === "string" ? message.user : undefined;
|
|
917
|
+
const authorName = userId
|
|
918
|
+
? mentionNames[userId]
|
|
919
|
+
: typeof message.username === "string"
|
|
920
|
+
? message.username
|
|
921
|
+
: typeof message.bot_id === "string"
|
|
922
|
+
? `bot:${message.bot_id}`
|
|
923
|
+
: "bot";
|
|
924
|
+
const rawFiles = Array.isArray(message.files)
|
|
925
|
+
? message.files
|
|
926
|
+
: [];
|
|
927
|
+
return {
|
|
928
|
+
ts: typeof message.ts === "string" ? message.ts : undefined,
|
|
929
|
+
authorName,
|
|
930
|
+
text: typeof message.text === "string" ? message.text : "",
|
|
931
|
+
files: rawFiles.map((file) => ({
|
|
932
|
+
name: typeof file.name === "string" ? file.name : undefined,
|
|
933
|
+
title: typeof file.title === "string" ? file.title : undefined,
|
|
934
|
+
mimetype: typeof file.mimetype === "string" ? file.mimetype : undefined,
|
|
935
|
+
filetype: typeof file.filetype === "string" ? file.filetype : undefined,
|
|
936
|
+
permalink: typeof file.permalink === "string" ? file.permalink : undefined,
|
|
937
|
+
urlPrivate: typeof file.url_private_download === "string"
|
|
938
|
+
? file.url_private_download
|
|
939
|
+
: typeof file.url_private === "string"
|
|
940
|
+
? file.url_private
|
|
941
|
+
: undefined,
|
|
942
|
+
preview: typeof file.preview === "string" ? file.preview : undefined,
|
|
943
|
+
})),
|
|
944
|
+
};
|
|
945
|
+
});
|
|
946
|
+
return {
|
|
947
|
+
mentionNames,
|
|
948
|
+
authors: [...new Set(exportedMessages.map((message) => message.authorName).filter(Boolean))],
|
|
949
|
+
messages: exportedMessages,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
async function resolveReactionChannel(threadTs, channel) {
|
|
953
|
+
const trackedThreadChannel = threadTs ? await resolveTrackedThreadChannel(threadTs) : null;
|
|
954
|
+
if (trackedThreadChannel) {
|
|
955
|
+
return trackedThreadChannel;
|
|
956
|
+
}
|
|
957
|
+
const channelInput = channel?.trim();
|
|
958
|
+
if (channelInput) {
|
|
959
|
+
return resolveChannel(channelInput);
|
|
960
|
+
}
|
|
961
|
+
throw new Error(threadTs
|
|
962
|
+
? "Unknown Slack thread. If you know the destination channel, pass channel explicitly."
|
|
963
|
+
: "Provide channel when reacting to a message outside the current tracked thread.");
|
|
964
|
+
}
|
|
965
|
+
async function resolveSlackModalThreadContext(threadTs) {
|
|
966
|
+
const normalizedThreadTs = threadTs?.trim();
|
|
967
|
+
if (!normalizedThreadTs) {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
const channel = await resolveTrackedThreadChannel(normalizedThreadTs);
|
|
971
|
+
if (!channel) {
|
|
972
|
+
throw new Error(`No active Slack thread for thread_ts ${normalizedThreadTs}. Pass a live Slack thread so modal submissions can route back correctly.`);
|
|
973
|
+
}
|
|
974
|
+
return {
|
|
975
|
+
threadTs: normalizedThreadTs,
|
|
976
|
+
channel,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
async function buildSlackModalView(view, threadTs) {
|
|
980
|
+
const normalizedView = normalizeSlackModalViewInput(view);
|
|
981
|
+
const threadContext = await resolveSlackModalThreadContext(threadTs);
|
|
982
|
+
const privateMetadata = typeof normalizedView.private_metadata === "string"
|
|
983
|
+
? normalizedView.private_metadata
|
|
984
|
+
: undefined;
|
|
985
|
+
const encodedPrivateMetadata = encodeSlackModalPrivateMetadata(privateMetadata, threadContext);
|
|
986
|
+
if (encodedPrivateMetadata !== undefined) {
|
|
987
|
+
normalizedView.private_metadata = encodedPrivateMetadata;
|
|
988
|
+
}
|
|
989
|
+
return normalizedView;
|
|
990
|
+
}
|
|
991
|
+
function rethrowSlackModalError(err, action) {
|
|
992
|
+
if (isSlackMethodError(err, `views.${action}`, "invalid_trigger")) {
|
|
993
|
+
throw new Error(`Slack trigger_id expired before views.${action} could run. Ask the user to click the button or retry the interaction again, then immediately reopen the modal.`);
|
|
994
|
+
}
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
997
|
+
const presenceCache = new TtlCache({
|
|
998
|
+
maxSize: 512,
|
|
999
|
+
ttlMs: 30_000,
|
|
1000
|
+
});
|
|
1001
|
+
let presenceDirectoryCache = null;
|
|
1002
|
+
function normalizeSlackPresenceTargets(user, users) {
|
|
1003
|
+
const requested = [user, ...(users ?? [])]
|
|
1004
|
+
.map((value) => value?.trim() ?? "")
|
|
1005
|
+
.filter((value) => value.length > 0);
|
|
1006
|
+
if (requested.length === 0) {
|
|
1007
|
+
throw new Error("Provide user or users so Slack knows whose presence to check.");
|
|
1008
|
+
}
|
|
1009
|
+
const seen = new Set();
|
|
1010
|
+
const deduped = [];
|
|
1011
|
+
for (const value of requested) {
|
|
1012
|
+
const key = stripSlackUserReference(value).toLowerCase();
|
|
1013
|
+
if (!seen.has(key)) {
|
|
1014
|
+
seen.add(key);
|
|
1015
|
+
deduped.push(value);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return deduped;
|
|
1019
|
+
}
|
|
1020
|
+
async function listSlackPresenceDirectoryUsers() {
|
|
1021
|
+
const now = Date.now();
|
|
1022
|
+
if (presenceDirectoryCache && now - presenceDirectoryCache.fetchedAt <= 5 * 60_000) {
|
|
1023
|
+
return presenceDirectoryCache.users;
|
|
1024
|
+
}
|
|
1025
|
+
const users = [];
|
|
1026
|
+
let cursor;
|
|
1027
|
+
do {
|
|
1028
|
+
const response = await slack("users.list", getBotToken(), {
|
|
1029
|
+
limit: 1000,
|
|
1030
|
+
...(cursor ? { cursor } : {}),
|
|
1031
|
+
});
|
|
1032
|
+
const batch = Array.isArray(response.members)
|
|
1033
|
+
? response.members
|
|
1034
|
+
: [];
|
|
1035
|
+
users.push(...batch.filter((member) => member.deleted !== true));
|
|
1036
|
+
const nextCursor = response.response_metadata
|
|
1037
|
+
?.next_cursor;
|
|
1038
|
+
cursor = typeof nextCursor === "string" && nextCursor.length > 0 ? nextCursor : undefined;
|
|
1039
|
+
} while (cursor);
|
|
1040
|
+
presenceDirectoryCache = { fetchedAt: now, users };
|
|
1041
|
+
return users;
|
|
1042
|
+
}
|
|
1043
|
+
async function resolveSlackPresenceTarget(identifier) {
|
|
1044
|
+
const normalized = stripSlackUserReference(identifier);
|
|
1045
|
+
if (!normalized) {
|
|
1046
|
+
throw new Error("Slack user identifier cannot be empty.");
|
|
1047
|
+
}
|
|
1048
|
+
if (isSlackUserId(normalized)) {
|
|
1049
|
+
return { userId: normalized.toUpperCase() };
|
|
1050
|
+
}
|
|
1051
|
+
const users = await listSlackPresenceDirectoryUsers();
|
|
1052
|
+
const match = findSlackPresenceDirectoryUser(users, normalized);
|
|
1053
|
+
if (!match?.id) {
|
|
1054
|
+
throw new Error(`Slack user '${identifier}' was not found.`);
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
userId: match.id,
|
|
1058
|
+
userName: getBestSlackPresenceUserName(match),
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
function parseSlackPresenceNumber(value) {
|
|
1062
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1063
|
+
return value;
|
|
1064
|
+
}
|
|
1065
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
1066
|
+
const parsed = Number(value);
|
|
1067
|
+
if (Number.isFinite(parsed)) {
|
|
1068
|
+
return parsed;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return undefined;
|
|
1072
|
+
}
|
|
1073
|
+
async function fetchSlackPresenceSnapshot(userId, userNameHint) {
|
|
1074
|
+
const cached = presenceCache.get(userId);
|
|
1075
|
+
if (cached) {
|
|
1076
|
+
if (userNameHint && cached.userName !== userNameHint) {
|
|
1077
|
+
const hydrated = { ...cached, userName: userNameHint };
|
|
1078
|
+
presenceCache.set(userId, hydrated);
|
|
1079
|
+
return hydrated;
|
|
1080
|
+
}
|
|
1081
|
+
return cached;
|
|
1082
|
+
}
|
|
1083
|
+
const [presenceResponse, dndResponse] = await Promise.all([
|
|
1084
|
+
slack("users.getPresence", getBotToken(), { user: userId }),
|
|
1085
|
+
slack("dnd.info", getBotToken(), { user: userId }),
|
|
1086
|
+
]);
|
|
1087
|
+
const dndEndTs = resolveSlackPresenceDndEndTs(dndResponse);
|
|
1088
|
+
const presenceValue = typeof presenceResponse.presence === "string" ? presenceResponse.presence : "unknown";
|
|
1089
|
+
const lastActivity = parseSlackPresenceNumber(presenceResponse.last_activity);
|
|
1090
|
+
const snapshot = {
|
|
1091
|
+
userId,
|
|
1092
|
+
userName: userNameHint ?? (await resolveUser(userId)),
|
|
1093
|
+
presence: presenceValue,
|
|
1094
|
+
dndEnabled: dndResponse.dnd_enabled === true || dndResponse.snooze_enabled === true,
|
|
1095
|
+
dndEndTs,
|
|
1096
|
+
dndEndAt: formatSlackPresenceTimestamp(dndEndTs),
|
|
1097
|
+
autoAway: presenceResponse.auto_away === true,
|
|
1098
|
+
manualAway: presenceResponse.manual_away === true,
|
|
1099
|
+
connectionCount: parseSlackPresenceNumber(presenceResponse.connection_count),
|
|
1100
|
+
lastActivity,
|
|
1101
|
+
online: typeof presenceResponse.online === "boolean"
|
|
1102
|
+
? presenceResponse.online
|
|
1103
|
+
: presenceValue === "active"
|
|
1104
|
+
? true
|
|
1105
|
+
: presenceValue === "away"
|
|
1106
|
+
? false
|
|
1107
|
+
: undefined,
|
|
1108
|
+
};
|
|
1109
|
+
presenceCache.set(userId, snapshot);
|
|
1110
|
+
return snapshot;
|
|
1111
|
+
}
|
|
1112
|
+
pi.registerTool({
|
|
1113
|
+
name: "slack_inbox",
|
|
1114
|
+
label: "Slack Inbox",
|
|
1115
|
+
description: "Return pending Slack messages that arrived since the last check, then clear the queue.",
|
|
1116
|
+
promptSnippet: "Check for new incoming Slack messages.",
|
|
1117
|
+
promptGuidelines: buildSlackInboxPromptGuidelines(),
|
|
1118
|
+
parameters: Type.Object({}),
|
|
1119
|
+
async execute() {
|
|
1120
|
+
const securityPrompt = getSecurityPrompt();
|
|
1121
|
+
const securityHeader = securityPrompt ? `${securityPrompt}\n\n` : "";
|
|
1122
|
+
const agentName = getAgentName();
|
|
1123
|
+
const agentEmoji = getAgentEmoji();
|
|
1124
|
+
if (inbox.length === 0) {
|
|
1125
|
+
return {
|
|
1126
|
+
content: [
|
|
1127
|
+
{
|
|
1128
|
+
type: "text",
|
|
1129
|
+
text: `${securityHeader}(no new messages) — you are ${agentEmoji} ${agentName}`,
|
|
1130
|
+
},
|
|
1131
|
+
],
|
|
1132
|
+
details: { count: 0, messages: [] },
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const pending = inbox.splice(0, inbox.length);
|
|
1136
|
+
updateBadge();
|
|
1137
|
+
const lines = [];
|
|
1138
|
+
for (const message of pending) {
|
|
1139
|
+
const name = await resolveUser(message.userId);
|
|
1140
|
+
const prefix = message.isChannelMention
|
|
1141
|
+
? `[thread ${message.threadTs}] (channel mention in <#${message.channel}>) ${name}`
|
|
1142
|
+
: `[thread ${message.threadTs}] ${name}`;
|
|
1143
|
+
const metadataSuffix = message.metadata?.kind === "slack_block_action"
|
|
1144
|
+
? ` | metadata=${JSON.stringify({
|
|
1145
|
+
kind: message.metadata.kind,
|
|
1146
|
+
triggerId: message.metadata.triggerId ?? null,
|
|
1147
|
+
actionId: message.metadata.actionId ?? null,
|
|
1148
|
+
value: message.metadata.value ?? null,
|
|
1149
|
+
parsedValue: message.metadata.parsedValue ?? null,
|
|
1150
|
+
viewId: message.metadata.viewId ?? null,
|
|
1151
|
+
})}`
|
|
1152
|
+
: message.metadata?.kind === "slack_view_submission"
|
|
1153
|
+
? ` | metadata=${JSON.stringify({
|
|
1154
|
+
kind: message.metadata.kind,
|
|
1155
|
+
triggerId: message.metadata.triggerId ?? null,
|
|
1156
|
+
callbackId: message.metadata.callbackId ?? null,
|
|
1157
|
+
viewId: message.metadata.viewId ?? null,
|
|
1158
|
+
stateValues: message.metadata.stateValues ?? null,
|
|
1159
|
+
})}`
|
|
1160
|
+
: message.metadata?.kind
|
|
1161
|
+
? ` | metadata.kind=${String(message.metadata.kind)}`
|
|
1162
|
+
: "";
|
|
1163
|
+
lines.push(`${prefix} (${message.timestamp}): ${message.text}${metadataSuffix}`);
|
|
1164
|
+
}
|
|
1165
|
+
return {
|
|
1166
|
+
content: [
|
|
1167
|
+
{
|
|
1168
|
+
type: "text",
|
|
1169
|
+
text: `${securityHeader}You are ${agentEmoji} ${agentName}.\n\n${lines.join("\n")}`,
|
|
1170
|
+
},
|
|
1171
|
+
],
|
|
1172
|
+
details: { count: pending.length, messages: pending },
|
|
1173
|
+
};
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
registerSlackAction({
|
|
1177
|
+
name: "slack_modal_open",
|
|
1178
|
+
label: "Slack Modal Open",
|
|
1179
|
+
description: "Open a Slack modal with views.open using a fresh trigger_id.",
|
|
1180
|
+
promptSnippet: "Open a Slack modal to collect structured input or approvals.",
|
|
1181
|
+
parameters: Type.Object({
|
|
1182
|
+
trigger_id: Type.String({ description: "Fresh Slack trigger_id from a recent interaction" }),
|
|
1183
|
+
view: Type.Record(Type.String(), Type.Unknown(), {
|
|
1184
|
+
description: "Slack modal view JSON object (type='modal')",
|
|
1185
|
+
}),
|
|
1186
|
+
thread_ts: Type.Optional(Type.String({
|
|
1187
|
+
description: "Optional Slack thread to bind into private_metadata so view submissions route back into the thread",
|
|
1188
|
+
})),
|
|
1189
|
+
}),
|
|
1190
|
+
async execute(_id, params) {
|
|
1191
|
+
requireToolPolicy("slack_modal_open", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | trigger_id=${params.trigger_id} | view=modal`);
|
|
1192
|
+
try {
|
|
1193
|
+
const view = await buildSlackModalView(params.view, params.thread_ts);
|
|
1194
|
+
const response = await slack("views.open", getBotToken(), {
|
|
1195
|
+
trigger_id: params.trigger_id,
|
|
1196
|
+
view,
|
|
1197
|
+
});
|
|
1198
|
+
const modalView = response.view;
|
|
1199
|
+
return {
|
|
1200
|
+
content: [
|
|
1201
|
+
{
|
|
1202
|
+
type: "text",
|
|
1203
|
+
text: `Opened Slack modal${params.thread_ts ? ` for thread ${params.thread_ts}` : ""}.`,
|
|
1204
|
+
},
|
|
1205
|
+
],
|
|
1206
|
+
details: {
|
|
1207
|
+
thread_ts: params.thread_ts ?? null,
|
|
1208
|
+
view_id: typeof modalView?.id === "string" ? modalView.id : null,
|
|
1209
|
+
external_id: typeof modalView?.external_id === "string" ? modalView.external_id : null,
|
|
1210
|
+
hash: typeof modalView?.hash === "string" ? modalView.hash : null,
|
|
1211
|
+
view,
|
|
1212
|
+
},
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
catch (err) {
|
|
1216
|
+
rethrowSlackModalError(err, "open");
|
|
1217
|
+
}
|
|
1218
|
+
},
|
|
1219
|
+
});
|
|
1220
|
+
registerSlackAction({
|
|
1221
|
+
name: "slack_modal_push",
|
|
1222
|
+
label: "Slack Modal Push",
|
|
1223
|
+
description: "Push a new view onto an open Slack modal stack using views.push.",
|
|
1224
|
+
promptSnippet: "Push another Slack modal view for a multi-step workflow.",
|
|
1225
|
+
parameters: Type.Object({
|
|
1226
|
+
trigger_id: Type.String({ description: "Fresh Slack trigger_id from a recent interaction" }),
|
|
1227
|
+
view: Type.Record(Type.String(), Type.Unknown(), {
|
|
1228
|
+
description: "Slack modal view JSON object (type='modal')",
|
|
1229
|
+
}),
|
|
1230
|
+
thread_ts: Type.Optional(Type.String({
|
|
1231
|
+
description: "Optional Slack thread to bind into private_metadata so later submissions route back into the thread",
|
|
1232
|
+
})),
|
|
1233
|
+
}),
|
|
1234
|
+
async execute(_id, params) {
|
|
1235
|
+
requireToolPolicy("slack_modal_push", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | trigger_id=${params.trigger_id} | view=modal`);
|
|
1236
|
+
try {
|
|
1237
|
+
const view = await buildSlackModalView(params.view, params.thread_ts);
|
|
1238
|
+
const response = await slack("views.push", getBotToken(), {
|
|
1239
|
+
trigger_id: params.trigger_id,
|
|
1240
|
+
view,
|
|
1241
|
+
});
|
|
1242
|
+
const modalView = response.view;
|
|
1243
|
+
return {
|
|
1244
|
+
content: [
|
|
1245
|
+
{
|
|
1246
|
+
type: "text",
|
|
1247
|
+
text: `Pushed a new Slack modal view${params.thread_ts ? ` for thread ${params.thread_ts}` : ""}.`,
|
|
1248
|
+
},
|
|
1249
|
+
],
|
|
1250
|
+
details: {
|
|
1251
|
+
thread_ts: params.thread_ts ?? null,
|
|
1252
|
+
view_id: typeof modalView?.id === "string" ? modalView.id : null,
|
|
1253
|
+
external_id: typeof modalView?.external_id === "string" ? modalView.external_id : null,
|
|
1254
|
+
hash: typeof modalView?.hash === "string" ? modalView.hash : null,
|
|
1255
|
+
view,
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
rethrowSlackModalError(err, "push");
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
});
|
|
1264
|
+
registerSlackAction({
|
|
1265
|
+
name: "slack_modal_update",
|
|
1266
|
+
label: "Slack Modal Update",
|
|
1267
|
+
description: "Update an open Slack modal using views.update.",
|
|
1268
|
+
promptSnippet: "Update an existing Slack modal with a new view.",
|
|
1269
|
+
parameters: Type.Object({
|
|
1270
|
+
view: Type.Record(Type.String(), Type.Unknown(), {
|
|
1271
|
+
description: "Slack modal view JSON object (type='modal')",
|
|
1272
|
+
}),
|
|
1273
|
+
view_id: Type.Optional(Type.String({ description: "Slack view_id from a modal interaction" })),
|
|
1274
|
+
external_id: Type.Optional(Type.String({ description: "Slack external_id for the modal" })),
|
|
1275
|
+
hash: Type.Optional(Type.String({ description: "Optional modal hash for optimistic locking" })),
|
|
1276
|
+
thread_ts: Type.Optional(Type.String({
|
|
1277
|
+
description: "Optional Slack thread to bind into private_metadata so later submissions route back into the thread",
|
|
1278
|
+
})),
|
|
1279
|
+
}),
|
|
1280
|
+
async execute(_id, params) {
|
|
1281
|
+
requireToolPolicy("slack_modal_update", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | view_id=${params.view_id ?? ""} | external_id=${params.external_id ?? ""} | view=modal`);
|
|
1282
|
+
if (!params.view_id && !params.external_id) {
|
|
1283
|
+
throw new Error("Provide either view_id or external_id when updating a Slack modal.");
|
|
1284
|
+
}
|
|
1285
|
+
try {
|
|
1286
|
+
const view = await buildSlackModalView(params.view, params.thread_ts);
|
|
1287
|
+
const response = await slack("views.update", getBotToken(), {
|
|
1288
|
+
...(params.view_id ? { view_id: params.view_id } : {}),
|
|
1289
|
+
...(params.external_id ? { external_id: params.external_id } : {}),
|
|
1290
|
+
...(params.hash ? { hash: params.hash } : {}),
|
|
1291
|
+
view,
|
|
1292
|
+
});
|
|
1293
|
+
const modalView = response.view;
|
|
1294
|
+
return {
|
|
1295
|
+
content: [
|
|
1296
|
+
{
|
|
1297
|
+
type: "text",
|
|
1298
|
+
text: `Updated Slack modal${params.view_id ? ` ${params.view_id}` : ""}.`,
|
|
1299
|
+
},
|
|
1300
|
+
],
|
|
1301
|
+
details: {
|
|
1302
|
+
thread_ts: params.thread_ts ?? null,
|
|
1303
|
+
view_id: typeof modalView?.id === "string" ? modalView.id : (params.view_id ?? null),
|
|
1304
|
+
external_id: typeof modalView?.external_id === "string"
|
|
1305
|
+
? modalView.external_id
|
|
1306
|
+
: (params.external_id ?? null),
|
|
1307
|
+
hash: typeof modalView?.hash === "string" ? modalView.hash : (params.hash ?? null),
|
|
1308
|
+
view,
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
catch (err) {
|
|
1313
|
+
rethrowSlackModalError(err, "update");
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
pi.registerTool({
|
|
1318
|
+
name: "slack_send",
|
|
1319
|
+
label: "Slack Send",
|
|
1320
|
+
description: "Send a message in a Slack assistant thread.",
|
|
1321
|
+
promptSnippet: "Reply in a Slack assistant thread. When you receive a task: ACK briefly, do the work, report blockers immediately, report the outcome when done. Always reply where the task came from.",
|
|
1322
|
+
promptGuidelines: buildSlackSendPromptGuidelines(),
|
|
1323
|
+
parameters: Type.Object({
|
|
1324
|
+
text: Type.String({ description: "Message text (Slack markdown)" }),
|
|
1325
|
+
thread_ts: Type.Optional(Type.String({
|
|
1326
|
+
description: "Thread to reply in. Omit to start a new conversation.",
|
|
1327
|
+
})),
|
|
1328
|
+
blocks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()), {
|
|
1329
|
+
description: "Optional Slack Block Kit blocks JSON array",
|
|
1330
|
+
})),
|
|
1331
|
+
}),
|
|
1332
|
+
async execute(_id, params) {
|
|
1333
|
+
requireToolPolicy("slack_send", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | text=${params.text} | blocks=${summarizeSlackBlocksForPolicy(params.blocks)}`);
|
|
1334
|
+
const channel = await resolveSlackSendChannel(params.thread_ts);
|
|
1335
|
+
if (!channel) {
|
|
1336
|
+
throw new Error(params.thread_ts
|
|
1337
|
+
? "No active Slack thread. If you know the channel and thread_ts, use the slack dispatcher action post_channel instead."
|
|
1338
|
+
: "No active Slack thread and no defaultChannel configured in settings.json. Set slack-bridge.defaultChannel or use the slack dispatcher action post_channel with a channel.");
|
|
1339
|
+
}
|
|
1340
|
+
const delivery = await deliverSlackMessage({
|
|
1341
|
+
channel,
|
|
1342
|
+
text: params.text,
|
|
1343
|
+
...(params.thread_ts ? { threadTs: params.thread_ts } : {}),
|
|
1344
|
+
...(params.blocks ? { blocks: params.blocks } : {}),
|
|
1345
|
+
});
|
|
1346
|
+
const ts = delivery.ts;
|
|
1347
|
+
const threadTs = params.thread_ts ?? delivery.threadTs;
|
|
1348
|
+
const actualTs = threadTs ?? ts;
|
|
1349
|
+
if (!actualTs) {
|
|
1350
|
+
throw new Error("Slack delivery did not return a message timestamp.");
|
|
1351
|
+
}
|
|
1352
|
+
noteThreadReply(actualTs, channel);
|
|
1353
|
+
if (params.thread_ts) {
|
|
1354
|
+
clearPendingThreadAttention(params.thread_ts);
|
|
1355
|
+
}
|
|
1356
|
+
return {
|
|
1357
|
+
content: [
|
|
1358
|
+
{
|
|
1359
|
+
type: "text",
|
|
1360
|
+
text: params.thread_ts
|
|
1361
|
+
? `Replied in thread ${params.thread_ts}.`
|
|
1362
|
+
: `Sent message (thread_ts: ${actualTs}). Use this to continue the conversation.`,
|
|
1363
|
+
},
|
|
1364
|
+
],
|
|
1365
|
+
details: {
|
|
1366
|
+
...(ts ? { ts } : {}),
|
|
1367
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
1368
|
+
channel,
|
|
1369
|
+
blocksCount: delivery.blocksCount,
|
|
1370
|
+
delivery: delivery.delivery,
|
|
1371
|
+
...(delivery.adapter ? { adapter: delivery.adapter } : {}),
|
|
1372
|
+
...(delivery.messageId ? { messageId: delivery.messageId } : {}),
|
|
1373
|
+
...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
|
|
1374
|
+
},
|
|
1375
|
+
};
|
|
1376
|
+
},
|
|
1377
|
+
});
|
|
1378
|
+
registerSlackAction({
|
|
1379
|
+
name: "slack_react",
|
|
1380
|
+
label: "Slack React",
|
|
1381
|
+
description: "Add an emoji reaction to a Slack message or thread root.",
|
|
1382
|
+
promptSnippet: "Add a reaction to a Slack thread root or message. Use this for lightweight acknowledgements like 👀, ✅, or 🔄.",
|
|
1383
|
+
parameters: Type.Object({
|
|
1384
|
+
emoji: Type.String({
|
|
1385
|
+
description: "Reaction emoji or Slack reaction name, e.g. 👀, ✅, :eyes:, or white_check_mark",
|
|
1386
|
+
}),
|
|
1387
|
+
thread_ts: Type.Optional(Type.String({
|
|
1388
|
+
description: "Tracked Slack thread. If timestamp is omitted, reacts to the thread root.",
|
|
1389
|
+
})),
|
|
1390
|
+
timestamp: Type.Optional(Type.String({
|
|
1391
|
+
description: "Specific message timestamp to react to. Defaults to thread_ts when omitted.",
|
|
1392
|
+
})),
|
|
1393
|
+
channel: Type.Optional(Type.String({
|
|
1394
|
+
description: "Optional channel name or ID. Required when reacting to a standalone message outside the current tracked thread.",
|
|
1395
|
+
})),
|
|
1396
|
+
}),
|
|
1397
|
+
async execute(_id, params) {
|
|
1398
|
+
const targetTimestamp = params.timestamp ?? params.thread_ts;
|
|
1399
|
+
if (!targetTimestamp) {
|
|
1400
|
+
throw new Error("Provide thread_ts or timestamp so Slack knows which message to react to.");
|
|
1401
|
+
}
|
|
1402
|
+
requireToolPolicy("slack_react", params.thread_ts, `emoji=${params.emoji} | thread_ts=${params.thread_ts ?? ""} | timestamp=${targetTimestamp} | channel=${params.channel ?? ""}`);
|
|
1403
|
+
const reactionName = normalizeReactionName(params.emoji);
|
|
1404
|
+
const channelId = await resolveReactionChannel(params.thread_ts, params.channel);
|
|
1405
|
+
await slack("reactions.add", getBotToken(), {
|
|
1406
|
+
channel: channelId,
|
|
1407
|
+
timestamp: targetTimestamp,
|
|
1408
|
+
name: reactionName,
|
|
1409
|
+
});
|
|
1410
|
+
return {
|
|
1411
|
+
content: [
|
|
1412
|
+
{
|
|
1413
|
+
type: "text",
|
|
1414
|
+
text: `Added :${reactionName}: to message ${targetTimestamp}.`,
|
|
1415
|
+
},
|
|
1416
|
+
],
|
|
1417
|
+
details: {
|
|
1418
|
+
channel: channelId,
|
|
1419
|
+
timestamp: targetTimestamp,
|
|
1420
|
+
emoji: reactionName,
|
|
1421
|
+
...(params.thread_ts ? { thread_ts: params.thread_ts } : {}),
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
},
|
|
1425
|
+
});
|
|
1426
|
+
registerSlackAction({
|
|
1427
|
+
name: "slack_upload",
|
|
1428
|
+
label: "Slack Upload",
|
|
1429
|
+
description: "Upload a file or snippet into Slack using the external upload flow. Supports inline content and guarded local file paths.",
|
|
1430
|
+
promptSnippet: "Upload files, snippets, diffs, logs, screenshots, or generated artifacts into Slack threads when inline text would be awkward or too long.",
|
|
1431
|
+
parameters: Type.Object({
|
|
1432
|
+
content: Type.Optional(Type.String({
|
|
1433
|
+
description: "Inline content to upload as a snippet or file. Provide exactly one of content or path.",
|
|
1434
|
+
})),
|
|
1435
|
+
path: Type.Optional(Type.String({
|
|
1436
|
+
description: "Local file path to upload. For safety, only files inside the current working directory or system temp directory are allowed.",
|
|
1437
|
+
})),
|
|
1438
|
+
filename: Type.Optional(Type.String({
|
|
1439
|
+
description: "Filename shown in Slack. Required for inline content, optional for path uploads.",
|
|
1440
|
+
})),
|
|
1441
|
+
filetype: Type.Optional(Type.String({
|
|
1442
|
+
description: "Optional filetype/snippet language override, e.g. diff, typescript, json.",
|
|
1443
|
+
})),
|
|
1444
|
+
title: Type.Optional(Type.String({ description: "Optional Slack title for the uploaded file" })),
|
|
1445
|
+
channel: Type.Optional(Type.String({
|
|
1446
|
+
description: "Optional channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
1447
|
+
})),
|
|
1448
|
+
thread_ts: Type.Optional(Type.String({ description: "Optional thread timestamp to attach the upload to" })),
|
|
1449
|
+
}),
|
|
1450
|
+
async execute(_id, params) {
|
|
1451
|
+
requireToolPolicy("slack_upload", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | channel=${params.channel ?? getDefaultChannel() ?? ""} | filename=${params.filename ?? ""} | path=${params.path ?? ""} | content_length=${params.content?.length ?? 0}`);
|
|
1452
|
+
const upload = await prepareSlackUpload(params, process.cwd(), os.tmpdir());
|
|
1453
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
1454
|
+
const { fileId, response } = await performSlackUpload({
|
|
1455
|
+
upload,
|
|
1456
|
+
channelId,
|
|
1457
|
+
threadTs: params.thread_ts,
|
|
1458
|
+
slack,
|
|
1459
|
+
token: getBotToken(),
|
|
1460
|
+
});
|
|
1461
|
+
if (params.thread_ts) {
|
|
1462
|
+
noteThreadReply(params.thread_ts, channelId);
|
|
1463
|
+
clearPendingThreadAttention(params.thread_ts);
|
|
1464
|
+
}
|
|
1465
|
+
const uploadedFiles = Array.isArray(response.files)
|
|
1466
|
+
? response.files
|
|
1467
|
+
: [];
|
|
1468
|
+
const uploadedFile = uploadedFiles[0];
|
|
1469
|
+
const permalink = uploadedFile && typeof uploadedFile.permalink === "string"
|
|
1470
|
+
? uploadedFile.permalink
|
|
1471
|
+
: undefined;
|
|
1472
|
+
return {
|
|
1473
|
+
content: [
|
|
1474
|
+
{
|
|
1475
|
+
type: "text",
|
|
1476
|
+
text: params.thread_ts
|
|
1477
|
+
? `Uploaded \`${upload.filename}\` to thread ${params.thread_ts}.`
|
|
1478
|
+
: `Uploaded \`${upload.filename}\` to channel ${params.channel ?? channelId}.`,
|
|
1479
|
+
},
|
|
1480
|
+
],
|
|
1481
|
+
details: {
|
|
1482
|
+
fileId,
|
|
1483
|
+
channel: channelId,
|
|
1484
|
+
filename: upload.filename,
|
|
1485
|
+
title: upload.title,
|
|
1486
|
+
source: upload.source,
|
|
1487
|
+
...(params.thread_ts ? { thread_ts: params.thread_ts } : {}),
|
|
1488
|
+
...(permalink ? { permalink } : {}),
|
|
1489
|
+
...(upload.resolvedPath ? { path: upload.resolvedPath } : {}),
|
|
1490
|
+
},
|
|
1491
|
+
};
|
|
1492
|
+
},
|
|
1493
|
+
});
|
|
1494
|
+
registerSlackAction({
|
|
1495
|
+
name: "slack_read",
|
|
1496
|
+
label: "Slack Read",
|
|
1497
|
+
description: "Read messages from a Slack assistant thread.",
|
|
1498
|
+
promptSnippet: "Read messages from a Slack assistant thread.",
|
|
1499
|
+
parameters: Type.Object({
|
|
1500
|
+
thread_ts: Type.String({ description: "Thread to read." }),
|
|
1501
|
+
limit: Type.Optional(Type.Number({ description: "Max messages (default 20)" })),
|
|
1502
|
+
...SLACK_OUTPUT_OPTION_PARAMETERS,
|
|
1503
|
+
}),
|
|
1504
|
+
async execute(_id, params) {
|
|
1505
|
+
requireToolPolicy("slack_read", params.thread_ts, `thread_ts=${params.thread_ts} | limit=${params.limit ?? 20}`);
|
|
1506
|
+
const channel = (await resolveTrackedThreadChannel(params.thread_ts)) ?? getLastDmChannel();
|
|
1507
|
+
if (!channel) {
|
|
1508
|
+
throw new Error("Unknown thread.");
|
|
1509
|
+
}
|
|
1510
|
+
const response = await slack("conversations.replies", getBotToken(), {
|
|
1511
|
+
channel,
|
|
1512
|
+
ts: params.thread_ts,
|
|
1513
|
+
limit: params.limit ?? 20,
|
|
1514
|
+
});
|
|
1515
|
+
const messages = response.messages;
|
|
1516
|
+
const full = params.full === true;
|
|
1517
|
+
const formattedMessages = await Promise.all(messages.map(async (message) => {
|
|
1518
|
+
const userId = message.user;
|
|
1519
|
+
const name = userId ? await resolveUser(userId) : "bot";
|
|
1520
|
+
const text = message.text ?? "";
|
|
1521
|
+
const ts = message.ts;
|
|
1522
|
+
return { ts, name, text, preview: truncateSlackText(text) };
|
|
1523
|
+
}));
|
|
1524
|
+
const lines = formattedMessages.map((message) => full
|
|
1525
|
+
? `[${message.ts}] ${message.name}: ${message.text}`
|
|
1526
|
+
: `[${message.ts}] ${message.name}: ${message.preview}`);
|
|
1527
|
+
if (!full && messages.length > 0) {
|
|
1528
|
+
lines.push("", "Use args.full=true for exact message text.");
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
content: [{ type: "text", text: lines.join("\n") || "(no messages)" }],
|
|
1532
|
+
details: full
|
|
1533
|
+
? { count: messages.length }
|
|
1534
|
+
: {
|
|
1535
|
+
count: messages.length,
|
|
1536
|
+
messages: formattedMessages.map((message) => ({
|
|
1537
|
+
ts: message.ts,
|
|
1538
|
+
user: message.name,
|
|
1539
|
+
preview: message.preview,
|
|
1540
|
+
})),
|
|
1541
|
+
},
|
|
1542
|
+
fullDetails: {
|
|
1543
|
+
count: messages.length,
|
|
1544
|
+
messages: formattedMessages.map((message) => ({
|
|
1545
|
+
ts: message.ts,
|
|
1546
|
+
user: message.name,
|
|
1547
|
+
text: message.text,
|
|
1548
|
+
})),
|
|
1549
|
+
},
|
|
1550
|
+
};
|
|
1551
|
+
},
|
|
1552
|
+
});
|
|
1553
|
+
registerSlackAction({
|
|
1554
|
+
name: "slack_status",
|
|
1555
|
+
label: "Slack Thread Status",
|
|
1556
|
+
description: "Set or clear Slack's in-thread shimmer/thinking status for the current thread. Best-effort; failures do not affect final replies.",
|
|
1557
|
+
promptSnippet: "Update visible Slack thread status with short safe progress hints such as Reading context… or Drafting reply…. Do not expose chain-of-thought.",
|
|
1558
|
+
parameters: Type.Object({
|
|
1559
|
+
thread_ts: Type.String({ description: "Slack thread timestamp to update." }),
|
|
1560
|
+
channel: Type.Optional(Type.String({
|
|
1561
|
+
description: "Optional channel name or ID. Omit when the thread is tracked by the Slack bridge.",
|
|
1562
|
+
})),
|
|
1563
|
+
status: Type.Optional(Type.Union([
|
|
1564
|
+
Type.Literal("is thinking…"),
|
|
1565
|
+
Type.Literal("Reading context…"),
|
|
1566
|
+
Type.Literal("Calling tool…"),
|
|
1567
|
+
Type.Literal("Drafting reply…"),
|
|
1568
|
+
Type.Literal("Checking Slack…"),
|
|
1569
|
+
], {
|
|
1570
|
+
description: "Controlled visible status text. Ignored when clear=true. Only safe operational progress labels are accepted.",
|
|
1571
|
+
})),
|
|
1572
|
+
clear: Type.Optional(Type.Boolean({ description: "Clear the visible thread status." })),
|
|
1573
|
+
}),
|
|
1574
|
+
async execute(_id, params) {
|
|
1575
|
+
requireToolPolicy("slack_status", params.thread_ts, `thread_ts=${params.thread_ts} | channel=${params.channel ?? ""} | clear=${params.clear === true}`);
|
|
1576
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
1577
|
+
const status = params.clear === true
|
|
1578
|
+
? ""
|
|
1579
|
+
: normalizeSlackThreadStatus(params.status ?? DEFAULT_SLACK_THREAD_STATUS);
|
|
1580
|
+
await setSlackThreadStatus({
|
|
1581
|
+
slack,
|
|
1582
|
+
token: getBotToken(),
|
|
1583
|
+
channelId,
|
|
1584
|
+
threadTs: params.thread_ts,
|
|
1585
|
+
status,
|
|
1586
|
+
loadingMessages: SLACK_THREAD_LOADING_MESSAGES,
|
|
1587
|
+
});
|
|
1588
|
+
return {
|
|
1589
|
+
content: [
|
|
1590
|
+
{
|
|
1591
|
+
type: "text",
|
|
1592
|
+
text: status
|
|
1593
|
+
? `Updated Slack thread status to “${status}”.`
|
|
1594
|
+
: "Cleared Slack thread status.",
|
|
1595
|
+
},
|
|
1596
|
+
],
|
|
1597
|
+
details: { channel: channelId, thread_ts: params.thread_ts, status },
|
|
1598
|
+
};
|
|
1599
|
+
},
|
|
1600
|
+
});
|
|
1601
|
+
registerSlackAction({
|
|
1602
|
+
name: "slack_presence",
|
|
1603
|
+
label: "Slack Presence",
|
|
1604
|
+
description: "Check whether one or more Slack users are active, away, or in Do Not Disturb before messaging them.",
|
|
1605
|
+
promptSnippet: "Check whether Slack users are active, away, or in DND before pinging them, routing work, or deciding whether to schedule a follow-up.",
|
|
1606
|
+
parameters: Type.Object({
|
|
1607
|
+
user: Type.Optional(Type.String({
|
|
1608
|
+
description: "Single Slack user ID, mention, @handle, display name, or real name",
|
|
1609
|
+
})),
|
|
1610
|
+
users: Type.Optional(Type.Array(Type.String({
|
|
1611
|
+
description: "Multiple Slack user IDs, mentions, @handles, or names to check in batch",
|
|
1612
|
+
}))),
|
|
1613
|
+
}),
|
|
1614
|
+
async execute(_id, params) {
|
|
1615
|
+
const targets = normalizeSlackPresenceTargets(params.user, params.users);
|
|
1616
|
+
requireToolPolicy("slack_presence", undefined, `user=${params.user ?? ""} | users=${targets.join(",")}`);
|
|
1617
|
+
const resolvedTargets = await Promise.all(targets.map(async (target) => ({
|
|
1618
|
+
target,
|
|
1619
|
+
...(await resolveSlackPresenceTarget(target)),
|
|
1620
|
+
})));
|
|
1621
|
+
const snapshots = await Promise.all(resolvedTargets.map(({ userId, userName }) => fetchSlackPresenceSnapshot(userId, userName)));
|
|
1622
|
+
return {
|
|
1623
|
+
content: [{ type: "text", text: snapshots.map(formatSlackPresenceLine).join("\n") }],
|
|
1624
|
+
details: {
|
|
1625
|
+
count: snapshots.length,
|
|
1626
|
+
results: snapshots.map((snapshot) => ({
|
|
1627
|
+
user: snapshot.userId,
|
|
1628
|
+
user_name: snapshot.userName,
|
|
1629
|
+
presence: snapshot.presence,
|
|
1630
|
+
online: snapshot.online,
|
|
1631
|
+
auto_away: snapshot.autoAway,
|
|
1632
|
+
manual_away: snapshot.manualAway,
|
|
1633
|
+
dnd_enabled: snapshot.dndEnabled,
|
|
1634
|
+
dnd_end_ts: snapshot.dndEndTs,
|
|
1635
|
+
dnd_end_at: snapshot.dndEndAt,
|
|
1636
|
+
connection_count: snapshot.connectionCount,
|
|
1637
|
+
last_activity: snapshot.lastActivity,
|
|
1638
|
+
})),
|
|
1639
|
+
},
|
|
1640
|
+
};
|
|
1641
|
+
},
|
|
1642
|
+
});
|
|
1643
|
+
registerSlackAction({
|
|
1644
|
+
name: "slack_export",
|
|
1645
|
+
label: "Slack Export",
|
|
1646
|
+
description: "Export a Slack thread as markdown, plain text, or JSON for documentation and archival.",
|
|
1647
|
+
promptSnippet: "Export a Slack thread before turning it into docs, ADRs, canvases, archives, or follow-up summaries.",
|
|
1648
|
+
parameters: Type.Object({
|
|
1649
|
+
thread_ts: Type.String({ description: "Thread timestamp to export" }),
|
|
1650
|
+
channel: Type.Optional(Type.String({
|
|
1651
|
+
description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
1652
|
+
})),
|
|
1653
|
+
format: Type.Optional(Type.String({ description: "Export format: 'markdown' (default), 'plain', or 'json'" })),
|
|
1654
|
+
include_metadata: Type.Optional(Type.Boolean({ description: "Include timestamps and author names (default true)" })),
|
|
1655
|
+
oldest: Type.Optional(Type.String({
|
|
1656
|
+
description: "Optional oldest boundary as a Slack ts or ISO-8601 UTC timestamp",
|
|
1657
|
+
})),
|
|
1658
|
+
latest: Type.Optional(Type.String({
|
|
1659
|
+
description: "Optional latest boundary as a Slack ts or ISO-8601 UTC timestamp",
|
|
1660
|
+
})),
|
|
1661
|
+
}),
|
|
1662
|
+
async execute(_id, params) {
|
|
1663
|
+
requireToolPolicy("slack_export", params.thread_ts, `thread_ts=${params.thread_ts} | channel=${params.channel ?? ""} | format=${params.format ?? "markdown"} | include_metadata=${params.include_metadata ?? true} | oldest=${params.oldest ?? ""} | latest=${params.latest ?? ""}`);
|
|
1664
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
1665
|
+
const oldestTs = params.oldest?.trim()
|
|
1666
|
+
? parseSlackExportBoundaryTs(params.oldest)
|
|
1667
|
+
: undefined;
|
|
1668
|
+
const latestTs = params.latest?.trim()
|
|
1669
|
+
? parseSlackExportBoundaryTs(params.latest)
|
|
1670
|
+
: undefined;
|
|
1671
|
+
if (oldestTs != null && latestTs != null && oldestTs > latestTs) {
|
|
1672
|
+
throw new Error("oldest must be earlier than or equal to latest.");
|
|
1673
|
+
}
|
|
1674
|
+
const rawMessages = await fetchSlackThreadMessages(channelId, params.thread_ts, oldestTs != null ? String(oldestTs) : undefined, latestTs != null ? String(latestTs) : undefined);
|
|
1675
|
+
const exportPayload = await buildSlackExportPayload(rawMessages);
|
|
1676
|
+
const filteredMessages = filterSlackExportMessagesByRange(exportPayload.messages, oldestTs, latestTs);
|
|
1677
|
+
const participants = [
|
|
1678
|
+
...new Set(filteredMessages.map((message) => message.authorName).filter(Boolean)),
|
|
1679
|
+
];
|
|
1680
|
+
const exportText = buildSlackThreadExport({
|
|
1681
|
+
format: params.format,
|
|
1682
|
+
includeMetadata: params.include_metadata,
|
|
1683
|
+
threadTs: params.thread_ts,
|
|
1684
|
+
channelId,
|
|
1685
|
+
channelLabel: params.channel,
|
|
1686
|
+
messages: filteredMessages,
|
|
1687
|
+
mentionNames: exportPayload.mentionNames,
|
|
1688
|
+
});
|
|
1689
|
+
return {
|
|
1690
|
+
content: [
|
|
1691
|
+
{
|
|
1692
|
+
type: "text",
|
|
1693
|
+
text: exportText || "(no messages)",
|
|
1694
|
+
},
|
|
1695
|
+
],
|
|
1696
|
+
details: {
|
|
1697
|
+
thread_ts: params.thread_ts,
|
|
1698
|
+
channel: channelId,
|
|
1699
|
+
format: params.format?.trim().toLowerCase() ?? "markdown",
|
|
1700
|
+
include_metadata: params.include_metadata ?? true,
|
|
1701
|
+
count: filteredMessages.length,
|
|
1702
|
+
participants,
|
|
1703
|
+
...(oldestTs != null ? { oldest: oldestTs } : {}),
|
|
1704
|
+
...(latestTs != null ? { latest: latestTs } : {}),
|
|
1705
|
+
},
|
|
1706
|
+
};
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
registerSlackAction({
|
|
1710
|
+
name: "slack_create_channel",
|
|
1711
|
+
label: "Slack Create Channel",
|
|
1712
|
+
description: "Create a new Slack channel, optionally setting its topic and purpose.",
|
|
1713
|
+
promptSnippet: "Create a new Slack channel.",
|
|
1714
|
+
parameters: Type.Object({
|
|
1715
|
+
name: Type.String({
|
|
1716
|
+
description: "Channel name (lowercase, no spaces, max 80 chars)",
|
|
1717
|
+
}),
|
|
1718
|
+
topic: Type.Optional(Type.String({ description: "Channel topic" })),
|
|
1719
|
+
purpose: Type.Optional(Type.String({ description: "Channel purpose" })),
|
|
1720
|
+
}),
|
|
1721
|
+
async execute(_id, params) {
|
|
1722
|
+
requireToolPolicy("slack_create_channel", undefined, `name=${params.name} | topic=${params.topic ?? ""} | purpose=${params.purpose ?? ""}`);
|
|
1723
|
+
const response = await slack("conversations.create", getBotToken(), {
|
|
1724
|
+
name: params.name,
|
|
1725
|
+
});
|
|
1726
|
+
const channel = response.channel;
|
|
1727
|
+
if (params.topic) {
|
|
1728
|
+
await slack("conversations.setTopic", getBotToken(), {
|
|
1729
|
+
channel: channel.id,
|
|
1730
|
+
topic: params.topic,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
if (params.purpose) {
|
|
1734
|
+
await slack("conversations.setPurpose", getBotToken(), {
|
|
1735
|
+
channel: channel.id,
|
|
1736
|
+
purpose: params.purpose,
|
|
1737
|
+
});
|
|
1738
|
+
}
|
|
1739
|
+
rememberChannel(channel.name, channel.id);
|
|
1740
|
+
return {
|
|
1741
|
+
content: [{ type: "text", text: `Created channel #${channel.name} (${channel.id})` }],
|
|
1742
|
+
details: { id: channel.id, name: channel.name },
|
|
1743
|
+
};
|
|
1744
|
+
},
|
|
1745
|
+
});
|
|
1746
|
+
// ─── Project channel creation ──────────────────────────
|
|
1747
|
+
registerSlackAction({
|
|
1748
|
+
name: "slack_project_create",
|
|
1749
|
+
label: "Slack Project Create",
|
|
1750
|
+
description: "Create a Slack project channel with an attached RFC canvas and bot membership in one step.",
|
|
1751
|
+
promptSnippet: "Create a project channel, attach an RFC/spec canvas, and invite the Pinet bot — all in one call.",
|
|
1752
|
+
parameters: Type.Object({
|
|
1753
|
+
name: Type.String({
|
|
1754
|
+
description: "Channel name (lowercase, no spaces, max 80 chars)",
|
|
1755
|
+
}),
|
|
1756
|
+
topic: Type.Optional(Type.String({ description: "Channel topic" })),
|
|
1757
|
+
purpose: Type.Optional(Type.String({ description: "Channel purpose" })),
|
|
1758
|
+
canvas_title: Type.Optional(Type.String({ description: "Title for the RFC/spec canvas (defaults to channel name)" })),
|
|
1759
|
+
canvas_markdown: Type.Optional(Type.String({ description: "Markdown content for the RFC/spec canvas" })),
|
|
1760
|
+
}),
|
|
1761
|
+
async execute(_id, params) {
|
|
1762
|
+
requireToolPolicy("slack_project_create", undefined, `name=${params.name} | topic=${params.topic ?? ""} | canvas=${params.canvas_title ?? params.name}`);
|
|
1763
|
+
// 1. Create the channel
|
|
1764
|
+
const createResponse = await slack("conversations.create", getBotToken(), {
|
|
1765
|
+
name: params.name,
|
|
1766
|
+
});
|
|
1767
|
+
const channel = createResponse.channel;
|
|
1768
|
+
rememberChannel(channel.name, channel.id);
|
|
1769
|
+
// 2. Set topic and purpose if provided
|
|
1770
|
+
if (params.topic) {
|
|
1771
|
+
await slack("conversations.setTopic", getBotToken(), {
|
|
1772
|
+
channel: channel.id,
|
|
1773
|
+
topic: params.topic,
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
if (params.purpose) {
|
|
1777
|
+
await slack("conversations.setPurpose", getBotToken(), {
|
|
1778
|
+
channel: channel.id,
|
|
1779
|
+
purpose: params.purpose,
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
// 3. Invite the bot to the channel (if we know the bot user id)
|
|
1783
|
+
const botId = getBotUserId();
|
|
1784
|
+
let botInvited = false;
|
|
1785
|
+
if (botId) {
|
|
1786
|
+
try {
|
|
1787
|
+
await slack("conversations.invite", getBotToken(), {
|
|
1788
|
+
channel: channel.id,
|
|
1789
|
+
users: botId,
|
|
1790
|
+
});
|
|
1791
|
+
botInvited = true;
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
// already_in_channel is fine — the bot created the channel so it's already a member
|
|
1795
|
+
if (isSlackMethodError(err, "conversations.invite", "already_in_channel")) {
|
|
1796
|
+
botInvited = true;
|
|
1797
|
+
}
|
|
1798
|
+
// Other errors are non-fatal — the channel still works, just without explicit bot membership
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
// 4. Create the channel canvas with the RFC/spec
|
|
1802
|
+
const canvasTitle = params.canvas_title?.trim() || `${channel.name} RFC`;
|
|
1803
|
+
let canvasId = null;
|
|
1804
|
+
let canvasKind = null;
|
|
1805
|
+
let canvasFallback = null;
|
|
1806
|
+
let canvasFailure = null;
|
|
1807
|
+
try {
|
|
1808
|
+
const canvasRequest = buildSlackCanvasCreateRequest({
|
|
1809
|
+
kind: "channel",
|
|
1810
|
+
title: canvasTitle,
|
|
1811
|
+
markdown: params.canvas_markdown,
|
|
1812
|
+
channelId: channel.id,
|
|
1813
|
+
});
|
|
1814
|
+
const canvasResponse = await slack(canvasRequest.method, getBotToken(), canvasRequest.body);
|
|
1815
|
+
canvasId = canvasResponse.canvas_id;
|
|
1816
|
+
canvasKind = "channel";
|
|
1817
|
+
}
|
|
1818
|
+
catch (err) {
|
|
1819
|
+
if (isSlackMethodError(err, "conversations.canvases.create", "canvas_tab_creation_failed")) {
|
|
1820
|
+
try {
|
|
1821
|
+
canvasFallback = await createStandaloneCanvasFallback({
|
|
1822
|
+
channelId: channel.id,
|
|
1823
|
+
channelLabel: `#${channel.name}`,
|
|
1824
|
+
title: canvasTitle,
|
|
1825
|
+
markdown: params.canvas_markdown,
|
|
1826
|
+
});
|
|
1827
|
+
canvasId = canvasFallback.canvasId;
|
|
1828
|
+
canvasKind = "standalone";
|
|
1829
|
+
}
|
|
1830
|
+
catch (fallbackErr) {
|
|
1831
|
+
canvasFailure =
|
|
1832
|
+
fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
else {
|
|
1836
|
+
// Canvas creation failure is non-fatal — the channel is still usable
|
|
1837
|
+
canvasFailure = err instanceof Error ? err.message : String(err);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
const parts = [`Created project channel #${channel.name} (${channel.id})`];
|
|
1841
|
+
if (canvasId && canvasKind === "standalone" && canvasFallback) {
|
|
1842
|
+
const bookmarkSummary = buildStandaloneCanvasFallbackBookmarkSummary({
|
|
1843
|
+
channelLabel: `#${channel.name}`,
|
|
1844
|
+
canvasId,
|
|
1845
|
+
permalink: canvasFallback.permalink,
|
|
1846
|
+
permalinkLookupError: canvasFallback.permalinkLookupError,
|
|
1847
|
+
bookmarkId: canvasFallback.bookmarkId,
|
|
1848
|
+
bookmarkStatus: canvasFallback.bookmarkStatus,
|
|
1849
|
+
bookmarkError: canvasFallback.bookmarkError,
|
|
1850
|
+
});
|
|
1851
|
+
parts.push(`Slack could not create the project channel canvas tab (canvas_tab_creation_failed). Created standalone fallback RFC canvas: ${canvasId} — "${canvasTitle}".${bookmarkSummary} Use canvas_update with canvas_id=${canvasId} for future updates.`);
|
|
1852
|
+
}
|
|
1853
|
+
else if (canvasId) {
|
|
1854
|
+
parts.push(`RFC canvas: ${canvasId} — "${canvasTitle}"`);
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
parts.push(`Canvas creation failed${canvasFailure ? ` (${canvasFailure})` : ""} — retry with canvas_create kind='channel' channel='#${channel.name}' so the Slack bridge can auto-create/bookmark a standalone fallback if Slack rejects channel tab creation.`);
|
|
1858
|
+
}
|
|
1859
|
+
if (botInvited) {
|
|
1860
|
+
parts.push("Bot joined the channel.");
|
|
1861
|
+
}
|
|
1862
|
+
return {
|
|
1863
|
+
content: [{ type: "text", text: parts.join("\n") }],
|
|
1864
|
+
details: {
|
|
1865
|
+
channel_id: channel.id,
|
|
1866
|
+
channel_name: channel.name,
|
|
1867
|
+
canvas_id: canvasId,
|
|
1868
|
+
canvas_title: canvasTitle,
|
|
1869
|
+
canvas_kind: canvasKind,
|
|
1870
|
+
bot_invited: botInvited,
|
|
1871
|
+
...(canvasFallback
|
|
1872
|
+
? {
|
|
1873
|
+
canvas_fallback: true,
|
|
1874
|
+
canvas_fallback_reason: "canvas_tab_creation_failed",
|
|
1875
|
+
bookmark_status: canvasFallback.bookmarkStatus,
|
|
1876
|
+
next_action: `canvas_update canvas_id=${canvasFallback.canvasId}`,
|
|
1877
|
+
...(canvasFallback.permalink ? { permalink: canvasFallback.permalink } : {}),
|
|
1878
|
+
...(canvasFallback.bookmarkId ? { bookmark_id: canvasFallback.bookmarkId } : {}),
|
|
1879
|
+
...(canvasFallback.bookmarkError
|
|
1880
|
+
? { bookmark_error: canvasFallback.bookmarkError }
|
|
1881
|
+
: {}),
|
|
1882
|
+
...(canvasFallback.permalinkLookupError
|
|
1883
|
+
? { permalink_lookup_error: canvasFallback.permalinkLookupError }
|
|
1884
|
+
: {}),
|
|
1885
|
+
}
|
|
1886
|
+
: {}),
|
|
1887
|
+
...(canvasFailure ? { canvas_error: canvasFailure } : {}),
|
|
1888
|
+
},
|
|
1889
|
+
};
|
|
1890
|
+
},
|
|
1891
|
+
});
|
|
1892
|
+
registerSlackAction({
|
|
1893
|
+
name: "slack_post_channel",
|
|
1894
|
+
label: "Slack Post Channel",
|
|
1895
|
+
description: "Post a message to a Slack channel (by name or ID), optionally in a thread. Uses defaultChannel from settings if channel is omitted.",
|
|
1896
|
+
promptSnippet: "Post a message to a Slack channel or thread. Use when you need to target a specific channel or thread by ID.",
|
|
1897
|
+
parameters: Type.Object({
|
|
1898
|
+
channel: Type.Optional(Type.String({
|
|
1899
|
+
description: "Channel name or ID (uses defaultChannel from settings if omitted)",
|
|
1900
|
+
})),
|
|
1901
|
+
text: Type.String({ description: "Message text (Slack markdown)" }),
|
|
1902
|
+
thread_ts: Type.Optional(Type.String({ description: "Thread timestamp to reply in" })),
|
|
1903
|
+
blocks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()), {
|
|
1904
|
+
description: "Optional Slack Block Kit blocks JSON array",
|
|
1905
|
+
})),
|
|
1906
|
+
}),
|
|
1907
|
+
async execute(_id, params) {
|
|
1908
|
+
requireToolPolicy("slack_post_channel", params.thread_ts, `channel=${params.channel ?? getDefaultChannel() ?? ""} | thread_ts=${params.thread_ts ?? ""} | text=${params.text} | blocks=${summarizeSlackBlocksForPolicy(params.blocks)}`);
|
|
1909
|
+
const resolvedThreadChannel = await resolveTrackedThreadChannel(params.thread_ts);
|
|
1910
|
+
const channelInput = params.channel ?? getDefaultChannel();
|
|
1911
|
+
let channelId = params.channel ? await resolveChannel(params.channel) : resolvedThreadChannel;
|
|
1912
|
+
if (!channelId && channelInput) {
|
|
1913
|
+
channelId = await resolveChannel(channelInput);
|
|
1914
|
+
}
|
|
1915
|
+
if (!channelId) {
|
|
1916
|
+
throw new Error("No channel specified and no defaultChannel configured in settings.json.");
|
|
1917
|
+
}
|
|
1918
|
+
const delivery = await deliverSlackMessage({
|
|
1919
|
+
channel: channelId,
|
|
1920
|
+
text: params.text,
|
|
1921
|
+
...(params.thread_ts ? { threadTs: params.thread_ts } : {}),
|
|
1922
|
+
...(params.blocks ? { blocks: params.blocks } : {}),
|
|
1923
|
+
});
|
|
1924
|
+
const ts = delivery.ts;
|
|
1925
|
+
const threadTs = params.thread_ts ?? delivery.threadTs;
|
|
1926
|
+
const actualTs = threadTs ?? ts;
|
|
1927
|
+
if (!actualTs) {
|
|
1928
|
+
throw new Error("Slack delivery did not return a message timestamp.");
|
|
1929
|
+
}
|
|
1930
|
+
noteThreadReply(actualTs, channelId);
|
|
1931
|
+
const channelLabel = params.channel ?? resolvedThreadChannel ?? channelInput ?? channelId;
|
|
1932
|
+
return {
|
|
1933
|
+
content: [
|
|
1934
|
+
{
|
|
1935
|
+
type: "text",
|
|
1936
|
+
text: params.thread_ts
|
|
1937
|
+
? `Replied in thread ${params.thread_ts} in channel ${channelLabel}.`
|
|
1938
|
+
: `Posted to #${channelLabel} (ts: ${actualTs}).`,
|
|
1939
|
+
},
|
|
1940
|
+
],
|
|
1941
|
+
details: {
|
|
1942
|
+
...(ts ? { ts } : {}),
|
|
1943
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
1944
|
+
channel: channelId,
|
|
1945
|
+
blocksCount: delivery.blocksCount,
|
|
1946
|
+
delivery: delivery.delivery,
|
|
1947
|
+
...(delivery.adapter ? { adapter: delivery.adapter } : {}),
|
|
1948
|
+
...(delivery.messageId ? { messageId: delivery.messageId } : {}),
|
|
1949
|
+
...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
|
|
1950
|
+
},
|
|
1951
|
+
};
|
|
1952
|
+
},
|
|
1953
|
+
});
|
|
1954
|
+
registerSlackAction({
|
|
1955
|
+
name: "slack_delete",
|
|
1956
|
+
label: "Slack Delete",
|
|
1957
|
+
description: "Delete a Slack message posted by the bot, or delete an entire thread rooted at a bot-posted message.",
|
|
1958
|
+
promptSnippet: "Delete a bot-posted Slack message. This is destructive — set confirm=true, and prefer explicit approval before deleting whole threads.",
|
|
1959
|
+
parameters: Type.Object({
|
|
1960
|
+
ts: Type.String({
|
|
1961
|
+
description: "Timestamp (ts) of the message to delete. When thread=true, this must be the thread root timestamp.",
|
|
1962
|
+
}),
|
|
1963
|
+
channel: Type.Optional(Type.String({
|
|
1964
|
+
description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
1965
|
+
})),
|
|
1966
|
+
thread_ts: Type.Optional(Type.String({
|
|
1967
|
+
description: "Optional thread timestamp used to resolve the current channel when channel is omitted.",
|
|
1968
|
+
})),
|
|
1969
|
+
thread: Type.Optional(Type.Boolean({
|
|
1970
|
+
description: "Delete the entire thread rooted at ts (default false).",
|
|
1971
|
+
})),
|
|
1972
|
+
confirm: Type.Optional(Type.Boolean({
|
|
1973
|
+
description: "Must be true to confirm this irreversible deletion.",
|
|
1974
|
+
})),
|
|
1975
|
+
}),
|
|
1976
|
+
async execute(_id, params) {
|
|
1977
|
+
if (params.confirm !== true) {
|
|
1978
|
+
throw new Error("Deleting Slack messages is irreversible. Re-run with confirm=true once you've verified the target.");
|
|
1979
|
+
}
|
|
1980
|
+
requireToolPolicy("slack_delete", params.thread_ts, summarizeSlackDeleteAction({
|
|
1981
|
+
channel: params.channel,
|
|
1982
|
+
defaultChannel: getDefaultChannel(),
|
|
1983
|
+
threadTs: params.thread_ts,
|
|
1984
|
+
ts: params.ts,
|
|
1985
|
+
thread: params.thread,
|
|
1986
|
+
}));
|
|
1987
|
+
const deleteThread = params.thread === true;
|
|
1988
|
+
const { channelId, messageTsList } = await resolveSlackDeleteTargets({
|
|
1989
|
+
channel: params.channel,
|
|
1990
|
+
threadTs: params.thread_ts,
|
|
1991
|
+
ts: params.ts,
|
|
1992
|
+
thread: deleteThread,
|
|
1993
|
+
});
|
|
1994
|
+
for (const messageTs of messageTsList) {
|
|
1995
|
+
await slack("chat.delete", getBotToken(), {
|
|
1996
|
+
channel: channelId,
|
|
1997
|
+
ts: messageTs,
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
const targetTs = params.ts.trim();
|
|
2001
|
+
const deletedCount = messageTsList.length;
|
|
2002
|
+
const channelLabel = params.channel ?? channelId;
|
|
2003
|
+
return {
|
|
2004
|
+
content: [
|
|
2005
|
+
{
|
|
2006
|
+
type: "text",
|
|
2007
|
+
text: deleteThread
|
|
2008
|
+
? `Deleted thread rooted at ${targetTs} in channel ${channelLabel} (${deletedCount} message${deletedCount === 1 ? "" : "s"}).`
|
|
2009
|
+
: `Deleted message ${targetTs} from channel ${channelLabel}.`,
|
|
2010
|
+
},
|
|
2011
|
+
],
|
|
2012
|
+
details: {
|
|
2013
|
+
channel: channelId,
|
|
2014
|
+
ts: targetTs,
|
|
2015
|
+
thread: deleteThread,
|
|
2016
|
+
deleted_count: deletedCount,
|
|
2017
|
+
deleted_ts: messageTsList,
|
|
2018
|
+
},
|
|
2019
|
+
};
|
|
2020
|
+
},
|
|
2021
|
+
});
|
|
2022
|
+
registerSlackAction({
|
|
2023
|
+
name: "slack_pin",
|
|
2024
|
+
label: "Slack Pin",
|
|
2025
|
+
description: "Pin or unpin a Slack message by timestamp.",
|
|
2026
|
+
promptSnippet: "Pin important Slack messages like decisions, confirmations, or follow-up items. Unpin stale ones when they are no longer relevant.",
|
|
2027
|
+
parameters: Type.Object({
|
|
2028
|
+
action: Type.String({ description: "'pin' to pin a message or 'unpin' to remove the pin" }),
|
|
2029
|
+
message_ts: Type.String({ description: "Timestamp (ts) of the message to pin or unpin" }),
|
|
2030
|
+
channel: Type.Optional(Type.String({
|
|
2031
|
+
description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
2032
|
+
})),
|
|
2033
|
+
thread_ts: Type.Optional(Type.String({
|
|
2034
|
+
description: "Optional thread timestamp used to resolve the current channel",
|
|
2035
|
+
})),
|
|
2036
|
+
}),
|
|
2037
|
+
async execute(_id, params) {
|
|
2038
|
+
requireToolPolicy("slack_pin", params.thread_ts, `action=${params.action} | channel=${params.channel ?? getDefaultChannel() ?? ""} | thread_ts=${params.thread_ts ?? ""} | message_ts=${params.message_ts}`);
|
|
2039
|
+
const action = normalizeSlackPinAction(params.action);
|
|
2040
|
+
const messageTs = params.message_ts.trim();
|
|
2041
|
+
if (!messageTs) {
|
|
2042
|
+
throw new Error("message_ts is required.");
|
|
2043
|
+
}
|
|
2044
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
2045
|
+
const method = action === "pin" ? "pins.add" : "pins.remove";
|
|
2046
|
+
const body = { channel: channelId, timestamp: messageTs };
|
|
2047
|
+
try {
|
|
2048
|
+
await slack(method, getBotToken(), body);
|
|
2049
|
+
}
|
|
2050
|
+
catch (err) {
|
|
2051
|
+
if (action === "pin" && isSlackMethodError(err, "pins.add", "already_pinned")) {
|
|
2052
|
+
return {
|
|
2053
|
+
content: [
|
|
2054
|
+
{
|
|
2055
|
+
type: "text",
|
|
2056
|
+
text: `Message ${messageTs} is already pinned in channel ${params.channel ?? channelId}.`,
|
|
2057
|
+
},
|
|
2058
|
+
],
|
|
2059
|
+
details: {
|
|
2060
|
+
channel: channelId,
|
|
2061
|
+
message_ts: messageTs,
|
|
2062
|
+
action,
|
|
2063
|
+
status: "already_pinned",
|
|
2064
|
+
},
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
if (action === "unpin" && isSlackMethodError(err, "pins.remove", "no_pin", "not_pinned")) {
|
|
2068
|
+
return {
|
|
2069
|
+
content: [
|
|
2070
|
+
{
|
|
2071
|
+
type: "text",
|
|
2072
|
+
text: `Message ${messageTs} is not currently pinned in channel ${params.channel ?? channelId}.`,
|
|
2073
|
+
},
|
|
2074
|
+
],
|
|
2075
|
+
details: { channel: channelId, message_ts: messageTs, action, status: "not_pinned" },
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
throw err;
|
|
2079
|
+
}
|
|
2080
|
+
return {
|
|
2081
|
+
content: [
|
|
2082
|
+
{
|
|
2083
|
+
type: "text",
|
|
2084
|
+
text: action === "pin"
|
|
2085
|
+
? `Pinned message ${messageTs} in channel ${params.channel ?? channelId}.`
|
|
2086
|
+
: `Unpinned message ${messageTs} in channel ${params.channel ?? channelId}.`,
|
|
2087
|
+
},
|
|
2088
|
+
],
|
|
2089
|
+
details: {
|
|
2090
|
+
channel: channelId,
|
|
2091
|
+
message_ts: messageTs,
|
|
2092
|
+
action,
|
|
2093
|
+
status: action === "pin" ? "pinned" : "unpinned",
|
|
2094
|
+
},
|
|
2095
|
+
};
|
|
2096
|
+
},
|
|
2097
|
+
});
|
|
2098
|
+
registerSlackAction({
|
|
2099
|
+
name: "slack_bookmark",
|
|
2100
|
+
label: "Slack Bookmark",
|
|
2101
|
+
description: "Add, list, or remove channel bookmarks for durable links like repos, dashboards, docs, and runbooks.",
|
|
2102
|
+
promptSnippet: "Use bookmarks for persistent channel-header links. Add repos, dashboards, docs, and runbooks; list existing bookmarks; remove stale ones by ID.",
|
|
2103
|
+
parameters: Type.Object({
|
|
2104
|
+
action: Type.String({ description: "'add', 'list', or 'remove'" }),
|
|
2105
|
+
channel: Type.Optional(Type.String({
|
|
2106
|
+
description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
2107
|
+
})),
|
|
2108
|
+
thread_ts: Type.Optional(Type.String({
|
|
2109
|
+
description: "Optional thread timestamp used to resolve the current channel",
|
|
2110
|
+
})),
|
|
2111
|
+
title: Type.Optional(Type.String({ description: "Bookmark title (required when action='add')" })),
|
|
2112
|
+
url: Type.Optional(Type.String({ description: "Bookmark URL (required when action='add')" })),
|
|
2113
|
+
emoji: Type.Optional(Type.String({ description: "Optional emoji label for the bookmark, e.g. :rocket:" })),
|
|
2114
|
+
bookmark_id: Type.Optional(Type.String({ description: "Bookmark ID (required when action='remove')" })),
|
|
2115
|
+
}),
|
|
2116
|
+
async execute(_id, params) {
|
|
2117
|
+
requireToolPolicy("slack_bookmark", params.thread_ts, `action=${params.action} | channel=${params.channel ?? getDefaultChannel() ?? ""} | thread_ts=${params.thread_ts ?? ""} | title=${params.title ?? ""} | url=${params.url ?? ""} | bookmark_id=${params.bookmark_id ?? ""}`);
|
|
2118
|
+
const action = normalizeSlackBookmarkAction(params.action);
|
|
2119
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
2120
|
+
const channelLabel = params.channel ?? channelId;
|
|
2121
|
+
if (action === "list") {
|
|
2122
|
+
const response = await slack("bookmarks.list", getBotToken(), { channel_id: channelId });
|
|
2123
|
+
const bookmarks = Array.isArray(response.bookmarks)
|
|
2124
|
+
? response.bookmarks
|
|
2125
|
+
: [];
|
|
2126
|
+
const lines = bookmarks.map((bookmark) => {
|
|
2127
|
+
const id = typeof bookmark.id === "string" ? bookmark.id : "(unknown-id)";
|
|
2128
|
+
const title = typeof bookmark.title === "string" ? bookmark.title : "(untitled)";
|
|
2129
|
+
const link = typeof bookmark.link === "string" ? bookmark.link : "(no link)";
|
|
2130
|
+
const emoji = typeof bookmark.emoji === "string" && bookmark.emoji.length > 0
|
|
2131
|
+
? `${bookmark.emoji} `
|
|
2132
|
+
: "";
|
|
2133
|
+
return `- ${id}: ${emoji}${title} -> ${link}`;
|
|
2134
|
+
});
|
|
2135
|
+
return {
|
|
2136
|
+
content: [
|
|
2137
|
+
{
|
|
2138
|
+
type: "text",
|
|
2139
|
+
text: lines.length > 0
|
|
2140
|
+
? `Bookmarks in ${channelLabel}:\n${lines.join("\n")}`
|
|
2141
|
+
: `No bookmarks found in ${channelLabel}.`,
|
|
2142
|
+
},
|
|
2143
|
+
],
|
|
2144
|
+
details: { channel: channelId, count: bookmarks.length, bookmarks },
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
if (action === "add") {
|
|
2148
|
+
const title = params.title?.trim();
|
|
2149
|
+
if (!title) {
|
|
2150
|
+
throw new Error("title is required when action='add'.");
|
|
2151
|
+
}
|
|
2152
|
+
const link = normalizeSlackBookmarkUrl(params.url ?? "");
|
|
2153
|
+
const emoji = params.emoji?.trim();
|
|
2154
|
+
const response = await slack("bookmarks.add", getBotToken(), {
|
|
2155
|
+
channel_id: channelId,
|
|
2156
|
+
title,
|
|
2157
|
+
type: "link",
|
|
2158
|
+
link,
|
|
2159
|
+
...(emoji ? { emoji } : {}),
|
|
2160
|
+
});
|
|
2161
|
+
const bookmark = response.bookmark && typeof response.bookmark === "object"
|
|
2162
|
+
? response.bookmark
|
|
2163
|
+
: undefined;
|
|
2164
|
+
const bookmarkId = bookmark && typeof bookmark.id === "string" ? bookmark.id : undefined;
|
|
2165
|
+
return {
|
|
2166
|
+
content: [
|
|
2167
|
+
{
|
|
2168
|
+
type: "text",
|
|
2169
|
+
text: `Added bookmark '${title}' to ${channelLabel}.`,
|
|
2170
|
+
},
|
|
2171
|
+
],
|
|
2172
|
+
details: {
|
|
2173
|
+
channel: channelId,
|
|
2174
|
+
action,
|
|
2175
|
+
title,
|
|
2176
|
+
url: link,
|
|
2177
|
+
...(emoji ? { emoji } : {}),
|
|
2178
|
+
...(bookmarkId ? { bookmark_id: bookmarkId } : {}),
|
|
2179
|
+
},
|
|
2180
|
+
};
|
|
2181
|
+
}
|
|
2182
|
+
const bookmarkId = params.bookmark_id?.trim();
|
|
2183
|
+
if (!bookmarkId) {
|
|
2184
|
+
throw new Error("bookmark_id is required when action='remove'.");
|
|
2185
|
+
}
|
|
2186
|
+
try {
|
|
2187
|
+
await slack("bookmarks.remove", getBotToken(), {
|
|
2188
|
+
channel_id: channelId,
|
|
2189
|
+
bookmark_id: bookmarkId,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
catch (err) {
|
|
2193
|
+
if (isSlackMethodError(err, "bookmarks.remove", "not_found")) {
|
|
2194
|
+
return {
|
|
2195
|
+
content: [
|
|
2196
|
+
{
|
|
2197
|
+
type: "text",
|
|
2198
|
+
text: `Bookmark ${bookmarkId} was not found in ${channelLabel}.`,
|
|
2199
|
+
},
|
|
2200
|
+
],
|
|
2201
|
+
details: { channel: channelId, action, bookmark_id: bookmarkId, status: "not_found" },
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
throw err;
|
|
2205
|
+
}
|
|
2206
|
+
return {
|
|
2207
|
+
content: [
|
|
2208
|
+
{
|
|
2209
|
+
type: "text",
|
|
2210
|
+
text: `Removed bookmark ${bookmarkId} from ${channelLabel}.`,
|
|
2211
|
+
},
|
|
2212
|
+
],
|
|
2213
|
+
details: { channel: channelId, action, bookmark_id: bookmarkId, status: "removed" },
|
|
2214
|
+
};
|
|
2215
|
+
},
|
|
2216
|
+
});
|
|
2217
|
+
registerSlackAction({
|
|
2218
|
+
name: "slack_schedule",
|
|
2219
|
+
label: "Slack Schedule",
|
|
2220
|
+
description: "Schedule a Slack message for later using chat.scheduleMessage. Supports relative delays and absolute times.",
|
|
2221
|
+
promptSnippet: "Schedule a Slack message for later instead of waiting around. Use it for reminders, timed announcements, and delayed follow-ups.",
|
|
2222
|
+
parameters: Type.Object({
|
|
2223
|
+
text: Type.String({ description: "Message text (Slack markdown)" }),
|
|
2224
|
+
channel: Type.Optional(Type.String({
|
|
2225
|
+
description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
|
|
2226
|
+
})),
|
|
2227
|
+
thread_ts: Type.Optional(Type.String({ description: "Optional thread timestamp to reply in later" })),
|
|
2228
|
+
delay: Type.Optional(Type.String({ description: "Relative delay like 5m, 30s, 1h30m, or 1d" })),
|
|
2229
|
+
at: Type.Optional(Type.String({ description: "Absolute ISO-8601 UTC time, e.g. 2026-04-02T14:30:00Z" })),
|
|
2230
|
+
}),
|
|
2231
|
+
async execute(_id, params) {
|
|
2232
|
+
requireToolPolicy("slack_schedule", params.thread_ts, `channel=${params.channel ?? getDefaultChannel() ?? ""} | thread_ts=${params.thread_ts ?? ""} | delay=${params.delay ?? ""} | at=${params.at ?? ""} | text=${params.text}`);
|
|
2233
|
+
const text = params.text.trim();
|
|
2234
|
+
if (!text) {
|
|
2235
|
+
throw new Error("text is required");
|
|
2236
|
+
}
|
|
2237
|
+
const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
|
|
2238
|
+
const fireAt = resolveScheduledWakeupFireAt({ delay: params.delay, at: params.at });
|
|
2239
|
+
const postAt = Math.floor(Date.parse(fireAt) / 1000);
|
|
2240
|
+
const body = {
|
|
2241
|
+
channel: channelId,
|
|
2242
|
+
text,
|
|
2243
|
+
post_at: postAt,
|
|
2244
|
+
};
|
|
2245
|
+
if (params.thread_ts) {
|
|
2246
|
+
body.thread_ts = params.thread_ts;
|
|
2247
|
+
}
|
|
2248
|
+
const response = await slack("chat.scheduleMessage", getBotToken(), body);
|
|
2249
|
+
const scheduledMessageId = typeof response.scheduled_message_id === "string"
|
|
2250
|
+
? response.scheduled_message_id
|
|
2251
|
+
: undefined;
|
|
2252
|
+
return {
|
|
2253
|
+
content: [
|
|
2254
|
+
{
|
|
2255
|
+
type: "text",
|
|
2256
|
+
text: params.thread_ts
|
|
2257
|
+
? `Scheduled message for ${fireAt} in thread ${params.thread_ts}.`
|
|
2258
|
+
: `Scheduled message for ${fireAt} in channel ${params.channel ?? channelId}.`,
|
|
2259
|
+
},
|
|
2260
|
+
],
|
|
2261
|
+
details: {
|
|
2262
|
+
channel: channelId,
|
|
2263
|
+
post_at: postAt,
|
|
2264
|
+
fire_at: fireAt,
|
|
2265
|
+
...(scheduledMessageId ? { scheduled_message_id: scheduledMessageId } : {}),
|
|
2266
|
+
...(params.thread_ts ? { thread_ts: params.thread_ts } : {}),
|
|
2267
|
+
},
|
|
2268
|
+
};
|
|
2269
|
+
},
|
|
2270
|
+
});
|
|
2271
|
+
registerSlackAction({
|
|
2272
|
+
name: "slack_read_channel",
|
|
2273
|
+
label: "Slack Read Channel",
|
|
2274
|
+
description: "Read messages from a Slack channel or a thread within a channel.",
|
|
2275
|
+
promptSnippet: "Read messages from a Slack channel.",
|
|
2276
|
+
parameters: Type.Object({
|
|
2277
|
+
channel: Type.String({ description: "Channel name or ID" }),
|
|
2278
|
+
thread_ts: Type.Optional(Type.String({ description: "Thread timestamp to read replies from" })),
|
|
2279
|
+
limit: Type.Optional(Type.Number({ description: "Max messages to return (default 20)" })),
|
|
2280
|
+
...SLACK_OUTPUT_OPTION_PARAMETERS,
|
|
2281
|
+
}),
|
|
2282
|
+
async execute(_id, params) {
|
|
2283
|
+
requireToolPolicy("slack_read_channel", params.thread_ts, `channel=${params.channel} | thread_ts=${params.thread_ts ?? ""} | limit=${params.limit ?? 20}`);
|
|
2284
|
+
const channelId = await resolveChannel(params.channel);
|
|
2285
|
+
const limit = params.limit ?? 20;
|
|
2286
|
+
let messages;
|
|
2287
|
+
if (params.thread_ts) {
|
|
2288
|
+
const response = await slack("conversations.replies", getBotToken(), {
|
|
2289
|
+
channel: channelId,
|
|
2290
|
+
ts: params.thread_ts,
|
|
2291
|
+
limit,
|
|
2292
|
+
});
|
|
2293
|
+
messages = response.messages;
|
|
2294
|
+
}
|
|
2295
|
+
else {
|
|
2296
|
+
const response = await slack("conversations.history", getBotToken(), {
|
|
2297
|
+
channel: channelId,
|
|
2298
|
+
limit,
|
|
2299
|
+
});
|
|
2300
|
+
messages = response.messages.reverse();
|
|
2301
|
+
}
|
|
2302
|
+
const full = params.full === true;
|
|
2303
|
+
const formattedMessages = await Promise.all(messages.map(async (message) => {
|
|
2304
|
+
const userId = message.user;
|
|
2305
|
+
const name = userId ? await resolveUser(userId) : "bot";
|
|
2306
|
+
const text = message.text ?? "";
|
|
2307
|
+
const ts = message.ts;
|
|
2308
|
+
return { ts, name, text, preview: truncateSlackText(text) };
|
|
2309
|
+
}));
|
|
2310
|
+
const lines = formattedMessages.map((message) => full
|
|
2311
|
+
? `[${message.ts}] ${message.name}: ${message.text}`
|
|
2312
|
+
: `[${message.ts}] ${message.name}: ${message.preview}`);
|
|
2313
|
+
if (!full && messages.length > 0) {
|
|
2314
|
+
lines.push("", "Use args.full=true for exact channel message text.");
|
|
2315
|
+
}
|
|
2316
|
+
return {
|
|
2317
|
+
content: [{ type: "text", text: lines.join("\n") || "(no messages)" }],
|
|
2318
|
+
details: full
|
|
2319
|
+
? { count: messages.length, channel: channelId }
|
|
2320
|
+
: {
|
|
2321
|
+
count: messages.length,
|
|
2322
|
+
channel: channelId,
|
|
2323
|
+
messages: formattedMessages.map((message) => ({
|
|
2324
|
+
ts: message.ts,
|
|
2325
|
+
user: message.name,
|
|
2326
|
+
preview: message.preview,
|
|
2327
|
+
})),
|
|
2328
|
+
},
|
|
2329
|
+
fullDetails: {
|
|
2330
|
+
count: messages.length,
|
|
2331
|
+
channel: channelId,
|
|
2332
|
+
messages: formattedMessages.map((message) => ({
|
|
2333
|
+
ts: message.ts,
|
|
2334
|
+
user: message.name,
|
|
2335
|
+
text: message.text,
|
|
2336
|
+
})),
|
|
2337
|
+
},
|
|
2338
|
+
};
|
|
2339
|
+
},
|
|
2340
|
+
});
|
|
2341
|
+
registerSlackAction({
|
|
2342
|
+
name: "slack_canvas_comments_read",
|
|
2343
|
+
label: "Slack Canvas Comments Read",
|
|
2344
|
+
description: "Read comments attached to a Slack canvas after validating the target with Slack's canvas APIs.",
|
|
2345
|
+
promptSnippet: "Inspect comments attached to a Slack canvas in read-only mode. This tool validates canvas targets before using files.info, and it does not inspect generic Slack files.",
|
|
2346
|
+
parameters: Type.Object({
|
|
2347
|
+
canvas_id: Type.Optional(Type.String({
|
|
2348
|
+
description: "Canvas ID to inspect. The ID is validated as a canvas before comments are read.",
|
|
2349
|
+
})),
|
|
2350
|
+
channel: Type.Optional(Type.String({ description: "Channel name or ID whose channel canvas should be inspected" })),
|
|
2351
|
+
limit: Type.Optional(Type.Number({ description: "Max comments to return per call (default 20, max 200)" })),
|
|
2352
|
+
cursor: Type.Optional(Type.String({
|
|
2353
|
+
description: "Pagination cursor returned by a previous canvas comment read",
|
|
2354
|
+
})),
|
|
2355
|
+
}),
|
|
2356
|
+
async execute(_id, params) {
|
|
2357
|
+
requireToolPolicy("slack_canvas_comments_read", undefined, `canvas_id=${params.canvas_id ?? ""} | channel=${params.channel ?? ""} | limit=${params.limit ?? 20} | cursor=${params.cursor ?? ""}`);
|
|
2358
|
+
const target = await resolveCanvasTarget(params.canvas_id, params.channel);
|
|
2359
|
+
const limit = normalizeSlackCanvasCommentsLimit(params.limit);
|
|
2360
|
+
const cursor = normalizeOptionalSlackCursor(params.cursor);
|
|
2361
|
+
await assertCanvasCommentsTargetIsCanvas(target.canvasId);
|
|
2362
|
+
let response;
|
|
2363
|
+
try {
|
|
2364
|
+
response = await slack("files.info", getBotToken(), {
|
|
2365
|
+
file: target.canvasId,
|
|
2366
|
+
limit,
|
|
2367
|
+
...(cursor ? { cursor } : {}),
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
catch (err) {
|
|
2371
|
+
if (isSlackMethodError(err, "files.info", "channel_canvas_deleted", "file_deleted", "file_not_found")) {
|
|
2372
|
+
throw new Error(`Canvas ${target.canvasId} is no longer available.`);
|
|
2373
|
+
}
|
|
2374
|
+
if (isSlackMethodError(err, "files.info", "access_denied")) {
|
|
2375
|
+
throw new Error(`Canvas ${target.canvasId} is not accessible with the current bot token.`);
|
|
2376
|
+
}
|
|
2377
|
+
throw err;
|
|
2378
|
+
}
|
|
2379
|
+
const page = extractSlackCanvasCommentsPage(response, target.canvasId);
|
|
2380
|
+
if (page.canvasId !== target.canvasId) {
|
|
2381
|
+
throw new Error(`Slack files.info returned comments for ${page.canvasId}, but canvas comment inspection requested ${target.canvasId}.`);
|
|
2382
|
+
}
|
|
2383
|
+
const comments = await Promise.all(page.comments.map(async (comment) => ({
|
|
2384
|
+
...comment,
|
|
2385
|
+
...(comment.userId ? { userName: await resolveUser(comment.userId) } : {}),
|
|
2386
|
+
})));
|
|
2387
|
+
return {
|
|
2388
|
+
content: [
|
|
2389
|
+
{
|
|
2390
|
+
type: "text",
|
|
2391
|
+
text: buildSlackCanvasCommentsSummaryText({
|
|
2392
|
+
canvasId: page.canvasId,
|
|
2393
|
+
title: page.title,
|
|
2394
|
+
commentsCount: page.commentsCount,
|
|
2395
|
+
returnedCount: page.returnedCount,
|
|
2396
|
+
nextCursor: page.nextCursor,
|
|
2397
|
+
comments,
|
|
2398
|
+
}),
|
|
2399
|
+
},
|
|
2400
|
+
],
|
|
2401
|
+
details: {
|
|
2402
|
+
canvas_id: page.canvasId,
|
|
2403
|
+
channel: target.channelId,
|
|
2404
|
+
title: page.title,
|
|
2405
|
+
permalink: page.permalink,
|
|
2406
|
+
comments_count: page.commentsCount,
|
|
2407
|
+
returned_count: page.returnedCount,
|
|
2408
|
+
...(page.page ? { page: page.page } : {}),
|
|
2409
|
+
...(page.pages ? { pages: page.pages } : {}),
|
|
2410
|
+
...(page.nextCursor ? { next_cursor: page.nextCursor } : {}),
|
|
2411
|
+
comments: comments.map((comment) => ({
|
|
2412
|
+
id: comment.id,
|
|
2413
|
+
user_id: comment.userId,
|
|
2414
|
+
user_name: comment.userName,
|
|
2415
|
+
created_ts: comment.createdTs,
|
|
2416
|
+
text: comment.text,
|
|
2417
|
+
})),
|
|
2418
|
+
},
|
|
2419
|
+
};
|
|
2420
|
+
},
|
|
2421
|
+
});
|
|
2422
|
+
registerSlackAction({
|
|
2423
|
+
name: "slack_canvas_create",
|
|
2424
|
+
label: "Slack Canvas Create",
|
|
2425
|
+
description: "Create a Slack canvas with markdown content, either standalone or as a channel canvas. If Slack cannot create a channel canvas tab, creates a standalone channel-attached fallback and bookmarks it when possible.",
|
|
2426
|
+
promptSnippet: "Create a Slack canvas for long-lived documentation. Use standalone canvases for shared docs and kind='channel' for a channel's main canvas; channel tab failures fall back to a standalone canvas plus bookmark when possible.",
|
|
2427
|
+
parameters: Type.Object({
|
|
2428
|
+
title: Type.Optional(Type.String({ description: "Canvas title" })),
|
|
2429
|
+
markdown: Type.Optional(Type.String({
|
|
2430
|
+
description: "Initial canvas content in markdown. Omit for an empty canvas.",
|
|
2431
|
+
})),
|
|
2432
|
+
channel: Type.Optional(Type.String({ description: "Channel name or ID to attach the canvas to" })),
|
|
2433
|
+
kind: Type.Optional(Type.String({
|
|
2434
|
+
description: "Canvas kind: 'standalone' (default) or 'channel'. Channel canvas tab failures fall back to a standalone canvas and return its canvas_id.",
|
|
2435
|
+
})),
|
|
2436
|
+
}),
|
|
2437
|
+
async execute(_id, params) {
|
|
2438
|
+
requireToolPolicy("slack_canvas_create", undefined, `kind=${params.kind ?? "standalone"} | channel=${params.channel ?? ""} | title=${params.title ?? ""}`);
|
|
2439
|
+
const channelInput = params.channel?.trim();
|
|
2440
|
+
const channelId = channelInput ? await resolveChannel(channelInput) : undefined;
|
|
2441
|
+
const kind = normalizeSlackCanvasCreateKind(params.kind);
|
|
2442
|
+
// For channel canvases, check if one already exists before creating
|
|
2443
|
+
if (kind === "channel" && channelId) {
|
|
2444
|
+
const info = await slack("conversations.info", getBotToken(), { channel: channelId });
|
|
2445
|
+
const existingCanvasId = extractSlackChannelCanvasId(info);
|
|
2446
|
+
if (existingCanvasId) {
|
|
2447
|
+
return {
|
|
2448
|
+
content: [
|
|
2449
|
+
{
|
|
2450
|
+
type: "text",
|
|
2451
|
+
text: `Canvas already exists for ${channelInput ?? channelId}: ${existingCanvasId}. Returning existing canvas instead of creating a duplicate.`,
|
|
2452
|
+
},
|
|
2453
|
+
],
|
|
2454
|
+
details: {
|
|
2455
|
+
canvas_id: existingCanvasId,
|
|
2456
|
+
kind: "channel",
|
|
2457
|
+
channel: channelId,
|
|
2458
|
+
existing: true,
|
|
2459
|
+
},
|
|
2460
|
+
};
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
const request = buildSlackCanvasCreateRequest({
|
|
2464
|
+
kind: params.kind,
|
|
2465
|
+
title: params.title,
|
|
2466
|
+
markdown: params.markdown,
|
|
2467
|
+
channelId,
|
|
2468
|
+
});
|
|
2469
|
+
if (channelInput && channelId) {
|
|
2470
|
+
rememberChannel(channelInput.replace(/^#/, ""), channelId);
|
|
2471
|
+
}
|
|
2472
|
+
let response;
|
|
2473
|
+
try {
|
|
2474
|
+
response = await slack(request.method, getBotToken(), request.body);
|
|
2475
|
+
}
|
|
2476
|
+
catch (err) {
|
|
2477
|
+
if (request.kind !== "channel" ||
|
|
2478
|
+
!channelId ||
|
|
2479
|
+
!isSlackMethodError(err, "conversations.canvases.create", "canvas_tab_creation_failed")) {
|
|
2480
|
+
throw err;
|
|
2481
|
+
}
|
|
2482
|
+
const channelLabel = channelInput ?? channelId;
|
|
2483
|
+
const fallback = await createStandaloneCanvasFallback({
|
|
2484
|
+
channelId,
|
|
2485
|
+
channelLabel,
|
|
2486
|
+
title: params.title,
|
|
2487
|
+
markdown: params.markdown,
|
|
2488
|
+
});
|
|
2489
|
+
const bookmarkSummary = buildStandaloneCanvasFallbackBookmarkSummary({
|
|
2490
|
+
channelLabel,
|
|
2491
|
+
canvasId: fallback.canvasId,
|
|
2492
|
+
permalink: fallback.permalink,
|
|
2493
|
+
permalinkLookupError: fallback.permalinkLookupError,
|
|
2494
|
+
bookmarkId: fallback.bookmarkId,
|
|
2495
|
+
bookmarkStatus: fallback.bookmarkStatus,
|
|
2496
|
+
bookmarkError: fallback.bookmarkError,
|
|
2497
|
+
});
|
|
2498
|
+
return {
|
|
2499
|
+
content: [
|
|
2500
|
+
{
|
|
2501
|
+
type: "text",
|
|
2502
|
+
text: `Slack could not create a channel canvas tab for ${channelLabel} (canvas_tab_creation_failed). Created standalone fallback canvas ${fallback.canvasId} attached to ${channelLabel}.${bookmarkSummary} Use canvas_update with canvas_id=${fallback.canvasId} for future updates. Initial content: ${getSlackCanvasSummary(params.markdown)}`,
|
|
2503
|
+
},
|
|
2504
|
+
],
|
|
2505
|
+
details: {
|
|
2506
|
+
canvas_id: fallback.canvasId,
|
|
2507
|
+
kind: "standalone",
|
|
2508
|
+
requested_kind: "channel",
|
|
2509
|
+
channel: channelId,
|
|
2510
|
+
fallback: true,
|
|
2511
|
+
fallback_reason: "canvas_tab_creation_failed",
|
|
2512
|
+
bookmark_status: fallback.bookmarkStatus,
|
|
2513
|
+
next_action: `canvas_update canvas_id=${fallback.canvasId}`,
|
|
2514
|
+
...(fallback.permalink ? { permalink: fallback.permalink } : {}),
|
|
2515
|
+
...(fallback.bookmarkId ? { bookmark_id: fallback.bookmarkId } : {}),
|
|
2516
|
+
...(fallback.bookmarkError ? { bookmark_error: fallback.bookmarkError } : {}),
|
|
2517
|
+
...(fallback.permalinkLookupError
|
|
2518
|
+
? { permalink_lookup_error: fallback.permalinkLookupError }
|
|
2519
|
+
: {}),
|
|
2520
|
+
},
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
const canvasId = response.canvas_id;
|
|
2524
|
+
const channelLabel = channelInput ?? channelId;
|
|
2525
|
+
const targetSummary = request.kind === "channel"
|
|
2526
|
+
? `Created channel canvas ${canvasId}${channelLabel ? ` for ${channelLabel}` : ""}.`
|
|
2527
|
+
: `Created standalone canvas ${canvasId}${channelLabel ? ` attached to ${channelLabel}` : ""}.`;
|
|
2528
|
+
return {
|
|
2529
|
+
content: [
|
|
2530
|
+
{
|
|
2531
|
+
type: "text",
|
|
2532
|
+
text: `${targetSummary} Initial content: ${getSlackCanvasSummary(params.markdown)}`,
|
|
2533
|
+
},
|
|
2534
|
+
],
|
|
2535
|
+
details: {
|
|
2536
|
+
canvas_id: canvasId,
|
|
2537
|
+
kind: request.kind,
|
|
2538
|
+
channel: channelId,
|
|
2539
|
+
},
|
|
2540
|
+
};
|
|
2541
|
+
},
|
|
2542
|
+
});
|
|
2543
|
+
registerSlackAction({
|
|
2544
|
+
name: "slack_canvas_update",
|
|
2545
|
+
label: "Slack Canvas Update",
|
|
2546
|
+
description: "Append, prepend, or replace content in an existing Slack canvas by canvas ID or channel canvas lookup.",
|
|
2547
|
+
promptSnippet: "Update a Slack canvas. Use mode='append' or 'prepend' for additive updates, or mode='replace' to replace the whole canvas or a matched section.",
|
|
2548
|
+
parameters: Type.Object({
|
|
2549
|
+
canvas_id: Type.Optional(Type.String({ description: "Canvas ID to update" })),
|
|
2550
|
+
channel: Type.Optional(Type.String({
|
|
2551
|
+
description: "Channel name or ID whose channel canvas should be updated. If Slack does not expose a channel canvas ID, provide the standalone fallback canvas_id returned by canvas_create.",
|
|
2552
|
+
})),
|
|
2553
|
+
markdown: Type.String({ description: "Canvas content in markdown" }),
|
|
2554
|
+
mode: Type.Optional(Type.String({ description: "Update mode: 'append' (default), 'prepend', or 'replace'" })),
|
|
2555
|
+
section_contains_text: Type.Optional(Type.String({
|
|
2556
|
+
description: "When mode='replace', replace the first section matching this text",
|
|
2557
|
+
})),
|
|
2558
|
+
section_type: Type.Optional(Type.String({
|
|
2559
|
+
description: "Optional section type for lookups: 'h1', 'h2', 'h3', or 'any_header'",
|
|
2560
|
+
})),
|
|
2561
|
+
section_index: Type.Optional(Type.Number({
|
|
2562
|
+
description: "Optional 1-based section index to choose when the lookup matches multiple sections",
|
|
2563
|
+
})),
|
|
2564
|
+
}),
|
|
2565
|
+
async execute(_id, params) {
|
|
2566
|
+
requireToolPolicy("slack_canvas_update", undefined, `canvas_id=${params.canvas_id ?? ""} | channel=${params.channel ?? ""} | mode=${params.mode ?? "append"} | section_contains_text=${params.section_contains_text ?? ""}`);
|
|
2567
|
+
const mode = normalizeSlackCanvasUpdateMode(params.mode);
|
|
2568
|
+
if (params.section_contains_text && mode !== "replace") {
|
|
2569
|
+
throw new Error("section_contains_text can only be used with mode='replace'.");
|
|
2570
|
+
}
|
|
2571
|
+
if (params.section_index != null && !params.section_contains_text) {
|
|
2572
|
+
throw new Error("section_index can only be used together with section_contains_text.");
|
|
2573
|
+
}
|
|
2574
|
+
const target = await resolveCanvasTarget(params.canvas_id, params.channel);
|
|
2575
|
+
let sectionId;
|
|
2576
|
+
if (params.section_contains_text) {
|
|
2577
|
+
const lookup = buildSlackCanvasSectionsLookupRequest({
|
|
2578
|
+
canvasId: target.canvasId,
|
|
2579
|
+
containsText: params.section_contains_text,
|
|
2580
|
+
sectionType: params.section_type,
|
|
2581
|
+
});
|
|
2582
|
+
const response = await slack("canvases.sections.lookup", getBotToken(), lookup);
|
|
2583
|
+
const sections = response.sections;
|
|
2584
|
+
try {
|
|
2585
|
+
sectionId = pickSlackCanvasSectionId(sections, params.section_index);
|
|
2586
|
+
}
|
|
2587
|
+
catch (err) {
|
|
2588
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2589
|
+
throw new Error(`Canvas section lookup for '${params.section_contains_text}' failed: ${message}`);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
const request = buildSlackCanvasEditRequest({
|
|
2593
|
+
canvasId: target.canvasId,
|
|
2594
|
+
markdown: params.markdown,
|
|
2595
|
+
mode,
|
|
2596
|
+
sectionId,
|
|
2597
|
+
});
|
|
2598
|
+
await slack("canvases.edit", getBotToken(), request);
|
|
2599
|
+
const sectionSummary = params.section_contains_text
|
|
2600
|
+
? ` Replaced section matching '${params.section_contains_text}'.`
|
|
2601
|
+
: "";
|
|
2602
|
+
const targetSummary = target.channelLabel
|
|
2603
|
+
? `Updated channel canvas ${target.canvasId} for ${target.channelLabel}.`
|
|
2604
|
+
: `Updated canvas ${target.canvasId}.`;
|
|
2605
|
+
return {
|
|
2606
|
+
content: [
|
|
2607
|
+
{
|
|
2608
|
+
type: "text",
|
|
2609
|
+
text: `${targetSummary} Mode: ${mode}.${sectionSummary} Content: ${getSlackCanvasSummary(params.markdown)}`,
|
|
2610
|
+
},
|
|
2611
|
+
],
|
|
2612
|
+
details: {
|
|
2613
|
+
canvas_id: target.canvasId,
|
|
2614
|
+
channel: target.channelId,
|
|
2615
|
+
mode,
|
|
2616
|
+
section_id: sectionId,
|
|
2617
|
+
},
|
|
2618
|
+
};
|
|
2619
|
+
},
|
|
2620
|
+
});
|
|
2621
|
+
registerSlackAction({
|
|
2622
|
+
name: "slack_confirm_action",
|
|
2623
|
+
label: "Slack Confirm Action",
|
|
2624
|
+
description: "Request user confirmation in a Slack thread before performing a dangerous action. Use when security guardrails require confirmation for a tool.",
|
|
2625
|
+
promptSnippet: "Request confirmation in Slack before dangerous actions.",
|
|
2626
|
+
parameters: Type.Object({
|
|
2627
|
+
thread_ts: Type.String({ description: "Thread to post confirmation request in" }),
|
|
2628
|
+
action: Type.String({
|
|
2629
|
+
description: "Exact action string required by the guarded tool. Copy it from the guardrail error and retry the guarded call unchanged after approval.",
|
|
2630
|
+
}),
|
|
2631
|
+
tool: Type.String({
|
|
2632
|
+
description: "Exact guardrail tool name that requires confirmation. For Slack dispatcher actions, use slack:<action> such as slack:delete.",
|
|
2633
|
+
}),
|
|
2634
|
+
}),
|
|
2635
|
+
async execute(_id, params) {
|
|
2636
|
+
const channelId = await resolveTrackedThreadChannel(params.thread_ts);
|
|
2637
|
+
if (!channelId) {
|
|
2638
|
+
throw new Error(`No active Slack thread for thread_ts: ${params.thread_ts}`);
|
|
2639
|
+
}
|
|
2640
|
+
const confirmationTool = normalizeSlackGuardrailToolName(params.tool);
|
|
2641
|
+
const confirmMessage = `⚠️ *Action requires confirmation*\n\n` +
|
|
2642
|
+
`Tool: \`${confirmationTool}\`\n` +
|
|
2643
|
+
`Action: ${params.action}\n\n` +
|
|
2644
|
+
`Reply *yes* to approve or *no* to reject.`;
|
|
2645
|
+
const registration = registerConfirmationRequest(params.thread_ts, confirmationTool, params.action);
|
|
2646
|
+
if (registration.status === "conflict") {
|
|
2647
|
+
throw new Error(`Thread ${params.thread_ts} already has a pending confirmation for tool "${registration.conflict?.toolPattern}" and action ${JSON.stringify(registration.conflict?.action ?? "")}. Wait for a reply or expiry before requesting another action in the same thread.`);
|
|
2648
|
+
}
|
|
2649
|
+
if (registration.status === "refreshed") {
|
|
2650
|
+
return {
|
|
2651
|
+
content: [
|
|
2652
|
+
{
|
|
2653
|
+
type: "text",
|
|
2654
|
+
text: `A matching confirmation request is already pending in thread ${params.thread_ts}. Wait for the user's response via slack_inbox before proceeding.`,
|
|
2655
|
+
},
|
|
2656
|
+
],
|
|
2657
|
+
details: {
|
|
2658
|
+
thread_ts: params.thread_ts,
|
|
2659
|
+
tool: confirmationTool,
|
|
2660
|
+
status: registration.status,
|
|
2661
|
+
},
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
await slack("chat.postMessage", getBotToken(), {
|
|
2665
|
+
channel: channelId,
|
|
2666
|
+
thread_ts: params.thread_ts,
|
|
2667
|
+
text: confirmMessage,
|
|
2668
|
+
metadata: {
|
|
2669
|
+
event_type: "pi_agent_msg",
|
|
2670
|
+
event_payload: { agent: getAgentName(), agent_owner: getAgentOwnerToken() },
|
|
2671
|
+
},
|
|
2672
|
+
});
|
|
2673
|
+
return {
|
|
2674
|
+
content: [
|
|
2675
|
+
{
|
|
2676
|
+
type: "text",
|
|
2677
|
+
text: `Confirmation requested in thread ${params.thread_ts}. Wait for the user's response via slack_inbox before proceeding. If the user approves, continue with the action. If denied, inform them and skip the action.`,
|
|
2678
|
+
},
|
|
2679
|
+
],
|
|
2680
|
+
details: {
|
|
2681
|
+
thread_ts: params.thread_ts,
|
|
2682
|
+
tool: confirmationTool,
|
|
2683
|
+
status: registration.status,
|
|
2684
|
+
},
|
|
2685
|
+
};
|
|
2686
|
+
},
|
|
2687
|
+
});
|
|
2688
|
+
}
|