デッドロックの原因になったファイル
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);
}
}
}
}