1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

非同期処理とデッドロックについて

Posted at

デッドロックの原因になったファイル

SlackAPI.cs
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
{
    /// <summary>
    /// C# から Slack へ送信する軽量ヘルパー
    /// - chat.postMessage(メッセージ送信)
    /// - files.upload     (ファイル送信)
    /// - Incoming Webhook(最短で試せる)
    ///
    /// 既定は環境変数を参照:
    ///   SLACK_BOT_TOKEN   : xoxb-...(Bot Token)
    ///   SLACK_CHANNEL_ID  : 例 "C0123456789"
    ///   SLACK_WEBHOOK_URL : Incoming Webhook のURL
    /// </summary>
    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();
            _disposeClient = _http is null;

            _botToken = botToken;
            _defaultChannel = defaultChannel;
            _defaultWebhookUrl = defaultWebhookUrl;
        }

        /// <summary>
        /// chat.postMessage でメッセージ送信
        /// </summary>
        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);
        }

        /// <summary>
        /// files.upload でファイル送信(channels へ共有)
        /// </summary>
        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);
        }

        /// <summary>
        /// Incoming Webhook でシンプル送信
        /// </summary>
        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);
        }

        // ===== 内部共通処理 =====

        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 を設定するかコンストラクタ引数で渡してください。");

        /// <summary>
        /// 429 レート制限時に Retry-After を待ってリトライ。
        /// 各試行で新しい HttpRequestMessage を生成する factory を受け取る。
        /// </summary>
        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);

                // 429: 待ってから再試行
                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);
                    continue;
                }

                // 通常の HTTP エラーは例外
                res.EnsureSuccessStatusCode();

                var json = await res.Content.ReadAsStringAsync(ct);
                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
                {
                    // Slack っぽい JSON でなければそのまま返す(Webhookなど)
                    return new SlackResponse(true, null, json);
                }

                return new SlackResponse(ok, error, json);
            }

            throw new Exception("Slack API: レート制限により最大試行回数を超えました。");
        }

        // SlackTool.SlackAPI クラス内に追記
        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: 署名付き upload_url と file_id を取得 ---
            using var req1 = new HttpRequestMessage(HttpMethod.Post, "https://slack.com/api/files.getUploadURLExternal");
            req1.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
            // ※ getUploadURLExternal は application/x-www-form-urlencoded が安定
            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);
            r1.EnsureSuccessStatusCode();
            var s1 = await r1.Content.ReadAsStringAsync(ct);

            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_url へ生バイトをPOST(認証ヘッダ不要) ---
            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);
                r2.EnsureSuccessStatusCode(); // 200 以外は失敗
            }

            // --- 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);
        }


        public void Dispose()
        {
            if (_disposeClient) _http.Dispose();
        }

        /// <summary>
        /// Slack Web API の戻り(ok/error と生JSON)
        /// </summary>
        public record SlackResponse(bool Ok, string? Error, string RawJson);

        public void SlackMessage_Send(string moji)
        {
            var text = moji;//Interaction.InputBox("送信するテキストを入力してください:", "Slack にメッセージ送信", "テスト投稿");
            if (string.IsNullOrWhiteSpace(text)) return;

            try
            {
                // 非同期を同期呼び出しに変更
                var res = PostMessageAsync(channel: null, text: text).GetAwaiter().GetResult();

            } catch (Exception ex)
            {
                MessageBox.Show($"送信中にエラー: {ex.Message}", "例外", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

        // 画像送信ボタン(新方式ファイルアップロード)
        public void SlackImage_Send(string path)
        {
            if (!File.Exists(path))
            {
                //MessageBox.Show("ファイルが見つかりません。", "エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
                return;
            }

            try
            {
                var title = Path.GetFileName(path);

                // 非同期を同期呼び出しに変更
                var res = UploadFileV2Async(
                    channel: null,   // 省略で .env の SLACK_CHANNEL_ID
                    filePath: path,
                    title: title,
                    initialComment: "画像を共有します"
                ).GetAwaiter().GetResult();

            } catch (Exception ex)
            {
                MessageBox.Show($"送信中にエラー: {ex.Message}", "例外", MessageBoxButtons.OK, MessageBoxIcon.Error);
            }
        }

    }
}

修正方法:

ライブラリ側で ConfigureAwait(false) を付けて同期呼び出しを許容

SlackAPI.cs
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);
            }
        }
    }
}
1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?