From 0bce7ba0fba0e43804c2682f1962a12d9116197d Mon Sep 17 00:00:00 2001 From: oupson Date: Thu, 6 Jan 2022 08:38:20 +0100 Subject: [PATCH] Initial Commit --- .gitignore | 3 + README.md | 1 + build.zig | 39 ++++++++++ src/client.zig | 197 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 136 +++++++++++++++++++++++++++++++++ src/twitch.zig | 22 ++++++ src/webhook.zig | 41 ++++++++++ 7 files changed, 439 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.zig create mode 100644 src/client.zig create mode 100644 src/main.zig create mode 100644 src/twitch.zig create mode 100644 src/webhook.zig diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94bcab0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +zig-cache +zig-out +config.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..da843ec --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Twitch Webhook \ No newline at end of file diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..0a9cfd1 --- /dev/null +++ b/build.zig @@ -0,0 +1,39 @@ +const std = @import("std"); + +pub fn build(b: *std.build.Builder) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard release options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. + const mode = b.standardReleaseOptions(); + + const exe = b.addExecutable("twitch-webhook", "src/main.zig"); + + exe.linkLibC(); + + exe.linkSystemLibrary("curl"); + + exe.setTarget(target); + exe.setBuildMode(mode); + exe.install(); + + const run_cmd = exe.run(); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_tests = b.addTest("src/main.zig"); + exe_tests.setTarget(target); + exe_tests.setBuildMode(mode); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&exe_tests.step); +} diff --git a/src/client.zig b/src/client.zig new file mode 100644 index 0000000..b56e7a6 --- /dev/null +++ b/src/client.zig @@ -0,0 +1,197 @@ +const std = @import("std"); + +const json = std.json; +const mem = std.mem; + +const cURL = @cImport({ + @cInclude("curl/curl.h"); +}); + +const ArrayListReader = struct { + items: []u8, + position: usize, +}; + +pub const Client = struct { + ptr: *cURL.CURL, + allocator: *mem.Allocator, + + pub fn init(allocator: *mem.Allocator) ?@This() { + const ptr = cURL.curl_easy_init() orelse return null; + + return @This(){ + .ptr = ptr, + .allocator = allocator, + }; + } + + pub fn globalInit() anyerror!void { + if (cURL.curl_global_init(cURL.CURL_GLOBAL_ALL) != cURL.CURLE_OK) + return error.CURLGlobalInitFailed; + } + + pub fn cleanup() anyerror!void { + cURL.curl_global_cleanup(); + } + + pub fn getJSON(self: *@This(), comptime T: type, url: [*:0]const u8, headers: ?*std.StringHashMap([]const u8)) anyerror!T { + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_URL, url) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_NOPROGRESS, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_MAXREDIRS, @as(c_long, 50)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + var header_slist: [*c]cURL.curl_slist = null; + + if (headers) |h| { + var iterator = h.iterator(); + + while (iterator.next()) |entry| { + var buf = try self.allocator.alloc(u8, entry.key_ptr.*.len + 3 + entry.value_ptr.*.len); + _ = try std.fmt.bufPrint(buf, "{s}: {s}\x00", .{ entry.key_ptr.*, entry.value_ptr.* }); + + header_slist = cURL.curl_slist_append(header_slist, buf.ptr); + self.allocator.free(buf); + } + } + + if (header_slist != null) { + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPHEADER, header_slist) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + } else { + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPHEADER, @as(c_long, 0)) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + } + + var response_buffer = std.ArrayList(u8).init(self.allocator.*); + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEDATA, &response_buffer) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (cURL.curl_easy_perform(self.ptr) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (header_slist != null) + cURL.curl_slist_free_all(header_slist); + + var stream = json.TokenStream.init(response_buffer.items); + + @setEvalBranchQuota(10_000); + const res = json.parse(T, &stream, .{ .allocator = self.allocator.*, .ignore_unknown_fields = true }); + + response_buffer.deinit(); + + return res; + } + + pub fn postJSON(self: *@This(), url: []const u8, data: anytype, headers: ?std.StringHashMap([]const u8)) anyerror![]const u8 { + var rawUrl = try self.allocator.allocSentinel(u8, url.len, 0); + std.mem.copy(u8, rawUrl, url); + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_URL, url.ptr) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_NOPROGRESS, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_MAXREDIRS, @as(c_long, 50)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_TCP_KEEPALIVE, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + var header_slist: [*c]cURL.curl_slist = null; + + if (headers) |h| { + var iterator = h.iterator(); + + while (iterator.next()) |entry| { + var buf = try self.allocator.alloc(u8, entry.key_ptr.*.len + 3 + entry.value_ptr.*.len); + _ = try std.fmt.bufPrint(buf, "{s}: {s}\x00", .{ entry.key_ptr.*, entry.value_ptr.* }); + + header_slist = cURL.curl_slist_append(header_slist, buf.ptr); + self.allocator.free(buf); + } + } + + header_slist = cURL.curl_slist_append(header_slist, "Content-Type: application/json"); + + var post_buffer = std.ArrayList(u8).init(self.allocator.*); + + try json.stringify(data, .{}, post_buffer.writer()); + std.log.debug("stringify : {s}", .{post_buffer.items}); + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_POST, @as(c_long, 1)) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_READDATA, &ArrayListReader{ + .items = post_buffer.items, + .position = 0, + }) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_READFUNCTION, readFromArrayListCallback) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (header_slist != null) { + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPHEADER, header_slist) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + } else { + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_HTTPHEADER, @as(c_long, 0)) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + } + + var response_buffer = std.ArrayList(u8).init(self.allocator.*); + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEFUNCTION, writeToArrayListCallback) != cURL.CURLE_OK) + return error.CURLSetOptFailed; + + if (cURL.curl_easy_setopt(self.ptr, cURL.CURLOPT_WRITEDATA, &response_buffer) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (cURL.curl_easy_perform(self.ptr) != cURL.CURLE_OK) + return error.CURLPerformFailed; + + if (header_slist != null) + cURL.curl_slist_free_all(header_slist); + + self.allocator.free(rawUrl); + + var res = response_buffer.toOwnedSlice(); + + response_buffer.deinit(); + + return res; + } + + pub fn deinit(self: *@This()) void { + cURL.curl_easy_cleanup(self.ptr); + } +}; + +fn writeToArrayListCallback(data: *anyopaque, size: c_uint, nmemb: c_uint, user_data: *anyopaque) callconv(.C) c_uint { + var buffer = @intToPtr(*std.ArrayList(u8), @ptrToInt(user_data)); + var typed_data = @intToPtr([*]u8, @ptrToInt(data)); + buffer.appendSlice(typed_data[0 .. nmemb * size]) catch return 0; + + return nmemb * size; +} + +fn readFromArrayListCallback(ptr: *anyopaque, ptr_size: c_uint, nmemb: c_uint, user_data: *anyopaque) callconv(.C) c_uint { + var buffer = @intToPtr(*ArrayListReader, @ptrToInt(user_data)); + var typed_data = @intToPtr([*]u8, @ptrToInt(ptr)); + + if (buffer.position < buffer.items.len) { + const size = @minimum(nmemb * ptr_size, @intCast(c_uint, buffer.items.len - buffer.position)); + + for (buffer.items[buffer.position .. buffer.position + size]) |s, i| + typed_data[i] = s; + + buffer.position += size; + + return size; + } + return 0; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..35da78e --- /dev/null +++ b/src/main.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const json = std.json; + +const twitch = @import("twitch.zig"); +const Client = @import("client.zig").Client; +const webhook = @import("webhook.zig"); + +const Config = struct { + token: []const u8, + client_id: []const u8, + user_logins: []const User, + webhook_url: []const u8, + + pub fn fromFile(allocator: std.mem.Allocator, path: []const u8) anyerror!@This() { + var file = try std.fs.cwd().openFile(path, .{ + .read = true, + .write = false, + }); + + var stat = try file.stat(); + const file_buffer = try allocator.alloc(u8, stat.size); + _ = try file.readAll(file_buffer); + + var stream = json.TokenStream.init(file_buffer); + + return json.parse(@This(), &stream, .{ .allocator = allocator }); + } +}; + +const User = struct { + user_login: []u8, + user_icon: []u8, +}; + +pub fn main() anyerror!void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + var allocator = arena.allocator(); + + var config = try Config.fromFile(allocator, "config.json"); + + try Client.globalInit(); + + var client = try Client.init(&allocator) orelse error.FailedInitClient; + + var headers = std.StringHashMap([]const u8).init(allocator); + try headers.put("Authorization", config.token); + try headers.put("Client-Id", config.client_id); + + try updateAlert(allocator, &client, &config, &headers); + + client.deinit(); + + try Client.cleanup(); + + arena.deinit(); +} + +pub fn updateAlert(allocator: std.mem.Allocator, client: *Client, config: *Config, headers: *std.StringHashMap([]const u8)) anyerror!void{ + var request = std.ArrayList(u8).init(allocator); + + try request.appendSlice("https://api.twitch.tv/helix/streams?"); + + { + var i: u8 = 0; + while (i < config.user_logins.len) : (i += 1) { + if (i != 0) + try request.append('&'); + try request.appendSlice("user_login="); + try request.appendSlice(config.user_logins[i].user_login); + } + } + + try request.append(0); + + const streams: twitch.TwitchRes([]const twitch.Stream) = try client.getJSON(twitch.TwitchRes([]const twitch.Stream), @ptrCast([*:0]const u8, request.items), headers); + + request.deinit(); + + std.log.info("{s}", .{streams}); + + if (streams.data.len > 0) { + var embeds = try allocator.alloc(webhook.Embed, streams.data.len); + + for (streams.data) |s, i| { + var viewer = std.ArrayList(u8).init(allocator); + try std.fmt.format(viewer.writer(), "{}", .{s.viewer_count}); + var fields = [_]webhook.Field{ + .{ + .name = "Viewer count", + .value = viewer.items, + .@"inline" = true, + }, + .{ + .name = "Game name", + .value = s.game_name, + .@"inline" = true, + }, + }; + + var thumbnail = try std.mem.replaceOwned(u8, allocator, s.thumbnail_url, "{width}", "1920"); + thumbnail = try std.mem.replaceOwned(u8, allocator, thumbnail, "{height}", "1080"); + + var stream_url = std.ArrayList(u8).init(allocator); + _ = try stream_url.appendSlice("https://twitch.tv/"); + _ = try stream_url.appendSlice(s.user_login); + + var icon_url: []u8 = ""; + for (config.user_logins) |u| { + if (std.mem.eql(u8, u.user_login, s.user_login)) { + icon_url = u.user_icon; + break; + } + } + + embeds[i] = .{ + .title = s.title, + .image = .{ + .url = thumbnail, + }, + .author = .{ + .name = s.user_name, + .url = stream_url.items, + .icon_url = icon_url, + }, + .color = 0xa970ff, + .fields = fields[0..], + }; + } + + _ = try client.postJSON(config.webhook_url, webhook.Webhook{ + .username = "Twitch", + .content = "Live alert", + .embeds = embeds, + }, null); + } +} diff --git a/src/twitch.zig b/src/twitch.zig new file mode 100644 index 0000000..b7408b7 --- /dev/null +++ b/src/twitch.zig @@ -0,0 +1,22 @@ +pub const Stream = struct { + id: []const u8, + user_id: []const u8, + user_login: []const u8, + user_name: []const u8, + game_id: []const u8, + game_name: []const u8, + type: []const u8, + title: []const u8, + viewer_count: u64, + started_at: []const u8, + language: []const u8, + thumbnail_url: []const u8, + tag_ids: ?[][]const u8, + is_mature: bool, +}; + +pub const Pagination = struct { cursor: ?[]u8 = null }; + +pub fn TwitchRes(comptime T: type) type { + return struct { data: T, pagination: Pagination }; +} diff --git a/src/webhook.zig b/src/webhook.zig new file mode 100644 index 0000000..3398d1c --- /dev/null +++ b/src/webhook.zig @@ -0,0 +1,41 @@ +pub const Webhook = struct { + username: []const u8, + avatar_url: ?[]const u8 = null, + content: []const u8, + embeds: ?[]Embed = null, + tts: bool = false, + // allowed_mentions +}; + +pub const Embed = struct { + author: ?Author = null, + title: []const u8, + url: ?[]const u8 = null, + description: ?[]const u8 = null, + color: u32, + fields: ?[]const Field = null, + thumbnail: ?Image = null, + image: ?Image = null, + footer: ?Footer = null, +}; + +pub const Author = struct { + name: []const u8, + url: ?[]const u8 = null, + icon_url: ?[]const u8 = null, +}; + +pub const Field = struct { + name: []const u8, + value: []const u8, + @"inline": bool, +}; + +pub const Image = struct { + url: []const u8, +}; + +pub const Footer = struct { + text: ?[]const u8 = null, + icon_url: ?[]const u8 = null, +};