Initial Commit
This commit is contained in:
commit
0bce7ba0fb
|
@ -0,0 +1,3 @@
|
|||
zig-cache
|
||||
zig-out
|
||||
config.json
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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,
|
||||
};
|
Loading…
Reference in New Issue