Slack 環境変数の設定手順と 新方式ファイルアップロード(C#) 実装
更新日: 2025-09-01
このドキュメントは次の2点をまとまって提供します。
- 環境変数の設定手順(Windows / macOS / Linux)
-
files.getUploadURLExternal→files.completeUploadExternalを使う 新方式ファイルアップロードの C# 実装(メッセージ送信・Incoming Webhook も同梱)
補足: 旧
files.uploadは 2025-11-12 に完全廃止予定です。新方式への移行を推奨します。
0. 必要な値(おさらい)
-
SLACK_BOT_TOKEN … Bot User OAuth Token(
xoxb-...) -
SLACK_CHANNEL_ID … 送信先チャネルの ID(
C.../ DM はD...) -
SLACK_WEBHOOK_URL … Incoming Webhooks の URL(
https://hooks.slack.com/services/...)
1. 環境変数の設定
Windows(PowerShell)
ユーザー環境変数として永続化
[Environment]::SetEnvironmentVariable("SLACK_BOT_TOKEN","xoxb-***","User")
[Environment]::SetEnvironmentVariable("SLACK_CHANNEL_ID","C0123456789","User")
[Environment]::SetEnvironmentVariable("SLACK_WEBHOOK_URL","https://hooks.slack.com/services/...","User")
一時設定(現在の PowerShell セッションのみ)
$env:SLACK_BOT_TOKEN="xoxb-***"
$env:SLACK_CHANNEL_ID="C0123456789"
$env:SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."
macOS / Linux(bash, zsh)
シェル設定に追記(ログイン時に自動適用)
echo 'export SLACK_BOT_TOKEN="xoxb-***"' >> ~/.bashrc # or ~/.zshrc
echo 'export SLACK_CHANNEL_ID="C0123456789"' >> ~/.bashrc
echo 'export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."' >> ~/.bashrc
source ~/.bashrc
一時設定(現在のシェルのみ)
export SLACK_BOT_TOKEN="xoxb-***"
export SLACK_CHANNEL_ID="C0123456789"
export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."
.NET の開発時は
dotnet user-secretsを使うのも安全で便利です(Webアプリ等で特に推奨)。
2. C# 実装(SlackAPI クラス)
-
メッセージ送信:
chat.postMessage -
新方式ファイル送信:
files.getUploadURLExternal→ バイトPOST →files.completeUploadExternal - Incoming Webhook: 最短で投げる用途に便利
必要スコープの例:
chat:write,files:write(チャネル横断ならchat:write.publicも)
using Microsoft.VisualBasic;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace SlackTool
{
public sealed class SlackAPI : IDisposable
{
private readonly HttpClient _http;
private readonly bool _disposeClient;
private readonly string? _botToken;
private readonly string? _defaultChannel;
private readonly string? _defaultWebhookUrl;
public SlackAPI(string? botToken, string? defaultChannel, string? defaultWebhookUrl)
{
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; // ★ 追加: タイムアウト
_disposeClient = true; // ★ 修正: 自前生成なので true
_botToken = botToken;
_defaultChannel = defaultChannel;
_defaultWebhookUrl = defaultWebhookUrl;
}
// ===== Public API =====
public async Task<SlackResponse> PostMessageAsync(
string? channel,
string text,
object? blocks = null,
string? threadTs = null,
CancellationToken ct = default)
{
channel ??= _defaultChannel ?? throw new ArgumentNullException(nameof(channel),
"channel を指定するか、環境変数 SLACK_CHANNEL_ID を設定してください。");
var token = RequireToken();
return await SendWithRetryAsync(() =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/chat.postMessage");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var body = new Dictionary<string, object?>
{
["channel"] = channel,
["text"] = text
};
if (blocks is not null) body["blocks"] = blocks;
if (threadTs is not null) body["thread_ts"] = threadTs;
req.Content = JsonContent(body);
return req;
}, ct).ConfigureAwait(false); // ★ 追加
}
public async Task<SlackResponse> UploadFileAsync(
string? channel,
string filePath,
string? initialComment = null,
string? threadTs = null,
string? title = null,
CancellationToken ct = default)
{
channel ??= _defaultChannel ?? throw new ArgumentNullException(nameof(channel),
"channel を指定するか、環境変数 SLACK_CHANNEL_ID を設定してください。");
if (!File.Exists(filePath)) throw new FileNotFoundException("File not found", filePath);
var token = RequireToken();
return await SendWithRetryAsync(() =>
{
var req = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/files.upload");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var form = new MultipartFormDataContent();
form.Add(new StringContent(channel), "channels");
if (initialComment is not null) form.Add(new StringContent(initialComment), "initial_comment");
if (threadTs is not null) form.Add(new StringContent(threadTs), "thread_ts");
if (title is not null) form.Add(new StringContent(title), "title");
// 各リトライで新しい StreamContent を作る(再送で失敗しないように)
var stream = File.OpenRead(filePath);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, "file", Path.GetFileName(filePath));
req.Content = form;
return req;
}, ct).ConfigureAwait(false); // ★ 追加
}
public async Task<HttpResponseMessage> PostWebhookAsync(
string? webhookUrl,
string text,
object? blocks = null,
CancellationToken ct = default)
{
webhookUrl ??= _defaultWebhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl),
"webhookUrl を指定するか、環境変数 SLACK_WEBHOOK_URL を設定してください。");
using var req = new HttpRequestMessage(HttpMethod.Post, webhookUrl);
var body = new Dictionary<string, object?> { ["text"] = text };
if (blocks is not null) body["blocks"] = blocks;
req.Content = JsonContent(body);
return await _http.SendAsync(req, ct).ConfigureAwait(false); // ★ 追加
}
// ===== 内部共通処理 =====
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private static StringContent JsonContent(object body) =>
new StringContent(JsonSerializer.Serialize(body, JsonOptions), Encoding.UTF8, "application/json");
private string RequireToken() =>
_botToken ?? throw new InvalidOperationException("Slack Bot Token がありません。SLACK_BOT_TOKEN を設定するかコンストラクタ引数で渡してください。");
private async Task<SlackResponse> SendWithRetryAsync(
Func<HttpRequestMessage> requestFactory,
CancellationToken ct)
{
const int maxAttempts = 5;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
using var req = requestFactory();
var res = await _http.SendAsync(req, ct).ConfigureAwait(false); // ★ 追加
if ((int)res.StatusCode == 429)
{
var wait = res.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(Math.Min(60, 2 + (int)Math.Pow(2, attempt)));
await Task.Delay(wait, ct).ConfigureAwait(false); // ★ 追加
continue;
}
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // ★ 追加
var ok = false;
string? error = null;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
ok = root.TryGetProperty("ok", out var okEl) && okEl.GetBoolean();
error = root.TryGetProperty("error", out var errEl) ? errEl.GetString() : null;
} catch
{
return new SlackResponse(true, null, json);
}
return new SlackResponse(ok, error, json);
}
throw new Exception("Slack API: レート制限により最大試行回数を超えました。");
}
public async Task<SlackResponse> UploadFileV2Async(
string? channel,
string filePath,
string? title = null,
string? initialComment = null,
CancellationToken ct = default)
{
channel ??= _defaultChannel ?? throw new ArgumentNullException(nameof(channel),
"channel を指定するか、環境変数 SLACK_CHANNEL_ID を設定してください。");
if (!File.Exists(filePath)) throw new FileNotFoundException("File not found", filePath);
var token = RequireToken();
var fileName = Path.GetFileName(filePath);
var length = new FileInfo(filePath).Length;
// Step 1: getUploadURLExternal
using var req1 = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/files.getUploadURLExternal");
req1.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req1.Content = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string,string>("filename", fileName),
new KeyValuePair<string,string>("length", length.ToString())
});
var r1 = await _http.SendAsync(req1, ct).ConfigureAwait(false); // ★ 追加
r1.EnsureSuccessStatusCode();
var s1 = await r1.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // ★ 追加
using var d1 = JsonDocument.Parse(s1);
var root = d1.RootElement;
if (!root.TryGetProperty("ok", out var okEl) || !okEl.GetBoolean())
{
var err = root.TryGetProperty("error", out var e) ? e.GetString() : "unknown_error";
throw new InvalidOperationException($"files.getUploadURLExternal failed: {err}");
}
var uploadUrl = root.GetProperty("upload_url").GetString()!;
var fileId = root.GetProperty("file_id").GetString()!;
// Step 2: upload bytes
using (var fs = File.OpenRead(filePath))
using (var content = new StreamContent(fs))
{
content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
var r2 = await _http.PostAsync(uploadUrl, content, ct).ConfigureAwait(false); // ★ 追加
r2.EnsureSuccessStatusCode();
}
// Step 3: complete
return await SendWithRetryAsync(() =>
{
var req3 = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/files.completeUploadExternal");
req3.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var files = new[] { new Dictionary<string, string?> { ["id"] = fileId, ["title"] = title ?? fileName } };
var body3 = new Dictionary<string, object?>
{
["files"] = files,
["channel_id"] = channel
};
if (!string.IsNullOrWhiteSpace(initialComment))
body3["initial_comment"] = initialComment;
req3.Content = JsonContent(body3);
return req3;
}, ct).ConfigureAwait(false); // ★ 追加
}
public void Dispose()
{
if (_disposeClient) _http.Dispose();
}
public record SlackResponse(bool Ok, string? Error, string RawJson);
// ===== 同期で呼びたい場合のラッパー(UIスレッドからの使用はブロックに注意) =====
public void SlackMessage_Send(string moji)
{
var text = moji;
if (string.IsNullOrWhiteSpace(text)) return;
try
{
var res = PostMessageAsync(channel: null, text: text).GetAwaiter().GetResult();
// 必要なら res.Ok など確認してメッセージ表示
} catch (Exception ex)
{
// UI ライブラリに依存しないようにするなら例外を上に投げるでもOK
System.Windows.Forms.MessageBox.Show($"送信中にエラー: {ex.Message}", "例外",
System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error);
}
}
public void SlackImage_Send(string path)
{
if (!File.Exists(path)) return;
try
{
var title = Path.GetFileName(path);
var res = UploadFileV2Async(
channel: null,
filePath: path,
title: title,
initialComment: "侵入者発見!!"
).GetAwaiter().GetResult();
} catch (Exception ex)
{
System.Windows.Forms.MessageBox.Show($"送信中にエラー: {ex.Message}", "例外",
System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Error);
}
}
}
}
使い方(例)
using System;
using System.Drawing;
using System.Windows.Forms;
using EnvAPI;
using SlackTool;
namespace BoobyTrap1
{
public partial class Form1 : Form
{
private SlackAPI? _slack;
private string SLACK_BOT_TOKEN = "xoxb-XXXXXXXXXXXXXXXXX";
private string SLACK_CHANNEL_ID = "XXXXXXXX";
private string SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXXXXXXXXXXX/XXXXXXXXXX";
public Form1(){
_slack = new SlackAPI(botToken: SLACK_BOT_TOKEN,
defaultChannel: SLACK_CHANNEL_ID,
defaultWebhookUrl: SLACK_WEBHOOK_URL);
}
// ★ async 化
private async void OkButton_Click(object sender, EventArgs e)
{
if (_slack != null)
{
_slack.SlackMessage_Send("侵入者発見");
}
}
}
}
3. トラブルシュート
-
channel_not_found→ チャネル ID 誤り / Bot 未参加。ID を再確認し、必要なら/invite @Bot名。 -
missing_scope/not_authed→ スコープ不足 / トークン不正。chat:writeとfiles:writeを付与 → 再インストール。 - 新方式で DM に直接共有したい場合は
channel_idが DM 形式(D...)である必要があります。 -
files.completeUploadExternalを 呼ばないとアップロードは破棄されます。 -
chat:write.publicが無い Bot は未参加の公開チャネルへは投稿できません。
4. 参考(公式)
- Working with files(新方式の全体像)
https://api.slack.com/messaging/files -
files.getUploadURLExternal/files.completeUploadExternal
https://api.slack.com/methods/files.getUploadURLExternal
https://api.slack.com/methods/files.completeUploadExternal -
files.uploadの廃止日(2025-11-12 にサンセット)
https://api.slack.com/changelog/2024-04-a-better-way-to-upload-files-is-here-to-stay - Incoming Webhooks
https://api.slack.com/messaging/webhooks - 会話の取得(
conversations.listでチャネルID検索)
https://api.slack.com/methods/conversations.list