diff --git a/SlackNet.Tests/AgentSessionStoppedTests.cs b/SlackNet.Tests/AgentSessionStoppedTests.cs new file mode 100644 index 00000000..ece16696 --- /dev/null +++ b/SlackNet.Tests/AgentSessionStoppedTests.cs @@ -0,0 +1,49 @@ +using EasyAssertions; +using Newtonsoft.Json; +using NUnit.Framework; +using SlackNet.Events; + +namespace SlackNet.Tests; + +public class AgentSessionStoppedTests +{ + private JsonSerializerSettings _sut; + + [SetUp] + public void SetUp() + { + _sut = Default.JsonSettings(Default.SlackTypeResolver(Default.AssembliesContainingSlackTypes)).SerializerSettings; + } + + [Test] + public void AgentSessionStopped_DeserializedFromSlackPayload() + { + var result = Deserialize(@"{ ""channel"": ""C0123ABC456"", ""event_ts"": ""1783536983.783769"", ""streaming_message_ts"": [""1782234987.693923""], ""thread_ts"": ""1782234671.392669"", ""type"": ""agent_session_stopped"", ""user"": ""U123ABC456"" }"); + + var stopped = result.ShouldBeA().And; + stopped.Channel.ShouldBe("C0123ABC456"); + stopped.ThreadTs.ShouldBe("1782234671.392669"); + stopped.User.ShouldBe("U123ABC456"); + stopped.StreamingMessageTs.ShouldMatch(["1782234987.693923"]); + stopped.EventTs.ShouldBe("1783536983.783769"); + stopped.ExtraProperties.ShouldBeEmpty(); + } + + [Test] + public void AgentSessionStopped_NoActiveStream_HasEmptyStreamingMessageTs() + { + var result = Deserialize(@"{ ""type"": ""agent_session_stopped"", ""channel"": ""C1"", ""thread_ts"": ""1.2"", ""user"": ""U1"", ""streaming_message_ts"": [] }"); + + result.ShouldBeA().And.StreamingMessageTs.ShouldBeEmpty(); + } + + [Test] + public void AgentSessionStopped_InSessionChannel_MayHaveNoThread() + { + var result = Deserialize(@"{ ""type"": ""agent_session_stopped"", ""channel"": ""C1"", ""user"": ""U1"", ""streaming_message_ts"": [] }"); + + result.ShouldBeA().And.ThreadTs.ShouldBeNull(); + } + + private T Deserialize(string json) => JsonConvert.DeserializeObject(json, _sut); +} \ No newline at end of file diff --git a/SlackNet.Tests/AgentsSessionsApiTests.cs b/SlackNet.Tests/AgentsSessionsApiTests.cs new file mode 100644 index 00000000..34196377 --- /dev/null +++ b/SlackNet.Tests/AgentsSessionsApiTests.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using EasyAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SlackNet.WebApi; + +namespace SlackNet.Tests; + +public class AgentsSessionsApiTests +{ + private TestHttp _http; + private ISlackApiClient _sut; + + [SetUp] + public void SetUp() + { + var jsonSettings = Default.JsonSettings(Default.SlackTypeResolver(Default.AssembliesContainingSlackTypes)); + _http = new TestHttp(); + _sut = new SlackApiClient(_http, Default.UrlBuilder(jsonSettings), jsonSettings, "token"); + } + + [Test] + public async Task SetStatus_SendsSessionArguments() + { + await _sut.AgentsSessions.SetStatus("C123", "1717171717.123456", AgentSessionStatus.Processing, + title: "Scuba diving research", initiatorUserId: "U123", iconEmoji: ":robot_face:", username: "Custom Agent Name"); + + _http.Method.ShouldBe("agents.sessions.setStatus"); + _http.Body.ShouldMatchJson(@"{ ""channel_id"": ""C123"", ""thread_ts"": ""1717171717.123456"", ""status"": ""processing"", ""title"": ""Scuba diving research"", ""initiator_user_id"": ""U123"", ""icon_emoji"": "":robot_face:"", ""username"": ""Custom Agent Name"" }"); + } + + [TestCase(AgentSessionStatus.Active, "active")] + [TestCase(AgentSessionStatus.Processing, "processing")] + [TestCase(AgentSessionStatus.Suspended, "suspended")] + [TestCase(AgentSessionStatus.Closed, "closed")] + public async Task SetStatus_SendsStatusInSlackCase(AgentSessionStatus status, string expected) + { + await _sut.AgentsSessions.SetStatus("C123", "1717171717.123456", status); + + _http.Body.ShouldMatchJson(@"{ ""channel_id"": ""C123"", ""thread_ts"": ""1717171717.123456"", ""status"": """ + expected + @""" }"); + } + + [Test] + public async Task SetStatus_InSessionChannel_OmitsThreadTs() + { + await _sut.AgentsSessions.SetStatus("C123", null, AgentSessionStatus.Active); + + _http.Body.ShouldMatchJson(@"{ ""channel_id"": ""C123"", ""status"": ""active"" }"); + } + + [Test] + public async Task SetStatus_ReadsSessionStatusAndAgentStatus() + { + _http.Response = JObject.Parse(@"{ ""ok"": true, ""status"": ""processing"", ""agent_status"": ""active"", ""title"": ""Scuba diving research"" }"); + + var result = await _sut.AgentsSessions.SetStatus("C123", "1717171717.123456", AgentSessionStatus.Active); + + result.Status.ShouldBe(AgentSessionStatus.Processing); + result.AgentStatus.ShouldBe(AgentSessionStatus.Active); + result.Title.ShouldBe("Scuba diving research"); + } +} \ No newline at end of file diff --git a/SlackNet.Tests/ApiLintTests.cs b/SlackNet.Tests/ApiLintTests.cs index fd4584af..708a6d44 100644 --- a/SlackNet.Tests/ApiLintTests.cs +++ b/SlackNet.Tests/ApiLintTests.cs @@ -231,6 +231,10 @@ private static object DummyValue(ParameterInfo param) => ArgumentFactories.TryGe { typeof(UnfurlMetadata), _ => null }, { typeof(WorkObjectEntity), _ => null }, { typeof(EntityError), _ => null }, + { typeof(IEnumerable), _ => Enumerable.Empty() }, + { typeof(AgentSessionStatus), _ => AgentSessionStatus.Active }, + { typeof(AgentSessionStatus?), _ => null }, + { typeof(TaskDisplayMode?), _ => null }, { typeof(CancellationToken), _ => null }, }; @@ -293,6 +297,7 @@ public Task Post(string apiMethod, Args args, HttpContent content, Cancell public ISlackApiClient WithAccessToken(string accessToken) => this; + public IAgentsSessionsApi AgentsSessions { get; } public IApiApi Api { get; } public IAppsConnectionsApi AppsConnectionsApi { get; } public IAppsEventAuthorizationsApi AppsEventAuthorizations { get; } diff --git a/SlackNet.Tests/BlockLintTests.cs b/SlackNet.Tests/BlockLintTests.cs index 9e66326f..cab373fd 100644 --- a/SlackNet.Tests/BlockLintTests.cs +++ b/SlackNet.Tests/BlockLintTests.cs @@ -25,7 +25,8 @@ public void AllBlockElementsHaveACorrespondingElementValue() static readonly HashSet ExcludedBlockElements = [ "button", - "image" + "image", + "url" ]; [Test] diff --git a/SlackNet.Tests/Configuration/FactorySlackHandlerConfigurationTests.cs b/SlackNet.Tests/Configuration/FactorySlackHandlerConfigurationTests.cs index 89ef746b..94327e94 100644 --- a/SlackNet.Tests/Configuration/FactorySlackHandlerConfigurationTests.cs +++ b/SlackNet.Tests/Configuration/FactorySlackHandlerConfigurationTests.cs @@ -878,6 +878,7 @@ protected class TestHandlerFactory : ISlackHandlerFactory protected class TestApiClient : ISlackApiClient { + public IAgentsSessionsApi AgentsSessions { get; } public IApiApi Api { get; } public IAppsConnectionsApi AppsConnectionsApi { get; } public IAppsEventAuthorizationsApi AppsEventAuthorizations { get; } diff --git a/SlackNet.Tests/StreamingApiTests.cs b/SlackNet.Tests/StreamingApiTests.cs new file mode 100644 index 00000000..19a5b2e8 --- /dev/null +++ b/SlackNet.Tests/StreamingApiTests.cs @@ -0,0 +1,157 @@ +using System.Threading.Tasks; +using EasyAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SlackNet.Blocks; +using SlackNet.WebApi; + +namespace SlackNet.Tests; + +public class StreamingApiTests +{ + private TestHttp _http; + private ISlackApiClient _sut; + + [SetUp] + public void SetUp() + { + var jsonSettings = Default.JsonSettings(Default.SlackTypeResolver(Default.AssembliesContainingSlackTypes)); + _http = new TestHttp(); + _sut = new SlackApiClient(_http, Default.UrlBuilder(jsonSettings), jsonSettings, "token"); + } + + [Test] + public async Task StartStream_WithMarkdownText_SendsOnlyTheMarkdownArguments() + { + await _sut.Chat.StartStream("C123", "1717171717.123456", "Hello", recipientUserId: "U123", recipientTeamId: "T123"); + + _http.Method.ShouldBe("chat.startStream"); + _http.Body.ShouldMatchJson(@"{ ""channel"": ""C123"", ""thread_ts"": ""1717171717.123456"", ""markdown_text"": ""Hello"", ""recipient_user_id"": ""U123"", ""recipient_team_id"": ""T123"" }"); + } + + [Test] + public async Task StartStream_WithChunks_SendsChunksAndTaskDisplayMode() + { + await _sut.Chat.StartStream("C123", "1717171717.123456", + chunks: + [ + new MarkdownTextChunk { Text = "Let me help you with that!" }, + new TaskUpdateChunk { Id = "t1", Title = "Searching", Status = TaskCardStatus.InProgress } + ], + taskDisplayMode: TaskDisplayMode.Plan, + iconEmoji: ":robot_face:", + iconUrl: "https://example.com/icon.png", + username: "Agent"); + + _http.Body.ShouldMatchJson(@"{ + ""channel"": ""C123"", + ""thread_ts"": ""1717171717.123456"", + ""chunks"": [ + { ""type"": ""markdown_text"", ""text"": ""Let me help you with that!"" }, + { ""type"": ""task_update"", ""id"": ""t1"", ""title"": ""Searching"", ""status"": ""in_progress"" } + ], + ""task_display_mode"": ""plan"", + ""icon_emoji"": "":robot_face:"", + ""icon_url"": ""https://example.com/icon.png"", + ""username"": ""Agent"" + }"); + } + + [Test] + public async Task AppendStream_WithMarkdownText_SendsOnlyTheMarkdownArguments() + { + await _sut.Chat.AppendStream("C123", "1503435956.000247", "more"); + + _http.Method.ShouldBe("chat.appendStream"); + _http.Body.ShouldMatchJson(@"{ ""channel"": ""C123"", ""ts"": ""1503435956.000247"", ""markdown_text"": ""more"" }"); + } + + [Test] + public async Task AppendStream_WithChunks_SendsEveryKindOfChunk() + { + await _sut.Chat.AppendStream("C123", "1503435956.000247", chunks: + [ + new MarkdownTextChunk { Text = "Here's what I found..." }, + new TaskUpdateChunk + { + Id = "t1", + Title = "Remind Sandra how amazing she is", + HideTitle = false, + Status = TaskCardStatus.Complete, + Details = "wow such good details", + Output = "amazing output here", + Sources = [new UrlSourceElement { Text = "Example.com", Url = "https://example.com" }] + }, + new PlanUpdateChunk { Title = "Sandra's new and improved plan" }, + new BlocksChunk { Blocks = [new MarkdownBlock { Text = "**Sandra's plan outline**" }] } + ]); + + _http.Body.ShouldMatchJson(@"{ + ""channel"": ""C123"", + ""ts"": ""1503435956.000247"", + ""chunks"": [ + { ""type"": ""markdown_text"", ""text"": ""Here's what I found..."" }, + { + ""type"": ""task_update"", + ""id"": ""t1"", + ""title"": ""Remind Sandra how amazing she is"", + ""hide_title"": false, + ""status"": ""complete"", + ""details"": ""wow such good details"", + ""output"": ""amazing output here"", + ""sources"": [ { ""type"": ""url"", ""url"": ""https://example.com"", ""text"": ""Example.com"" } ] + }, + { ""type"": ""plan_update"", ""title"": ""Sandra's new and improved plan"" }, + { ""type"": ""blocks"", ""blocks"": [ { ""type"": ""markdown"", ""text"": ""**Sandra's plan outline**"" } ] } + ] + }"); + } + + [Test] + public async Task TaskUpdateChunk_WithIcon_SendsIcon() + { + await _sut.Chat.AppendStream("C123", "1503435956.000247", chunks: [new TaskUpdateChunk { Id = "t1", Title = "Task", Status = TaskCardStatus.InProgress, Icon = new TaskCardIcon { Name = "bug" } }]); + + _http.Body.ShouldMatchJson(@"{ ""channel"": ""C123"", ""ts"": ""1503435956.000247"", ""chunks"": [ { ""type"": ""task_update"", ""id"": ""t1"", ""title"": ""Task"", ""status"": ""in_progress"", ""icon"": { ""type"": ""icon"", ""name"": ""bug"" } } ] }"); + } + + [TestCase(TaskCardStatus.Pending, "pending")] + [TestCase(TaskCardStatus.InProgress, "in_progress")] + [TestCase(TaskCardStatus.Complete, "complete")] + [TestCase(TaskCardStatus.Error, "error")] + public async Task TaskUpdateChunk_SendsStatusInSlackCase(TaskCardStatus status, string expected) + { + await _sut.Chat.AppendStream("C123", "1503435956.000247", chunks: [new TaskUpdateChunk { Id = "t1", Title = "Task", Status = status }]); + + ((string)_http.Body["chunks"][0]["status"]).ShouldBe(expected); + } + + [Test] + public async Task StopStream_WithoutChunksOrSessionStatus_SendsOnlyTheMarkdownAndBlocks() + { + await _sut.Chat.StopStream("C123", "1503435956.000247", "done", blocks: [new MarkdownBlock { Text = "footer" }]); + + _http.Method.ShouldBe("chat.stopStream"); + _http.Body.ShouldMatchJson(@"{ ""channel"": ""C123"", ""ts"": ""1503435956.000247"", ""markdown_text"": ""done"", ""blocks"": [ { ""type"": ""markdown"", ""text"": ""footer"" } ] }"); + } + + [Test] + public async Task StopStream_WithChunksAndSessionStatus_SendsThem() + { + await _sut.Chat.StopStream("C123", "1503435956.000247", + chunks: [new MarkdownTextChunk { Text = "Hope this helps!" }], + sessionStatus: AgentSessionStatus.Suspended); + + _http.Body.ShouldMatchJson(@"{ ""channel"": ""C123"", ""ts"": ""1503435956.000247"", ""chunks"": [ { ""type"": ""markdown_text"", ""text"": ""Hope this helps!"" } ], ""session_status"": ""suspended"" }"); + } + + [Test] + public void AppendStream_StoppedByUser_ThrowsSlackExceptionWithErrorCode() + { + _http.Response = JObject.Parse(@"{ ""ok"": false, ""error"": ""stopped_by_user"" }"); + + var exception = Assert.ThrowsAsync(() => _sut.Chat.AppendStream("C123", "1503435956.000247", "more")); + + exception.ErrorCode.ShouldBe("stopped_by_user"); + } +} \ No newline at end of file diff --git a/SlackNet.Tests/TaskBlockTests.cs b/SlackNet.Tests/TaskBlockTests.cs new file mode 100644 index 00000000..96625048 --- /dev/null +++ b/SlackNet.Tests/TaskBlockTests.cs @@ -0,0 +1,119 @@ +using EasyAssertions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SlackNet.Blocks; + +namespace SlackNet.Tests; + +public class TaskBlockTests +{ + private JsonSerializerSettings _sut; + + [SetUp] + public void SetUp() + { + _sut = Default.JsonSettings(Default.SlackTypeResolver(Default.AssembliesContainingSlackTypes)).SerializerSettings; + } + + [Test] + public void PlanBlock_DeserializedFromSlackPayload_WithTasksThatHaveNoType() + { + var result = Deserialize(@"{ + ""type"": ""plan"", + ""title"": ""Thinking completed"", + ""tasks"": [ + { + ""task_id"": ""call_001"", + ""title"": ""Fetched user profile information"", + ""status"": ""in_progress"", + ""details"": { ""type"": ""rich_text"", ""block_id"": ""viMWO"", ""elements"": [ { ""type"": ""rich_text_section"", ""elements"": [ { ""type"": ""text"", ""text"": ""Searched database..."" } ] } ] } + }, + { ""task_id"": ""call_002"", ""title"": ""Checked user permissions"", ""status"": ""pending"" }, + { ""task_id"": ""call_003"", ""title"": ""Generated report"", ""status"": ""complete"", ""sources"": [ { ""type"": ""url"", ""url"": ""https://example.com"", ""text"": ""Example.com"" } ] } + ] + }"); + + var plan = result.ShouldBeA().And; + plan.Title.ShouldBe("Thinking completed"); + plan.Tasks.Count.ShouldBe(3); + plan.Tasks[0].TaskId.ShouldBe("call_001"); + plan.Tasks[0].Status.ShouldBe(TaskCardStatus.InProgress); + plan.Tasks[0].Details.Elements.Count.ShouldBe(1); + plan.Tasks[1].Status.ShouldBe(TaskCardStatus.Pending); + plan.Tasks[2].Status.ShouldBe(TaskCardStatus.Complete); + plan.Tasks[2].Sources[0].Url.ShouldBe("https://example.com"); + plan.Tasks[2].Sources[0].Text.ShouldBe("Example.com"); + } + + [Test] + public void TaskCardBlock_DeserializedFromSlackPayload() + { + var result = Deserialize(@"{ + ""type"": ""task_card"", + ""block_id"": ""b1"", + ""task_id"": ""call_001"", + ""title"": ""Fetched user profile information"", + ""status"": ""error"", + ""hide_title"": true, + ""output"": { ""type"": ""rich_text"", ""elements"": [ { ""type"": ""rich_text_section"", ""elements"": [ { ""type"": ""text"", ""text"": ""Failed"" } ] } ] } + }"); + + var card = result.ShouldBeA().And; + card.BlockId.ShouldBe("b1"); + card.TaskId.ShouldBe("call_001"); + card.Status.ShouldBe(TaskCardStatus.Error); + card.HideTitle.ShouldBe(true); + card.Output.Elements.Count.ShouldBe(1); + card.Details.ShouldBeNull(); + } + + [Test] + public void TaskCardBlock_SerializedToSlackPayload() + { + var result = Serialize(new TaskCardBlock + { + TaskId = "call_001", + Title = "Fetched user profile information", + Status = TaskCardStatus.InProgress, + Sources = [new UrlSourceElement { Text = "Example.com", Url = "https://example.com" }] + }); + + JToken.DeepEquals(JObject.Parse(@"{ ""type"": ""task_card"", ""task_id"": ""call_001"", ""title"": ""Fetched user profile information"", ""status"": ""in_progress"", ""sources"": [ { ""type"": ""url"", ""url"": ""https://example.com"", ""text"": ""Example.com"" } ] }"), JObject.Parse(result)) + .ShouldBe(true, result); + } + + [Test] + public void TaskCardBlock_WithIcon_SerializedToSlackPayload() + { + var result = Serialize(new TaskCardBlock { TaskId = "call_001", Title = "Fetched user profile information", Status = TaskCardStatus.Complete, Icon = new TaskCardIcon { Name = "rocket" } }); + + JToken.DeepEquals(JObject.Parse(@"{ ""type"": ""task_card"", ""task_id"": ""call_001"", ""title"": ""Fetched user profile information"", ""status"": ""complete"", ""icon"": { ""type"": ""icon"", ""name"": ""rocket"" } }"), JObject.Parse(result)) + .ShouldBe(true, result); + } + + [Test] + public void TaskCardBlock_WithIcon_DeserializedFromSlackPayload() + { + var result = Deserialize(@"{ ""type"": ""task_card"", ""task_id"": ""call_001"", ""title"": ""t"", ""status"": ""complete"", ""icon"": { ""type"": ""icon"", ""name"": ""rocket"" } }"); + + result.ShouldBeA().And.Icon.Name.ShouldBe("rocket"); + } + + [Test] + public void PlanBlock_SerializedToSlackPayload() + { + var result = Serialize(new PlanBlock + { + Title = "Thinking completed", + Tasks = [new TaskCardBlock { TaskId = "call_002", Title = "Checked user permissions", Status = TaskCardStatus.Pending }] + }); + + JToken.DeepEquals(JObject.Parse(@"{ ""type"": ""plan"", ""title"": ""Thinking completed"", ""tasks"": [ { ""type"": ""task_card"", ""task_id"": ""call_002"", ""title"": ""Checked user permissions"", ""status"": ""pending"" } ] }"), JObject.Parse(result)) + .ShouldBe(true, result); + } + + private string Serialize(object obj) => JsonConvert.SerializeObject(obj, _sut); + + private T Deserialize(string json) => JsonConvert.DeserializeObject(json, _sut); +} \ No newline at end of file diff --git a/SlackNet.Tests/TestHttp.cs b/SlackNet.Tests/TestHttp.cs new file mode 100644 index 00000000..91879def --- /dev/null +++ b/SlackNet.Tests/TestHttp.cs @@ -0,0 +1,30 @@ +#nullable enable +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using EasyAssertions; +using Newtonsoft.Json.Linq; +using SlackNet.WebApi; + +namespace SlackNet.Tests; + +class TestHttp : IHttp +{ + public string Method { get; private set; } = ""; + public JObject Body { get; private set; } = new(); + public JObject Response { get; set; } = new() { ["ok"] = true }; + + public async Task Execute(HttpRequestMessage requestMessage, CancellationToken cancellationToken = default) + { + Method = requestMessage.RequestUri!.Segments[^1]; + Body = JObject.Parse(await requestMessage.Content!.ReadAsStringAsync().ConfigureAwait(false)); + return (T)(object)new WebApiResponse { Ok = (bool)Response["ok"]!, Data = Response }; + } +} + +static class JsonAssertions +{ + public static void ShouldMatchJson(this JObject actual, string expectedJson) => + JToken.DeepEquals(JObject.Parse(expectedJson), actual) + .ShouldBe(true, $"Expected JSON {JObject.Parse(expectedJson)} but was {actual}"); +} \ No newline at end of file diff --git a/SlackNet/Blocks/PlanBlock.cs b/SlackNet/Blocks/PlanBlock.cs new file mode 100644 index 00000000..a611ad1d --- /dev/null +++ b/SlackNet/Blocks/PlanBlock.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace SlackNet.Blocks; + +/// +/// Groups related s under a single title, so users can follow a multi-step process as one list. +/// Each task in a plan must have a unique , and a plan can contain at most 50 tasks. +/// Slack doesn't allow a message to contain both a plan block and s. +/// When streaming, set on to collect task updates into a plan, +/// and send s to change its title. +/// +/// See the Slack documentation for more information. +[SlackType("plan")] +public class PlanBlock() : Block("plan") +{ + /// + /// Title of the plan in plain text. + /// + public string Title { get; set; } + + /// + /// The tasks of the plan, at most 50. + /// + public IList Tasks { get; set; } = []; +} \ No newline at end of file diff --git a/SlackNet/Blocks/TaskCardBlock.cs b/SlackNet/Blocks/TaskCardBlock.cs new file mode 100644 index 00000000..84c26e69 --- /dev/null +++ b/SlackNet/Blocks/TaskCardBlock.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; + +namespace SlackNet.Blocks; + +/// +/// Displays a single task, which represents a single action: a title, a status indicator and optional details, output and sources. +/// Use a to show several related tasks together. +/// Slack doesn't allow a message to contain both a plan block and task cards. +/// When streaming, the equivalent is a . +/// +/// See the Slack documentation for more information. +[SlackType("task_card")] +public class TaskCardBlock() : Block("task_card") +{ + /// + /// ID for the task. Within a each task ID must be unique. + /// + public string TaskId { get; set; } + + /// + /// Title of the task in plain text. + /// + public string Title { get; set; } + + public TaskCardStatus Status { get; set; } + + /// + /// Details of the task. + /// + public RichTextBlock Details { get; set; } + + /// + /// Output of the task. + /// + public RichTextBlock Output { get; set; } + + /// + /// URLs that were used to generate a response. + /// + [IgnoreIfEmpty] + public IList Sources { get; set; } = []; + + /// + /// When true, is not displayed and becomes the top element. is still required. + /// + public bool? HideTitle { get; set; } + + /// + /// Icon associated with the task. + /// + public TaskCardIcon Icon { get; set; } +} + +/// +/// One of Slack's named task icons. +/// +public class TaskCardIcon +{ + public string Type { get; set; } = "icon"; + + /// + /// The name of the icon, e.g. rocket, bug, globe or check. Slack only accepts the names it defines, and rejects the message with invalid_blocks for any other. + /// + public string Name { get; set; } +} + +/// +/// The state of a task. +/// +public enum TaskCardStatus +{ + /// + /// The task hasn't started. Slack accepts this for the tasks of a and in streamed task updates, + /// but rejects it for a that isn't part of a plan. + /// + Pending, + InProgress, + Complete, + Error +} \ No newline at end of file diff --git a/SlackNet/Blocks/UrlSourceElement.cs b/SlackNet/Blocks/UrlSourceElement.cs new file mode 100644 index 00000000..3b66d514 --- /dev/null +++ b/SlackNet/Blocks/UrlSourceElement.cs @@ -0,0 +1,20 @@ +namespace SlackNet.Blocks; + +/// +/// Displays a clickable URL reference. This element can only be used in a (or in the equivalent when streaming). +/// Slack doesn't validate that the URL resolves. +/// +/// See the Slack documentation for more information. +[SlackType("url")] +public class UrlSourceElement() : BlockElement("url") +{ + /// + /// The URL being referenced. + /// + public string Url { get; set; } + + /// + /// Display text for the URL. + /// + public string Text { get; set; } +} \ No newline at end of file diff --git a/SlackNet/Events/AgentSessionStopped.cs b/SlackNet/Events/AgentSessionStopped.cs new file mode 100644 index 00000000..6c947a92 --- /dev/null +++ b/SlackNet/Events/AgentSessionStopped.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; + +namespace SlackNet.Events; + +/// +/// Sent when a user clicks the native stop button shown while an agent session is . +/// The button is only shown if the app is subscribed to this event, which requires the chat:write scope. +/// When you receive it, stop any in-progress work for the channel and thread, confirm to the user that work has stopped, +/// and move the session out of with or the sessionStatus argument of . +/// The session status is not updated automatically. +/// +/// See the Slack documentation for more information. +public class AgentSessionStopped : Event +{ + /// + /// The channel containing the session. + /// + public string Channel { get; set; } + + /// + /// The root of the thread the session is scoped to. + /// + public string ThreadTs { get; set; } + + /// + /// The user who clicked the stop button. + /// + public string User { get; set; } + + /// + /// Timestamps of the app's in-progress streaming messages that Slack stopped in response to the click. + /// Empty when no stream was active, so the event still tells you to stop work that isn't a stream. + /// + public IList StreamingMessageTs { get; set; } = []; +} \ No newline at end of file diff --git a/SlackNet/SlackApiClient.cs b/SlackNet/SlackApiClient.cs index 005d7d3e..a5188efd 100644 --- a/SlackNet/SlackApiClient.cs +++ b/SlackNet/SlackApiClient.cs @@ -14,6 +14,7 @@ namespace SlackNet; public interface ISlackApiClient { + IAgentsSessionsApi AgentsSessions { get; } IApiApi Api { get; } IAppsConnectionsApi AppsConnectionsApi { get; } IAppsEventAuthorizationsApi AppsEventAuthorizations { get; } @@ -170,6 +171,7 @@ public ISlackApiClient WithAccessToken(string accessToken) => WarningsAsErrors = WarningsAsErrors }; + public IAgentsSessionsApi AgentsSessions => new AgentsSessionsApi(this); public IApiApi Api => new ApiApi(this); public IAppsConnectionsApi AppsConnectionsApi => new AppsConnectionsApi(this); public IAppsEventAuthorizationsApi AppsEventAuthorizations => new AppsEventAuthorizationsApi(this); diff --git a/SlackNet/WebApi/AgentSessionStatus.cs b/SlackNet/WebApi/AgentSessionStatus.cs new file mode 100644 index 00000000..d5ac2dae --- /dev/null +++ b/SlackNet/WebApi/AgentSessionStatus.cs @@ -0,0 +1,29 @@ +namespace SlackNet.WebApi; + +/// +/// The lifecycle status of an agent in an agent session. +/// +/// See the Slack documentation for more information. +public enum AgentSessionStatus +{ + /// + /// The agent is ready for the next prompt or task. + /// + Active, + + /// + /// The agent is working on a task. Slack shows a loading indicator, and a stop button if the app is subscribed to . + /// + Processing, + + /// + /// The agent cannot make progress until the user intervenes, for example when it needs clarification, a decision, or a tool approval. + /// An agent that has posted a question and is waiting on the answer is suspended, not active. + /// + Suspended, + + /// + /// The agent has closed the session and will no longer respond on it. + /// + Closed +} \ No newline at end of file diff --git a/SlackNet/WebApi/AgentsSessionsApi.cs b/SlackNet/WebApi/AgentsSessionsApi.cs new file mode 100644 index 00000000..b193be28 --- /dev/null +++ b/SlackNet/WebApi/AgentsSessionsApi.cs @@ -0,0 +1,68 @@ +using System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using Args = System.Collections.Generic.Dictionary; + +namespace SlackNet.WebApi; + +public interface IAgentsSessionsApi +{ + /// + /// Sets the lifecycle status of an agent session, creating the session if it doesn't exist yet. + /// Only apps declared as agents in the app settings can create sessions. + /// While a session is , Slack shows a loading indicator, + /// along with a stop button if the app subscribes to . + /// + /// See the Slack documentation for more information. + /// ID of the channel containing the agent session. The app must be a member of it. Slack requires this for public channels. + /// + /// Timestamp of the thread root message the session is scoped to. + /// Required for thread-based sessions in regular channels and DMs; must be null for session channels (where the whole channel is one session). + /// + /// The lifecycle status to set. + /// Title for the agent session (max 200 characters). Only used when the session is created; use agents.sessions.rename to rename an existing session. + /// The user who initiated the session. Only used when the session is created. Must be a member of the channel. + /// + /// Emoji to use as the agent's icon. Takes priority over . Requires the chat:write.customize scope. + /// Icon and username overrides persist until replaced: a call that sets none of , and leaves them in place, + /// while a call that sets any of them replaces all three, so those it leaves out are cleared. + /// + /// URL to an image to use as the agent's icon. Requires the chat:write.customize scope. + /// Display name override for the agent (max 200 characters). Requires the chat:write.customize scope. + /// + Task SetStatus( + string channelId, + [CanBeNull] string threadTs, + AgentSessionStatus status, + [CanBeNull] string title = null, + [CanBeNull] string initiatorUserId = null, + [CanBeNull] string iconEmoji = null, + [CanBeNull] string iconUrl = null, + [CanBeNull] string username = null, + CancellationToken cancellationToken = default); +} + +public class AgentsSessionsApi(ISlackApiClient client) : IAgentsSessionsApi +{ + public Task SetStatus( + string channelId, + string threadTs, + AgentSessionStatus status, + string title = null, + string initiatorUserId = null, + string iconEmoji = null, + string iconUrl = null, + string username = null, + CancellationToken cancellationToken = default) => + client.Post("agents.sessions.setStatus", new Args + { + { "channel_id", channelId }, + { "thread_ts", threadTs }, + { "status", status }, + { "title", title }, + { "initiator_user_id", initiatorUserId }, + { "icon_emoji", iconEmoji }, + { "icon_url", iconUrl }, + { "username", username } + }, cancellationToken); +} \ No newline at end of file diff --git a/SlackNet/WebApi/ChatApi.cs b/SlackNet/WebApi/ChatApi.cs index b6d8cbb1..853ef206 100644 --- a/SlackNet/WebApi/ChatApi.cs +++ b/SlackNet/WebApi/ChatApi.cs @@ -151,25 +151,62 @@ Task Unfurl( /// /// Starts a new streaming conversation. + /// Starting a stream creates an agent session if there isn't one yet, and sets it to . /// /// See the Slack documentation for more information. /// An encoded ID that represents a channel, private group, or DM. - /// Provide another message's timestamp value to reply to. Streamed messages should always be replies to a user request. - /// Accepts message text formatted in markdown. This text is what will be appended to the message received so far. + /// + /// Provide another message's timestamp value to reply to. Streamed messages should always be replies to a user request. + /// Can be null only in channels where the whole channel is one session, such as Slack Code. + /// + /// + /// Accepts message text formatted in markdown. Limit this field to 12,000 characters. + /// Can't be combined with , and a stream started with markdown text can only be appended to with markdown text. + /// /// The encoded ID of the user to receive the streaming text. Required when streaming to channels. /// The encoded ID of the team the user receiving the streaming text belongs to. Required when streaming to channels. + /// + /// Chunks to stream: markdown text, task updates, plan updates and blocks. + /// Can't be combined with , and a stream started with chunks can only be appended to with chunks. + /// + /// How task updates are displayed. Slack's default is . + /// Emoji to use as the icon for this message. Overrides . + /// Image URL to use as the icon for this message. + /// The bot's username to display. /// - Task StartStream(string channel, string threadTs, string markdownText = null, string recipientUserId = null, string recipientTeamId = null, CancellationToken cancellationToken = default); + Task StartStream( + string channel, + string threadTs, + string markdownText = null, + string recipientUserId = null, + string recipientTeamId = null, + IEnumerable chunks = null, + TaskDisplayMode? taskDisplayMode = null, + string iconEmoji = null, + string iconUrl = null, + string username = null, + CancellationToken cancellationToken = default); /// - /// Appends text to an existing streaming conversation. + /// Appends text or chunks to an existing streaming conversation. + /// Fails with stopped_by_user if the user stopped the stream. /// /// See the Slack documentation for more information. /// An encoded ID that represents a channel, private group, or DM. /// The timestamp of the streaming message. - /// Accepts message text formatted in markdown. This text is what will be appended to the message received so far. + /// + /// Accepts message text formatted in markdown. Limit this field to 12,000 characters. + /// This text is what will be appended to the message received so far. + /// Use this if the stream was started with markdown text, and if it was started with chunks. + /// + /// Chunks to append to the stream: markdown text, task updates, plan updates and blocks. /// - Task AppendStream(string channel, string ts, string markdownText, CancellationToken cancellationToken = default); + Task AppendStream( + string channel, + string ts, + string markdownText = null, + IEnumerable chunks = null, + CancellationToken cancellationToken = default); /// /// Stops a streaming conversation. @@ -177,7 +214,7 @@ Task Unfurl( /// See the Slack documentation for more information. /// An encoded ID that represents a channel, private group, or DM. /// A message's timestamp, uniquely identifying it within a channel. - /// Accepts message text formatted in markdown. This text is what will be appended to the message received so far. + /// Accepts message text formatted in markdown. Limit this field to 12,000 characters. This text is what will be appended to the message received so far. /// A list of blocks that will be rendered at the bottom of the finalized message. /// /// A custom event attached to the message. @@ -187,10 +224,21 @@ Task Unfurl( /// /// A custom event attached to the message. /// Metadata you post to Slack is accessible to any app or user who is a member of that workspace. - /// Will take precedence over . + /// Will take precedence over . /// + /// Chunks to append before the stream is stopped: markdown text, task updates, plan updates and blocks. + /// The status to set the agent session to after the stream has stopped. Slack's default is . /// - Task StopStream(string channel, string ts, string markdownText = null, IEnumerable blocks = null, object metadataObject = null, MessageMetadata metadataJson = null, CancellationToken cancellationToken = default); + Task StopStream( + string channel, + string ts, + string markdownText = null, + IEnumerable blocks = null, + object metadataObject = null, + MessageMetadata metadataJson = null, + IEnumerable chunks = null, + AgentSessionStatus? sessionStatus = null, + CancellationToken cancellationToken = default); } public class ChatApi(ISlackApiClient client, SlackJsonSettings jsonSettings) : IChatApi @@ -333,22 +381,46 @@ public Task Update(MessageUpdate messageUpdate, Cancellat }, cancellationToken); - public Task StartStream(string channel, string threadTs, string markdownText = null, string recipientUserId = null, string recipientTeamId = null, CancellationToken cancellationToken = default) => + public Task StartStream( + string channel, + string threadTs, + string markdownText = null, + string recipientUserId = null, + string recipientTeamId = null, + IEnumerable chunks = null, + TaskDisplayMode? taskDisplayMode = null, + string iconEmoji = null, + string iconUrl = null, + string username = null, + CancellationToken cancellationToken = default + ) => client.Post("chat.startStream", new Args { { "channel", channel }, { "thread_ts", threadTs }, { "markdown_text", markdownText }, { "recipient_user_id", recipientUserId }, - { "recipient_team_id", recipientTeamId } + { "recipient_team_id", recipientTeamId }, + { "chunks", chunks }, + { "task_display_mode", taskDisplayMode }, + { "icon_emoji", iconEmoji }, + { "icon_url", iconUrl }, + { "username", username } }, cancellationToken); - public Task AppendStream(string channel, string ts, string markdownText, CancellationToken cancellationToken = default) => + public Task AppendStream( + string channel, + string ts, + string markdownText = null, + IEnumerable chunks = null, + CancellationToken cancellationToken = default + ) => client.Post("chat.appendStream", new Args { { "channel", channel }, { "ts", ts }, - { "markdown_text", markdownText } + { "markdown_text", markdownText }, + { "chunks", chunks } }, cancellationToken); public Task StopStream( @@ -358,6 +430,8 @@ public Task StopStream( IEnumerable blocks = null, object metadataObject = null, MessageMetadata metadataJson = null, + IEnumerable chunks = null, + AgentSessionStatus? sessionStatus = null, CancellationToken cancellationToken = default ) => client.Post("chat.stopStream", new Args @@ -365,7 +439,9 @@ public Task StopStream( { "channel", channel }, { "ts", ts }, { "markdown_text", markdownText }, + { "chunks", chunks }, { "blocks", blocks }, - { "metadata", metadataJson ?? MessageMetadata.FromObject(metadataObject, jsonSettings) } + { "metadata", metadataJson ?? MessageMetadata.FromObject(metadataObject, jsonSettings) }, + { "session_status", sessionStatus } }, cancellationToken); } diff --git a/SlackNet/WebApi/Responses/AgentSessionStatusResponse.cs b/SlackNet/WebApi/Responses/AgentSessionStatusResponse.cs new file mode 100644 index 00000000..e0c30413 --- /dev/null +++ b/SlackNet/WebApi/Responses/AgentSessionStatusResponse.cs @@ -0,0 +1,21 @@ +namespace SlackNet.WebApi; + +public class AgentSessionStatusResponse +{ + /// + /// The status of the session as a whole, derived from the statuses of all the agents in it + /// ( over over over ). + /// + public AgentSessionStatus Status { get; set; } + + /// + /// The calling agent's own status, i.e. the value that was just written. + /// In a session with more than one agent this can differ from . + /// + public AgentSessionStatus AgentStatus { get; set; } + + /// + /// The title of the session, if it has one. + /// + public string Title { get; set; } +} \ No newline at end of file diff --git a/SlackNet/WebApi/StreamChunk.cs b/SlackNet/WebApi/StreamChunk.cs new file mode 100644 index 00000000..da04b437 --- /dev/null +++ b/SlackNet/WebApi/StreamChunk.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using SlackNet.Blocks; + +namespace SlackNet.WebApi; + +/// +/// A piece of a streaming message, sent in the chunks argument of , and . +/// A stream that was started with chunks must be appended to with chunks, and a stream that was started with markdown text must be appended to with markdown text. +/// +/// See the Slack documentation for more information. +public abstract class StreamChunk(string type) +{ + public string Type { get; set; } = type; +} + +/// +/// Streams text, formatted as standard markdown (not Slack's mrkdwn). +/// +public class MarkdownTextChunk() : StreamChunk("markdown_text") +{ + /// + /// The markdown text to append to the message received so far. + /// + public string Text { get; set; } +} + +/// +/// Creates or updates a task, to display the progress of a unit of work. +/// Tasks are displayed according to the of the stream. +/// This is the streaming counterpart of the . +/// Slack limits the size of task_update and plan_update chunks to 256 characters. +/// +public class TaskUpdateChunk() : StreamChunk("task_update") +{ + /// + /// A unique ID for the task. Sending another update with the same ID updates the task. + /// + public string Id { get; set; } + + /// + /// Title of the task in plain text. + /// + public string Title { get; set; } + + /// + /// When true, is not displayed and becomes the top element. + /// + public bool? HideTitle { get; set; } + + /// + /// The state of the task. Slack marks tasks that are still or when the stream stops as . + /// + public TaskCardStatus Status { get; set; } + + /// + /// Icon associated with the task. + /// + public TaskCardIcon Icon { get; set; } + + /// + /// Details of the task. + /// + public string Details { get; set; } + + /// + /// Output of the task. + /// + public string Output { get; set; } + + /// + /// URLs that were used in the task. + /// + [IgnoreIfEmpty] + public IList Sources { get; set; } = []; +} + +/// +/// Updates the title of the plan that tasks are collected into when the stream's is . +/// Slack limits the size of task_update and plan_update chunks to 256 characters. +/// +public class PlanUpdateChunk() : StreamChunk("plan_update") +{ + public string Title { get; set; } +} + +/// +/// Streams blocks as part of the message. At most 50 blocks can be sent in one chunk; Slack drops any extra blocks and returns a warning. +/// +public class BlocksChunk() : StreamChunk("blocks") +{ + public IList Blocks { get; set; } = []; +} + +/// +/// How task updates are displayed in a streaming message. +/// +public enum TaskDisplayMode +{ + /// + /// Task updates render as individual task cards, interleaved with the streamed text. This is Slack's default. + /// + Timeline, + + /// + /// Task updates render together, in a single plan block. + /// + Plan +} \ No newline at end of file