diff --git a/docs/guide/groups.md b/docs/guide/groups.md index 069b315..a75e858 100644 --- a/docs/guide/groups.md +++ b/docs/guide/groups.md @@ -64,5 +64,6 @@ Send a message to a group: ## Next Steps - Learn about [attachments](/guide/attachments) +- Learn about [polls](/guide/polls) - Explore [profile management](/guide/profiles) -- Check out [advanced examples](/examples/) \ No newline at end of file +- Check out [advanced examples](/examples/) diff --git a/docs/guide/polls.md b/docs/guide/polls.md new file mode 100644 index 0000000..bc7ea5a --- /dev/null +++ b/docs/guide/polls.md @@ -0,0 +1,19 @@ +# Polls Management + +Manage polls created by your bot or any other user. + +## Creating a poll + +<<< ./../../src/Signal.Bot.Example/Guide/Polls.cs#CreatingPoll{csharp} + +## Closing a poll + +<<< ./../../src/Signal.Bot.Example/Guide/Polls.cs#ClosingPoll{csharp} + +## Voting in a poll + +<<< ./../../src/Signal.Bot.Example/Guide/Polls.cs#VotingInPoll{csharp} + +## Next Steps + +- Check out [examples](/examples/) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a053889..8e6afe4 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -1,19 +1,21 @@ - - true - - - - - - - - - - - - - - - + + true + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Signal.Bot.Example/Guide/Polls.cs b/src/Signal.Bot.Example/Guide/Polls.cs new file mode 100644 index 0000000..76d79a6 --- /dev/null +++ b/src/Signal.Bot.Example/Guide/Polls.cs @@ -0,0 +1,35 @@ +namespace Signal.Bot.Example.Guide; + +public class Polls +{ + private readonly SignalBotClient client = null!; + + public async Task CreatingPoll() + { + #region CreatingPoll + await client.AddPollAsync(true, + new[] { "Option 1", "Option 2", "Option 3" }, + "Question", + "+1111111111"); + #endregion + } + + public async Task ClosingPoll() + { + #region ClosingPoll + await client.ClosePollAsync( new DateTime(2026,04,06,12,00,00), + "+1111111111"); + #endregion + } + + public async Task VotingInPoll() + { + #region VotingInPoll + + await client.VotePollAsync("+2222222222", + new DateTime(2026, 04, 06, 12, 00, 00), + "+1111111111", + [0, 1]); + #endregion + } +} \ No newline at end of file diff --git a/src/Signal.Bot.Example/Signal.Bot.Example.csproj b/src/Signal.Bot.Example/Signal.Bot.Example.csproj index 802fff4..2b6b376 100644 --- a/src/Signal.Bot.Example/Signal.Bot.Example.csproj +++ b/src/Signal.Bot.Example/Signal.Bot.Example.csproj @@ -8,6 +8,8 @@ + + diff --git a/src/Signal.Bot.UnitTests/Extensions/PollTests.cs b/src/Signal.Bot.UnitTests/Extensions/PollTests.cs new file mode 100644 index 0000000..abf4091 --- /dev/null +++ b/src/Signal.Bot.UnitTests/Extensions/PollTests.cs @@ -0,0 +1,180 @@ +using NSubstitute; +using Signal.Bot.UnitTests.Utils; + +namespace Signal.Bot.UnitTests.Extensions; + +public class PollTests : BotTestBase +{ + [Fact(Timeout = 5000)] + public async Task AddPollAsync_WithNoArguments_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(null, null, null, null, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Theory(Timeout = 5000)] + [InlineData(true)] + [InlineData(false)] + public async Task AddPollAsync_WithMultipleSelections_CallsHttpClient(bool allowMultiple) + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(allowMultiple, null, null, null, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task AddPollAsync_WithAnswers_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(null, ["abc", "def", "ghi", "jkl"], null, null, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task AddPollAsync_WithQuestion_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(null, null, "What?!", null, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task AddPollAsync_WithRecipient_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(null, null, null, "123456789", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task AddPollAsync_WithFullDataset_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.AddPollAsync(false, ["abc", "def", "ghi", "jkl"], "What?!", "123456789", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task ClosePollAsync_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.ClosePollAsync(DateTime.Now, "123456789", cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Delete), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task VotePollAsync_WithRequiredArguments_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.VotePollAsync("987654321", DateTime.Now, "123456789", null, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task VotePollAsync_WithSingleSelectedAnswer_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.VotePollAsync("987654321", DateTime.Now, "123456789", [1], cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } + + [Fact(Timeout = 5000)] + public async Task VotePollAsync_WithMultipleSelectedAnswers_CallsHttpClient() + { + // Arrange + SetupResponse(); + + // Act + await Client.VotePollAsync("987654321", DateTime.Now, "123456789", [1, 3], cancellationToken: TestContext.Current.CancellationToken); + + // Assert + await HttpClientMock + .Received(1) + .SendAsync(Arg.Is(req => + req.Method == HttpMethod.Post), + Arg.Any()); + } +} \ No newline at end of file diff --git a/src/Signal.Bot.UnitTests/Serialization/PollSerializationTests.cs b/src/Signal.Bot.UnitTests/Serialization/PollSerializationTests.cs new file mode 100644 index 0000000..2308f07 --- /dev/null +++ b/src/Signal.Bot.UnitTests/Serialization/PollSerializationTests.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using Signal.Bot.Requests; + +namespace Signal.Bot.UnitTests.Serialization; +public class PollSerializationTests +{ + [Fact(Timeout = 5000)] + public void TestAddPollRequestSerializationAndDeserialization() + { + // Arrange + var addPollRequest = new AddPollRequest("") + { + AllowMultipleSelections = true, + Answers = ["yes", "no", "maybe"], + Question = "Does this test succeed?", + Recipient = "123456789" + }; + + // Act + var json = JsonSerializer.Serialize(addPollRequest); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.AllowMultipleSelections); + Assert.Equal(true, deserialized.AllowMultipleSelections); + Assert.NotNull(deserialized.Answers); + Assert.Equal("yes", deserialized.Answers[0]); + Assert.Equal("no", deserialized.Answers[1]); + Assert.Equal("maybe", deserialized.Answers[2]); + Assert.NotNull(deserialized.Question); + Assert.Equal("Does this test succeed?", deserialized.Question); + Assert.NotNull(deserialized.Recipient); + Assert.Equal("123456789", deserialized.Recipient); + } + + [Fact(Timeout = 5000)] + public void TestClosePollRequestSerializationAndDeserialization() + { + // Arrange + var timestamp = DateTime.Now; + var closePollRequest = new ClosePollRequest("") + { + Timestamp = timestamp, + Recipient = "123456789" + }; + + // Act + var json = JsonSerializer.Serialize(closePollRequest); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(timestamp, deserialized.Timestamp); + Assert.NotNull(deserialized.Recipient); + Assert.Equal("123456789", deserialized.Recipient); + } + + [Fact(Timeout = 5000)] + public void TestVotePollRequestSerializationAndDeserialization_SingleAnswer() + { + // Arrange + var timestamp = DateTime.Now; + var votePollRequest = new VotePollRequest("") + { + Recipient = "123456789", + Timestamp = timestamp, + SelectedAnswers = [0], + PollAuthor = "98765421" + }; + + // Act + var json = JsonSerializer.Serialize(votePollRequest); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(timestamp, deserialized.Timestamp); + Assert.NotNull(deserialized.SelectedAnswers); + Assert.Equal(0, deserialized.SelectedAnswers[0]); + Assert.NotNull(deserialized.PollAuthor); + Assert.Equal("98765421", deserialized.PollAuthor); + Assert.NotNull(deserialized.Recipient); + Assert.Equal("123456789", deserialized.Recipient); + } + + + + [Fact(Timeout = 5000)] + public void TestVotePollRequestSerializationAndDeserialization_MultipleAnswers() + { + // Arrange + var timestamp = DateTime.Now; + var votePollRequest = new VotePollRequest("") + { + Recipient = "123456789", + Timestamp = timestamp, + SelectedAnswers = [2, 0], + PollAuthor = "98765421" + }; + + // Act + var json = JsonSerializer.Serialize(votePollRequest); + var deserialized = JsonSerializer.Deserialize(json); + + // Assert + Assert.NotNull(deserialized); + Assert.Equal(timestamp, deserialized.Timestamp); + Assert.NotNull(deserialized.SelectedAnswers); + Assert.Equal(2, deserialized.SelectedAnswers[0]); + Assert.Equal(0, deserialized.SelectedAnswers[1]); + Assert.NotNull(deserialized.PollAuthor); + Assert.Equal("98765421", deserialized.PollAuthor); + Assert.NotNull(deserialized.Recipient); + Assert.Equal("123456789", deserialized.Recipient); + } +} \ No newline at end of file diff --git a/src/Signal.Bot/Extensions.cs b/src/Signal.Bot/Extensions.cs index 1634056..3f9535a 100644 --- a/src/Signal.Bot/Extensions.cs +++ b/src/Signal.Bot/Extensions.cs @@ -943,4 +943,81 @@ public static async Task GetContactAsync(this ISignalBotClient client, } #endregion + + #region Polls + + /// + /// Creates a new poll + /// + /// The instance. + /// A flag indicating if multiple answers are allowed + /// A list of possible answers + /// The question for the poll + /// The recipient for this poll (user or group) + /// A to observe for cancellation requests. + /// + public static async Task AddPollAsync(this ISignalBotClient client, + bool? allowMultipleSelections, + string[]? answers, + string? question, + string? recipient, + CancellationToken cancellationToken = default) + { + var request = new AddPollRequest(client.Number) + { + AllowMultipleSelections = allowMultipleSelections, + Answers = answers, + Question = question, + Recipient = recipient + }; + return await client.SendRequestAsync(request, cancellationToken: cancellationToken); + } + + /// + /// Closes an open poll + /// + /// The instance. + /// The timestamp of the poll to close + /// The recipient where the poll to close is located (user or group) + /// A to observe for cancellation requests. + public static async Task ClosePollAsync(this ISignalBotClient client, + DateTime timestamp, + string recipient, + CancellationToken cancellationToken = default) + { + var request = new ClosePollRequest(client.Number) + { + Timestamp = timestamp, + Recipient = recipient + }; + await client.SendRequestAsync(request, cancellationToken: cancellationToken); + } + + /// + /// + /// + /// The instance. + /// The phone number OR uuid of the author of the poll to vote for + /// The timestamp of the poll to vor in + /// The recipient where the poll to vote in is located (user or group) + /// The answer(s) to vote for. Voting for multiple answers may not always be possible. + /// A to observe for cancellation requests. + public static async Task VotePollAsync(this ISignalBotClient client, + string pollAuthor, + DateTime timestamp, + string recipient, + int[]? selectedAnswers, + CancellationToken cancellationToken = default) + { + var request = new VotePollRequest(client.Number) + { + PollAuthor = pollAuthor, + Timestamp = timestamp, + Recipient = recipient, + SelectedAnswers = selectedAnswers, + }; + await client.SendRequestAsync(request, cancellationToken: cancellationToken); + } + + #endregion } \ No newline at end of file diff --git a/src/Signal.Bot/Requests/AddPollRequest.cs b/src/Signal.Bot/Requests/AddPollRequest.cs new file mode 100644 index 0000000..f3f8232 --- /dev/null +++ b/src/Signal.Bot/Requests/AddPollRequest.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; +using Signal.Bot.Types; + +namespace Signal.Bot.Requests; + +/// +/// Represents a request to create a new poll +/// +/// The phone number of the Signal account from which the poll will be created. +public record AddPollRequest(string Number) : RequestBase($"v1/polls/{Number}", HttpMethod.Post) +{ + /// + /// Gets or sets the indicator if multiple selections are allowed + /// + [JsonPropertyName("allow_multiple_selections")] + public bool? AllowMultipleSelections { get; set; } + + /// + /// Gets or sets the answers for this poll request + /// + [JsonPropertyName("answers")] + public string[]? Answers { get; set; } + + /// + /// Gets or sets the question of this poll + /// + [JsonPropertyName("question")] + public string? Question { get; set; } + + /// + /// Gets or sets the recipient for this poll + /// + [JsonPropertyName("recipient")] + public string? Recipient { get; set; } +} \ No newline at end of file diff --git a/src/Signal.Bot/Requests/ClosePollRequest.cs b/src/Signal.Bot/Requests/ClosePollRequest.cs new file mode 100644 index 0000000..4658a1a --- /dev/null +++ b/src/Signal.Bot/Requests/ClosePollRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace Signal.Bot.Requests; + +/// +/// Represents a request to close a poll +/// +/// The phone number of the Signal account which will close the poll. +public record ClosePollRequest(string Number) : RequestBase($"v1/polls/{Number}", HttpMethod.Delete) +{ + /// + /// Gets or sets the timestamp of the poll to close + /// + [JsonPropertyName("poll_timestamp")] + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the recipient for this poll + /// + [JsonPropertyName("recipient")] + public string? Recipient { get; set; } +} \ No newline at end of file diff --git a/src/Signal.Bot/Requests/VotePollRequest.cs b/src/Signal.Bot/Requests/VotePollRequest.cs new file mode 100644 index 0000000..13a87db --- /dev/null +++ b/src/Signal.Bot/Requests/VotePollRequest.cs @@ -0,0 +1,34 @@ +using System.Text.Json.Serialization; + +namespace Signal.Bot.Requests; + +/// +/// Represents a request to vote on a poll +/// +/// The phone number of the Signal account to vote in the poll. +public record VotePollRequest(string Number) : RequestBase($"v1/polls/{Number}/vote", HttpMethod.Post) +{ + /// + /// Gets or sets the uuid or phone number of the poll author + /// + [JsonPropertyName("poll_author")] + public string? PollAuthor { get; set; } + + /// + /// Gets or sets the timestamp of the poll to delete + /// + [JsonPropertyName("poll_timestamp")] + public DateTime Timestamp { get; set; } + + /// + /// Gets or sets the recipient for this poll + /// + [JsonPropertyName("recipient")] + public string? Recipient { get; set; } + + /// + /// Gets or sets an array of answers to vote for + /// + [JsonPropertyName("selected_answers")] + public int[]? SelectedAnswers { get; set; } +} \ No newline at end of file diff --git a/src/Signal.Bot/Serialization/JsonBotAPI.cs b/src/Signal.Bot/Serialization/JsonBotAPI.cs index b162ac0..3bf49e8 100644 --- a/src/Signal.Bot/Serialization/JsonBotAPI.cs +++ b/src/Signal.Bot/Serialization/JsonBotAPI.cs @@ -90,6 +90,7 @@ public static JsonTypeInfo Get(Type key) { typeof(OfferMessage), JsonBotSerializerContext.Default.OfferMessage }, { typeof(HangupMessage), JsonBotSerializerContext.Default.HangupMessage }, { typeof(IceUpdateMessage), JsonBotSerializerContext.Default.IceUpdateMessage }, + { typeof(PollResponse), JsonBotSerializerContext.Default.PollResponse }, // Request Types { typeof(AddDeviceRequest), JsonBotSerializerContext.Default.AddDeviceRequest }, { typeof(AddGroupAdminRequest), JsonBotSerializerContext.Default.AddGroupAdminRequest }, @@ -141,7 +142,10 @@ public static JsonTypeInfo Get(Type key) { typeof(UpdateGroupRequest), JsonBotSerializerContext.Default.UpdateGroupRequest }, { typeof(UpdateProfileRequest), JsonBotSerializerContext.Default.UpdateProfileRequest }, { typeof(VerifyNumberRequest), JsonBotSerializerContext.Default.VerifyNumberRequest }, - + { typeof(AddPollRequest), JsonBotSerializerContext.Default.AddPollRequest }, + { typeof(ClosePollRequest), JsonBotSerializerContext.Default.ClosePollRequest }, + { typeof(VotePollRequest), JsonBotSerializerContext.Default.VotePollRequest }, + // Collection Types { typeof(List), JsonBotSerializerContext.Default.ListGroup }, { typeof(List), JsonBotSerializerContext.Default.ListAttachment }, diff --git a/src/Signal.Bot/Serialization/JsonBotSerializerContext.cs b/src/Signal.Bot/Serialization/JsonBotSerializerContext.cs index 7c20e14..66354f3 100644 --- a/src/Signal.Bot/Serialization/JsonBotSerializerContext.cs +++ b/src/Signal.Bot/Serialization/JsonBotSerializerContext.cs @@ -57,6 +57,7 @@ namespace Signal.Bot.Serialization; [JsonSerializable(typeof(OfferMessage))] [JsonSerializable(typeof(HangupMessage))] [JsonSerializable(typeof(IceUpdateMessage))] +[JsonSerializable(typeof(PollResponse))] // Requests [JsonSerializable(typeof(AddDeviceRequest))] [JsonSerializable(typeof(AddGroupAdminRequest))] @@ -108,6 +109,9 @@ namespace Signal.Bot.Serialization; [JsonSerializable(typeof(UpdateGroupRequest))] [JsonSerializable(typeof(UpdateProfileRequest))] [JsonSerializable(typeof(VerifyNumberRequest))] +[JsonSerializable(typeof(AddPollRequest))] +[JsonSerializable(typeof(ClosePollRequest))] +[JsonSerializable(typeof(VotePollRequest))] // Arrays/Collections [JsonSerializable(typeof(List))] diff --git a/src/Signal.Bot/Signal.Bot.csproj b/src/Signal.Bot/Signal.Bot.csproj index 4581e5a..db3924b 100644 --- a/src/Signal.Bot/Signal.Bot.csproj +++ b/src/Signal.Bot/Signal.Bot.csproj @@ -36,6 +36,8 @@ + + diff --git a/src/Signal.Bot/Types/PollResponse.cs b/src/Signal.Bot/Types/PollResponse.cs new file mode 100644 index 0000000..63618a3 --- /dev/null +++ b/src/Signal.Bot/Types/PollResponse.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; +using Signal.Bot.Requests; + +namespace Signal.Bot.Types; + +/// +/// Represents a response to a +/// +public record PollResponse() +{ + /// + /// Gets or sets the timestamp + /// + [JsonPropertyName("timestamp")] + public DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/Signal.Bot/packages.lock.json b/src/Signal.Bot/packages.lock.json index 2f2a0d8..2973462 100644 --- a/src/Signal.Bot/packages.lock.json +++ b/src/Signal.Bot/packages.lock.json @@ -2,11 +2,17 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "yadTZIkStCVsG8nGwvfroSfBApPsgjQbodQyaIfp53dgayE0qhZpywixiCB6lx57JYQ+KVg1m1AFLrj54pxpZg==" + }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.3, )", - "resolved": "10.0.3", - "contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA==" + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA==" }, "R3": { "type": "Direct",