Skip to content

Commit

Permalink
add Streamlabs HTTP client with donations and token API
Browse files Browse the repository at this point in the history
  • Loading branch information
Felk committed Dec 11, 2024
1 parent e5cdcd8 commit 72fbabd
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 0 deletions.
9 changes: 9 additions & 0 deletions TPP.Core/Configuration/BaseConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public sealed class BaseConfig : ConfigBase, IRootConfig
public Duration AdvertisePollsInterval { get; init; } = Duration.FromHours(1);

public ImmutableHashSet<TppFeatures> DisabledFeatures { get; init; } = ImmutableHashSet<TppFeatures>.Empty;

[Description("Donation handling via Streamlabs")]
public StreamlabsConfig StreamlabsConfig { get; init; } = new();
}

/// <summary>
Expand Down Expand Up @@ -79,3 +82,9 @@ public sealed class DiscordLoggingConfig : ConfigBase
public string WebhookToken { get; init; } = "";
public LogEventLevel MinLogLevel { get; init; } = LogEventLevel.Warning;
}

public sealed class StreamlabsConfig : ConfigBase
{
public bool Enabled { get; init; } = false;
public string AccessToken { get; init; } = "";
}
7 changes: 7 additions & 0 deletions TPP.Core/Modes/ModeBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using TPP.Core.Configuration;
using TPP.Core.Moderation;
using TPP.Core.Overlay;
using TPP.Core.Streamlabs;
using TPP.Core.Utils;
using TPP.Inputting;
using TPP.Model;
Expand Down Expand Up @@ -152,6 +153,12 @@ public ModeBase(
? null
: new ChattersWorker(loggerFactory, clock,
((TwitchChat)_chats[primaryChat.Name]).TwitchApi, repos.ChattersSnapshotsRepo, primaryChat);

if (baseConfig.StreamlabsConfig.Enabled)
{
StreamlabsClient streamlabsClient = new(loggerFactory.CreateLogger<StreamlabsClient>(), baseConfig.StreamlabsConfig.AccessToken);
// TODO do something with the client, e.g. open Websocket and add donations refresh worker
}
}

public void InstallAdditionalCommand(Command command)
Expand Down
105 changes: 105 additions & 0 deletions TPP.Core/Streamlabs/StreamlabsClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NodaTime;
using TPP.Core.Utils;

namespace TPP.Core.Streamlabs;

public class StreamlabsClient
{
private readonly ILogger<StreamlabsClient> _logger;
private readonly HttpClient _http;
public StreamlabsClient(ILogger<StreamlabsClient> logger, string accessToken)
{
_logger = logger;
_http = new HttpClient();
_http.BaseAddress = new Uri("https://streamlabs.com/api/v2.0/");
_http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}

/// The Streamlabs API puts responses that are lists into an envelope like this. This is probably to have the
/// top level JSON always be an object, as some (old) JSON parsers don't understand top level lists.
private record ListEnvelope<T>(List<T> Data);

private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};

public record Donation(
// e.g. 192911565
// Official documentation and examples say this is a string in json, but that's a lie!
long DonationId,

// e.g. 1733865557
// Similar to the donation ID, it's also a number in JSON, even if streamlabs documentation says otherwise.
[property: JsonConverter(typeof(InstantAsUnixSecondsConverter))]
Instant CreatedAt,

// e.g. "USD" or "EUR"
string Currency,

// e.g. "20.0000000000", in currency units (not cents or anything, that's what the fraction is for)
[property: JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)]
decimal Amount,

// The donor's username (though realistically this can be anything, as it's arbitrary)
string Name,

// This theoratically also exists (albeit undocumented), but it's PII, and we don't need it.
// string Email,

// The donation's message, which may be absend or just an empty string.
string? Message
);

/// <summary>
/// Fetch donations for the authenticated user. Results are ordered by creation date, descending.
/// </summary>
/// <param name="limit">Limit allows you to limit the number of results output.</param>
/// <param name="before">The before value is your donation id.</param>
/// <param name="after">The after value is your donation id.</param>
/// <param name="currency">The desired currency code. If empty, each record will be in the originating currency.</param>
/// <param name="verified">If verified is set to 1, response will only include verified donations from paypal,
/// credit card, skrill and unitpay, if it is set to 0 response will only include streamer added donations from
/// My Donations page, do not pass this field if you want to include both.</param>
/// <returns></returns>
public async Task<List<Donation>> GetDonations(
int? limit = null,
int? before = null,
int? after = null,
string? currency = null,
bool? verified = null)
{
Dictionary<string, string> queryParams = new();
if (limit != null) queryParams.Add("limit", limit.Value.ToString());
if (before != null) queryParams.Add("before", before.Value.ToString());
if (after != null) queryParams.Add("after", after.Value.ToString());
if (currency != null) queryParams.Add("currency", currency);
if (verified != null) queryParams.Add("verified", verified.Value.ToString());

string queryString = QueryStringBuilder.FromDictionary(queryParams);
var response = await _http.GetFromJsonAsync<ListEnvelope<Donation>>(
requestUri: "donations?" + queryString,
options: SerializerOptions);
return response!.Data;
}

private record SocketTokenEnvelope(string SocketToken);
/// Allows you to obtain a token which can be used to listen to user's event through sockets.
public async Task<string> GetSocketToken()
{
var response = await _http.GetFromJsonAsync<SocketTokenEnvelope>(
requestUri: "socket/token",
options: SerializerOptions);
return response!.SocketToken;
}
}
18 changes: 18 additions & 0 deletions TPP.Core/Utils/InstantAsUnixSecondsConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using NodaTime;

namespace TPP.Core.Utils;

/// <summary>
/// Converts between NodaTime <see cref="Instant"/> and unix epoch seconds as a 64-bit number.
/// E.g. during deserialization <c>1733865557</c> becomes <c>2024-12-10T21:19:17Z</c>.
/// </summary>
public class InstantAsUnixSecondsConverter : JsonConverter<Instant>
{
public override Instant Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Instant.FromUnixTimeSeconds(reader.GetInt64());
public override void Write(Utf8JsonWriter writer, Instant value, JsonSerializerOptions options) =>
writer.WriteNumberValue(value.ToUnixTimeSeconds());
}
12 changes: 12 additions & 0 deletions TPP.Core/Utils/QueryStringBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace TPP.Core.Utils;

public static class QueryStringBuilder
{
public static string FromDictionary(IDictionary<string, string> dict) =>
string.Join('&', dict.Select(kvp =>
HttpUtility.UrlEncode(kvp.Key) + '=' + HttpUtility.UrlEncode(kvp.Value)));
}
21 changes: 21 additions & 0 deletions TPP.Core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@
]
}
}
},
"StreamlabsConfig": {
"description": "Donation handling via Streamlabs",
"type": [
"object",
"null"
],
"properties": {
"Enabled": {
"type": "boolean"
},
"AccessToken": {
"type": [
"string",
"null"
]
}
}
}
},
"type": "object",
Expand Down Expand Up @@ -184,6 +202,9 @@
"Cosmetics"
]
}
},
"StreamlabsConfig": {
"$ref": "#/definitions/StreamlabsConfig"
}
}
}

0 comments on commit 72fbabd

Please sign in to comment.