@pinet/slack-bridge 0.1.2 → 0.2.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.
Files changed (47) hide show
  1. package/README.md +59 -32
  2. package/dist/broker/adapters/slack.d.ts +19 -1
  3. package/dist/broker/adapters/slack.js +111 -22
  4. package/dist/broker/client.d.ts +2 -1
  5. package/dist/broker/client.js +1 -0
  6. package/dist/broker/socket-server.js +18 -0
  7. package/dist/deploy-manifest.d.ts +5 -0
  8. package/dist/deploy-manifest.js +30 -1
  9. package/dist/follower-runtime.js +5 -1
  10. package/dist/helpers.d.ts +14 -0
  11. package/dist/helpers.js +45 -21
  12. package/dist/index.js +60 -0
  13. package/dist/pinet-commands.d.ts +6 -1
  14. package/dist/pinet-commands.js +166 -1
  15. package/dist/pinet-mesh-ops.d.ts +11 -0
  16. package/dist/pinet-mesh-ops.js +17 -0
  17. package/dist/pinet-tools.d.ts +47 -0
  18. package/dist/pinet-tools.js +496 -36
  19. package/dist/prompts/broker/tmux.md +2 -2
  20. package/dist/reaction-triggers.d.ts +1 -0
  21. package/dist/reaction-triggers.js +26 -15
  22. package/dist/runtime-agent-context.js +19 -0
  23. package/dist/runtime-mode.js +7 -1
  24. package/dist/single-player-runtime.js +22 -26
  25. package/dist/slack-access.d.ts +11 -0
  26. package/dist/slack-access.js +30 -0
  27. package/dist/slack-agents-command.d.ts +19 -0
  28. package/dist/slack-agents-command.js +90 -0
  29. package/dist/slack-export.d.ts +1 -1
  30. package/dist/slack-export.js +6 -4
  31. package/dist/slack-file-access.d.ts +34 -0
  32. package/dist/slack-file-access.js +209 -0
  33. package/dist/slack-message-context.d.ts +0 -1
  34. package/dist/slack-message-context.js +1 -6
  35. package/dist/slack-pinet-runtime-adapter.d.ts +4 -2
  36. package/dist/slack-pinet-runtime-adapter.js +12 -0
  37. package/dist/slack-tools.d.ts +6 -0
  38. package/dist/slack-tools.js +290 -36
  39. package/dist/slack-upload.d.ts +13 -1
  40. package/dist/slack-upload.js +29 -2
  41. package/dist/stale-slack-messages.d.ts +12 -0
  42. package/dist/stale-slack-messages.js +29 -0
  43. package/dist/subtree-broker-runtime.d.ts +109 -0
  44. package/dist/subtree-broker-runtime.js +558 -0
  45. package/manifest.yaml +9 -0
  46. package/package.json +9 -7
  47. package/skills/slack-bridge/SKILL.md +60 -1
@@ -7,7 +7,9 @@ import { findSlackPresenceDirectoryUser, formatSlackPresenceLine, formatSlackPre
7
7
  import { buildSlackThreadExport, filterSlackExportMessagesByRange, parseSlackExportBoundaryTs, } from "./slack-export.js";
8
8
  import { normalizeReactionName } from "./reaction-triggers.js";
9
9
  import { resolveScheduledWakeupFireAt } from "./scheduled-wakeups.js";
10
- import { performSlackUpload, prepareSlackUpload } from "./slack-upload.js";
10
+ import { fetchSlackFileToCache } from "./slack-file-access.js";
11
+ import { extractSlackMessageFileMetadata } from "./slack-message-context.js";
12
+ import { performSlackUpload, performSlackUploads, prepareSlackUpload } from "./slack-upload.js";
11
13
  import { TtlCache } from "./ttl-cache.js";
12
14
  import { DEFAULT_SLACK_THREAD_STATUS, normalizeSlackThreadStatus, setSlackThreadStatus, SLACK_THREAD_LOADING_MESSAGES, } from "./slack-thread-status.js";
13
15
  const SLACK_OUTPUT_OPTION_PARAMETERS = {
@@ -20,6 +22,14 @@ const SLACK_OUTPUT_OPTION_PARAMETERS = {
20
22
  })),
21
23
  };
22
24
  const SLACK_ACTIONS_WITH_FORMAT_ARG = new Set(["export"]);
25
+ const SLACK_LOCAL_FILE_ATTACHMENT_PARAMETERS = Type.Array(Type.Object({
26
+ path: Type.String({
27
+ description: "Local file path to attach. For safety, only files inside the current working directory or system temp directory are allowed.",
28
+ }),
29
+ filename: Type.Optional(Type.String({ description: "Optional Slack filename override" })),
30
+ title: Type.Optional(Type.String({ description: "Optional Slack title" })),
31
+ filetype: Type.Optional(Type.String({ description: "Optional Slack filetype override" })),
32
+ }), { description: "Optional local files to attach to the same Slack message as text." });
23
33
  const SLACK_DISPATCHER_EXAMPLES = {
24
34
  react: [{ action: "react", args: { emoji: "👀", thread_ts: "1712345678.000100" } }],
25
35
  read: [{ action: "read", args: { thread_ts: "1712345678.000100", limit: 20 } }],
@@ -34,6 +44,16 @@ const SLACK_DISPATCHER_EXAMPLES = {
34
44
  },
35
45
  },
36
46
  ],
47
+ file: [
48
+ {
49
+ action: "file",
50
+ args: {
51
+ op: "download",
52
+ file_id: "F0123456789",
53
+ thread_ts: "1712345678.000100",
54
+ },
55
+ },
56
+ ],
37
57
  schedule: [
38
58
  {
39
59
  action: "schedule",
@@ -51,6 +71,14 @@ const SLACK_DISPATCHER_EXAMPLES = {
51
71
  action: "post_channel",
52
72
  args: { channel: "#deployments", text: "Deploy complete" },
53
73
  },
74
+ {
75
+ action: "post_channel",
76
+ args: {
77
+ channel: "#deployments",
78
+ text: "Deploy evidence attached",
79
+ files: [{ path: "/tmp/evidence.png", filename: "evidence.png" }],
80
+ },
81
+ },
54
82
  ],
55
83
  read_channel: [{ action: "read_channel", args: { channel: "#deployments", limit: 20 } }],
56
84
  confirm_action: [
@@ -167,6 +195,7 @@ function isPinetDeliveryFallbackError(error) {
167
195
  lower.includes("socket") ||
168
196
  lower.includes("no transport source") ||
169
197
  lower.includes("no transport channel") ||
198
+ lower.includes("only allows local file paths") ||
170
199
  lower.includes("no adapter") ||
171
200
  lower.includes("identity is unavailable"));
172
201
  }
@@ -387,7 +416,7 @@ function buildSlackInboxPromptGuidelines() {
387
416
  "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
417
  "Call slack with action='help' for the cold-action catalogue, or action='help' with args.topic for a specific action schema and examples.",
389
418
  "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.",
419
+ "Slack emoji reactions are ignored by default. Only treat opt-in structured 'Reaction trigger from Slack:' inbox messages from authorized Pinet threads as user instructions tied to the referenced Slack message or thread; never infer work from a plain emoji reaction alone.",
391
420
  ];
392
421
  }
393
422
  function buildSlackSendPromptGuidelines() {
@@ -477,6 +506,26 @@ function asTrimmedSlackString(value) {
477
506
  const trimmed = value.trim();
478
507
  return trimmed.length > 0 ? trimmed : undefined;
479
508
  }
509
+ function extractSlackUploadMessageTs(response, channelId) {
510
+ const files = Array.isArray(response.files) ? response.files : [];
511
+ for (const fileValue of files) {
512
+ const file = asSlackObject(fileValue);
513
+ const shares = asSlackObject(file?.shares);
514
+ for (const shareKind of ["public", "private"]) {
515
+ const shareByChannel = asSlackObject(shares?.[shareKind]);
516
+ const channelShares = shareByChannel?.[channelId];
517
+ if (!Array.isArray(channelShares))
518
+ continue;
519
+ for (const shareValue of channelShares) {
520
+ const share = asSlackObject(shareValue);
521
+ const ts = asTrimmedSlackString(share?.ts);
522
+ if (ts)
523
+ return ts;
524
+ }
525
+ }
526
+ }
527
+ return undefined;
528
+ }
480
529
  function extractSlackCanvasPermalink(response) {
481
530
  const direct = asTrimmedSlackString(response.permalink) ??
482
531
  asTrimmedSlackString(response.url) ??
@@ -510,8 +559,39 @@ function isPiAgentSlackMessage(message, botUserId, agentOwnerToken) {
510
559
  }
511
560
  return eventPayload.agent_owner === agentOwnerToken;
512
561
  }
562
+ /**
563
+ * Build the canonical guarded action string for a Slack delete.
564
+ *
565
+ * This must be derived from RESOLVED values (resolved channel ID, normalized
566
+ * ts list) — never raw caller params — so that the string quoted in guardrail
567
+ * errors, registered via confirm_action, and re-checked on the post-approval
568
+ * retry always agree regardless of how the caller phrased the request (#814).
569
+ */
513
570
  function summarizeSlackDeleteAction(input) {
514
- return `channel=${input.channel ?? input.defaultChannel ?? ""} | thread_ts=${input.threadTs ?? ""} | ts=${input.ts} | thread=${input.thread ?? false}`;
571
+ return `channel=${input.channel} | thread_ts=${input.threadTs ?? ""} | ts=${input.tsList.join(",")} | thread=${input.thread ?? false}`;
572
+ }
573
+ /**
574
+ * Normalize the delete `ts` input into a deterministic target list.
575
+ *
576
+ * Multiple comma/whitespace-separated timestamps are allowed for single-message
577
+ * deletes so one explicit approval can cover one batch of bot-authored cleanup
578
+ * messages in the same thread (#814). The list is trimmed, deduplicated, and
579
+ * sorted so the canonical action string is stable across caller phrasings.
580
+ */
581
+ function parseSlackDeleteTsInput(ts, deleteThread) {
582
+ const tsList = [
583
+ ...new Set(ts
584
+ .split(/[\s,]+/)
585
+ .map((entry) => entry.trim())
586
+ .filter((entry) => entry.length > 0)),
587
+ ].sort();
588
+ if (tsList.length === 0) {
589
+ throw new Error("ts is required.");
590
+ }
591
+ if (deleteThread && tsList.length > 1) {
592
+ throw new Error("When thread=true, ts must be a single thread root timestamp.");
593
+ }
594
+ return tsList;
515
595
  }
516
596
  export function registerSlackTools(pi, deps) {
517
597
  const { getBotToken, getDefaultChannel, getSecurityPrompt, inbox, slack, getAgentName, getAgentEmoji, getAgentOwnerToken, getLastDmChannel, updateBadge, resolveUser, threadContext, resolveChannel, rememberChannel, requireToolPolicy: requireToolPolicyForName, registerConfirmationRequest, getBotUserId, pinetDelivery, } = deps;
@@ -553,6 +633,7 @@ export function registerSlackTools(pi, deps) {
553
633
  channel: input.channel,
554
634
  text: input.text,
555
635
  ...(blocks ? { blocks } : {}),
636
+ ...(input.files ? { files: input.files } : {}),
556
637
  });
557
638
  return {
558
639
  threadTs: input.threadTs,
@@ -570,6 +651,34 @@ export function registerSlackTools(pi, deps) {
570
651
  fallbackReason = getErrorMessage(error);
571
652
  }
572
653
  }
654
+ if (input.files && input.files.length > 0) {
655
+ if (blocks && blocks.length > 0) {
656
+ throw new Error("Slack text+file replies use Slack's external upload flow, which does not support Block Kit blocks in the same upload message. Omit blocks or send a separate block-only message.");
657
+ }
658
+ const uploads = await Promise.all(input.files.map((file) => prepareSlackUpload({
659
+ path: file.path,
660
+ ...(file.filename ? { filename: file.filename } : {}),
661
+ ...(file.title ? { title: file.title } : {}),
662
+ ...(file.filetype ? { filetype: file.filetype } : {}),
663
+ }, process.cwd(), os.tmpdir())));
664
+ const uploadResult = await performSlackUploads({
665
+ uploads,
666
+ channelId: input.channel,
667
+ ...(input.threadTs ? { threadTs: input.threadTs } : {}),
668
+ initialComment: input.text,
669
+ slack,
670
+ token: getBotToken(),
671
+ });
672
+ const uploadTs = extractSlackUploadMessageTs(uploadResult.response, input.channel);
673
+ return {
674
+ ...(uploadTs ? { ts: uploadTs } : {}),
675
+ threadTs: input.threadTs ?? uploadTs,
676
+ channel: input.channel,
677
+ blocksCount: blocks?.length ?? 0,
678
+ delivery: "slack",
679
+ ...(fallbackReason ? { fallbackReason } : {}),
680
+ };
681
+ }
573
682
  const body = {
574
683
  channel: input.channel,
575
684
  text: input.text,
@@ -584,9 +693,10 @@ export function registerSlackTools(pi, deps) {
584
693
  if (input.threadTs)
585
694
  body.thread_ts = input.threadTs;
586
695
  const response = await slack("chat.postMessage", getBotToken(), body);
587
- const ts = response.message.ts;
696
+ const message = isRecord(response.message) ? response.message : null;
697
+ const ts = typeof message?.ts === "string" ? message.ts : undefined;
588
698
  return {
589
- ts,
699
+ ...(ts ? { ts } : {}),
590
700
  channel: input.channel,
591
701
  blocksCount: blocks?.length ?? 0,
592
702
  delivery: "slack",
@@ -650,7 +760,7 @@ export function registerSlackTools(pi, deps) {
650
760
  pi.registerTool({
651
761
  name: "slack",
652
762
  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.",
763
+ description: "Dispatcher for non-hot Slack actions: react, read, upload, file, 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
764
  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
765
  promptGuidelines: [
656
766
  "Use slack_inbox and slack_send for the hot path. Use this dispatcher for every other Slack action.",
@@ -660,7 +770,7 @@ export function registerSlackTools(pi, deps) {
660
770
  ],
661
771
  parameters: Type.Object({
662
772
  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",
773
+ description: "Action name: help | react | read | upload | file | 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
774
  }),
665
775
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
666
776
  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.",
@@ -862,20 +972,20 @@ export function registerSlackTools(pi, deps) {
862
972
  return messages;
863
973
  }
864
974
  async function resolveSlackDeleteTargets(input) {
865
- const channelId = await resolveSlackTargetChannel(input.threadTs, input.channel);
866
- const targetTs = input.ts.trim();
975
+ const { channelId } = input;
976
+ const targetTs = input.tsList[0];
867
977
  if (!targetTs) {
868
978
  throw new Error("ts is required.");
869
979
  }
870
980
  if (!input.thread) {
871
- return { channelId, messageTsList: [targetTs] };
981
+ return { channelId, messageTsList: input.tsList };
872
982
  }
873
983
  const messages = await fetchSlackThreadMessages(channelId, targetTs, undefined, undefined, true);
874
984
  const threadRootTs = messages.length > 0 && typeof messages[0]?.ts === "string"
875
985
  ? messages[0].ts
876
986
  : undefined;
877
987
  if (!threadRootTs) {
878
- throw new Error(`Slack did not return a thread rooted at ${targetTs} in channel ${input.channel ?? channelId}.`);
988
+ throw new Error(`Slack did not return a thread rooted at ${targetTs} in channel ${channelId}.`);
879
989
  }
880
990
  if (threadRootTs !== targetTs) {
881
991
  throw new Error("When thread=true, ts must be the thread root timestamp.");
@@ -929,16 +1039,12 @@ export function registerSlackTools(pi, deps) {
929
1039
  authorName,
930
1040
  text: typeof message.text === "string" ? message.text : "",
931
1041
  files: rawFiles.map((file) => ({
1042
+ id: typeof file.id === "string" ? file.id : undefined,
932
1043
  name: typeof file.name === "string" ? file.name : undefined,
933
1044
  title: typeof file.title === "string" ? file.title : undefined,
934
1045
  mimetype: typeof file.mimetype === "string" ? file.mimetype : undefined,
935
1046
  filetype: typeof file.filetype === "string" ? file.filetype : undefined,
936
1047
  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
1048
  preview: typeof file.preview === "string" ? file.preview : undefined,
943
1049
  })),
944
1050
  };
@@ -1328,9 +1434,10 @@ export function registerSlackTools(pi, deps) {
1328
1434
  blocks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()), {
1329
1435
  description: "Optional Slack Block Kit blocks JSON array",
1330
1436
  })),
1437
+ files: Type.Optional(SLACK_LOCAL_FILE_ATTACHMENT_PARAMETERS),
1331
1438
  }),
1332
1439
  async execute(_id, params) {
1333
- requireToolPolicy("slack_send", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | text=${params.text} | blocks=${summarizeSlackBlocksForPolicy(params.blocks)}`);
1440
+ requireToolPolicy("slack_send", params.thread_ts, `thread_ts=${params.thread_ts ?? ""} | text=${params.text} | blocks=${summarizeSlackBlocksForPolicy(params.blocks)} | files=${Array.isArray(params.files) ? params.files.length : 0}`);
1334
1441
  const channel = await resolveSlackSendChannel(params.thread_ts);
1335
1442
  if (!channel) {
1336
1443
  throw new Error(params.thread_ts
@@ -1342,6 +1449,7 @@ export function registerSlackTools(pi, deps) {
1342
1449
  text: params.text,
1343
1450
  ...(params.thread_ts ? { threadTs: params.thread_ts } : {}),
1344
1451
  ...(params.blocks ? { blocks: params.blocks } : {}),
1452
+ ...(params.files ? { files: params.files } : {}),
1345
1453
  });
1346
1454
  const ts = delivery.ts;
1347
1455
  const threadTs = params.thread_ts ?? delivery.threadTs;
@@ -1368,6 +1476,7 @@ export function registerSlackTools(pi, deps) {
1368
1476
  channel,
1369
1477
  blocksCount: delivery.blocksCount,
1370
1478
  delivery: delivery.delivery,
1479
+ filesCount: Array.isArray(params.files) ? params.files.length : 0,
1371
1480
  ...(delivery.adapter ? { adapter: delivery.adapter } : {}),
1372
1481
  ...(delivery.messageId ? { messageId: delivery.messageId } : {}),
1373
1482
  ...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
@@ -1491,6 +1600,50 @@ export function registerSlackTools(pi, deps) {
1491
1600
  };
1492
1601
  },
1493
1602
  });
1603
+ registerSlackAction({
1604
+ name: "slack_file",
1605
+ label: "Slack File Access",
1606
+ description: "Download a Slack-hosted file from a known file ID, optionally verified against a thread/message, into controlled local temp cache storage without exposing private Slack URLs.",
1607
+ promptSnippet: "Use slack action=file with op=download to turn a Slack-hosted file_id from slackFiles metadata into a safe local file descriptor/path. Private Slack download URLs are never returned.",
1608
+ parameters: Type.Object({
1609
+ op: Type.String({ description: "Operation. Currently only download is supported." }),
1610
+ file_id: Type.String({ description: "Slack file ID, for example F0123456789." }),
1611
+ thread_ts: Type.Optional(Type.String({
1612
+ description: "Optional Slack thread timestamp. When provided, the file must appear in that thread.",
1613
+ })),
1614
+ message_ts: Type.Optional(Type.String({
1615
+ description: "Optional Slack message timestamp inside the thread. When provided, the file must appear on that exact message.",
1616
+ })),
1617
+ channel: Type.Optional(Type.String({
1618
+ description: "Optional channel name or ID. Omit when thread_ts is tracked by the Slack bridge.",
1619
+ })),
1620
+ }),
1621
+ async execute(_id, params) {
1622
+ if (params.op !== "download") {
1623
+ throw new Error("slack file op must be download.");
1624
+ }
1625
+ requireToolPolicy("slack_file", params.thread_ts, `op=download | file_id=${params.file_id} | thread_ts=${params.thread_ts ?? ""} | message_ts=${params.message_ts ?? ""} | channel=${params.channel ?? ""}`);
1626
+ const channelId = params.thread_ts
1627
+ ? await resolveSlackTargetChannel(params.thread_ts, params.channel)
1628
+ : params.channel
1629
+ ? await resolveChannel(params.channel)
1630
+ : undefined;
1631
+ const descriptor = await fetchSlackFileToCache(params.file_id, {
1632
+ ...(channelId ? { channelId } : {}),
1633
+ ...(params.thread_ts ? { threadTs: params.thread_ts } : {}),
1634
+ ...(params.message_ts ? { messageTs: params.message_ts } : {}),
1635
+ }, { slack, token: getBotToken() });
1636
+ return {
1637
+ content: [
1638
+ {
1639
+ type: "text",
1640
+ text: `Downloaded Slack file ${descriptor.fileId} to ${descriptor.path}.`,
1641
+ },
1642
+ ],
1643
+ details: descriptor,
1644
+ };
1645
+ },
1646
+ });
1494
1647
  registerSlackAction({
1495
1648
  name: "slack_read",
1496
1649
  label: "Slack Read",
@@ -1499,10 +1652,13 @@ export function registerSlackTools(pi, deps) {
1499
1652
  parameters: Type.Object({
1500
1653
  thread_ts: Type.String({ description: "Thread to read." }),
1501
1654
  limit: Type.Optional(Type.Number({ description: "Max messages (default 20)" })),
1655
+ download_files: Type.Optional(Type.Boolean({
1656
+ description: "Download attached Slack-hosted files to the local temp cache and return safe descriptors. Defaults to true.",
1657
+ })),
1502
1658
  ...SLACK_OUTPUT_OPTION_PARAMETERS,
1503
1659
  }),
1504
1660
  async execute(_id, params) {
1505
- requireToolPolicy("slack_read", params.thread_ts, `thread_ts=${params.thread_ts} | limit=${params.limit ?? 20}`);
1661
+ requireToolPolicy("slack_read", params.thread_ts, `thread_ts=${params.thread_ts} | limit=${params.limit ?? 20} | download_files=${params.download_files !== false}`);
1506
1662
  const channel = (await resolveTrackedThreadChannel(params.thread_ts)) ?? getLastDmChannel();
1507
1663
  if (!channel) {
1508
1664
  throw new Error("Unknown thread.");
@@ -1514,37 +1670,126 @@ export function registerSlackTools(pi, deps) {
1514
1670
  });
1515
1671
  const messages = response.messages;
1516
1672
  const full = params.full === true;
1673
+ const shouldDownloadFiles = params.download_files !== false;
1674
+ const downloadFile = async (fileId, messageTs) => fetchSlackFileToCache(fileId, { channelId: channel, threadTs: params.thread_ts, messageTs }, { slack, token: getBotToken() });
1517
1675
  const formattedMessages = await Promise.all(messages.map(async (message) => {
1518
1676
  const userId = message.user;
1519
1677
  const name = userId ? await resolveUser(userId) : "bot";
1520
1678
  const text = message.text ?? "";
1521
1679
  const ts = message.ts;
1522
- return { ts, name, text, preview: truncateSlackText(text) };
1680
+ const files = await Promise.all(extractSlackMessageFileMetadata(message.files).map(async (file) => {
1681
+ const fileId = file.id;
1682
+ if (!fileId) {
1683
+ return {
1684
+ messageTs: ts,
1685
+ ...(file.name ? { filename: file.name } : {}),
1686
+ ...(file.mimetype ? { mimetype: file.mimetype } : {}),
1687
+ ...(file.filetype ? { filetype: file.filetype } : {}),
1688
+ ...(file.prettyType ? { prettyType: file.prettyType } : {}),
1689
+ ...(file.size != null ? { size: file.size } : {}),
1690
+ downloadStatus: "metadata-only",
1691
+ };
1692
+ }
1693
+ const base = {
1694
+ fileId,
1695
+ messageTs: ts,
1696
+ ...(file.name ? { filename: file.name } : {}),
1697
+ ...(file.mimetype ? { mimetype: file.mimetype } : {}),
1698
+ ...(file.filetype ? { filetype: file.filetype } : {}),
1699
+ ...(file.prettyType ? { prettyType: file.prettyType } : {}),
1700
+ ...(file.size != null ? { size: file.size } : {}),
1701
+ };
1702
+ if (!shouldDownloadFiles) {
1703
+ return {
1704
+ ...base,
1705
+ downloadStatus: "metadata-only",
1706
+ };
1707
+ }
1708
+ try {
1709
+ const descriptor = await downloadFile(fileId, ts);
1710
+ return {
1711
+ ...base,
1712
+ filename: descriptor.filename,
1713
+ ...(descriptor.mimetype ? { mimetype: descriptor.mimetype } : {}),
1714
+ ...(descriptor.filetype ? { filetype: descriptor.filetype } : {}),
1715
+ ...(descriptor.prettyType ? { prettyType: descriptor.prettyType } : {}),
1716
+ size: descriptor.size,
1717
+ path: descriptor.path,
1718
+ sha256: descriptor.sha256,
1719
+ cacheDir: descriptor.cacheDir,
1720
+ expiresAt: descriptor.expiresAt,
1721
+ downloadStatus: "downloaded",
1722
+ };
1723
+ }
1724
+ catch (error) {
1725
+ return {
1726
+ ...base,
1727
+ downloadStatus: "failed",
1728
+ error: getErrorMessage(error),
1729
+ };
1730
+ }
1731
+ }));
1732
+ return { ts, name, text, preview: truncateSlackText(text), files };
1523
1733
  }));
1524
- const lines = formattedMessages.map((message) => full
1525
- ? `[${message.ts}] ${message.name}: ${message.text}`
1526
- : `[${message.ts}] ${message.name}: ${message.preview}`);
1734
+ const lines = formattedMessages.flatMap((message) => {
1735
+ const messageLine = full
1736
+ ? `[${message.ts}] ${message.name}: ${message.text}`
1737
+ : `[${message.ts}] ${message.name}: ${message.preview}`;
1738
+ const fileLines = message.files.map((file) => {
1739
+ const filename = file.filename ? ` ${file.filename}` : "";
1740
+ const type = file.prettyType ?? file.filetype ?? file.mimetype ?? "file";
1741
+ if (file.downloadStatus === "downloaded") {
1742
+ return ` [file downloaded] ${file.fileId}${filename} (${type}) -> ${file.path}`;
1743
+ }
1744
+ if (file.downloadStatus === "failed") {
1745
+ return ` [file metadata] ${file.fileId}${filename} (${type}) download failed: ${file.error}`;
1746
+ }
1747
+ return ` [file metadata] ${"fileId" in file ? file.fileId : "unknown-file"}${filename} (${type})`;
1748
+ });
1749
+ return [messageLine, ...fileLines];
1750
+ });
1751
+ const downloadedFilesCount = formattedMessages.reduce((count, message) => count + message.files.filter((file) => file.downloadStatus === "downloaded").length, 0);
1752
+ const failedFilesCount = formattedMessages.reduce((count, message) => count + message.files.filter((file) => file.downloadStatus === "failed").length, 0);
1527
1753
  if (!full && messages.length > 0) {
1528
1754
  lines.push("", "Use args.full=true for exact message text.");
1529
1755
  }
1756
+ if (downloadedFilesCount > 0 || failedFilesCount > 0) {
1757
+ lines.push("", `File attachments: ${downloadedFilesCount} downloaded to the local temp cache${failedFilesCount > 0 ? `; ${failedFilesCount} failed and returned metadata only` : ""}.`);
1758
+ }
1530
1759
  return {
1531
1760
  content: [{ type: "text", text: lines.join("\n") || "(no messages)" }],
1532
1761
  details: full
1533
- ? { count: messages.length }
1762
+ ? {
1763
+ count: messages.length,
1764
+ downloadedFilesCount,
1765
+ failedFilesCount,
1766
+ messages: formattedMessages.map((message) => ({
1767
+ ts: message.ts,
1768
+ user: message.name,
1769
+ text: message.text,
1770
+ files: message.files,
1771
+ })),
1772
+ }
1534
1773
  : {
1535
1774
  count: messages.length,
1775
+ downloadedFilesCount,
1776
+ failedFilesCount,
1536
1777
  messages: formattedMessages.map((message) => ({
1537
1778
  ts: message.ts,
1538
1779
  user: message.name,
1539
1780
  preview: message.preview,
1781
+ files: message.files,
1540
1782
  })),
1541
1783
  },
1542
1784
  fullDetails: {
1543
1785
  count: messages.length,
1786
+ downloadedFilesCount,
1787
+ failedFilesCount,
1544
1788
  messages: formattedMessages.map((message) => ({
1545
1789
  ts: message.ts,
1546
1790
  user: message.name,
1547
1791
  text: message.text,
1792
+ files: message.files,
1548
1793
  })),
1549
1794
  },
1550
1795
  };
@@ -1903,9 +2148,10 @@ export function registerSlackTools(pi, deps) {
1903
2148
  blocks: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()), {
1904
2149
  description: "Optional Slack Block Kit blocks JSON array",
1905
2150
  })),
2151
+ files: Type.Optional(SLACK_LOCAL_FILE_ATTACHMENT_PARAMETERS),
1906
2152
  }),
1907
2153
  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)}`);
2154
+ requireToolPolicy("slack_post_channel", params.thread_ts, `channel=${params.channel ?? getDefaultChannel() ?? ""} | thread_ts=${params.thread_ts ?? ""} | text=${params.text} | blocks=${summarizeSlackBlocksForPolicy(params.blocks)} | files=${Array.isArray(params.files) ? params.files.length : 0}`);
1909
2155
  const resolvedThreadChannel = await resolveTrackedThreadChannel(params.thread_ts);
1910
2156
  const channelInput = params.channel ?? getDefaultChannel();
1911
2157
  let channelId = params.channel ? await resolveChannel(params.channel) : resolvedThreadChannel;
@@ -1920,6 +2166,7 @@ export function registerSlackTools(pi, deps) {
1920
2166
  text: params.text,
1921
2167
  ...(params.thread_ts ? { threadTs: params.thread_ts } : {}),
1922
2168
  ...(params.blocks ? { blocks: params.blocks } : {}),
2169
+ ...(params.files ? { files: params.files } : {}),
1923
2170
  });
1924
2171
  const ts = delivery.ts;
1925
2172
  const threadTs = params.thread_ts ?? delivery.threadTs;
@@ -1944,6 +2191,7 @@ export function registerSlackTools(pi, deps) {
1944
2191
  channel: channelId,
1945
2192
  blocksCount: delivery.blocksCount,
1946
2193
  delivery: delivery.delivery,
2194
+ filesCount: Array.isArray(params.files) ? params.files.length : 0,
1947
2195
  ...(delivery.adapter ? { adapter: delivery.adapter } : {}),
1948
2196
  ...(delivery.messageId ? { messageId: delivery.messageId } : {}),
1949
2197
  ...(delivery.fallbackReason ? { fallbackReason: delivery.fallbackReason } : {}),
@@ -1958,7 +2206,7 @@ export function registerSlackTools(pi, deps) {
1958
2206
  promptSnippet: "Delete a bot-posted Slack message. This is destructive — set confirm=true, and prefer explicit approval before deleting whole threads.",
1959
2207
  parameters: Type.Object({
1960
2208
  ts: Type.String({
1961
- description: "Timestamp (ts) of the message to delete. When thread=true, this must be the thread root timestamp.",
2209
+ description: "Timestamp (ts) of the message to delete, or a comma-separated list of bot-posted message timestamps to delete in one confirmed batch. When thread=true, this must be a single thread root timestamp.",
1962
2210
  }),
1963
2211
  channel: Type.Optional(Type.String({
1964
2212
  description: "Channel name or ID. Omit to use the current thread channel, active DM, or defaultChannel.",
@@ -1977,18 +2225,22 @@ export function registerSlackTools(pi, deps) {
1977
2225
  if (params.confirm !== true) {
1978
2226
  throw new Error("Deleting Slack messages is irreversible. Re-run with confirm=true once you've verified the target.");
1979
2227
  }
2228
+ const deleteThread = params.thread === true;
2229
+ const requestedTsList = parseSlackDeleteTsInput(params.ts, deleteThread);
2230
+ // Resolve the actual deletion channel BEFORE the guardrail check so the
2231
+ // canonical action quoted in errors, registered via confirm_action, and
2232
+ // enforced on the post-approval retry is identical regardless of how the
2233
+ // caller phrased channel/ts (#814).
2234
+ const channelId = await resolveSlackTargetChannel(params.thread_ts, params.channel);
1980
2235
  requireToolPolicy("slack_delete", params.thread_ts, summarizeSlackDeleteAction({
1981
- channel: params.channel,
1982
- defaultChannel: getDefaultChannel(),
2236
+ channel: channelId,
1983
2237
  threadTs: params.thread_ts,
1984
- ts: params.ts,
1985
- thread: params.thread,
2238
+ tsList: requestedTsList,
2239
+ thread: deleteThread,
1986
2240
  }));
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,
2241
+ const { messageTsList } = await resolveSlackDeleteTargets({
2242
+ channelId,
2243
+ tsList: requestedTsList,
1992
2244
  thread: deleteThread,
1993
2245
  });
1994
2246
  for (const messageTs of messageTsList) {
@@ -1997,7 +2249,7 @@ export function registerSlackTools(pi, deps) {
1997
2249
  ts: messageTs,
1998
2250
  });
1999
2251
  }
2000
- const targetTs = params.ts.trim();
2252
+ const targetTs = requestedTsList[0];
2001
2253
  const deletedCount = messageTsList.length;
2002
2254
  const channelLabel = params.channel ?? channelId;
2003
2255
  return {
@@ -2006,7 +2258,9 @@ export function registerSlackTools(pi, deps) {
2006
2258
  type: "text",
2007
2259
  text: deleteThread
2008
2260
  ? `Deleted thread rooted at ${targetTs} in channel ${channelLabel} (${deletedCount} message${deletedCount === 1 ? "" : "s"}).`
2009
- : `Deleted message ${targetTs} from channel ${channelLabel}.`,
2261
+ : deletedCount === 1
2262
+ ? `Deleted message ${targetTs} from channel ${channelLabel}.`
2263
+ : `Deleted ${deletedCount} messages (${messageTsList.join(", ")}) from channel ${channelLabel}.`,
2010
2264
  },
2011
2265
  ],
2012
2266
  details: {
@@ -26,11 +26,22 @@ export interface PerformSlackUploadOptions extends SlackUploadDeps {
26
26
  upload: PreparedSlackUpload;
27
27
  channelId: string;
28
28
  threadTs?: string;
29
+ initialComment?: string;
30
+ }
31
+ export interface PerformSlackUploadsOptions extends SlackUploadDeps {
32
+ uploads: readonly PreparedSlackUpload[];
33
+ channelId: string;
34
+ threadTs?: string;
35
+ initialComment?: string;
29
36
  }
30
37
  export interface CompletedSlackUpload {
31
38
  fileId: string;
32
39
  response: SlackResult;
33
40
  }
41
+ export interface CompletedSlackUploads {
42
+ fileIds: string[];
43
+ response: SlackResult;
44
+ }
34
45
  interface PrepareSlackUploadFs {
35
46
  readFileImpl?: typeof readFile;
36
47
  realpathImpl?: typeof realpath;
@@ -45,5 +56,6 @@ export declare function chooseSlackSnippetType(upload: {
45
56
  }): string | undefined;
46
57
  export declare function resolveSlackUploadPath(inputPath: string, cwd: string, tmpdir: string, fsDeps?: PrepareSlackUploadFs): Promise<string>;
47
58
  export declare function prepareSlackUpload(params: SlackUploadParams, cwd: string, tmpdir: string, fsDeps?: PrepareSlackUploadFs): Promise<PreparedSlackUpload>;
48
- export declare function performSlackUpload({ upload, channelId, threadTs, slack, token, fetchImpl, }: PerformSlackUploadOptions): Promise<CompletedSlackUpload>;
59
+ export declare function performSlackUploads({ uploads, channelId, threadTs, initialComment, slack, token, fetchImpl, }: PerformSlackUploadsOptions): Promise<CompletedSlackUploads>;
60
+ export declare function performSlackUpload({ upload, channelId, threadTs, initialComment, slack, token, fetchImpl, }: PerformSlackUploadOptions): Promise<CompletedSlackUpload>;
49
61
  export {};
@@ -162,7 +162,7 @@ export async function prepareSlackUpload(params, cwd, tmpdir, fsDeps = {}) {
162
162
  ...(resolvedPath ? { resolvedPath } : {}),
163
163
  };
164
164
  }
165
- export async function performSlackUpload({ upload, channelId, threadTs, slack, token, fetchImpl = fetch, }) {
165
+ async function reserveAndUploadSlackFile(upload, slack, token, fetchImpl) {
166
166
  let getUploadResponse;
167
167
  try {
168
168
  getUploadResponse = await slack("files.getUploadURLExternal", token, buildUploadMetadataPayload(upload, true));
@@ -215,10 +215,37 @@ export async function performSlackUpload({ upload, channelId, threadTs, slack, t
215
215
  const hint = isProxyOrFirewallFailure ? " [possible outbound proxy/firewall block]" : "";
216
216
  throw new Error(`Slack raw upload failed (HTTP ${rawUploadResponse.status}${statusTextForHeader})${withDetails ? ` ${withDetails}` : ""}${hint}; host=${uploadHost}; filename=${upload.filename} byte_length=${upload.byteLength}`);
217
217
  }
218
+ return { fileId, title: upload.title };
219
+ }
220
+ export async function performSlackUploads({ uploads, channelId, threadTs, initialComment, slack, token, fetchImpl = fetch, }) {
221
+ if (uploads.length === 0) {
222
+ throw new Error("At least one upload is required.");
223
+ }
224
+ const files = [];
225
+ for (const upload of uploads) {
226
+ files.push(await reserveAndUploadSlackFile(upload, slack, token, fetchImpl));
227
+ }
218
228
  const response = await slack("files.completeUploadExternal", token, {
219
- files: [{ id: fileId, title: upload.title }],
229
+ files: files.map((file) => ({ id: file.fileId, title: file.title })),
220
230
  channel_id: channelId,
221
231
  ...(threadTs ? { thread_ts: threadTs } : {}),
232
+ ...(initialComment ? { initial_comment: initialComment } : {}),
222
233
  });
234
+ return { fileIds: files.map((file) => file.fileId), response };
235
+ }
236
+ export async function performSlackUpload({ upload, channelId, threadTs, initialComment, slack, token, fetchImpl = fetch, }) {
237
+ const { fileIds, response } = await performSlackUploads({
238
+ uploads: [upload],
239
+ channelId,
240
+ ...(threadTs ? { threadTs } : {}),
241
+ ...(initialComment ? { initialComment } : {}),
242
+ slack,
243
+ token,
244
+ fetchImpl,
245
+ });
246
+ const fileId = fileIds[0];
247
+ if (!fileId) {
248
+ throw new Error("Slack upload did not return a file ID.");
249
+ }
223
250
  return { fileId, response };
224
251
  }