tide-commander 1.62.0 → 1.63.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 (40) hide show
  1. package/dist/assets/{BossLogsModal-Cc26KWtF.js → BossLogsModal-BpNtzCTX.js} +1 -1
  2. package/dist/assets/{BossSpawnModal-79zixDGu.js → BossSpawnModal-ByiEkNZl.js} +1 -1
  3. package/dist/assets/{ControlsModal-sIxaSPTd.js → ControlsModal-BgeLGCGT.js} +1 -1
  4. package/dist/assets/{DockerLogsModal-RAIqQDHy.js → DockerLogsModal-CaK9YJT4.js} +1 -1
  5. package/dist/assets/{EmbeddedEditor-SYtH65mY.js → EmbeddedEditor-DcGJCiuP.js} +1 -1
  6. package/dist/assets/{GmailOAuthSetup-DK2GlEv1.js → GmailOAuthSetup-CYIBkOXn.js} +1 -1
  7. package/dist/assets/{GoogleOAuthSetup-CprVeTSW.js → GoogleOAuthSetup-CY_EF39t.js} +1 -1
  8. package/dist/assets/{IframeModal-pzXWPx2i.js → IframeModal-DDFIBCq1.js} +1 -1
  9. package/dist/assets/{IntegrationsPanel-BzPNGPs-.js → IntegrationsPanel-CssC1pup.js} +2 -2
  10. package/dist/assets/{LogViewerModal-oqzbJ5iy.js → LogViewerModal-C_f0Z-2y.js} +1 -1
  11. package/dist/assets/{MonitoringModal-DadiFtmu.js → MonitoringModal-CkOkdUvI.js} +1 -1
  12. package/dist/assets/{PM2LogsModal-C4hQRREX.js → PM2LogsModal-BtqM8TkL.js} +1 -1
  13. package/dist/assets/{RestoreArchivedAreaModal-fHOJ9ZwM.js → RestoreArchivedAreaModal-CIqWIO9m.js} +1 -1
  14. package/dist/assets/{Scene2DCanvas-BeHXBqTf.js → Scene2DCanvas-CJ_MZU-J.js} +1 -1
  15. package/dist/assets/{SceneManager-B6E_cAYg.js → SceneManager-DyX0JqRQ.js} +1 -1
  16. package/dist/assets/{SkillsPanel-B3-ni6t4.js → SkillsPanel-DR7WNV5l.js} +1 -1
  17. package/dist/assets/{SpawnModal-00Lv0ZQy.js → SpawnModal-BCJDoSQg.js} +1 -1
  18. package/dist/assets/{SubordinateAssignmentModal-DVgwAkbS.js → SubordinateAssignmentModal-DAlLWBtv.js} +1 -1
  19. package/dist/assets/{TriggerManagerPanel-GUPU3sup.js → TriggerManagerPanel-ChBctjk3.js} +1 -1
  20. package/dist/assets/{WorkflowEditorPanel-CP26YA9V.js → WorkflowEditorPanel-Bk-QD7-7.js} +1 -1
  21. package/dist/assets/{index-alq8vlxv.js → index-COaYNxnC.js} +1 -1
  22. package/dist/assets/{index-DwJ7OcGh.js → index-CVIkvsfJ.js} +1 -1
  23. package/dist/assets/{index-B9SzB9ov.js → index-DcxPBYWc.js} +1 -1
  24. package/dist/assets/{index-BS2yVyno.js → index-DoOLLVEG.js} +2 -2
  25. package/dist/assets/{index-DLkUpBdu.js → index-DwTs55lG.js} +1 -1
  26. package/dist/assets/{index-C7PPAB37.js → index-Dwt45QE5.js} +1 -1
  27. package/dist/assets/{index-D8tCoR93.js → index-M-tYhxQp.js} +3 -3
  28. package/dist/assets/{index-DKziSiqq.js → index-vx9STDIr.js} +1 -1
  29. package/dist/assets/{main-DxFgIntP.js → main-5l14ueeE.js} +4 -4
  30. package/dist/assets/{web-BGvcteqx.js → web-BYu5GvMD.js} +1 -1
  31. package/dist/assets/{web-DBp9A4mr.js → web-BsMMNK0E.js} +1 -1
  32. package/dist/index.html +1 -1
  33. package/dist/src/packages/server/integrations/jira/jira-client.js +155 -0
  34. package/dist/src/packages/server/integrations/jira/jira-routes.js +73 -0
  35. package/dist/src/packages/server/integrations/jira/jira-skill.js +32 -0
  36. package/dist/src/packages/server/integrations/slack/slack-client.js +194 -4
  37. package/dist/src/packages/server/integrations/slack/slack-routes.js +131 -0
  38. package/dist/src/packages/server/integrations/slack/slack-skill.js +74 -0
  39. package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +9 -1
  40. package/package.json +1 -1
@@ -1 +1 @@
1
- import{bH as a}from"./main-DxFgIntP.js";import{ImpactStyle as i,NotificationType as r}from"./index-BS2yVyno.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
1
+ import{bH as a}from"./main-5l14ueeE.js";import{ImpactStyle as i,NotificationType as r}from"./index-DoOLLVEG.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class h extends a{constructor(){super(...arguments),this.selectionStarted=!1}async impact(t){const e=this.patternForImpact(t==null?void 0:t.style);this.vibrateWithPattern(e)}async notification(t){const e=this.patternForNotification(t==null?void 0:t.type);this.vibrateWithPattern(e)}async vibrate(t){const e=(t==null?void 0:t.duration)||300;this.vibrateWithPattern([e])}async selectionStart(){this.selectionStarted=!0}async selectionChanged(){this.selectionStarted&&this.vibrateWithPattern([70])}async selectionEnd(){this.selectionStarted=!1}patternForImpact(t=i.Heavy){return t===i.Medium?[43]:t===i.Light?[20]:[61]}patternForNotification(t=r.Success){return t===r.Warning?[30,40,30,50,60]:t===r.Error?[27,45,50]:[35,65,21]}vibrateWithPattern(t){if(navigator.vibrate)navigator.vibrate(t);else throw this.unavailable("Browser does not support the vibrate API")}}export{h as HapticsWeb};
@@ -1 +1 @@
1
- import{bH as s}from"./main-DxFgIntP.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
1
+ import{bH as s}from"./main-5l14ueeE.js";import"./vendor-react--Eh9ivFN.js";import"./vendor-three-Chj50gSY.js";class l extends s{constructor(){super(...arguments),this.pending=[],this.deliveredNotifications=[],this.hasNotificationSupport=()=>{if(!("Notification"in window)||!Notification.requestPermission)return!1;if(Notification.permission!=="granted")try{new Notification("")}catch(i){if(i instanceof Error&&i.name==="TypeError")return!1}return!0}}async getDeliveredNotifications(){const i=[];for(const t of this.deliveredNotifications){const e={title:t.title,id:parseInt(t.tag),body:t.body};i.push(e)}return{notifications:i}}async removeDeliveredNotifications(i){for(const t of i.notifications){const e=this.deliveredNotifications.find(n=>n.tag===String(t.id));e==null||e.close(),this.deliveredNotifications=this.deliveredNotifications.filter(()=>!e)}}async removeAllDeliveredNotifications(){for(const i of this.deliveredNotifications)i.close();this.deliveredNotifications=[]}async createChannel(){throw this.unimplemented("Not implemented on web.")}async deleteChannel(){throw this.unimplemented("Not implemented on web.")}async listChannels(){throw this.unimplemented("Not implemented on web.")}async schedule(i){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");for(const t of i.notifications)this.sendNotification(t);return{notifications:i.notifications.map(t=>({id:t.id}))}}async getPending(){return{notifications:this.pending}}async registerActionTypes(){throw this.unimplemented("Not implemented on web.")}async cancel(i){this.pending=this.pending.filter(t=>!i.notifications.find(e=>e.id===t.id))}async areEnabled(){const{display:i}=await this.checkPermissions();return{value:i==="granted"}}async changeExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async checkExactNotificationSetting(){throw this.unimplemented("Not implemented on web.")}async requestPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(await Notification.requestPermission())}}async checkPermissions(){if(!this.hasNotificationSupport())throw this.unavailable("Notifications not supported in this browser.");return{display:this.transformNotificationPermission(Notification.permission)}}transformNotificationPermission(i){switch(i){case"granted":return"granted";case"denied":return"denied";default:return"prompt"}}sendPending(){var i;const t=[],e=new Date().getTime();for(const n of this.pending)!((i=n.schedule)===null||i===void 0)&&i.at&&n.schedule.at.getTime()<=e&&(this.buildNotification(n),t.push(n));this.pending=this.pending.filter(n=>!t.find(o=>o===n))}sendNotification(i){var t;if(!((t=i.schedule)===null||t===void 0)&&t.at){const e=i.schedule.at.getTime()-new Date().getTime();this.pending.push(i),setTimeout(()=>{this.sendPending()},e);return}this.buildNotification(i)}buildNotification(i){const t=new Notification(i.title,{body:i.body,tag:String(i.id)});return t.addEventListener("click",this.onClick.bind(this,i),!1),t.addEventListener("show",this.onShow.bind(this,i),!1),t.addEventListener("close",()=>{this.deliveredNotifications=this.deliveredNotifications.filter(()=>!this)},!1),this.deliveredNotifications.push(t),t}onClick(i){const t={actionId:"tap",notification:i};this.notifyListeners("localNotificationActionPerformed",t)}onShow(i){this.notifyListeners("localNotificationReceived",i)}}export{l as LocalNotificationsWeb};
package/dist/index.html CHANGED
@@ -22,7 +22,7 @@
22
22
  <link rel="icon" type="image/png" sizes="16x16" href="/assets/icons/favicon-16x16.png" />
23
23
  <link rel="apple-touch-icon" sizes="180x180" href="/assets/icons/apple-touch-icon.png" />
24
24
  <title>Tide Commander</title>
25
- <script type="module" crossorigin src="/assets/main-DxFgIntP.js"></script>
25
+ <script type="module" crossorigin src="/assets/main-5l14ueeE.js"></script>
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-react--Eh9ivFN.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-three-Chj50gSY.js">
28
28
  <link rel="stylesheet" crossorigin href="/assets/main-a5zo94fK.css">
@@ -3,6 +3,8 @@
3
3
  * REST API v3 wrapper using raw fetch (no extra dependencies).
4
4
  * Authentication: Basic (email:apiToken) for Atlassian Cloud.
5
5
  */
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
6
8
  import { parseCustomFieldMappings } from './jira-config.js';
7
9
  // ─── Client ───
8
10
  export class JiraClient {
@@ -141,6 +143,136 @@ export class JiraClient {
141
143
  const resp = (await this.request('POST', '/rest/servicedeskapi/request', body));
142
144
  return this.getIssue(resp.issueKey);
143
145
  }
146
+ // ─── Attachments ───
147
+ /**
148
+ * List all attachments on a Jira issue.
149
+ * @param issueKey Issue key or numeric id (e.g. "SD-1234").
150
+ * @returns Array of attachment descriptors (id, filename, mimeType, size, contentUrl, author, created).
151
+ */
152
+ async listAttachments(issueKey) {
153
+ const resp = (await this.request('GET', `/rest/api/3/issue/${encodeURIComponent(issueKey)}?fields=attachment`));
154
+ const list = resp.fields?.attachment ?? [];
155
+ return list.map((a) => ({
156
+ id: a.id,
157
+ filename: a.filename,
158
+ mimeType: a.mimeType,
159
+ size: a.size,
160
+ contentUrl: a.content,
161
+ authorDisplayName: a.author?.displayName,
162
+ created: a.created,
163
+ }));
164
+ }
165
+ /**
166
+ * List attachments referenced by comments on an issue.
167
+ * Comments reference media via ADF nodes of type "media"; those ids resolve to entries in the
168
+ * issue's own attachment list.
169
+ * @param issueKey Issue key or numeric id.
170
+ * @param commentId Optional — restrict to a single comment.
171
+ * @returns Attachments referenced by matching comment ADF bodies.
172
+ */
173
+ async listCommentAttachments(issueKey, commentId) {
174
+ const resp = (await this.request('GET', `/rest/api/3/issue/${encodeURIComponent(issueKey)}/comment`));
175
+ const comments = commentId
176
+ ? (resp.comments ?? []).filter((c) => c.id === commentId)
177
+ : resp.comments ?? [];
178
+ const mediaIds = new Set();
179
+ for (const c of comments) {
180
+ for (const id of extractMediaIdsFromADF(c.body))
181
+ mediaIds.add(id);
182
+ }
183
+ if (mediaIds.size === 0)
184
+ return [];
185
+ const all = await this.listAttachments(issueKey);
186
+ return all.filter((a) => mediaIds.has(a.id));
187
+ }
188
+ /**
189
+ * Download a single attachment to disk.
190
+ * @param attachment Either the attachment id (string) or a full {@link JiraAttachment} object.
191
+ * @param outputPath Absolute or relative destination file path. Parent directory is created.
192
+ * @returns The destination path and bytes written.
193
+ */
194
+ async downloadAttachment(attachment, outputPath) {
195
+ if (!this.isConfigured) {
196
+ throw new Error('Jira client is not configured. Set base URL, email, and API token.');
197
+ }
198
+ const url = typeof attachment === 'string'
199
+ ? `${this.baseUrl}/rest/api/3/attachment/content/${encodeURIComponent(attachment)}`
200
+ : attachment.contentUrl;
201
+ const response = await fetch(url, {
202
+ method: 'GET',
203
+ headers: {
204
+ Authorization: `Basic ${this.auth}`,
205
+ Accept: '*/*',
206
+ },
207
+ redirect: 'follow',
208
+ });
209
+ if (!response.ok) {
210
+ const detail = await response.text().catch(() => '');
211
+ const id = typeof attachment === 'string' ? attachment : attachment.id;
212
+ throw new Error(`Jira attachment download failed for id ${id} (${response.status}): ${detail}`);
213
+ }
214
+ const buf = Buffer.from(await response.arrayBuffer());
215
+ await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
216
+ await fs.writeFile(outputPath, buf);
217
+ return { path: outputPath, bytes: buf.byteLength };
218
+ }
219
+ /**
220
+ * Fetch an attachment's raw bytes along with relevant response headers. Used by the HTTP
221
+ * proxy route to stream content back to a caller without exposing credentials.
222
+ * @param attachmentId Jira attachment id.
223
+ */
224
+ async fetchAttachmentBytes(attachmentId) {
225
+ if (!this.isConfigured) {
226
+ throw new Error('Jira client is not configured. Set base URL, email, and API token.');
227
+ }
228
+ const response = await fetch(`${this.baseUrl}/rest/api/3/attachment/content/${encodeURIComponent(attachmentId)}`, {
229
+ method: 'GET',
230
+ headers: {
231
+ Authorization: `Basic ${this.auth}`,
232
+ Accept: '*/*',
233
+ },
234
+ redirect: 'follow',
235
+ });
236
+ if (!response.ok) {
237
+ const detail = await response.text().catch(() => '');
238
+ throw new Error(`Jira attachment fetch failed for id ${attachmentId} (${response.status}): ${detail}`);
239
+ }
240
+ return {
241
+ buffer: Buffer.from(await response.arrayBuffer()),
242
+ contentType: response.headers.get('content-type'),
243
+ contentDisposition: response.headers.get('content-disposition'),
244
+ contentLength: response.headers.get('content-length'),
245
+ };
246
+ }
247
+ /**
248
+ * Download every attachment on an issue (optionally also those referenced by comments) to a directory.
249
+ * Filename collisions overwrite.
250
+ * @param issueKey Issue key or numeric id.
251
+ * @param outputDir Directory to write files into; created recursively if missing.
252
+ * @param opts.includeComments When true, union in attachments referenced by the issue's comments.
253
+ * @returns The list of attachments that were downloaded.
254
+ */
255
+ async downloadAllAttachments(issueKey, outputDir, opts) {
256
+ await fs.mkdir(outputDir, { recursive: true });
257
+ const issueAtts = await this.listAttachments(issueKey);
258
+ const byId = new Map();
259
+ for (const a of issueAtts)
260
+ byId.set(a.id, a);
261
+ if (opts?.includeComments) {
262
+ const commentAtts = await this.listCommentAttachments(issueKey);
263
+ for (const a of commentAtts)
264
+ if (!byId.has(a.id))
265
+ byId.set(a.id, a);
266
+ }
267
+ const downloaded = [];
268
+ for (const att of byId.values()) {
269
+ const safeName = sanitizeFilename(att.filename) || att.id;
270
+ const outPath = path.join(outputDir, safeName);
271
+ await this.downloadAttachment(att, outPath);
272
+ downloaded.push(att);
273
+ }
274
+ return downloaded;
275
+ }
144
276
  // ─── Helpers ───
145
277
  /** Convert plain text to Atlassian Document Format (ADF). */
146
278
  toADF(text) {
@@ -201,3 +333,26 @@ export class JiraClient {
201
333
  return response.json();
202
334
  }
203
335
  }
336
+ // ─── Module-level helpers ───
337
+ /** Walk an ADF document and return all media-node ids (`{type:"media",attrs:{id,...}}`). */
338
+ function extractMediaIdsFromADF(adf) {
339
+ const ids = [];
340
+ const walk = (node) => {
341
+ if (!node || typeof node !== 'object')
342
+ return;
343
+ const n = node;
344
+ if (n.type === 'media' && typeof n.attrs?.id === 'string') {
345
+ ids.push(n.attrs.id);
346
+ }
347
+ if (Array.isArray(n.content)) {
348
+ for (const child of n.content)
349
+ walk(child);
350
+ }
351
+ };
352
+ walk(adf);
353
+ return ids;
354
+ }
355
+ /** Strip path separators and NUL so an attachment filename is safe to join with an outputDir. */
356
+ function sanitizeFilename(name) {
357
+ return name.replace(/[\\/\0]/g, '_').replace(/^\.+/, '_').trim();
358
+ }
@@ -10,6 +10,10 @@
10
10
  * GET /issues/:key/comments - Get comments
11
11
  * GET /issues/:key/transitions - List transitions
12
12
  * POST /issues/:key/transitions - Transition issue
13
+ * GET /issues/:key/attachments - List issue attachments
14
+ * GET /issues/:key/comments/attachments - List attachments referenced by comments (optional ?commentId=)
15
+ * POST /issues/:key/attachments/download-all - Server-side bulk download to outputDir
16
+ * GET /attachments/:id/content - Proxy an attachment's binary content
13
17
  * GET /search - Search via JQL
14
18
  * POST /service-desk/:deskId/requests - Create SD request
15
19
  */
@@ -197,6 +201,75 @@ export function createJiraRoutes(client, ctx) {
197
201
  res.status(500).json({ error: message });
198
202
  }
199
203
  });
204
+ // ─── Attachments ───
205
+ // List attachments on an issue
206
+ router.get('/issues/:key/attachments', async (req, res) => {
207
+ try {
208
+ const attachments = await client.listAttachments(req.params.key);
209
+ res.json({ attachments });
210
+ }
211
+ catch (err) {
212
+ const message = err instanceof Error ? err.message : 'Failed to list attachments';
213
+ res.status(500).json({ error: message });
214
+ }
215
+ });
216
+ // List attachments referenced by issue comments (optionally filter by commentId)
217
+ router.get('/issues/:key/comments/attachments', async (req, res) => {
218
+ try {
219
+ const commentId = req.query.commentId;
220
+ const attachments = await client.listCommentAttachments(req.params.key, commentId);
221
+ res.json({ attachments });
222
+ }
223
+ catch (err) {
224
+ const message = err instanceof Error ? err.message : 'Failed to list comment attachments';
225
+ res.status(500).json({ error: message });
226
+ }
227
+ });
228
+ // Server-side bulk download of all attachments for an issue
229
+ router.post('/issues/:key/attachments/download-all', async (req, res) => {
230
+ try {
231
+ const { outputDir, includeComments } = req.body;
232
+ if (!outputDir) {
233
+ res.status(400).json({ error: 'outputDir is required' });
234
+ return;
235
+ }
236
+ const downloaded = await client.downloadAllAttachments(req.params.key, outputDir, {
237
+ includeComments,
238
+ });
239
+ res.json({
240
+ outputDir,
241
+ count: downloaded.length,
242
+ attachments: downloaded.map((a) => ({
243
+ id: a.id,
244
+ filename: a.filename,
245
+ mimeType: a.mimeType,
246
+ size: a.size,
247
+ })),
248
+ });
249
+ }
250
+ catch (err) {
251
+ const message = err instanceof Error ? err.message : 'Failed to download attachments';
252
+ ctx.log.error(`Bulk download for ${req.params.key} failed: ${message}`);
253
+ res.status(500).json({ error: message });
254
+ }
255
+ });
256
+ // Proxy a single attachment's binary content (auth is added server-side)
257
+ router.get('/attachments/:id/content', async (req, res) => {
258
+ try {
259
+ const { buffer, contentType, contentDisposition, contentLength } = await client.fetchAttachmentBytes(req.params.id);
260
+ if (contentType)
261
+ res.setHeader('Content-Type', contentType);
262
+ if (contentDisposition)
263
+ res.setHeader('Content-Disposition', contentDisposition);
264
+ if (contentLength)
265
+ res.setHeader('Content-Length', contentLength);
266
+ res.status(200).send(buffer);
267
+ }
268
+ catch (err) {
269
+ const message = err instanceof Error ? err.message : 'Failed to fetch attachment';
270
+ res.status(500).json({ error: message });
271
+ }
272
+ });
200
273
  // ─── Search ───
201
274
  router.get('/search', async (req, res) => {
202
275
  try {
@@ -138,6 +138,38 @@ curl -s -X POST http://localhost:5174/api/jira/issues/SD-1234/transitions \\
138
138
  -d '{"transitionId": "31", "comment": "Transition reason"}'
139
139
  \`\`\`
140
140
 
141
+ ## Attachments
142
+
143
+ **List attachments on an issue:**
144
+ \`\`\`bash
145
+ curl -s http://localhost:5174/api/jira/issues/SD-1234/attachments
146
+ \`\`\`
147
+ Returns \`{ "attachments": [ { "id", "filename", "mimeType", "size", "contentUrl", "authorDisplayName?", "created?" } ] }\`.
148
+
149
+ **List attachments referenced by comments (optionally a single comment):**
150
+ \`\`\`bash
151
+ # All comment attachments on the issue
152
+ curl -s http://localhost:5174/api/jira/issues/SD-1234/comments/attachments
153
+
154
+ # Only attachments referenced by a single comment
155
+ curl -s "http://localhost:5174/api/jira/issues/SD-1234/comments/attachments?commentId=10050"
156
+ \`\`\`
157
+
158
+ **Download a single attachment to disk (proxy streams binary, auth handled server-side):**
159
+ \`\`\`bash
160
+ curl -s http://localhost:5174/api/jira/attachments/10042/content -o /tmp/myfile.pdf
161
+ \`\`\`
162
+ The response preserves \`Content-Type\` and \`Content-Disposition\` from Jira.
163
+
164
+ **Server-side bulk download of every attachment into a directory:**
165
+ \`\`\`bash
166
+ curl -s -X POST http://localhost:5174/api/jira/issues/SD-1234/attachments/download-all \\
167
+ -H "Content-Type: application/json" \\
168
+ -d '{"outputDir": "/tmp/jira/SD-1234", "includeComments": true}'
169
+ \`\`\`
170
+ The server writes files into \`outputDir\` (created if missing), overwriting on filename collision.
171
+ Returns \`{ "outputDir", "count", "attachments": [ { "id", "filename", "mimeType", "size" } ] }\`.
172
+
141
173
  ## Service Desk Requests
142
174
 
143
175
  \`\`\`bash
@@ -3,9 +3,35 @@
3
3
  * Manages Slack Web API + Socket Mode connection, message sending/receiving,
4
4
  * channel/user lookup, and long-poll reply waiting.
5
5
  */
6
+ import { promises as fs } from 'node:fs';
7
+ import path from 'node:path';
6
8
  import { WebClient } from '@slack/web-api';
7
9
  import { SocketModeClient } from '@slack/socket-mode';
8
10
  import { loadConfig, updateConfig } from './slack-config.js';
11
+ // Subtypes we never want to trigger on: edits, deletions, channel housekeeping, bot echoes.
12
+ // NOTE: `file_share` (legacy) and undefined (modern file-share) MUST NOT be in this set.
13
+ const SKIP_MESSAGE_SUBTYPES = new Set([
14
+ 'bot_message',
15
+ 'message_changed',
16
+ 'message_deleted',
17
+ 'message_replied',
18
+ 'channel_join',
19
+ 'channel_leave',
20
+ 'channel_topic',
21
+ 'channel_purpose',
22
+ 'channel_name',
23
+ 'channel_archive',
24
+ 'channel_unarchive',
25
+ 'group_join',
26
+ 'group_leave',
27
+ 'group_topic',
28
+ 'group_purpose',
29
+ 'group_name',
30
+ 'group_archive',
31
+ 'group_unarchive',
32
+ 'pinned_item',
33
+ 'unpinned_item',
34
+ ]);
9
35
  // ─── State ───
10
36
  let webClient = null;
11
37
  let socketClient = null;
@@ -100,7 +126,16 @@ function setupSocketHandlers() {
100
126
  return;
101
127
  socketClient.on('message', async ({ event, ack }) => {
102
128
  await ack();
103
- if (!event || !event.text || event.subtype)
129
+ if (!event)
130
+ return;
131
+ // Drop non-trigger-worthy subtypes (edits, joins, bot echoes, topic changes, etc.).
132
+ // Modern file-share messages have NO subtype; legacy ones use `file_share` — both must pass.
133
+ if (event.subtype && SKIP_MESSAGE_SUBTYPES.has(event.subtype))
134
+ return;
135
+ const hasFiles = Array.isArray(event.files) && event.files.length > 0;
136
+ const text = event.text ?? '';
137
+ // Require either text or attached files — otherwise there's nothing useful to trigger on.
138
+ if (!text && !hasFiles)
104
139
  return;
105
140
  // Skip bot's own messages
106
141
  const config = loadConfig();
@@ -109,14 +144,18 @@ function setupSocketHandlers() {
109
144
  // Resolve username
110
145
  const user = await resolveUser(event.user);
111
146
  const userName = user?.displayName || user?.name || event.user;
147
+ const files = hasFiles
148
+ ? event.files.map((f) => normalizeSlackFile(f))
149
+ : undefined;
112
150
  const message = {
113
151
  ts: event.ts,
114
152
  threadTs: event.thread_ts,
115
153
  channel: event.channel,
116
154
  userId: event.user,
117
155
  userName,
118
- text: event.text,
156
+ text,
119
157
  timestamp: parseSlackTs(event.ts),
158
+ files,
120
159
  };
121
160
  // Log to SQLite
122
161
  ctx?.eventDb.logSlackMessage({
@@ -126,7 +165,7 @@ function setupSocketHandlers() {
126
165
  channelName: channelNameCache.get(event.channel),
127
166
  userId: event.user,
128
167
  userName,
129
- text: event.text,
168
+ text,
130
169
  direction: 'inbound',
131
170
  rawEvent: event,
132
171
  receivedAt: Date.now(),
@@ -134,7 +173,13 @@ function setupSocketHandlers() {
134
173
  // Broadcast to WS clients
135
174
  ctx?.broadcast({
136
175
  type: 'slack_message_received',
137
- payload: { channel: event.channel, userName, text: event.text, ts: event.ts },
176
+ payload: {
177
+ channel: event.channel,
178
+ userName,
179
+ text,
180
+ ts: event.ts,
181
+ fileCount: files?.length ?? 0,
182
+ },
138
183
  });
139
184
  // Notify trigger listeners
140
185
  for (const listener of messageListeners) {
@@ -363,6 +408,148 @@ export async function sendDm(params) {
363
408
  workflowInstanceId: params.workflowInstanceId,
364
409
  });
365
410
  }
411
+ /**
412
+ * Upload a file to Slack via the new two-step flow:
413
+ * 1. files.getUploadURLExternal (filename, length) → { upload_url, file_id }
414
+ * 2. PUT raw bytes to upload_url
415
+ * 3. files.completeUploadExternal (files[], channel_id?, initial_comment?, thread_ts?)
416
+ * Requires the bot token to have the `files:write` scope.
417
+ */
418
+ export async function uploadFile(params) {
419
+ if (!webClient)
420
+ throw new Error('Slack not connected');
421
+ const length = params.bytes instanceof Buffer ? params.bytes.length : params.bytes.byteLength;
422
+ if (!length)
423
+ throw new Error('uploadFile: bytes is empty');
424
+ // Step 1: get an external upload URL
425
+ const step1 = await webClient.files.getUploadURLExternal({
426
+ filename: params.filename,
427
+ length,
428
+ });
429
+ if (!step1.ok || !step1.upload_url || !step1.file_id) {
430
+ throw new Error(`Slack files.getUploadURLExternal failed: ${step1.error ?? 'unknown error'}`);
431
+ }
432
+ // Step 2: POST the raw bytes to the signed upload URL (no Slack auth on this call).
433
+ // Node's global fetch accepts Buffer/Uint8Array at runtime; BodyInit types require a cast.
434
+ const bodyBytes = params.bytes instanceof Buffer
435
+ ? new Uint8Array(params.bytes.buffer, params.bytes.byteOffset, params.bytes.byteLength)
436
+ : params.bytes;
437
+ const putResp = await fetch(step1.upload_url, {
438
+ method: 'POST',
439
+ headers: { 'Content-Type': 'application/octet-stream' },
440
+ body: bodyBytes,
441
+ });
442
+ if (!putResp.ok) {
443
+ const detail = await putResp.text().catch(() => '');
444
+ throw new Error(`Slack upload_url POST failed (${putResp.status}): ${detail}`);
445
+ }
446
+ // Step 3: finalize and (optionally) share
447
+ const files = [
448
+ { id: step1.file_id, title: params.title ?? params.filename },
449
+ ];
450
+ const step3 = params.channelId
451
+ ? await webClient.files.completeUploadExternal({
452
+ files,
453
+ channel_id: params.channelId,
454
+ initial_comment: params.initialComment,
455
+ thread_ts: params.threadTs,
456
+ })
457
+ : await webClient.files.completeUploadExternal({ files });
458
+ if (!step3.ok) {
459
+ throw new Error(`Slack files.completeUploadExternal failed: ${step3.error ?? 'unknown error'}`);
460
+ }
461
+ const file = (step3.files?.[0] ?? { id: step1.file_id });
462
+ return { fileId: step1.file_id, file };
463
+ }
464
+ /**
465
+ * List files visible to the bot, optionally filtered by channel/user/time/type.
466
+ * Requires the bot token to have `files:read`.
467
+ */
468
+ export async function listFiles(params = {}) {
469
+ if (!webClient)
470
+ throw new Error('Slack not connected');
471
+ const result = await webClient.files.list({
472
+ channel: params.channelId,
473
+ user: params.userId,
474
+ ts_from: params.tsFrom,
475
+ ts_to: params.tsTo,
476
+ types: params.types,
477
+ count: params.count ?? 50,
478
+ page: params.page,
479
+ });
480
+ return (result.files ?? []).map((f) => normalizeSlackFile(f));
481
+ }
482
+ /** Fetch metadata for a single file (title, permalink, url_private, mimetype, size, ...). */
483
+ export async function getFileInfo(fileId) {
484
+ if (!webClient)
485
+ throw new Error('Slack not connected');
486
+ const result = await webClient.files.info({ file: fileId });
487
+ if (!result.ok || !result.file) {
488
+ throw new Error(`Slack files.info failed for ${fileId}: ${result.error ?? 'unknown error'}`);
489
+ }
490
+ return normalizeSlackFile(result.file);
491
+ }
492
+ /**
493
+ * Fetch a file's raw bytes using the bot token Bearer auth.
494
+ * Slack's `url_private` redirects to the CDN but requires the bot token on the initial request.
495
+ */
496
+ export async function fetchFileBytes(fileId) {
497
+ const token = ctx?.secrets.get('SLACK_BOT_TOKEN');
498
+ if (!token)
499
+ throw new Error('Slack bot token is not configured');
500
+ const info = await getFileInfo(fileId);
501
+ const url = info.url_private_download || info.url_private;
502
+ if (!url)
503
+ throw new Error(`Slack file ${fileId} has no url_private`);
504
+ const response = await fetch(url, {
505
+ method: 'GET',
506
+ headers: {
507
+ Authorization: `Bearer ${token}`,
508
+ Accept: '*/*',
509
+ },
510
+ redirect: 'follow',
511
+ });
512
+ if (!response.ok) {
513
+ const detail = await response.text().catch(() => '');
514
+ throw new Error(`Slack file download failed for ${fileId} (${response.status}): ${detail}`);
515
+ }
516
+ return {
517
+ buffer: Buffer.from(await response.arrayBuffer()),
518
+ contentType: response.headers.get('content-type'),
519
+ contentDisposition: response.headers.get('content-disposition'),
520
+ contentLength: response.headers.get('content-length'),
521
+ filename: info.name,
522
+ };
523
+ }
524
+ /**
525
+ * Download a Slack file to disk. Accepts a file id or a full {@link SlackFile} object.
526
+ * Parent directory is created if missing; filename collisions overwrite.
527
+ */
528
+ export async function downloadFile(file, outputPath) {
529
+ const fileId = typeof file === 'string' ? file : file.id;
530
+ const { buffer, filename, contentType } = await fetchFileBytes(fileId);
531
+ await fs.mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
532
+ await fs.writeFile(outputPath, buffer);
533
+ return {
534
+ path: outputPath,
535
+ bytes: buffer.byteLength,
536
+ filename,
537
+ mimeType: contentType ?? undefined,
538
+ };
539
+ }
540
+ function normalizeSlackFile(f) {
541
+ return {
542
+ id: f.id,
543
+ name: f.name,
544
+ title: f.title,
545
+ mimetype: f.mimetype,
546
+ size: f.size,
547
+ permalink: f.permalink,
548
+ permalink_public: f.permalink_public,
549
+ url_private: f.url_private,
550
+ url_private_download: f.url_private_download,
551
+ };
552
+ }
366
553
  export async function searchUsers(query) {
367
554
  if (!webClient)
368
555
  throw new Error('Slack not connected');
@@ -428,6 +615,8 @@ async function slackApiMessageToSlackMessage(msg, channel) {
428
615
  catch {
429
616
  // Use userId as fallback
430
617
  }
618
+ const rawFiles = msg.files;
619
+ const files = rawFiles?.length ? rawFiles.map(normalizeSlackFile) : undefined;
431
620
  return {
432
621
  ts: msg.ts,
433
622
  threadTs: msg.thread_ts,
@@ -436,5 +625,6 @@ async function slackApiMessageToSlackMessage(msg, channel) {
436
625
  userName,
437
626
  text: msg.text || '',
438
627
  timestamp: parseSlackTs(msg.ts),
628
+ files,
439
629
  };
440
630
  }