Skip to content

Commit 7f1bc5b

Browse files
committed
Draft of file upload
1 parent 66f1ec6 commit 7f1bc5b

10 files changed

Lines changed: 269 additions & 4 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: test-15-slack-upload-file
2+
3+
on: [push]
4+
5+
permissions: {}
6+
7+
jobs:
8+
slack-action:
9+
#if: ${{ false }}
10+
runs-on: ubuntu-latest
11+
name: Test 15 [ubuntu-latest]
12+
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Upload File to Slack
18+
uses: archive/github-actions-slack@upload-files
19+
id: upload-file
20+
with:
21+
slack-function: upload-file
22+
slack-bot-user-oauth-access-token: ${{ secrets.SLACK_BOT_USER_OAUTH_ACCESS_TOKEN }}
23+
slack-channel: C0ARB84BQC9
24+
slack-upload-file-path: README.md
25+
slack-upload-file-title: Test 15 - README upload
26+
slack-upload-initial-comment: Test 15 - uploading a file 📎
27+
28+
- name: Result from "Upload File"
29+
run: echo '${{ steps.upload-file.outputs.slack-result }}'

action.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,19 @@ inputs:
5656
description: "https://api.slack.com/methods/chat.postMessage#arg_username"
5757
required: false
5858
slack-function:
59-
description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update)
59+
description: send-message (https://api.slack.com/methods/chat.postMessage) or send-reaction (https://api.slack.com/methods/reactions.add) or update-message (https://api.slack.com/methods/chat.update) or upload-file (https://api.slack.com/methods/files.upload)
60+
required: false
61+
slack-upload-file-path:
62+
description: "https://api.slack.com/methods/files.upload#arg_file"
63+
required: false
64+
slack-upload-filename:
65+
description: "https://api.slack.com/methods/files.upload#arg_filename"
66+
required: false
67+
slack-upload-file-title:
68+
description: "https://api.slack.com/methods/files.upload#arg_title"
69+
required: false
70+
slack-upload-initial-comment:
71+
description: "https://api.slack.com/methods/files.upload#arg_initial_comment"
6072
required: false
6173
slack-emoji-name:
6274
description: "https://api.slack.com/methods/reactions.add#arg_name"

integration-test/end-to-end.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
apiPostMessage,
55
apiAddReaction,
66
apiUpdateMessage,
7+
apiUploadFile,
78
} from "../src/integration/slack-api.js";
89
import buildMessage from "../src/message/build-message.js";
910
import buildReaction from "../src/reaction/build-reaction.js";
@@ -103,4 +104,16 @@ const testUpdateMessage = async (channel, token) => {
103104
process.env.CHANNEL,
104105
process.env.BOT_USER_OAUTH_ACCESS_TOKEN
105106
);
107+
108+
const uploadResult = await apiUploadFile(
109+
process.env.BOT_USER_OAUTH_ACCESS_TOKEN,
110+
{
111+
channel: process.env.CHANNEL,
112+
filePath: "integration-test/one-does-not-simply.jpg",
113+
filename: "one-does-not-simply.jpg",
114+
title: "Test 5 - Upload File",
115+
initialComment: "Test 5 - testUploadFile 📎",
116+
}
117+
);
118+
assert.strictEqual(uploadResult.statusCode, 200);
106119
})();
60 KB
Loading

src/integration/slack-api-post.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,101 @@ const post = (token, path, message) => {
6464
});
6565
};
6666

67-
export { post };
67+
// Used for Slack methods that require application/x-www-form-urlencoded (e.g. files.getUploadURLExternal)
68+
const postForm = (token, path, fields) => {
69+
return new Promise((resolve, reject) => {
70+
const payload = new URLSearchParams(fields).toString();
71+
72+
context.debugExtra("SLACK POST FORM PAYLOAD", payload);
73+
74+
const options = {
75+
hostname: "slack.com",
76+
port: 443,
77+
path: path,
78+
method: "POST",
79+
headers: {
80+
"Content-Type": "application/x-www-form-urlencoded",
81+
"Content-Length": Buffer.byteLength(payload),
82+
Authorization: `Bearer ${token}`,
83+
},
84+
};
85+
86+
const req = https.request(options, (res) => {
87+
const chunks = [];
88+
89+
res.on("data", (chunk) => {
90+
chunks.push(chunk);
91+
});
92+
93+
res.on("end", () => {
94+
const result = Buffer.concat(chunks).toString();
95+
const response = JSON.parse(result);
96+
97+
let isOk = res.statusCode >= 200 && res.statusCode <= 299;
98+
99+
if (response && response.hasOwnProperty("ok") && response.ok === false) {
100+
isOk = false;
101+
}
102+
103+
resolve({
104+
statusCode: res.statusCode,
105+
statusMessage: res.statusMessage,
106+
ok: isOk,
107+
result: result,
108+
response: response,
109+
});
110+
});
111+
});
112+
113+
req.on("error", (error) => {
114+
reject(error);
115+
});
116+
117+
req.write(payload);
118+
req.end();
119+
});
120+
};
121+
122+
// Used for step 2 of the files.getUploadURLExternal flow — posts raw binary
123+
// to the pre-signed upload URL returned by Slack (may be a different hostname).
124+
const postBinary = (uploadUrl, fileContent) => {
125+
return new Promise((resolve, reject) => {
126+
const url = new URL(uploadUrl);
127+
const options = {
128+
hostname: url.hostname,
129+
port: 443,
130+
path: url.pathname + url.search,
131+
method: "POST",
132+
headers: {
133+
"Content-Type": "application/octet-stream",
134+
"Content-Length": fileContent.length,
135+
},
136+
};
137+
138+
context.debug("SLACK UPLOAD BINARY to " + url.hostname + url.pathname);
139+
140+
const req = https.request(options, (res) => {
141+
const chunks = [];
142+
143+
res.on("data", (chunk) => {
144+
chunks.push(chunk);
145+
});
146+
147+
res.on("end", () => {
148+
resolve({
149+
statusCode: res.statusCode,
150+
ok: res.statusCode >= 200 && res.statusCode <= 299,
151+
});
152+
});
153+
});
154+
155+
req.on("error", (error) => {
156+
reject(error);
157+
});
158+
159+
req.write(fileContent);
160+
req.end();
161+
});
162+
};
163+
164+
export { post, postForm, postBinary };

src/integration/slack-api.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { post } from "./slack-api-post.js";
1+
import fs from "fs";
2+
import path from "path";
3+
import { post, postForm, postBinary } from "./slack-api-post.js";
24

35
const hasErrors = (res) => !res || !res.ok;
46

@@ -39,4 +41,46 @@ const apiUpdateMessage = async (token, message) => {
3941
return res;
4042
};
4143

42-
export { apiPostMessage, apiAddReaction, apiUpdateMessage };
44+
const apiUploadFile = async (token, payload) => {
45+
const fileContent = fs.readFileSync(payload.filePath);
46+
const filename = payload.filename || path.basename(payload.filePath);
47+
48+
// Step 1: Get upload URL and file ID (requires form encoding, not JSON)
49+
const urlRes = await postForm(token, "/api/files.getUploadURLExternal", {
50+
filename,
51+
length: fileContent.length,
52+
});
53+
if (hasErrors(urlRes)) {
54+
throw buildErrorMessage(urlRes);
55+
}
56+
57+
const { upload_url, file_id } = urlRes.response;
58+
59+
// Step 2: Upload the file binary to the pre-signed URL
60+
const uploadRes = await postBinary(upload_url, fileContent);
61+
if (!uploadRes.ok) {
62+
throw `Error uploading file content (status ${uploadRes.statusCode})`;
63+
}
64+
65+
// Step 3: Complete the upload and share to channel
66+
const completePayload = {
67+
files: [{ id: file_id, title: payload.title || filename }],
68+
channel_id: payload.channel,
69+
};
70+
if (payload.initialComment) {
71+
completePayload.initial_comment = payload.initialComment;
72+
}
73+
74+
const result = await post(
75+
token,
76+
"/api/files.completeUploadExternal",
77+
completePayload
78+
);
79+
if (hasErrors(result)) {
80+
throw buildErrorMessage(result);
81+
}
82+
83+
return result;
84+
};
85+
86+
export { apiPostMessage, apiAddReaction, apiUpdateMessage, apiUploadFile };

src/integration/slack-api.test.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ describe("slack api", () => {
1212
post: function (token, path, message) {
1313
return errorResponse;
1414
},
15+
postForm: function (token, path, fields) {
16+
return errorResponse;
17+
},
18+
postBinary: function (uploadUrl, fileContent) {
19+
return errorResponse;
20+
},
1521
}));
1622

1723
const slackApi = await import("./slack-api.js");

src/invoke.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as context from "./context.js";
22
import { postMessage } from "./message/index.js";
33
import { addReaction } from "./reaction/index.js";
44
import { updateMessage } from "./update-message/index.js";
5+
import { uploadFile } from "./upload-file/index.js";
56

67
const jsonPretty = (data) => JSON.stringify(data, undefined, 2);
78

@@ -19,6 +20,9 @@ const invoke = async () => {
1920
case "update-message":
2021
await updateMessage();
2122
break;
23+
case "upload-file":
24+
await uploadFile();
25+
break;
2226
default:
2327
context.setFailed("Unhandled `slack-function`: " + func);
2428
break;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
const buildUploadFile = (
2+
channel = "",
3+
filePath = "",
4+
filename = "",
5+
title = "",
6+
initialComment = ""
7+
) => {
8+
if (!channel) {
9+
throw new Error("Channel must be set");
10+
}
11+
12+
if (!filePath) {
13+
throw new Error("File path must be set");
14+
}
15+
16+
return {
17+
channel,
18+
filePath,
19+
filename,
20+
title,
21+
initialComment,
22+
};
23+
};
24+
25+
export default buildUploadFile;

src/upload-file/index.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as context from "../context.js";
2+
import { apiUploadFile } from "../integration/slack-api.js";
3+
import buildUploadFile from "./build-upload-file.js";
4+
5+
const jsonPretty = (data) => JSON.stringify(data, undefined, 2);
6+
7+
const uploadFile = async () => {
8+
try {
9+
const token = context.getRequired("slack-bot-user-oauth-access-token");
10+
const channel = context.getRequired("slack-channel");
11+
const filePath = context.getRequired("slack-upload-file-path");
12+
const filename = context.getOptional("slack-upload-filename");
13+
const title = context.getOptional("slack-upload-file-title");
14+
const initialComment = context.getOptional("slack-upload-initial-comment");
15+
16+
const payload = buildUploadFile(
17+
channel,
18+
filePath,
19+
filename,
20+
title,
21+
initialComment
22+
);
23+
24+
context.debugExtra("Upload File PAYLOAD", payload);
25+
const result = await apiUploadFile(token, payload);
26+
context.debugExtra("Upload File RESULT", result);
27+
28+
const resultAsJson = jsonPretty(result);
29+
context.setOutput("slack-result", resultAsJson);
30+
} catch (error) {
31+
context.setFailed(jsonPretty(error));
32+
}
33+
};
34+
35+
export { uploadFile };

0 commit comments

Comments
 (0)