0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Slack API の使い方(C#)

Posted at

Slack 環境変数の設定手順と 新方式ファイルアップロード(C#) 実装

更新日: 2025-09-01

このドキュメントは次の2点をまとまって提供します。

  1. 環境変数の設定手順(Windows / macOS / Linux)
  2. files.getUploadURLExternalfiles.completeUploadExternal を使う 新方式ファイルアップロードの C# 実装(メッセージ送信・Incoming Webhook も同梱)

補足: 旧 files.upload2025-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:writefiles:write を付与 → 再インストール。
  • 新方式で DM に直接共有したい場合は channel_id が DM 形式(D...)である必要があります。
  • files.completeUploadExternal呼ばないとアップロードは破棄されます。
  • chat:write.public が無い Bot は未参加の公開チャネルへは投稿できません。

4. 参考(公式)


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?