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.
- package/dist/assets/{BossLogsModal-Cc26KWtF.js → BossLogsModal-BpNtzCTX.js} +1 -1
- package/dist/assets/{BossSpawnModal-79zixDGu.js → BossSpawnModal-ByiEkNZl.js} +1 -1
- package/dist/assets/{ControlsModal-sIxaSPTd.js → ControlsModal-BgeLGCGT.js} +1 -1
- package/dist/assets/{DockerLogsModal-RAIqQDHy.js → DockerLogsModal-CaK9YJT4.js} +1 -1
- package/dist/assets/{EmbeddedEditor-SYtH65mY.js → EmbeddedEditor-DcGJCiuP.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-DK2GlEv1.js → GmailOAuthSetup-CYIBkOXn.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-CprVeTSW.js → GoogleOAuthSetup-CY_EF39t.js} +1 -1
- package/dist/assets/{IframeModal-pzXWPx2i.js → IframeModal-DDFIBCq1.js} +1 -1
- package/dist/assets/{IntegrationsPanel-BzPNGPs-.js → IntegrationsPanel-CssC1pup.js} +2 -2
- package/dist/assets/{LogViewerModal-oqzbJ5iy.js → LogViewerModal-C_f0Z-2y.js} +1 -1
- package/dist/assets/{MonitoringModal-DadiFtmu.js → MonitoringModal-CkOkdUvI.js} +1 -1
- package/dist/assets/{PM2LogsModal-C4hQRREX.js → PM2LogsModal-BtqM8TkL.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-fHOJ9ZwM.js → RestoreArchivedAreaModal-CIqWIO9m.js} +1 -1
- package/dist/assets/{Scene2DCanvas-BeHXBqTf.js → Scene2DCanvas-CJ_MZU-J.js} +1 -1
- package/dist/assets/{SceneManager-B6E_cAYg.js → SceneManager-DyX0JqRQ.js} +1 -1
- package/dist/assets/{SkillsPanel-B3-ni6t4.js → SkillsPanel-DR7WNV5l.js} +1 -1
- package/dist/assets/{SpawnModal-00Lv0ZQy.js → SpawnModal-BCJDoSQg.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DVgwAkbS.js → SubordinateAssignmentModal-DAlLWBtv.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-GUPU3sup.js → TriggerManagerPanel-ChBctjk3.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-CP26YA9V.js → WorkflowEditorPanel-Bk-QD7-7.js} +1 -1
- package/dist/assets/{index-alq8vlxv.js → index-COaYNxnC.js} +1 -1
- package/dist/assets/{index-DwJ7OcGh.js → index-CVIkvsfJ.js} +1 -1
- package/dist/assets/{index-B9SzB9ov.js → index-DcxPBYWc.js} +1 -1
- package/dist/assets/{index-BS2yVyno.js → index-DoOLLVEG.js} +2 -2
- package/dist/assets/{index-DLkUpBdu.js → index-DwTs55lG.js} +1 -1
- package/dist/assets/{index-C7PPAB37.js → index-Dwt45QE5.js} +1 -1
- package/dist/assets/{index-D8tCoR93.js → index-M-tYhxQp.js} +3 -3
- package/dist/assets/{index-DKziSiqq.js → index-vx9STDIr.js} +1 -1
- package/dist/assets/{main-DxFgIntP.js → main-5l14ueeE.js} +4 -4
- package/dist/assets/{web-BGvcteqx.js → web-BYu5GvMD.js} +1 -1
- package/dist/assets/{web-DBp9A4mr.js → web-BsMMNK0E.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/src/packages/server/integrations/jira/jira-client.js +155 -0
- package/dist/src/packages/server/integrations/jira/jira-routes.js +73 -0
- package/dist/src/packages/server/integrations/jira/jira-skill.js +32 -0
- package/dist/src/packages/server/integrations/slack/slack-client.js +194 -4
- package/dist/src/packages/server/integrations/slack/slack-routes.js +131 -0
- package/dist/src/packages/server/integrations/slack/slack-skill.js +74 -0
- package/dist/src/packages/server/integrations/slack/slack-trigger-handler.js +9 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
import{bH as a}from"./main-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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: {
|
|
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
|
}
|