├── .gitignore
├── .github
└── bork.png
├── src
├── remote.zig
├── network
│ ├── twitch
│ │ ├── collect_fragment.html
│ │ ├── EmoteCache.zig
│ │ ├── event_parser.zig
│ │ ├── Auth.zig
│ │ └── irc_parser.zig
│ ├── collect_fragment.html
│ ├── youtube
│ │ ├── Auth.zig
│ │ └── livechat.zig
│ └── oauth.zig
├── utils
│ ├── url.zig
│ └── channel.zig
├── config.ziggy-schema
├── remote
│ ├── utils.zig
│ ├── client.zig
│ └── Server.zig
├── logging.zig
├── Config.zig
├── Chat.zig
├── main.zig
├── Network.zig
└── display.zig
├── LICENSE
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .zig-cache/
2 | .DS_Store
3 | *.log
4 | zig-out/
5 | release/
6 |
--------------------------------------------------------------------------------
/.github/bork.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kristoff-it/bork/HEAD/.github/bork.png
--------------------------------------------------------------------------------
/src/remote.zig:
--------------------------------------------------------------------------------
1 | pub const client = @import("remote/client.zig");
2 | pub const Server = @import("remote/Server.zig");
3 |
--------------------------------------------------------------------------------
/src/network/twitch/collect_fragment.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Success! Click here if your browser doesn't redirect you automatically.
4 |
5 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/src/network/collect_fragment.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Success! Click here
8 | if your browser doesn't redirect you automatically.
9 |
10 |
11 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/utils/url.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 |
3 | /// AI-powered URL detection
4 | pub fn sense(word: []const u8) bool {
5 | return std.mem.startsWith(u8, word, "http") or
6 | std.mem.startsWith(u8, word, "(http");
7 | }
8 |
9 | pub fn clean(url: []const u8) []const u8 {
10 | if (url[0] == '(') {
11 | if (url[url.len - 1] == ')') {
12 | return url[1..(url.len - 1)];
13 | }
14 | return url[1..];
15 | }
16 | return url;
17 | }
18 |
--------------------------------------------------------------------------------
/src/config.ziggy-schema:
--------------------------------------------------------------------------------
1 | root = Config
2 |
3 |
4 | struct Config {
5 | ///When enabled, you will need to run `bork quit`
6 | ///in another terminal to close your main bork instance.
7 | ctrl_c_protection: bool,
8 | /// Settings relative to highlighted notifications diplayed in bork.
9 | notifications: Notifications,
10 | /// Enable YouTube LiveChat support (when simulcasting).
11 | /// Defaults to disabled.
12 | youtube: ?bool,
13 | }
14 |
15 | struct Notifications {
16 | ///Enable new follower notifications.
17 | ///
18 | ///Since users can unfollow and refollow right after,
19 | ///effectively spamming their notifications, only one
20 | ///follow notification per user will be displayed per
21 | ///session.
22 | follows: bool,
23 | ///Enable charity donation notifications.
24 | ///
25 | ///Bork only supports notifications for new monetary
26 | ///donations, so no notifications will be generated
27 | ///when creating new charity fundraisers.
28 | charity: bool,
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Loris Cro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/src/remote/utils.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 |
3 | const component = enum { h, m, s };
4 |
5 | pub fn parseTime(time: []const u8) !i64 {
6 | var total_seconds: i64 = 0;
7 | var current: component = .h;
8 | var searching_digits = true;
9 | var start: usize = 0;
10 | var i: usize = 0;
11 | while (i < time.len) : (i += 1) {
12 | if (time[i] >= '0' and time[i] <= '9') {
13 | searching_digits = true;
14 | continue;
15 | }
16 |
17 | searching_digits = false;
18 |
19 | const number = try std.fmt.parseInt(i64, time[start..i], 10);
20 | start = i + 1;
21 |
22 | // Searching for h, m, s
23 | switch (time[i]) {
24 | else => return error.ParseError,
25 | 'h' => {
26 | if (current != .h) {
27 | return error.ParseError;
28 | }
29 |
30 | total_seconds += number * 60 * 60;
31 | current = .m;
32 | },
33 | 'm' => {
34 | if (current == .s) {
35 | return error.ParseError;
36 | }
37 |
38 | total_seconds += number * 60;
39 | current = .s;
40 | },
41 |
42 | 's' => {
43 | if (i + 1 != time.len) {
44 | return error.ParseError;
45 | }
46 | total_seconds += number;
47 | },
48 | }
49 | }
50 |
51 | if (searching_digits) return error.ParseError;
52 |
53 | return total_seconds;
54 | }
55 |
56 | test {
57 | try std.testing.expectError(error.ParseError, parseTime("1"));
58 | try std.testing.expectEqual(parseTime("10m"), 600);
59 | }
60 |
--------------------------------------------------------------------------------
/src/logging.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const builtin = @import("builtin");
3 | const options = @import("build_options");
4 | const folders = @import("known-folders");
5 |
6 | var log_file: ?std.fs.File = switch (builtin.target.os.tag) {
7 | .windows => null,
8 | else => std.io.getStdErr(),
9 | };
10 |
11 | pub fn logFn(
12 | comptime level: std.log.Level,
13 | comptime scope: @Type(.enum_literal),
14 | comptime format: []const u8,
15 | args: anytype,
16 | ) void {
17 | // if (scope != .display) return;
18 |
19 | const l = log_file orelse return;
20 | const scope_prefix = "(" ++ @tagName(scope) ++ "): ";
21 | const prefix = "[" ++ @tagName(level) ++ "] " ++ scope_prefix;
22 | std.debug.lockStdErr();
23 | defer std.debug.unlockStdErr();
24 |
25 | const writer = l.writer();
26 | writer.print(prefix ++ format ++ "\n", args) catch return;
27 | }
28 |
29 | pub fn setup(gpa: std.mem.Allocator) void {
30 | std.debug.lockStdErr();
31 | defer std.debug.unlockStdErr();
32 |
33 | log_file = std.io.getStdErr();
34 |
35 | setup_internal(gpa) catch {
36 | log_file = null;
37 | };
38 | }
39 |
40 | fn setup_internal(gpa: std.mem.Allocator) !void {
41 | const cache_base = try folders.open(gpa, .cache, .{}) orelse
42 | try folders.open(gpa, .home, .{}) orelse
43 | try folders.open(gpa, .executable_dir, .{}) orelse
44 | std.fs.cwd();
45 |
46 | try cache_base.makePath("bork");
47 |
48 | const log_name = if (options.local) "bork-local.log" else "bork.log";
49 | const log_path = try std.fmt.allocPrint(gpa, "bork/{s}", .{log_name});
50 | defer gpa.free(log_path);
51 |
52 | const file = try cache_base.createFile(log_path, .{ .truncate = false });
53 | const end = try file.getEndPos();
54 | try file.seekTo(end);
55 |
56 | log_file = file;
57 | }
58 |
--------------------------------------------------------------------------------
/src/utils/channel.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 |
3 | pub fn Channel(comptime T: type) type {
4 | return struct {
5 | lock: std.Thread.Mutex = .{},
6 | fifo: Fifo,
7 | writeable: std.Thread.Condition = .{},
8 | readable: std.Thread.Condition = .{},
9 |
10 | const Fifo = std.fifo.LinearFifo(T, .Slice);
11 | const Self = @This();
12 |
13 | pub fn init(buffer: []T) Self {
14 | return Self{ .fifo = Fifo.init(buffer) };
15 | }
16 |
17 | pub fn put(self: *Self, item: T) void {
18 | self.lock.lock();
19 | defer {
20 | self.lock.unlock();
21 | self.readable.signal();
22 | }
23 |
24 | while (true) return self.fifo.writeItem(item) catch {
25 | self.writeable.wait(&self.lock);
26 | continue;
27 | };
28 | }
29 |
30 | pub fn tryPut(self: *Self, item: T) !void {
31 | self.lock.lock();
32 | defer self.lock.unlock();
33 |
34 | try self.fifo.writeItem(item);
35 |
36 | // only signal on success
37 | self.readable.signal();
38 | }
39 |
40 | pub fn get(self: *Self) T {
41 | self.lock.lock();
42 | defer {
43 | self.lock.unlock();
44 | self.writeable.signal();
45 | }
46 |
47 | while (true) return self.fifo.readItem() orelse {
48 | self.readable.wait(&self.lock);
49 | continue;
50 | };
51 | }
52 |
53 | pub fn getOrNull(self: *Self) ?T {
54 | self.lock.lock();
55 | defer self.lock.unlock();
56 |
57 | if (self.fifo.readItem()) |item| return item;
58 |
59 | // signal on empty queue
60 | self.writeable.signal();
61 | }
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # bork
2 | *A TUI chat client tailored for livecoding on Twitch, currently in alpha stage.*
3 |
4 |
5 |
6 | ### Main features
7 | - Displays Twitch emotes in the terminal, **including your own custom emotes!**
8 | - Understands Twitch-specific concepts (subcriptions, gifted subs, ...).
9 | - Displays badges for your subs, mods, etc.
10 | - Supports clearing chat and deletes messages from banned users.
11 | - Click on a message to highlight it and let your viewers know who you're relpying to.
12 |
13 | ## Why?
14 | Many livecoders show their chat feed on stream. It makes sense for the livecoding genre, since the content is text-heavy and you want viewers to be aware of all social interactions taking place, even when they put the video in full screen mode.
15 |
16 | It's also common for livecoders to use terminal applications to show chat on screen, partially out of convenience, partially because of the appeal of the terminal aestetic. Unfortunately the most common solution, irssi, is an IRC client that can show basic Twitch messages, but that doesn't understand any of the Twitch-specific concepts such as subs, sub gifts, highlighted messages, etc.
17 |
18 | Bork is designed to replace irssi for this usecase by providing all the functionality that isn't present in a general-purpose IRC client.
19 |
20 | ## Requirements
21 | To see Twitch emotes in the terminal, you will need [Kitty](https://github.com/kovidgoyal/kitty), Ghostty, or any terminal emulator that supports the Kitty graphics protocol.
22 | Bork will otherwise fallback to showing the emote name (eg "Kappa").
23 |
24 | Bork also temporarily has a dependency on `curl`. Bork will try to invoke it to check the validity of your Twitch OAuth token. This requirement will go away in the future.
25 |
26 | ## Obtaining bork
27 | Get a copy of bork from [the Releases section of GitHub](https://github.com/kristoff-it/bork/releases) or build it yourself (see below).
28 |
29 | ## Usage
30 | Run `bork start` to run the main bork instance. On first run you will be greeded by a config wizard.
31 |
32 | Run `bork` (without subcommand) for the main help menu.
33 |
34 | Supported subcommands:
35 |
36 | - `start` runs the main bork instance
37 | - `quit` quits the main bork instance
38 | - `afk` shows an afk window in bork with a countdown
39 | - `reconnect` makes bork to reconnect
40 | - `send` sends a message
41 | - `links` obtains a list of links sent to chat
42 | - `ban` bans a user (also deletes all their messages)
43 | - `unban` unbans a user
44 | - `version` prints the version
45 |
46 | ## Build
47 | Requires a **very** recent version of Zig, as bork development tracks Zig master.
48 |
49 | Run `zig build` to obtain a debug build of bork.
50 |
51 | Run `zig build -Doptimize=ReleaseFast` to obtain a ReleaseFast build of bork.
52 |
53 | ## Demo
54 | https://youtu.be/Px8rVB3ZpKA
55 |
--------------------------------------------------------------------------------
/src/network/twitch/EmoteCache.zig:
--------------------------------------------------------------------------------
1 | const EmoteCache = @This();
2 |
3 | const builtin = @import("builtin");
4 | const std = @import("std");
5 | const os = std.os;
6 | const b64 = std.base64.standard.Encoder;
7 | const Emote = @import("../../Chat.zig").Message.Emote;
8 |
9 | const EmoteHashMap = std.StringHashMap(struct {
10 | data: []const u8,
11 | idx: u32,
12 | });
13 |
14 | gpa: std.mem.Allocator,
15 | idx_counter: u32 = 1,
16 | cache: EmoteHashMap,
17 | read_buf: std.ArrayList(u8),
18 |
19 | // TODO: for people with 8k SUMQHD terminals, let them use bigger size emotes
20 | // const path_fmt = "https://localhost:443/emoticons/v1/{s}/3.0";
21 | const hostname = "static-cdn.jtvnw.net";
22 |
23 | pub fn init(gpa: std.mem.Allocator) EmoteCache {
24 | return EmoteCache{
25 | .gpa = gpa,
26 | .cache = EmoteHashMap.init(gpa),
27 | .read_buf = std.ArrayList(u8).init(gpa),
28 | };
29 | }
30 |
31 | // TODO: make this concurrent
32 | // TODO: make so failing one emote doesn't fail the whole job!
33 | pub fn fetch(self: *EmoteCache, emote_list: []Emote) !void {
34 | var client: std.http.Client = .{
35 | .allocator = self.gpa,
36 | };
37 | defer client.deinit();
38 |
39 | for (emote_list) |*emote| {
40 | self.read_buf.clearRetainingCapacity();
41 |
42 | std.log.debug("fetching {}", .{emote.*});
43 | const result = try self.cache.getOrPut(emote.twitch_id);
44 | errdefer _ = self.cache.remove(emote.twitch_id);
45 | if (!result.found_existing) {
46 | std.log.debug("need to download", .{});
47 | // Need to download the image
48 | const img = img: {
49 | const url = try std.fmt.allocPrint(
50 | self.gpa,
51 | "https://{s}/emoticons/v1/{s}/1.0",
52 | .{ hostname, emote.twitch_id },
53 | );
54 | defer self.gpa.free(url);
55 |
56 | const res = try client.fetch(.{
57 | .location = .{ .url = url },
58 | .response_storage = .{ .dynamic = &self.read_buf },
59 | });
60 |
61 | if (res.status != .ok) {
62 | std.log.debug("http bad response code: {s}", .{@tagName(res.status)});
63 | return error.HttpFailed;
64 | }
65 |
66 | break :img self.read_buf.items;
67 | };
68 |
69 | const encode_buf = try self.gpa.alloc(u8, std.base64.standard.Encoder.calcSize(img.len));
70 | result.value_ptr.* = .{
71 | .data = b64.encode(encode_buf, img),
72 | .idx = self.idx_counter,
73 | };
74 | self.idx_counter += 1;
75 | }
76 |
77 | emote.img_data = result.value_ptr.data;
78 | emote.idx = result.value_ptr.idx;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/network/twitch/event_parser.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 |
3 | const log = std.log.scoped(.ws);
4 |
5 | pub const Event = union(enum) {
6 | none,
7 | session_keepalive,
8 | session_welcome: []const u8, // the session id
9 | follower: struct {
10 | login_name: []const u8,
11 | display_name: []const u8,
12 | time: [5]u8,
13 | },
14 | charity: struct {
15 | login_name: []const u8,
16 | display_name: []const u8,
17 | time: [5]u8,
18 | amount: []const u8,
19 | },
20 | };
21 |
22 | const MessageSausage = struct {
23 | metadata: struct {
24 | message_id: []const u8,
25 | message_type: []const u8,
26 | message_timestamp: []const u8,
27 | subscription_type: ?[]const u8 = null,
28 | subscription_version: ?[]const u8 = null,
29 | },
30 | payload: struct {
31 | session: ?struct {
32 | id: []const u8,
33 | status: []const u8,
34 | keepalive_timeout_seconds: ?usize,
35 | reconnect_url: ?[]const u8 = null,
36 | connected_at: []const u8,
37 | } = null,
38 | subscription: ?struct {
39 | id: []const u8,
40 | status: []const u8,
41 | type: []const u8,
42 | version: []const u8,
43 | cost: usize,
44 | condition: struct {},
45 | transport: struct {},
46 | created_at: []const u8,
47 | } = null,
48 | event: ?struct {
49 | // channel.follow
50 | user_login: ?[]const u8 = null,
51 | user_name: ?[]const u8 = null,
52 | // channel.charity_campaign.donate
53 | charity_name: ?[]const u8 = null,
54 | amount: ?Amount = null,
55 | } = null,
56 | },
57 | };
58 | const Amount = struct {
59 | value: usize,
60 | decimal_places: usize,
61 | currency: []const u8,
62 | };
63 | pub fn parseEvent(gpa: std.mem.Allocator, data: []const u8) !Event {
64 | const message = try std.json.parseFromSliceLeaky(MessageSausage, gpa, data, .{
65 | .allocate = .alloc_always,
66 | .ignore_unknown_fields = true,
67 | });
68 | log.debug("ws message: {any}", .{message});
69 |
70 | const msg_type = message.metadata.message_type;
71 | if (std.mem.eql(u8, msg_type, "session_welcome")) {
72 | return .{
73 | .session_welcome = message.payload.session.?.id,
74 | };
75 | } else if (std.mem.eql(u8, msg_type, "session_keepalive")) {
76 | return .session_keepalive;
77 | } else if (std.mem.eql(u8, msg_type, "notification")) {
78 | return parseNotification(gpa, message);
79 | } else {
80 | log.debug("unhandled message type: {s}", .{msg_type});
81 | return .none;
82 | }
83 | }
84 |
85 | fn parseNotification(gpa: std.mem.Allocator, message: MessageSausage) !Event {
86 | const sub_type = message.metadata.subscription_type.?;
87 | const event = message.payload.event.?;
88 | if (std.mem.eql(u8, sub_type, "channel.follow")) {
89 | return .{
90 | .follower = .{
91 | .login_name = event.user_login.?,
92 | .display_name = event.user_name.?,
93 | .time = try getTime(message),
94 | },
95 | };
96 | } else if (std.mem.eql(u8, sub_type, "channel.charity_campaign.donate")) {
97 | return .{
98 | .charity = .{
99 | .login_name = event.user_login.?,
100 | .display_name = event.user_name.?,
101 | .time = try getTime(message),
102 | .amount = try parseAmount(gpa, event.amount.?),
103 | },
104 | };
105 | } else {
106 | log.debug("TODO: handle notification of type {s}", .{sub_type});
107 | return .none;
108 | }
109 | }
110 |
111 | fn getTime(message: MessageSausage) ![5]u8 {
112 | var it = std.mem.tokenizeScalar(u8, message.metadata.message_timestamp, 'T');
113 | _ = it.next() orelse return error.BadMessage;
114 | const rest = it.next() orelse return error.BadMessage;
115 | if (rest.len < 5) return error.BadMessage;
116 | return rest[0..5].*;
117 | }
118 |
119 | fn parseAmount(gpa: std.mem.Allocator, amount: Amount) ![]const u8 {
120 | var buf: [1024]u8 = undefined;
121 | var number = try std.fmt.bufPrint(&buf, "{}", .{amount.value});
122 | const split = number.len - amount.decimal_places;
123 | const before = number[0..split];
124 | const after = number[split..];
125 | if (std.mem.eql(u8, amount.currency, "USD")) {
126 | return std.fmt.allocPrint(gpa, "${s}.{s}", .{ before, after });
127 | } else {
128 | return std.fmt.allocPrint(
129 | gpa,
130 | "{s}.{s} {s}",
131 | .{ before, after, amount.currency },
132 | );
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/src/network/twitch/Auth.zig:
--------------------------------------------------------------------------------
1 | const Auth = @This();
2 | const std = @import("std");
3 | const build_opts = @import("build_options");
4 | const folders = @import("known-folders");
5 | const Config = @import("../../Config.zig");
6 | const oauth = @import("../oauth.zig");
7 |
8 | const log = std.log.scoped(.twitch_auth);
9 | const url = "https://id.twitch.tv/oauth2/validate";
10 |
11 | user_id: []const u8,
12 | login: []const u8,
13 | token: []const u8 = "",
14 |
15 | pub fn get(gpa: std.mem.Allocator, config_base: std.fs.Dir) !Auth {
16 | const token = blk: {
17 | const file = config_base.openFile("bork/twitch-token.secret", .{}) catch |err| {
18 | switch (err) {
19 | else => return err,
20 | error.FileNotFound => {
21 | const t = try oauth.createToken(gpa, config_base, .twitch, false);
22 | break :blk t.twitch;
23 | },
24 | }
25 | };
26 | defer file.close();
27 | const token_raw = try file.reader().readAllAlloc(gpa, 4096);
28 | const token = std.mem.trimRight(u8, token_raw, " \n");
29 | break :blk token;
30 | };
31 |
32 | return authenticateToken(gpa, token) catch |err| switch (err) {
33 | // Twitch token needs to be renewed
34 | error.TokenExpired => {
35 | const new_token = try oauth.createToken(gpa, config_base, .twitch, true);
36 |
37 | const auth = authenticateToken(gpa, new_token.twitch) catch |new_err| {
38 | std.debug.print("\nCould not validate the token with Twitch: {s}\n", .{
39 | @errorName(new_err),
40 | });
41 |
42 | std.process.exit(1);
43 | };
44 |
45 | return auth;
46 | },
47 | else => {
48 | std.debug.print("Error while renewing Twitch OAuth token: {s}\n", .{
49 | @errorName(err),
50 | });
51 | std.process.exit(1);
52 | },
53 | };
54 | }
55 |
56 | pub fn authenticateToken(gpa: std.mem.Allocator, token: []const u8) !Auth {
57 | if (build_opts.local) return .{
58 | .user_id = "$user_id",
59 | .login = "$login",
60 | .token = "$token",
61 | };
62 |
63 | std.debug.print("Twitch auth...\n", .{});
64 |
65 | const header_oauth = try std.fmt.allocPrint(
66 | gpa,
67 | "Authorization: {s}",
68 | .{token},
69 | );
70 | defer gpa.free(header_oauth);
71 |
72 | const result = try std.process.Child.run(.{
73 | .allocator = gpa,
74 | .argv = &.{
75 | "curl",
76 | "-s",
77 | "-H",
78 | header_oauth,
79 | url,
80 | },
81 | });
82 |
83 | defer {
84 | gpa.free(result.stdout);
85 | gpa.free(result.stderr);
86 | }
87 |
88 | if (result.stdout.len == 0) {
89 | return error.TokenExpired;
90 | }
91 |
92 | var auth = std.json.parseFromSliceLeaky(Auth, gpa, result.stdout, .{
93 | .allocate = .alloc_always,
94 | .ignore_unknown_fields = true,
95 | }) catch {
96 | // std.debug.print("auth fail: {s}\n", .{result.stdout});
97 | // NOTE: A parsing error means token exprired for us because
98 | // twitch likes to reply with 200 `{"status":401,"message":"invalid access token"}`
99 | // as one does.
100 | return error.TokenExpired;
101 | };
102 |
103 | auth.token = token;
104 | return auth;
105 | }
106 |
107 | // TODO: re-enable once either Twitch starts supporting TLS 1.3 or Zig
108 | // adds support for TLS 1.2
109 | fn authenticateTokenNative(gpa: std.mem.Allocator, token: []const u8) !?Auth {
110 | if (build_opts.local) return true;
111 |
112 | var client: std.http.Client = .{
113 | .allocator = gpa,
114 | };
115 |
116 | var it = std.mem.tokenize(u8, token, ":");
117 | _ = it.next();
118 | const header_oauth = try std.fmt.allocPrint(gpa, "OAuth {s}", .{it.next().?});
119 | defer gpa.free(header_oauth);
120 |
121 | const headers = try std.http.Headers.initList(gpa, &.{
122 | .{
123 | .name = "User-Agent",
124 | .value = "Bork",
125 | },
126 | .{
127 | .name = "Accept",
128 | .value = "*/*",
129 | },
130 | .{
131 | .name = "Authorization",
132 | .value = header_oauth,
133 | },
134 | });
135 |
136 | const result = try client.fetch(gpa, .{
137 | .headers = headers,
138 | .location = .{ .url = url },
139 | });
140 |
141 | if (result.status != .ok) {
142 | log.debug("token is not good: {s}", .{@tagName(result.status)});
143 | return null;
144 | }
145 |
146 | const auth = try std.json.parseFromSlice(Auth, gpa, result.body, .{
147 | .allocate = .alloc_always,
148 | .ignore_unknown_fields = true,
149 | });
150 | auth.token = token;
151 | return auth;
152 | }
153 |
--------------------------------------------------------------------------------
/src/Config.zig:
--------------------------------------------------------------------------------
1 | const Config = @This();
2 | const std = @import("std");
3 | const ziggy = @import("ziggy");
4 |
5 | youtube: bool = false,
6 | ctrl_c_protection: bool = false,
7 | notifications: struct {
8 | follows: bool = true,
9 | charity: bool = true,
10 | } = .{},
11 |
12 | pub fn get(gpa: std.mem.Allocator, config_base: std.fs.Dir) !Config {
13 | const bytes = config_base.readFileAllocOptions(gpa, "bork/config.ziggy", ziggy.max_size, null, 1, 0) catch |err| switch (err) {
14 | else => return err,
15 | error.FileNotFound => return create(config_base),
16 | };
17 | defer gpa.free(bytes);
18 |
19 | return ziggy.parseLeaky(Config, gpa, bytes, .{});
20 | }
21 |
22 | pub fn create(config_base: std.fs.Dir) !Config {
23 | const in = std.io.getStdIn();
24 | const in_reader = in.reader();
25 |
26 | std.debug.print(
27 | \\
28 | \\Hi, welcome to Bork!
29 | \\This is the initial setup procedure that will
30 | \\help you create an initial config file.
31 | \\
32 | , .{});
33 |
34 | // Inside this scope user input is set to immediate mode.
35 | const config: Config = blk: {
36 | var config: Config = .{};
37 | // const original_termios = try std.posix.tcgetattr(in.handle);
38 | // defer std.posix.tcsetattr(in.handle, .FLUSH, original_termios) catch {};
39 | {
40 | // var termios = original_termios;
41 | // // set immediate input mode
42 | // termios.lflag.ICANON = false;
43 | // try std.posix.tcsetattr(in.handle, .FLUSH, termios);
44 |
45 | std.debug.print(
46 | \\
47 | \\=============================================================
48 | \\
49 | \\Bork allows you to interact with it in three ways:
50 | \\
51 | \\- Keyboard
52 | \\ Up/Down Arrows and Page Up/Down will allow you to
53 | \\ scroll message history.
54 | \\
55 | \\- Mouse
56 | \\ Left click on messages to highlight them, clicking
57 | \\ on the message author will toggle highlight all
58 | \\ messages from that same user.
59 | \\ Wheel Up/Down to scroll message history.
60 | \\
61 | \\- Remote CLI
62 | \\ By invoking the `bork` command in a shell you will
63 | \\ be able to issue various commands, from sending
64 | \\ messages to issuing bans. See the full list of
65 | \\ commands by calling `bork help`.
66 | \\
67 | \\Press any key to continue reading...
68 | \\
69 | \\
70 | , .{});
71 |
72 | _ = try in_reader.readByte();
73 |
74 | std.debug.print(
75 | \\
76 | \\--- YouTube Support
77 | \\
78 | \\If you plan to simulcast to both Twitch and YouTube,
79 | \\Bork can display live chat from both platforms in a
80 | \\unified stream.
81 | \\
82 | \\Enabling YouTube support will require you to authenticate
83 | \\with YouTube when launching Bork. You can always enable
84 | \\it later by modifiyng Bork's config file.
85 | \\
86 | \\Enable YouTube support? [y/N]
87 | , .{});
88 |
89 | config.youtube = switch (try in_reader.readByte()) {
90 | else => false,
91 | 'y', 'Y' => true,
92 | };
93 |
94 | std.debug.print(
95 | \\
96 | \\
97 | \\ ======> ! IMPORTANT ! <======
98 | \\To protect you from accidentally closing Bork while
99 | \\streaming, with CTRL+C protection enabled, Bork will
100 | \\not close when you press CTRL+C.
101 | \\
102 | \\To close it, you will instead have to execute in a
103 | \\separate shell:
104 | \\
105 | \\ `bork quit`
106 | \\
107 | \\Enable CTRL+C protection? [Y/n]
108 | , .{});
109 |
110 | config.ctrl_c_protection = switch (try in_reader.readByte()) {
111 | else => false,
112 | 'y', 'Y', '\n' => true,
113 | };
114 | }
115 | break :blk config;
116 | };
117 |
118 | // create the config file
119 | var file = try config_base.createFile("bork/config.ziggy", .{});
120 | defer file.close();
121 | try file.writer().print(".ctrl_c_protection = {},\n", .{config.ctrl_c_protection});
122 | try file.writer().print(".youtube = {},\n", .{config.youtube});
123 |
124 | // ensure presence of the schema file
125 | var schema_file = try config_base.createFile("bork/config.ziggy-schema", .{});
126 | defer schema_file.close();
127 | try schema_file.writeAll(@embedFile("config.ziggy-schema"));
128 |
129 | return config;
130 | }
131 |
--------------------------------------------------------------------------------
/src/network/youtube/Auth.zig:
--------------------------------------------------------------------------------
1 | const Auth = @This();
2 |
3 | const std = @import("std");
4 | const oauth = @import("../oauth.zig");
5 | const livechat = @import("livechat.zig");
6 | const Config = @import("../../Config.zig");
7 |
8 | const log = std.log.scoped(.yt_auth);
9 |
10 | enabled: bool = false,
11 | chat_id: ?[]const u8 = null,
12 | token: oauth.Token.YouTube = undefined,
13 |
14 | const google_oauth = "https://accounts.google.com/o/oauth2/v2/auth?client_id=519150430990-68hvu66hl7vdtpb4u1mngb0qq2hqoiv8.apps.googleusercontent.com&redirect_uri=http://localhost:22890&response_type=token&scope=https://www.googleapis.com/auth/youtube.readonly";
15 |
16 | pub fn get(gpa: std.mem.Allocator, config_base: std.fs.Dir) !Auth {
17 | const token: oauth.Token.YouTube = blk: {
18 | const file = config_base.openFile("bork/youtube-token.secret", .{}) catch |err| {
19 | switch (err) {
20 | else => return err,
21 | error.FileNotFound => {
22 | const t = try oauth.createToken(gpa, config_base, .youtube, false);
23 | break :blk t.youtube;
24 | },
25 | }
26 | };
27 | defer file.close();
28 | const token_raw = try file.reader().readAllAlloc(gpa, 4096);
29 | const refresh_token = std.mem.trimRight(u8, token_raw, " \n");
30 | break :blk refreshToken(gpa, refresh_token) catch |err| switch (err) {
31 | error.InvalidToken => ct: {
32 | const t = try oauth.createToken(
33 | gpa,
34 | config_base,
35 | .youtube,
36 | true,
37 | );
38 | break :ct t.youtube;
39 | },
40 | else => return err,
41 | };
42 | };
43 |
44 | return authenticateToken(gpa, token) catch |err| switch (err) {
45 | // Twitch token needs to be renewed
46 | error.InvalidToken => {
47 | const new_token = try oauth.createToken(gpa, config_base, .youtube, true);
48 |
49 | const auth = authenticateToken(gpa, new_token.youtube) catch |new_err| {
50 | std.debug.print("\nCould not validate the token with YouTube: {s}\n", .{
51 | @errorName(new_err),
52 | });
53 |
54 | std.process.exit(1);
55 | };
56 |
57 | return auth;
58 | },
59 | else => {
60 | std.debug.print("Error while renewing YouTube OAuth token: {s}\n", .{
61 | @errorName(err),
62 | });
63 | std.process.exit(1);
64 | },
65 | };
66 | }
67 |
68 | pub fn authenticateToken(gpa: std.mem.Allocator, token: oauth.Token.YouTube) !Auth {
69 | std.debug.print("YouTube auth... \n", .{});
70 | const chat_id = try livechat.findLive(gpa, token);
71 | return .{
72 | .enabled = true,
73 | .token = token,
74 | .chat_id = chat_id,
75 | };
76 | }
77 |
78 | const google_refresh = "https://oauth2.googleapis.com/token?client_id=519150430990-68hvu66hl7vdtpb4u1mngb0qq2hqoiv8.apps.googleusercontent.com&client_secret=GOC" ++ "SPX-5e1VALKHYwGJZDlnLyUKKgN_I1KW&grant_type=refresh_token&refresh_token={s}";
79 |
80 | var not_first = false;
81 |
82 | pub fn refreshToken(
83 | gpa: std.mem.Allocator,
84 | refresh_token: []const u8,
85 | ) !oauth.Token.YouTube {
86 | var arena_impl = std.heap.ArenaAllocator.init(gpa);
87 | defer arena_impl.deinit();
88 |
89 | const arena = arena_impl.allocator();
90 |
91 | // if (not_first) @breakpoint();
92 | // not_first = true;
93 |
94 | var yt: std.http.Client = .{ .allocator = arena };
95 | defer yt.deinit();
96 |
97 | const refresh_url = try std.fmt.allocPrint(arena, google_refresh, .{
98 | refresh_token,
99 | });
100 |
101 | var buf = std.ArrayList(u8).init(arena);
102 |
103 | log.debug("YT REQUEST: refresh access token url: {s}", .{refresh_url});
104 |
105 | const res = yt.fetch(.{
106 | .location = .{ .url = refresh_url },
107 | .method = .POST,
108 | .response_storage = .{ .dynamic = &buf },
109 | .extra_headers = &.{.{ .name = "Content-Length", .value = "0" }},
110 | }) catch |err| {
111 | log.debug("refresh url request failed: {}, url: {s}", .{
112 | err,
113 | refresh_url,
114 | });
115 | return error.YouTubeRefreshTokenFailed;
116 | };
117 |
118 | log.debug("yt token refresh = {}", .{res});
119 | log.debug("data = {s}", .{buf.items});
120 |
121 | if (res.status != .ok) {
122 | return error.InvalidToken;
123 | }
124 |
125 | const payload = std.json.parseFromSliceLeaky(struct {
126 | access_token: []const u8,
127 | expires_in: i64,
128 | scope: []const u8,
129 | token_type: []const u8,
130 | }, arena, buf.items, .{}) catch {
131 | log.err("Error while parsing YouTube token refresh payoload: {s}", .{buf.items});
132 | return error.BadYouTubeRefreshData;
133 | };
134 |
135 | return .{
136 | .refresh = refresh_token,
137 | .access = try std.fmt.allocPrint(
138 | gpa,
139 | "Bearer {s}",
140 | .{payload.access_token},
141 | ),
142 | .expires_at_seconds = std.time.timestamp() + payload.expires_in,
143 | };
144 | }
145 |
--------------------------------------------------------------------------------
/src/remote/client.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const folders = @import("known-folders");
3 | const ArgIterator = std.process.ArgIterator;
4 | const clap = @import("clap");
5 | const Event = @import("../remote.zig").Event;
6 | const Config = @import("../Config.zig");
7 | const parseTime = @import("./utils.zig").parseTime;
8 |
9 | fn connect(gpa: std.mem.Allocator) std.net.Stream {
10 | const tmp_dir_path = (folders.getPath(gpa, .cache) catch @panic("oom")) orelse "/tmp";
11 | defer gpa.free(tmp_dir_path);
12 |
13 | const socket_path = std.fmt.allocPrint(
14 | gpa,
15 | "{s}/bork.sock",
16 | .{tmp_dir_path},
17 | ) catch @panic("oom");
18 | defer gpa.free(socket_path);
19 |
20 | return std.net.connectUnixSocket(socket_path) catch |err| switch (err) {
21 | error.ConnectionRefused => {
22 | std.debug.print(
23 | \\Connection refused!
24 | \\Is Bork running?
25 | \\
26 | , .{});
27 | std.process.exit(1);
28 | },
29 | else => {
30 | std.debug.print(
31 | \\Unexpected error: {}
32 | \\
33 | , .{err});
34 | std.process.exit(1);
35 | },
36 | };
37 | }
38 |
39 | pub fn send(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
40 | const message = it.next() orelse {
41 | std.debug.print("Usage ./bork send \"my message Kappa\"\n", .{});
42 | return;
43 | };
44 |
45 | const conn = connect(gpa);
46 | defer conn.close();
47 |
48 | try conn.writer().writeAll("SEND\n");
49 | try conn.writer().writeAll(message);
50 | try conn.writer().writeAll("\n");
51 | }
52 |
53 | pub fn quit(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
54 | _ = it;
55 | const conn = connect(gpa);
56 | defer conn.close();
57 |
58 | try conn.writer().writeAll("QUIT\n");
59 | }
60 |
61 | pub fn reconnect(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
62 | // TODO: validation
63 | _ = it;
64 |
65 | const conn = connect(gpa);
66 | defer conn.close();
67 |
68 | try conn.writer().writeAll("RECONNECT\n");
69 | }
70 |
71 | pub fn links(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
72 | // TODO: validation
73 | _ = it;
74 |
75 | const conn = connect(gpa);
76 | defer conn.close();
77 |
78 | try conn.writer().writeAll("LINKS\n");
79 |
80 | std.debug.print("Latest links (not sent by you)\n\n", .{});
81 |
82 | var buf: [100]u8 = undefined;
83 | var n = try conn.read(&buf);
84 |
85 | const out = std.io.getStdOut();
86 | while (n != 0) : (n = try conn.read(&buf)) {
87 | try out.writeAll(buf[0..n]);
88 | }
89 | }
90 |
91 | pub fn youtube(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
92 | const video_url_or_id = it.next() orelse {
93 | std.debug.print("Usage ./bork youtube video_url_or_id\n", .{});
94 | return;
95 | };
96 |
97 | const video_id = if (std.mem.startsWith(u8, video_url_or_id, "https://")) blk: {
98 | var url_it = std.mem.tokenizeSequence(u8, video_url_or_id, "v=");
99 | _ = url_it.next() orelse @panic("bad url");
100 | var query_it = std.mem.tokenizeAny(u8, url_it.next() orelse @panic("bad url"), "");
101 | break :blk query_it.next() orelse @panic("bad url");
102 | } else video_url_or_id;
103 |
104 | const conn = connect(gpa);
105 | defer conn.close();
106 |
107 | try conn.writer().writeAll("YT\n");
108 | try conn.writer().writeAll(video_id);
109 | try conn.writer().writeAll("\n");
110 |
111 | var buf: [100]u8 = undefined;
112 | var n = try conn.read(&buf);
113 |
114 | const out = std.io.getStdOut();
115 | while (n != 0) : (n = try conn.read(&buf)) {
116 | try out.writeAll(buf[0..n]);
117 | }
118 | }
119 |
120 | pub fn ban(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
121 | const user = it.next() orelse {
122 | std.debug.print("Usage ./bork ban \"username\"\n", .{});
123 | return;
124 | };
125 |
126 | const conn = connect(gpa);
127 | defer conn.close();
128 |
129 | try conn.writer().writeAll("BAN\n");
130 | try conn.writer().writeAll(user);
131 | try conn.writer().writeAll("\n");
132 | }
133 |
134 | pub fn unban(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
135 | const user = try it.next(gpa);
136 |
137 | if (it.next(gpa)) |_| {
138 | std.debug.print(
139 | \\Usage ./bork unban ["username"]
140 | \\Omitting will try to unban the last banned
141 | \\user in the current session.
142 | \\
143 | , .{});
144 | return;
145 | }
146 |
147 | const conn = connect(gpa);
148 | defer conn.close();
149 |
150 | try conn.writer().writeAll("UNBAN\n");
151 | try conn.writer().writeAll(user);
152 | try conn.writer().writeAll("\n");
153 | }
154 |
155 | pub fn afk(gpa: std.mem.Allocator, it: *std.process.ArgIterator) !void {
156 | const summary =
157 | \\Creates an AFK message with a countdown.
158 | \\Click on the message to dismiss it.
159 | \\
160 | \\Usage: bork afk TIMER [REASON] [-t TITLE]
161 | \\
162 | \\TIMER: the countdown timer, eg: '1h25m' or '500s'
163 | \\REASON: the reason for being afk, eg: 'dinner'
164 | \\
165 | ;
166 | const params = comptime clap.parseParamsComptime(
167 | \\-h, --help display this help message
168 | \\-t, --title changes the title shown, defaults to 'AFK'
169 | \\ the countdown timer, eg: '1h25m' or '500s'
170 | \\ the reason for being afk, eg: 'dinner'
171 | \\
172 | );
173 |
174 | const parsers = .{
175 | .TITLE = clap.parsers.string,
176 | .MSG = clap.parsers.string,
177 | .TIMER = clap.parsers.string,
178 | };
179 |
180 | var diag: clap.Diagnostic = undefined;
181 | const res = clap.parseEx(clap.Help, ¶ms, parsers, it, .{
182 | .allocator = gpa,
183 | .diagnostic = &diag,
184 | }) catch |err| {
185 | // Report any useful error and exit
186 | diag.report(std.io.getStdErr().writer(), err) catch {};
187 | return err;
188 | };
189 |
190 | const positionals = res.positionals;
191 | const pos_ok = positionals.len > 0 and positionals.len < 3;
192 | if (res.args.help != 0 or !pos_ok) {
193 | std.debug.print("{s}\n", .{summary});
194 | clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{}) catch {};
195 | std.debug.print("\n", .{});
196 | return;
197 | }
198 |
199 | const time = positionals[0].?;
200 | _ = parseTime(time) catch {
201 | std.debug.print(
202 | \\Bad timer!
203 | \\Format: 1h15m, 60s, 7m
204 | \\
205 | , .{});
206 | return;
207 | };
208 |
209 | const reason = if (positionals.len == 2) positionals[1] else null;
210 | if (reason) |r| {
211 | for (r) |c| switch (c) {
212 | else => {},
213 | '\n', '\r', '\t' => {
214 | std.debug.print(
215 | \\Reason cannot contain newlines!
216 | \\
217 | , .{});
218 | return;
219 | },
220 | };
221 | }
222 |
223 | const title = res.args.title;
224 | if (title) |t| {
225 | for (t) |c| switch (c) {
226 | else => {},
227 | '\n', '\r', '\t' => {
228 | std.debug.print(
229 | \\Title cannot contain newlines!
230 | \\
231 | , .{});
232 | return;
233 | },
234 | };
235 | }
236 |
237 | const conn = connect(gpa);
238 | defer conn.close();
239 |
240 | const w = conn.writer();
241 |
242 | try w.writeAll("AFK\n");
243 | try w.writeAll(time);
244 | try w.writeAll("\n");
245 | if (reason) |r| try w.writeAll(r);
246 | try conn.writer().writeAll("\n");
247 | if (title) |t| try w.writeAll(t);
248 | try conn.writer().writeAll("\n");
249 | }
250 |
--------------------------------------------------------------------------------
/src/network/youtube/livechat.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const build_opts = @import("build_options");
3 | const Network = @import("../../Network.zig");
4 | const oauth = @import("../oauth.zig");
5 | const auth = @import("Auth.zig");
6 |
7 | const log = std.log.scoped(.livechat);
8 |
9 | const broadcasts_url = "https://www.googleapis.com/youtube/v3/liveBroadcasts?mine=true&part=id,snippet,status&maxResults=5";
10 | const livechat_url = "https://www.googleapis.com/youtube/v3/liveChat/messages?part=id,snippet,authorDetails&liveChatId={s}&pageToken={s}";
11 |
12 | // Must be accessed atomically, used by the `bork yt` remote command
13 | // to set a new youtube livestream to connect to.
14 | pub var new_chat_id: ?[*:0]const u8 = null;
15 |
16 | pub fn poll(n: *Network) !void {
17 | var arena_impl = std.heap.ArenaAllocator.init(n.gpa);
18 | const arena = arena_impl.allocator();
19 |
20 | var yt: std.http.Client = .{ .allocator = n.gpa };
21 |
22 | var state: union(enum) {
23 | searching,
24 | attached: []const u8,
25 | err,
26 | } = .searching;
27 |
28 | var livechat: std.BoundedArray(u8, 4096) = .{};
29 | var page_token: std.BoundedArray(u8, 128) = .{};
30 | var token = n.auth.youtube.token;
31 | while (true) : (_ = arena_impl.reset(.retain_capacity)) {
32 | if (@atomicRmw(?[*:0]const u8, &new_chat_id, .Xchg, null, .acq_rel)) |nc| {
33 | const new = std.mem.span(nc);
34 | switch (state) {
35 | else => {},
36 | .attached => |chat_id| {
37 | n.gpa.free(chat_id);
38 | },
39 | }
40 | state = .{ .attached = new };
41 | page_token.len = 0;
42 | }
43 |
44 | const now = std.time.timestamp();
45 | log.debug("YT token expires at: {} now: {} delta: {}", .{
46 | token.expires_at_seconds,
47 | now,
48 | token.expires_at_seconds -| now,
49 | });
50 | // Refresh the token once it's about to expire
51 | if (token.expires_at_seconds -| now < 60 * 10) { // <10mins
52 | log.debug("youtube refreshing token", .{});
53 | token = try auth.refreshToken(n.gpa, token.refresh);
54 | log.debug("youtube token refresh succeeded", .{});
55 | } else {
56 | log.debug("Not refreshing YT token as expiry >= {}", .{
57 | 60 * 10,
58 | });
59 | }
60 |
61 | switch (state) {
62 | .err => {
63 | // Sleep for a bit waiting for a new remote command
64 | std.time.sleep(1 * std.time.ns_per_s);
65 | },
66 | .searching => {
67 | const maybe_chat_id = findLive(arena, token) catch {
68 | state = .err;
69 | continue;
70 | };
71 |
72 | if (maybe_chat_id) |c| {
73 | state = .{ .attached = try n.gpa.dupe(u8, c) };
74 | continue;
75 | }
76 |
77 | log.debug("YT SEARCHING for active broadcast, sleeping", .{});
78 | std.time.sleep(10 * std.time.ns_per_s);
79 | },
80 | .attached => |chat_id| {
81 | livechat.len = 0;
82 | try livechat.writer().print(livechat_url, .{
83 | chat_id,
84 | page_token.slice(),
85 | });
86 | // std.debug.print("polling {s}\n", .{url_buf.items});
87 |
88 | log.debug("YT POLLING for messages, url: {s} token: {s}", .{
89 | livechat.slice(),
90 | token.access,
91 | });
92 | var buf = std.ArrayList(u8).init(arena);
93 | const chat_res = yt.fetch(.{
94 | .location = .{ .url = livechat.slice() },
95 | .method = .GET,
96 | .response_storage = .{ .dynamic = &buf },
97 | .extra_headers = &.{
98 | .{ .name = "Authorization", .value = token.access },
99 | },
100 | }) catch |err| {
101 | log.err("error fetching chat from youtube: {}", .{err});
102 | state = .err;
103 | continue;
104 | };
105 |
106 | if (chat_res.status != .ok) {
107 | log.err("bad reply: {s}\n{s}\n", .{
108 | livechat.slice(),
109 | buf.items,
110 | });
111 | state = .err;
112 | }
113 |
114 | const messages = std.json.parseFromSliceLeaky(Messages, arena, buf.items, .{
115 | .ignore_unknown_fields = true,
116 | }) catch {
117 | log.err("bad chat json: {s}\n{s}\n", .{
118 | livechat.slice(),
119 | buf.items,
120 | });
121 | state = .err;
122 | continue;
123 | };
124 |
125 | page_token.len = 0;
126 | page_token.appendSlice(messages.nextPageToken) catch {
127 | @panic("increase pageToken buffer");
128 | };
129 |
130 | for (messages.items) |m| {
131 | const name = try n.gpa.dupe(u8, m.authorDetails.displayName);
132 | const msg = try n.gpa.dupe(u8, m.snippet.textMessageDetails.messageText);
133 |
134 | log.debug("{s}\n{s}\n\n", .{ name, msg });
135 |
136 | n.ch.postEvent(.{
137 | .network = .{
138 | .message = .{
139 | .login_name = name,
140 | .time = "--:--".*,
141 | .kind = .{
142 | .chat = .{
143 | .text = msg,
144 | .display_name = name,
145 | .sub_months = 0,
146 | .is_founder = false,
147 | },
148 | },
149 | },
150 | },
151 | });
152 | }
153 |
154 | const delay = @max(5000, messages.pollingIntervalMillis) * std.time.ns_per_ms;
155 |
156 | log.debug("YT POLLING sleep for {}", .{delay});
157 | std.time.sleep(delay);
158 | },
159 | }
160 | }
161 | }
162 |
163 | // Searches for an active livestream and doubles as a token validation
164 | // function since the call will fail if the token has expired.
165 | pub fn findLive(gpa: std.mem.Allocator, token: oauth.Token.YouTube) !?[]const u8 {
166 | var yt: std.http.Client = .{ .allocator = gpa };
167 | defer yt.deinit();
168 |
169 | var buf = std.ArrayList(u8).init(gpa);
170 | defer buf.deinit();
171 |
172 | log.debug("YT REQUEST: find live broadcast", .{});
173 |
174 | const res = try yt.fetch(.{
175 | .location = .{ .url = broadcasts_url },
176 | .method = .GET,
177 | .response_storage = .{ .dynamic = &buf },
178 | .extra_headers = &.{
179 | .{ .name = "Authorization", .value = token.access },
180 | },
181 | });
182 |
183 | if (res.status != .ok) {
184 | log.debug("yt broadcast api error = {s}", .{buf.items});
185 | return error.InvalidToken;
186 | }
187 |
188 | const lives = try std.json.parseFromSlice(LiveBroadcasts, gpa, buf.items, .{
189 | .ignore_unknown_fields = true,
190 | });
191 | defer lives.deinit();
192 |
193 | const chat_id: ?[]const u8 = for (lives.value.items) |l| {
194 | if (std.mem.eql(u8, l.status.lifeCycleStatus, "live")) break try gpa.dupe(u8, l.snippet.liveChatId);
195 | } else null;
196 |
197 | log.debug("youtube chat_id: {?s}", .{chat_id});
198 |
199 | return chat_id;
200 | }
201 |
202 | pub const LiveBroadcasts = struct {
203 | items: []const struct {
204 | id: []const u8,
205 | snippet: struct {
206 | channelId: []const u8,
207 | liveChatId: []const u8,
208 | title: []const u8,
209 | },
210 | status: struct {
211 | lifeCycleStatus: []const u8,
212 | },
213 | },
214 | };
215 |
216 | const Messages = struct {
217 | nextPageToken: []const u8,
218 | offlineAt: ?[]const u8 = null,
219 | pollingIntervalMillis: usize,
220 | items: []const ChatMessage,
221 |
222 | pub const ChatMessage = struct {
223 | snippet: struct {
224 | textMessageDetails: struct {
225 | messageText: []const u8,
226 | },
227 | },
228 | authorDetails: struct {
229 | displayName: []const u8,
230 | },
231 | };
232 | };
233 |
--------------------------------------------------------------------------------
/src/Chat.zig:
--------------------------------------------------------------------------------
1 | const Chat = @This();
2 |
3 | const std = @import("std");
4 | const display = @import("zbox");
5 | const url = @import("./utils/url.zig");
6 |
7 | allocator: std.mem.Allocator,
8 | nick: []const u8,
9 | last_message: ?*Message = null,
10 | last_link_message: ?*Message = null,
11 | bottom_message: ?*Message = null,
12 | scroll_offset: isize = 0,
13 |
14 | disconnected: bool = false,
15 |
16 | const log = std.log.scoped(.chat);
17 |
18 | pub const Message = struct {
19 | prev: ?*Message = null,
20 | next: ?*Message = null,
21 |
22 | // Points to the closest previous message that contains a link.
23 | prev_links: ?*Message = null,
24 | next_links: ?*Message = null,
25 |
26 | login_name: []const u8,
27 | time: [5]u8,
28 | // TODO: line doesn't really have a associated login name,
29 | // check how much of a problem that is.
30 |
31 | kind: union(enum) {
32 | chat: Comment,
33 | line,
34 | raid: Raid,
35 | resub: Resub,
36 | sub_mistery_gift: SubMisteryGift,
37 | sub_gift: SubGift,
38 | sub: Sub,
39 | follow: Follow,
40 | charity: Charity,
41 | },
42 |
43 | pub const Comment = struct {
44 | text: []const u8,
45 | /// Author's name (w/ unicode support, empty if not present)
46 | display_name: []const u8,
47 | /// Total months the user was subbed (0 = non sub)
48 | sub_months: usize,
49 | /// Does the user have a founder badge?
50 | is_founder: bool,
51 | /// List of emotes and their position. Must be sorted (asc) by end position
52 | emotes: []Emote = &[0]Emote{},
53 | /// Moderator status
54 | is_mod: bool = false,
55 | /// Highlighed message by redeeming points
56 | is_highlighted: bool = false,
57 | };
58 |
59 | pub const Follow = struct {
60 | display_name: []const u8,
61 | };
62 | pub const Charity = struct {
63 | display_name: []const u8,
64 | amount: []const u8,
65 | };
66 |
67 | pub const Raid = struct {
68 | display_name: []const u8,
69 | profile_picture_url: []const u8,
70 | /// How many raiders
71 | count: usize,
72 | };
73 |
74 | /// When somebody gifts X subs to random people
75 | pub const SubMisteryGift = struct {
76 | display_name: []const u8,
77 | count: usize,
78 | tier: SubTier,
79 | };
80 |
81 | pub const SubGift = struct {
82 | sender_display_name: []const u8,
83 | months: usize,
84 | tier: SubTier,
85 | recipient_login_name: []const u8,
86 | recipient_display_name: []const u8,
87 | };
88 |
89 | pub const Sub = struct {
90 | display_name: []const u8,
91 | tier: SubTier,
92 | };
93 |
94 | pub const Resub = struct {
95 | display_name: []const u8,
96 | count: usize,
97 | tier: SubTier,
98 | resub_message: []const u8,
99 | resub_message_emotes: []Emote,
100 | };
101 |
102 | // ------
103 |
104 | pub const SubTier = enum {
105 | prime,
106 | t1,
107 | t2,
108 | t3,
109 |
110 | pub fn name(self: SubTier) []const u8 {
111 | return switch (self) {
112 | .prime => "Prime",
113 | .t1 => "Tier 1",
114 | .t2 => "Tier 2",
115 | .t3 => "Tier 3",
116 | };
117 | }
118 | };
119 |
120 | pub const Emote = struct {
121 | twitch_id: []const u8,
122 | start: usize,
123 | end: usize,
124 | img_data: ?[]const u8 = null, // TODO: should this be in
125 | idx: u32 = 0, // surely this will never cause problematic bugs
126 | // Used to sort the emote list by ending poisition.
127 | pub fn lessThan(_: void, lhs: Emote, rhs: Emote) bool {
128 | return lhs.end < rhs.end;
129 | }
130 | };
131 | };
132 |
133 | pub fn setConnectionStatus(self: *Chat, status: enum { disconnected, reconnected }) !void {
134 | switch (status) {
135 | .disconnected => self.disconnected = true,
136 | .reconnected => {
137 | if (self.disconnected) {
138 | self.disconnected = false;
139 |
140 | const last = self.last_message orelse return;
141 | if (last.kind != .line) {
142 | // TODO print a line or something also it needs a time.
143 | // var msg = try self.allocator.create(Message);
144 | // msg.* = Message{ .kind = .line, .login_name = &[0]u8{}, tim };
145 | // _ = self.addMessage(msg);
146 | }
147 | }
148 | },
149 | }
150 | }
151 |
152 | pub fn scroll(self: *Chat, n: isize) void {
153 | self.scroll_offset += n;
154 | if (self.scroll_offset < 0) {
155 | const changed = self.scrollBottomMessage(.down);
156 | if (!changed) self.scroll_offset = 0;
157 | }
158 | }
159 |
160 | // returns if the scroll did any effect
161 | pub fn scrollBottomMessage(self: *Chat, direction: enum { up, down }) bool {
162 | log.debug("scroll {}", .{direction});
163 |
164 | var msg = self.bottom_message;
165 | if (msg) |m| {
166 | msg = switch (direction) {
167 | .up => m.prev,
168 | .down => m.next,
169 | };
170 |
171 | if (msg != null) {
172 | self.bottom_message = msg;
173 | return true;
174 | }
175 | }
176 |
177 | return false;
178 | }
179 |
180 | // Automatically scrolls down unless the user scrolled up.
181 | // Returns whether there was any change in the view.
182 | pub fn addMessage(self: *Chat, msg: *Message) bool {
183 | log.debug("message", .{});
184 |
185 | // Find if the message has URLs and attach it
186 | // to the URL linked list, unless it's our own
187 | // message.
188 | if (!std.mem.eql(u8, msg.login_name, self.nick)) {
189 | switch (msg.kind) {
190 | .chat => |c| {
191 | var it = std.mem.tokenizeScalar(u8, c.text, ' ');
192 | while (it.next()) |word| {
193 | if (url.sense(word)) {
194 | if (self.last_link_message) |old| {
195 | msg.prev_links = old;
196 | old.next_links = msg;
197 | }
198 |
199 | self.last_link_message = msg;
200 | break;
201 | }
202 | }
203 | },
204 | else => {
205 | // TODO: when the resub msg hack gets removed
206 | // we'll need to analize also that type of message.
207 | },
208 | }
209 | }
210 |
211 | var need_render = false;
212 | if (self.last_message == self.bottom_message) {
213 | // Scroll!
214 | self.bottom_message = msg;
215 | need_render = true;
216 | }
217 |
218 | if (self.last_message) |last| {
219 | last.next = msg;
220 | msg.prev = self.last_message;
221 | }
222 |
223 | self.last_message = msg;
224 |
225 | return need_render;
226 | }
227 |
228 | /// TODO: we leakin, we scanning
229 | pub fn clearChat(self: *Chat, all_or_name: ?[]const u8) void {
230 | if (all_or_name) |login_name| {
231 | log.debug("clear chat: {s}", .{login_name});
232 | var current = self.last_message;
233 | while (current) |c| : (current = c.prev) {
234 | if (std.mem.eql(u8, login_name, c.login_name)) {
235 | // Update main linked list
236 | {
237 | if (c.prev) |p| p.next = c.next;
238 | if (c.next) |n| n.prev = c.prev;
239 |
240 | // If it's the last message, update the reference
241 | if (c == self.last_message) self.last_message = c.prev;
242 | }
243 |
244 | // Update URLs linked list
245 | {
246 | if (c.prev_links) |p| p.next_links = c.next_links;
247 | if (c.next_links) |n| n.prev_links = c.prev_links;
248 |
249 | // If it's the last message, update the reference
250 | if (c == self.last_link_message) self.last_link_message = c.prev_links;
251 | }
252 |
253 | // If it's the bottom message, scroll the view
254 | if (self.bottom_message) |b| {
255 | if (c == b) {
256 | if (c.next) |n| {
257 | self.bottom_message = n;
258 | } else {
259 | self.bottom_message = c.prev;
260 | }
261 | }
262 | }
263 | }
264 | }
265 | } else {
266 | log.debug("clear chat all", .{});
267 | self.last_message = null;
268 | self.bottom_message = null;
269 | self.last_link_message = null;
270 | }
271 | }
272 |
--------------------------------------------------------------------------------
/src/network/oauth.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const log = std.log.scoped(.oauth);
3 |
4 | const twitch_oauth_url = "https://id.twitch.tv/oauth2/authorize?client_id={s}&response_type=token&scope={s}&redirect_uri={s}";
5 |
6 | pub const client_id = "qlw2m6rgpnlcn17cnj5p06xtlh36b4";
7 |
8 | const scopes = "bits:read" ++ "+" ++
9 | "channel:read:ads" ++ "+" ++
10 | "channel:read:charity" ++ "+" ++
11 | "channel:read:goals" ++ "+" ++
12 | "channel:read:guest_star" ++ "+" ++
13 | "channel:read:hype_train" ++ "+" ++
14 | "channel:read:polls" ++ "+" ++
15 | "channel:read:predictions" ++ "+" ++
16 | "channel:read:redemptions" ++ "+" ++
17 | "channel:bot" ++ "+" ++
18 | "channel:moderate" ++ "+" ++
19 | "moderator:read:followers" ++ "+" ++
20 | "chat:read" ++ "+" ++
21 | "chat:edit";
22 |
23 | const google_oauth = "https://accounts.google.com/o/oauth2/v2/auth?client_id=519150430990-68hvu66hl7vdtpb4u1mngb0qq2hqoiv8.apps.googleusercontent.com&redirect_uri=http://localhost:22890&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/youtube.readonly&prompt=consent";
24 | const google_token = "https://oauth2.googleapis.com/token?client_id=519150430990-68hvu66hl7vdtpb4u1mngb0qq2hqoiv8.apps.googleusercontent.com&redirect_uri=http://localhost:22890&client_secret=GOC" ++ "SPX-5e1VALKHYwGJZDlnLyUKKgN_I1KW&grant_type=authorization_code&code={s}";
25 | const broadcasts_url = "https://www.googleapis.com/youtube/v3/liveBroadcasts?mine=true&part=id,snippet,status&maxResults=50";
26 |
27 | const redirect_uri = "http://localhost:22890/";
28 |
29 | pub const Platform = enum { twitch, youtube };
30 | pub const Token = union(Platform) {
31 | twitch: []const u8,
32 | youtube: YouTube,
33 |
34 | pub const YouTube = struct {
35 | access: []const u8,
36 | refresh: []const u8,
37 | expires_at_seconds: i64,
38 | };
39 | };
40 | pub fn createToken(
41 | gpa: std.mem.Allocator,
42 | config_base: std.fs.Dir,
43 | platform: Platform,
44 | renew: bool,
45 | ) !Token {
46 | switch (platform) {
47 | .youtube => {
48 | std.debug.print(
49 | \\
50 | \\====================== YOUTUBE ======================
51 | \\
52 | , .{});
53 | },
54 | .twitch => {
55 | std.debug.print(
56 | \\
57 | \\====================== TWITCH =======================
58 | \\
59 | , .{});
60 | },
61 | }
62 |
63 | if (renew) {
64 | std.debug.print(
65 | \\
66 | \\Please authenticate with the platform by navigating to the
67 | \\following URL:
68 | \\
69 | \\
70 | , .{});
71 | } else {
72 | std.debug.print(
73 | \\
74 | \\The OAuth token expired, we must refresh it.
75 | \\Please re-authenticate:
76 | \\
77 | \\
78 | , .{});
79 | }
80 |
81 | switch (platform) {
82 | .youtube => {
83 | std.debug.print(google_oauth, .{});
84 | },
85 | .twitch => {
86 | std.debug.print(twitch_oauth_url, .{
87 | client_id,
88 | scopes,
89 | redirect_uri,
90 | });
91 | },
92 | }
93 |
94 | std.debug.print("\n\nWaiting...\n", .{});
95 |
96 | const token = waitForToken(gpa, platform) catch |err| {
97 | std.debug.print("\nAn error occurred while waiting for the OAuth flow to complete: {s}\n", .{@errorName(err)});
98 | std.process.exit(1);
99 | };
100 |
101 | const path = switch (platform) {
102 | .youtube => "bork/youtube-token.secret",
103 | .twitch => "bork/twitch-token.secret",
104 | };
105 |
106 | var token_file = try config_base.createFile(path, .{ .truncate = true });
107 | defer token_file.close();
108 |
109 | switch (platform) {
110 | .twitch => {
111 | try token_file.writer().print("{s}\n", .{token.twitch});
112 | },
113 | .youtube => {
114 | try token_file.writer().print("{s}\n", .{token.youtube.refresh});
115 | },
116 | }
117 |
118 | const in = std.io.getStdIn();
119 | // const original_termios = try std.posix.tcgetattr(in.handle);
120 | {
121 | // defer std.posix.tcsetattr(in.handle, .FLUSH, original_termios) catch {};
122 | // var termios = original_termios;
123 | // // set immediate input mode
124 | // termios.lflag.ICANON = false;
125 | // try std.posix.tcsetattr(in.handle, .FLUSH, termios);
126 |
127 | std.debug.print(
128 | \\
129 | \\
130 | \\Success, great job!
131 | \\The token has been saved in your Bork config directory.
132 | \\
133 | \\Press any key to continue.
134 | \\
135 | , .{});
136 |
137 | _ = try in.reader().readByte();
138 | }
139 |
140 | return token;
141 | }
142 |
143 | fn waitForToken(gpa: std.mem.Allocator, platform: Platform) !Token {
144 | const address = try std.net.Address.parseIp("127.0.0.1", 22890);
145 | var tcp_server = try address.listen(.{
146 | .reuse_address = true,
147 | .reuse_port = true,
148 | });
149 | defer tcp_server.deinit();
150 |
151 | accept: while (true) {
152 | var conn = try tcp_server.accept();
153 | defer conn.stream.close();
154 |
155 | var read_buffer: [8000]u8 = undefined;
156 | var server = std.http.Server.init(conn, &read_buffer);
157 | while (server.state == .ready) {
158 | var request = server.receiveHead() catch |err| {
159 | std.debug.print("error: {s}\n", .{@errorName(err)});
160 | continue :accept;
161 | };
162 | const maybe_auth = try handleRequest(gpa, &request, platform);
163 | return maybe_auth orelse continue :accept;
164 | }
165 | }
166 | }
167 |
168 | const collect_fragment_html = @embedFile("collect_fragment.html");
169 | fn handleRequest(
170 | gpa: std.mem.Allocator,
171 | request: *std.http.Server.Request,
172 | platform: Platform,
173 | ) !?Token {
174 | const query = request.head.target;
175 |
176 | if (std.mem.eql(u8, query, "/")) {
177 | try request.respond(collect_fragment_html, .{
178 | .extra_headers = &.{.{ .name = "content-type", .value = "text/html" }},
179 | });
180 | return null;
181 | } else {
182 | const response_html = "Success! You can now return to bork
";
183 | try request.respond(response_html, .{
184 | .extra_headers = &.{.{ .name = "content-type", .value = "text/html" }},
185 | });
186 |
187 | if (!std.mem.startsWith(u8, query, "/?")) {
188 | return error.BadURI;
189 | }
190 |
191 | var it = std.mem.tokenizeScalar(u8, query[2..], '&');
192 |
193 | while (it.next()) |kv| {
194 | var kv_it = std.mem.splitScalar(u8, kv, '=');
195 | const key = kv_it.next() orelse return error.BadURI;
196 | const value = kv_it.next() orelse return error.BadURI;
197 | switch (platform) {
198 | .twitch => {
199 | if (std.mem.eql(u8, key, "access_token")) {
200 | return .{
201 | .twitch = try std.fmt.allocPrint(gpa, "Bearer {s}", .{value}),
202 | };
203 | }
204 | },
205 | .youtube => {
206 | if (std.mem.eql(u8, key, "code")) {
207 | const code = value;
208 |
209 | var arena_impl = std.heap.ArenaAllocator.init(gpa);
210 | defer arena_impl.deinit();
211 |
212 | const arena = arena_impl.allocator();
213 |
214 | var yt: std.http.Client = .{ .allocator = arena };
215 | defer yt.deinit();
216 |
217 | const access_exchange_url = try std.fmt.allocPrint(
218 | arena,
219 | google_token,
220 | .{code},
221 | );
222 |
223 | var buf = std.ArrayList(u8).init(arena);
224 |
225 | const res = yt.fetch(.{
226 | .location = .{ .url = access_exchange_url },
227 | .method = .POST,
228 | .response_storage = .{ .dynamic = &buf },
229 | .extra_headers = &.{.{ .name = "Content-Length", .value = "0" }},
230 | }) catch {
231 | return error.AccessTokenToRefreshTokenFailed;
232 | };
233 |
234 | log.debug("yt access code exchange result = {}", .{res});
235 | log.debug("data = {s}", .{buf.items});
236 |
237 | const payload = std.json.parseFromSliceLeaky(struct {
238 | access_token: []const u8,
239 | expires_in: i64,
240 | refresh_token: []const u8,
241 | scope: []const u8,
242 | token_type: []const u8,
243 | }, arena, buf.items, .{}) catch {
244 | log.err("Error while parsing YouTube auth payoload: {s}", .{buf.items});
245 | return error.BadYouTubeAuthData;
246 | };
247 |
248 | return .{
249 | .youtube = .{
250 | .access = try std.fmt.allocPrint(
251 | gpa,
252 | "Bearer {s}",
253 | .{payload.access_token},
254 | ),
255 | .refresh = try gpa.dupe(u8, payload.refresh_token),
256 | .expires_at_seconds = std.time.timestamp() + payload.expires_in,
257 | },
258 | };
259 | }
260 | },
261 | }
262 | }
263 |
264 | return error.BadURI;
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/src/main.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const builtin = @import("builtin");
3 | const options = @import("build_options");
4 | const datetime = @import("datetime");
5 | const zfetch = @import("zfetch");
6 | const folders = @import("known-folders");
7 | const vaxis = @import("vaxis");
8 |
9 | const logging = @import("logging.zig");
10 | const Channel = @import("utils/channel.zig").Channel;
11 | const remote = @import("remote.zig");
12 | const Config = @import("Config.zig");
13 | const Network = @import("Network.zig");
14 | const display = @import("display.zig");
15 | const Auth = Network.Auth;
16 | const TwitchAuth = Network.TwitchAuth;
17 | const YouTubeAuth = Network.YouTubeAuth;
18 | const Chat = @import("Chat.zig");
19 |
20 | pub const known_folders_config: folders.KnownFolderConfig = .{
21 | .xdg_force_default = true,
22 | .xdg_on_mac = true,
23 | };
24 |
25 | pub const std_options: std.Options = .{
26 | .logFn = logging.logFn,
27 | };
28 |
29 | pub fn panic(
30 | msg: []const u8,
31 | error_return_trace: ?*std.builtin.StackTrace,
32 | ret_addr: ?usize,
33 | ) noreturn {
34 | display.teardown();
35 | vaxis.recover();
36 | std.log.err("{s}\n\n", .{msg});
37 | if (error_return_trace) |t| std.debug.dumpStackTrace(t.*);
38 | std.debug.dumpCurrentStackTrace(ret_addr orelse @returnAddress());
39 |
40 | if (builtin.mode == .Debug) @breakpoint();
41 | std.process.exit(1);
42 | }
43 |
44 | pub const Event = union(enum) {
45 | display: display.Event,
46 | network: Network.Event,
47 | remote: remote.Server.Event,
48 |
49 | // vaxis-specific events
50 | key_press: vaxis.Key,
51 | mouse: vaxis.Mouse,
52 | winsize: vaxis.Winsize,
53 | // focus_in,
54 | };
55 |
56 | const Subcommand = enum {
57 | help,
58 | @"--help",
59 | @"-h",
60 | start,
61 | links,
62 | send,
63 | ban,
64 | afk,
65 | quit,
66 | reconnect,
67 | version,
68 | yt,
69 | youtube,
70 | };
71 |
72 | pub fn main() !void {
73 | var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){};
74 | const gpa = gpa_impl.allocator();
75 |
76 | logging.setup(gpa);
77 |
78 | var it = try std.process.ArgIterator.initWithAllocator(gpa);
79 | defer it.deinit();
80 |
81 | _ = it.skip(); // exe name
82 |
83 | const subcommand = subcommand: {
84 | const subc_string = it.next() orelse printHelpFatal();
85 |
86 | break :subcommand std.meta.stringToEnum(Subcommand, subc_string) orelse {
87 | std.debug.print("Invalid subcommand: {s}\n\n", .{subc_string});
88 | printHelpFatal();
89 | };
90 | };
91 |
92 | switch (subcommand) {
93 | .start => try borkStart(gpa),
94 | .send => try remote.client.send(gpa, &it),
95 | .quit => try remote.client.quit(gpa, &it),
96 | .reconnect => try remote.client.reconnect(gpa, &it),
97 | .links => try remote.client.links(gpa, &it),
98 | .afk => try remote.client.afk(gpa, &it),
99 | .ban => try remote.client.ban(gpa, &it),
100 | .youtube, .yt => try remote.client.youtube(gpa, &it),
101 | .version => printVersion(),
102 | .help, .@"--help", .@"-h" => printHelpFatal(),
103 | }
104 | }
105 |
106 | fn borkStart(gpa: std.mem.Allocator) !void {
107 | const config_base = try folders.open(gpa, .local_configuration, .{}) orelse
108 | try folders.open(gpa, .home, .{}) orelse
109 | try folders.open(gpa, .executable_dir, .{}) orelse
110 | std.fs.cwd();
111 |
112 | try config_base.makePath("bork");
113 |
114 | const config = try Config.get(gpa, config_base);
115 | const auth: Network.Auth = .{
116 | .twitch = try TwitchAuth.get(gpa, config_base),
117 | .youtube = if (config.youtube) try YouTubeAuth.get(gpa, config_base) else .{},
118 | };
119 |
120 | var tty = try vaxis.Tty.init();
121 | defer tty.deinit();
122 |
123 | var vx = try vaxis.init(gpa, .{});
124 | defer vx.deinit(null, tty.anyWriter());
125 |
126 | var loop: vaxis.Loop(Event) = .{
127 | .tty = &tty,
128 | .vaxis = &vx,
129 | };
130 | try loop.init();
131 |
132 | try loop.start();
133 | defer loop.stop();
134 |
135 | var remote_server: remote.Server = undefined;
136 | remote_server.init(gpa, auth, &loop) catch |err| {
137 | std.debug.print(
138 | \\ Unable to listen for remote control.
139 | \\ Error: {}
140 | \\
141 | , .{err});
142 | std.process.exit(1);
143 | };
144 |
145 | defer remote_server.deinit();
146 |
147 | var network: Network = undefined;
148 | try network.init(gpa, &loop, config, auth);
149 | defer network.deinit();
150 |
151 | var chat = Chat{ .allocator = gpa, .nick = auth.twitch.login };
152 |
153 | try vx.enterAltScreen(tty.anyWriter());
154 |
155 | try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s);
156 |
157 | try display.setup(gpa, &loop, config, &chat);
158 | defer display.teardown();
159 |
160 | // Initial paint!
161 | // try Display.render();
162 |
163 | // Main control loop
164 | while (true) {
165 | var need_repaint = false;
166 | const event = loop.nextEvent();
167 | switch (event) {
168 | .remote => |re| {
169 | switch (re) {
170 | .quit => return,
171 | .reconnect => {},
172 | .send => |msg| {
173 | std.log.debug("got send event in channel: {s}", .{msg});
174 | network.sendCommand(.{ .message = msg });
175 | },
176 | .links => |conn| {
177 | remote.Server.replyLinks(&chat, conn);
178 | },
179 | .afk => |afk| {
180 | try display.setAfkMessage(afk.target_time, afk.reason, afk.title);
181 | need_repaint = true;
182 | },
183 | }
184 | },
185 | .winsize => |ws| {
186 | need_repaint = display.sizeChanged(.{
187 | .rows = ws.rows,
188 | .cols = ws.cols,
189 | });
190 |
191 | // We don't call resize directly because we
192 | // don't want libvaxis to allocate any memory
193 | // for its internal cell grid, since we render
194 | // everything manually.
195 | //
196 | // try vx.resize(gpa, tty.anyWriter(), ws),
197 | vx.screen.width = ws.cols;
198 | vx.screen.height = ws.rows;
199 | vx.screen.width_pix = ws.x_pixel;
200 | vx.screen.height_pix = ws.y_pixel;
201 | },
202 |
203 | .key_press => |key| {
204 | if (key.matches('c', .{ .ctrl = true })) {
205 | if (config.ctrl_c_protection) {
206 | need_repaint = try display.showCtrlCMessage();
207 | } else {
208 | break;
209 | }
210 | } else if (key.matches(vaxis.Key.up, .{})) {
211 | chat.scroll(1);
212 | need_repaint = true;
213 | } else if (key.matches(vaxis.Key.down, .{})) {
214 | chat.scroll(-1);
215 | need_repaint = true;
216 | } else {
217 | // need_repaint = true;
218 | std.log.debug("key pressed: {}", .{key});
219 | }
220 | },
221 | .mouse => |m| {
222 | if (m.type != .press) continue;
223 |
224 | switch (m.button) {
225 | else => {},
226 | .left => {
227 | std.log.debug("click at {}:{}", .{ m.row, m.col });
228 | need_repaint = try display.handleClick(m.row + 1, m.col + 1);
229 | },
230 | .wheel_up => {
231 | chat.scroll(1);
232 | need_repaint = true;
233 | },
234 | .wheel_down => {
235 | chat.scroll(-1);
236 | need_repaint = true;
237 | },
238 | }
239 | },
240 | .display => |de| {
241 | switch (de) {
242 | .tick => {
243 | need_repaint = display.wantTick();
244 | },
245 | // .left, .right => {},
246 | }
247 | },
248 | .network => |ne| switch (ne) {
249 | .connected => {},
250 | .disconnected => {
251 | try chat.setConnectionStatus(.disconnected);
252 | need_repaint = true;
253 | },
254 | .reconnected => {
255 | try chat.setConnectionStatus(.reconnected);
256 | need_repaint = true;
257 | },
258 | .message => |m| {
259 | const msg = try display.prepareMessage(m);
260 | need_repaint = chat.addMessage(msg);
261 | },
262 | .clear => |c| {
263 | display.clearActiveInteraction(c);
264 | chat.clearChat(c);
265 | need_repaint = true;
266 | },
267 | },
268 | }
269 |
270 | if (need_repaint) try display.render();
271 | }
272 |
273 | // TODO: implement real cleanup
274 | }
275 |
276 | fn printHelpFatal() noreturn {
277 | std.debug.print(
278 | \\Bork is a TUI chat client for Twitch.
279 | \\
280 | \\Available commands: start, quit, send, links, ban, unban, afk, version.
281 | \\
282 | \\Examples:
283 | \\ bork start
284 | \\ bork quit
285 | \\ bork reconnect
286 | \\ bork send "welcome to my stream Kappa"
287 | \\ bork links
288 | \\ bork ban "baduser"
289 | \\ bork unban "innocentuser"
290 | \\ bork afk 25m "dinner"
291 | \\ bork version
292 | \\
293 | \\Use `bork --help` to get subcommand-specific information.
294 | \\
295 | , .{});
296 | std.process.exit(1);
297 | }
298 |
299 | fn printVersion() void {
300 | std.debug.print("{s}\n", .{options.version});
301 | }
302 |
--------------------------------------------------------------------------------
/src/remote/Server.zig:
--------------------------------------------------------------------------------
1 | const Server = @This();
2 |
3 | const std = @import("std");
4 | const builtin = @import("builtin");
5 | const folders = @import("known-folders");
6 | const vaxis = @import("vaxis");
7 |
8 | const url = @import("../utils/url.zig");
9 | const GlobalEventUnion = @import("../main.zig").Event;
10 | const Chat = @import("../Chat.zig");
11 | const Network = @import("../Network.zig");
12 | const livechat = @import("../network/youtube/livechat.zig");
13 | const parseTime = @import("./utils.zig").parseTime;
14 |
15 | const log = std.log.scoped(.server);
16 |
17 | pub const Event = union(enum) {
18 | quit,
19 | reconnect,
20 | links: std.net.Stream,
21 | send: []const u8,
22 | afk: struct {
23 | title: []const u8,
24 | target_time: i64,
25 | reason: []const u8,
26 | },
27 | };
28 |
29 | auth: Network.Auth,
30 | listener: std.net.Server,
31 | gpa: std.mem.Allocator,
32 | ch: *vaxis.Loop(GlobalEventUnion),
33 | thread: std.Thread,
34 |
35 | pub fn init(
36 | self: *Server,
37 | alloc: std.mem.Allocator,
38 | auth: Network.Auth,
39 | ch: *vaxis.Loop(GlobalEventUnion),
40 | ) !void {
41 | self.gpa = alloc;
42 | self.auth = auth;
43 | self.ch = ch;
44 |
45 | const tmp_dir_path = try folders.getPath(alloc, .cache) orelse "/tmp";
46 | const socket_path = try std.fmt.allocPrint(
47 | alloc,
48 | "{s}/bork.sock",
49 | .{tmp_dir_path},
50 | );
51 |
52 | std.fs.cwd().deleteFile(socket_path) catch |err| switch (err) {
53 | error.FileNotFound => {},
54 | else => return err,
55 | };
56 |
57 | const address = try std.net.Address.initUnix(socket_path);
58 | self.listener = try address.listen(.{
59 | .reuse_address = builtin.target.os.tag != .windows,
60 | .reuse_port = builtin.target.os.tag != .windows,
61 | });
62 |
63 | errdefer self.listener.deinit();
64 |
65 | self.thread = try std.Thread.spawn(.{}, start, .{self});
66 | }
67 |
68 | pub fn start(self: *Server) !void {
69 | defer self.listener.deinit();
70 | var buf: [100]u8 = undefined;
71 |
72 | while (true) {
73 | const conn = try self.listener.accept();
74 |
75 | const cmd = conn.stream.reader().readUntilDelimiter(&buf, '\n') catch |err| {
76 | std.log.debug("remote could not read: {}", .{err});
77 | return;
78 | };
79 |
80 | defer if (!std.mem.eql(u8, cmd, "LINKS")) conn.stream.close();
81 |
82 | self.handle(conn.stream, cmd) catch |err| {
83 | log.err("Error while handling remote command: {s}", .{@errorName(err)});
84 | };
85 | }
86 | }
87 |
88 | pub fn deinit(self: *Server) void {
89 | std.log.debug("deiniting Remote Server", .{});
90 | std.posix.shutdown(self.listener.stream.handle, .both) catch |err| {
91 | std.log.debug("remote shutdown encountered an error: {}", .{err});
92 | };
93 | std.log.debug("deinit done", .{});
94 | }
95 |
96 | fn handle(self: *Server, stream: std.net.Stream, cmd: []const u8) !void {
97 | defer std.log.debug("remote cmd: {s}", .{cmd});
98 |
99 | if (std.mem.eql(u8, cmd, "SEND")) {
100 | const msg = stream.reader().readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
101 | std.log.debug("remote could read: {}", .{err});
102 | return;
103 | };
104 | defer self.gpa.free(msg);
105 |
106 | std.log.debug("remote msg: {s}", .{msg});
107 |
108 | // Since sending the message from the main connection
109 | // makes it so that twitch doesn't echo it back, we're
110 | // opening a one-off connection to send the message.
111 | // This way we don't have to implement locally emote
112 | // parsing.
113 | var twitch_conn = Network.connect(
114 | self.gpa,
115 | self.auth.twitch.login,
116 | self.auth.twitch.token,
117 | ) catch return;
118 | defer twitch_conn.close();
119 | twitch_conn.writer().print("PRIVMSG #{s} :{s}\n", .{
120 | self.auth.twitch.login,
121 | msg,
122 | }) catch return;
123 | }
124 |
125 | if (std.mem.eql(u8, cmd, "QUIT")) {
126 | self.ch.postEvent(GlobalEventUnion{ .remote = .quit });
127 | }
128 |
129 | if (std.mem.eql(u8, cmd, "RECONNECT")) {
130 | self.ch.postEvent(GlobalEventUnion{ .remote = .reconnect });
131 | }
132 |
133 | if (std.mem.eql(u8, cmd, "LINKS")) {
134 | self.ch.postEvent(GlobalEventUnion{ .remote = .{ .links = stream } });
135 | }
136 |
137 | if (std.mem.eql(u8, cmd, "BAN")) {
138 | const user = stream.reader().readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
139 | std.log.debug("remote could read: {}", .{err});
140 | return;
141 | };
142 |
143 | defer self.gpa.free(user);
144 |
145 | std.log.debug("remote msg: {s}", .{user});
146 |
147 | // Since sending the message from the main connection
148 | // makes it so that twitch doesn't echo it back, we're
149 | // opening a one-off connection to send the message.
150 | // This way we don't have to implement locally emote
151 | // parsing.
152 | var twitch_conn = Network.connect(
153 | self.gpa,
154 | self.auth.twitch.login,
155 | self.auth.twitch.token,
156 | ) catch return;
157 | defer twitch_conn.close();
158 | twitch_conn.writer().print("PRIVMSG #{s} :/ban {s}\n", .{
159 | self.auth.twitch.login,
160 | user,
161 | }) catch return;
162 | }
163 |
164 | if (std.mem.eql(u8, cmd, "YT")) {
165 | const video_id = stream.reader().readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
166 | std.log.debug("remote could read: {}", .{err});
167 | return;
168 | };
169 | defer self.gpa.free(video_id);
170 |
171 | const url_fmt = "https://www.googleapis.com/youtube/v3/liveBroadcasts?id={s}&part=id,snippet,status";
172 |
173 | var yt: std.http.Client = .{
174 | .allocator = self.gpa,
175 | };
176 | defer yt.deinit();
177 |
178 | const live_url = try std.fmt.allocPrint(self.gpa, url_fmt, .{video_id});
179 | defer self.gpa.free(live_url);
180 |
181 | var live_buf = std.ArrayList(u8).init(self.gpa);
182 | defer live_buf.deinit();
183 |
184 | const res = try yt.fetch(.{
185 | .location = .{ .url = live_url },
186 | .method = .GET,
187 | .response_storage = .{ .dynamic = &live_buf },
188 | .extra_headers = &.{
189 | .{ .name = "Authorization", .value = self.auth.youtube.token.access },
190 | },
191 | });
192 |
193 | const w = stream.writer();
194 |
195 | if (res.status != .ok) {
196 | try w.print("Error while fetching livestream details: {} \n{s}\n\n", .{
197 | res.status, live_buf.items,
198 | });
199 | return;
200 | }
201 |
202 | const lives = std.json.parseFromSlice(livechat.LiveBroadcasts, self.gpa, live_buf.items, .{
203 | .ignore_unknown_fields = true,
204 | }) catch {
205 | try w.print("Error while parsing livestream details.\n", .{});
206 | return;
207 | };
208 |
209 | defer lives.deinit();
210 |
211 | const chat_id = for (lives.value.items) |l| {
212 | if (std.mem.eql(u8, l.status.lifeCycleStatus, "live")) break try self.gpa.dupeZ(u8, l.snippet.liveChatId);
213 | } else {
214 | try w.print("The provided livestream does not seem to be live.\n", .{});
215 | return;
216 | };
217 |
218 | try w.print("Success!\n", .{});
219 |
220 | const maybe_old = @atomicRmw(?[*:0]const u8, &livechat.new_chat_id, .Xchg, chat_id, .acq_rel);
221 | if (maybe_old) |m| self.gpa.free(std.mem.span(m));
222 | }
223 |
224 | if (std.mem.eql(u8, cmd, "UNBAN")) {
225 | const user = stream.reader().readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
226 | std.log.debug("remote could read: {}", .{err});
227 | return;
228 | };
229 | defer self.gpa.free(user);
230 |
231 | std.log.debug("remote msg: {s}", .{user});
232 |
233 | // Since sending the message from the main connection
234 | // makes it so that twitch doesn't echo it back, we're
235 | // opening a one-off connection to send the message.
236 | // This way we don't have to implement locally emote
237 | // parsing.
238 | var twitch_conn = Network.connect(
239 | self.gpa,
240 | self.auth.twitch.login,
241 | self.auth.twitch.token,
242 | ) catch return;
243 | defer twitch_conn.close();
244 | twitch_conn.writer().print("PRIVMSG #{s} :/ban {s}\n", .{
245 | self.auth.twitch.login,
246 | user,
247 | }) catch return;
248 | }
249 |
250 | if (std.mem.eql(u8, cmd, "AFK")) {
251 | const reader = stream.reader();
252 | const time_string = reader.readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
253 | std.log.debug("remote could read: {}", .{err});
254 | return;
255 | };
256 | defer self.gpa.free(time_string);
257 |
258 | const parsed_time = parseTime(time_string) catch {
259 | std.log.debug("remote failed to parse time", .{});
260 | return;
261 | };
262 |
263 | std.log.debug("parsed_time in seconds: {d}", .{parsed_time});
264 |
265 | const target_time = std.time.timestamp() + parsed_time;
266 |
267 | const reason = reader.readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
268 | std.log.debug("remote could read: {}", .{err});
269 | return;
270 | };
271 |
272 | errdefer self.gpa.free(reason);
273 |
274 | for (reason) |c| switch (c) {
275 | else => {},
276 | '\n', '\r', '\t' => return error.BadReason,
277 | };
278 |
279 | const title = reader.readUntilDelimiterAlloc(self.gpa, '\n', 4096) catch |err| {
280 | std.log.debug("remote could read: {}", .{err});
281 | return;
282 | };
283 |
284 | errdefer self.gpa.free(title);
285 |
286 | for (title) |c| switch (c) {
287 | else => {},
288 | '\n', '\r', '\t' => return error.BadReason,
289 | };
290 |
291 | self.ch.postEvent(GlobalEventUnion{
292 | .remote = .{
293 | .afk = .{
294 | .target_time = target_time,
295 | .reason = reason,
296 | .title = title,
297 | },
298 | },
299 | });
300 | }
301 | }
302 |
303 | // NOTE: this function should only be called by
304 | // the thread that's also running the main control
305 | // loop
306 | pub fn replyLinks(chat: *Chat, stream: std.net.Stream) void {
307 | var maybe_current = chat.last_link_message;
308 | while (maybe_current) |c| : (maybe_current = c.prev_links) {
309 | const text = switch (c.kind) {
310 | .chat => |comment| comment.text,
311 | else => continue,
312 | };
313 | var it = std.mem.tokenizeScalar(u8, text, ' ');
314 | while (it.next()) |word| {
315 | if (url.sense(word)) {
316 | const indent = " >>";
317 | stream.writer().print("{s} [{s}]\n{s} {s}\n\n", .{
318 | c.time,
319 | c.login_name,
320 | indent,
321 | url.clean(word),
322 | }) catch return;
323 | }
324 | }
325 | }
326 |
327 | stream.close();
328 | }
329 |
--------------------------------------------------------------------------------
/src/Network.zig:
--------------------------------------------------------------------------------
1 | const Network = @This();
2 |
3 | const options = @import("build_options");
4 | const std = @import("std");
5 | const zeit = @import("zeit");
6 | const ws = @import("ws");
7 | const vaxis = @import("vaxis");
8 |
9 | const GlobalEventUnion = @import("main.zig").Event;
10 | const Chat = @import("Chat.zig");
11 | const Config = @import("Config.zig");
12 | const oauth = @import("network/oauth.zig");
13 | const livechat = @import("network/youtube/livechat.zig");
14 | const irc_parser = @import("network/twitch/irc_parser.zig");
15 | const event_parser = @import("network/twitch/event_parser.zig");
16 | const EmoteCache = @import("network/twitch/EmoteCache.zig");
17 | pub const TwitchAuth = @import("network/twitch/Auth.zig");
18 | pub const YouTubeAuth = @import("network/youtube/Auth.zig");
19 |
20 | pub const Auth = struct {
21 | twitch: TwitchAuth,
22 | youtube: YouTubeAuth = .{},
23 | };
24 |
25 | pub const Event = union(enum) {
26 | // chat
27 | message: Chat.Message,
28 | connected,
29 | disconnected,
30 | reconnected,
31 | clear: ?[]const u8, // optional nickname, if empty delete all
32 | };
33 |
34 | pub const UserCommand = union(enum) {
35 | message: []const u8,
36 | // ban: []const u8,
37 | };
38 |
39 | const log = std.log.scoped(.network);
40 | const wslog = std.log.scoped(.ws);
41 |
42 | const Command = union(enum) {
43 | user: UserCommand,
44 | pong,
45 | };
46 |
47 | config: Config,
48 | auth: Auth,
49 | tz: zeit.TimeZone,
50 | gpa: std.mem.Allocator,
51 | ch: *vaxis.Loop(GlobalEventUnion),
52 | emote_cache: EmoteCache,
53 | socket: std.net.Stream = undefined,
54 | writer_lock: std.Thread.Mutex = .{},
55 |
56 | pub fn init(
57 | self: *Network,
58 | gpa: std.mem.Allocator,
59 | ch: *vaxis.Loop(GlobalEventUnion),
60 | config: Config,
61 | auth: Auth,
62 | ) !void {
63 | var env = try std.process.getEnvMap(gpa);
64 | defer env.deinit();
65 | const tz = try zeit.local(gpa, &env);
66 |
67 | self.* = Network{
68 | .config = config,
69 | .auth = auth,
70 | .tz = tz,
71 | .gpa = gpa,
72 | .ch = ch,
73 | .emote_cache = EmoteCache.init(gpa),
74 | };
75 |
76 | const irc_thread = try std.Thread.spawn(.{}, ircHandler, .{self});
77 | irc_thread.detach();
78 |
79 | const ws_thread = try std.Thread.spawn(.{}, wsHandler, .{self});
80 | ws_thread.detach();
81 |
82 | if (auth.youtube.enabled) {
83 | const yt_thread = try std.Thread.spawn(.{}, livechat.poll, .{self});
84 | yt_thread.detach();
85 | }
86 | }
87 |
88 | const ws_host = if (options.local) "localhost" else "eventsub.wss.twitch.tv";
89 | fn noopSigHandler(_: c_int) callconv(.C) void {}
90 | fn wsHandler(self: *Network) void {
91 | // copy and pasted from std.start.maybeIgnoreSigpipe
92 | // TODO make maybeIgnoreSigpipe pub so we don't have to copy and paste it
93 | const have_sigpipe_support = switch (@import("builtin").os.tag) {
94 | .linux,
95 | .plan9,
96 | .solaris,
97 | .netbsd,
98 | .openbsd,
99 | .haiku,
100 | .macos,
101 | .ios,
102 | .watchos,
103 | .tvos,
104 | .dragonfly,
105 | .freebsd,
106 | => true,
107 |
108 | else => false,
109 | };
110 |
111 | if (have_sigpipe_support and !std.options.keep_sigpipe) {
112 | // const posix = std.posix;
113 | // const act: posix.Sigaction = .{
114 | // // Set handler to a noop function instead of `SIG.IGN` to prevent
115 | // // leaking signal disposition to a child process.
116 | // .handler = .{ .handler = noopSigHandler },
117 | // .mask = posix.empty_sigset,
118 | // .flags = 0,
119 | // };
120 | // posix.sigaction(posix.SIG.PIPE, &act, null) catch |err|
121 | // std.debug.panic("failed to set noop SIGPIPE handler: {s}", .{@errorName(err)});
122 | }
123 |
124 | const h: Handler = .{ .network = self };
125 | while (true) {
126 | var retries: usize = 0;
127 | var client = while (true) : (retries += 1) {
128 | switch (retries) {
129 | 0...1 => {},
130 | else => {
131 | const t: usize = @min(10, 2 * retries);
132 | std.time.sleep(t * std.time.ns_per_s);
133 | },
134 | }
135 | var client = ws.Client.init(self.gpa, .{
136 | .host = ws_host,
137 | .port = 443,
138 | .tls = !options.local,
139 | }) catch |err| {
140 | wslog.debug("connection failed: {s}", .{@errorName(err)});
141 | continue;
142 | };
143 |
144 | client.handshake("/ws", .{
145 | .timeout_ms = 5000,
146 | .headers = "Host: " ++ ws_host,
147 | }) catch |err| {
148 | wslog.debug("handshake failed: {s}", .{@errorName(err)});
149 | continue;
150 | };
151 | break client;
152 | };
153 |
154 | wslog.debug("connected!", .{});
155 |
156 | client.readLoop(h) catch |err| {
157 | wslog.debug("read loop failed: {s}", .{@errorName(err)});
158 | continue;
159 | };
160 | }
161 | }
162 |
163 | const Handler = struct {
164 | network: *Network,
165 |
166 | var seen_follows: std.StringHashMapUnmanaged(void) = .{};
167 | pub fn serverMessage(self: Handler, data: []u8) !void {
168 | errdefer |err| {
169 | wslog.debug("websocket handler errored out: {s}", .{@errorName(err)});
170 | }
171 |
172 | wslog.debug("ws event: {s}", .{data});
173 |
174 | const event = try event_parser.parseEvent(self.network.gpa, data);
175 |
176 | wslog.debug("parsed event: {any}", .{event});
177 | switch (event) {
178 | .none, .session_keepalive => {
179 | wslog.debug("event: {s}", .{@tagName(event)});
180 | },
181 | .charity => |c| {
182 | self.network.ch.postEvent(GlobalEventUnion{
183 | .network = .{
184 | .message = Chat.Message{
185 | .login_name = c.login_name,
186 | .time = c.time,
187 | .kind = .{
188 | .charity = .{
189 | .display_name = c.display_name,
190 | .amount = c.amount,
191 | },
192 | },
193 | },
194 | },
195 | });
196 | },
197 | .follower => |f| {
198 | const gop = try seen_follows.getOrPut(
199 | self.network.gpa,
200 | f.login_name,
201 | );
202 | if (!gop.found_existing) {
203 | self.network.ch.postEvent(GlobalEventUnion{
204 | .network = .{
205 | .message = Chat.Message{
206 | .login_name = f.login_name,
207 | .time = f.time,
208 | .kind = .{
209 | .follow = .{
210 | .display_name = f.display_name,
211 | },
212 | },
213 | },
214 | },
215 | });
216 | }
217 | },
218 | .session_welcome => |session_id| {
219 | log.debug("got session welcome, subscribing!", .{});
220 | const notifs = self.network.config.notifications;
221 | if (notifs.follows) {
222 | try self.subscribeToEvent(
223 | session_id,
224 | "channel.follow",
225 | "2",
226 | );
227 | }
228 | if (notifs.charity) {
229 | try self.subscribeToEvent(
230 | session_id,
231 | "channel.charity_campaign.donate",
232 | "1",
233 | );
234 | }
235 | },
236 | }
237 | // try self.network.ws_client.write(data); // echo the message back
238 | }
239 |
240 | pub fn close(_: Handler) void {}
241 |
242 | fn subscribeToEvent(
243 | self: Handler,
244 | session_id: []const u8,
245 | event_name: []const u8,
246 | version: []const u8,
247 | ) !void {
248 | const client_id = oauth.client_id;
249 | const user_id = self.network.auth.twitch.user_id;
250 | const token = self.network.auth.twitch.token;
251 | const gpa = self.network.gpa;
252 |
253 | var client: std.http.Client = .{
254 | .allocator = gpa,
255 | };
256 |
257 | const headers: []const std.http.Header = &.{
258 | .{
259 | .name = "Authorization",
260 | .value = token,
261 | },
262 | .{
263 | .name = "Client-Id",
264 | .value = client_id,
265 | },
266 | };
267 |
268 | const body_fmt =
269 | \\{{
270 | \\ "type": "{s}",
271 | \\ "version": "{s}",
272 | \\ "condition": {{
273 | \\ "broadcaster_user_id": "{s}",
274 | \\ "moderator_user_id": "{s}"
275 | \\ }},
276 | \\ "transport": {{
277 | \\ "method": "websocket",
278 | \\ "session_id": "{s}"
279 | \\ }}
280 | \\}}
281 | ;
282 |
283 | const body = try std.fmt.allocPrint(gpa, body_fmt, .{
284 | event_name, version, user_id, user_id, session_id,
285 | });
286 |
287 | const url = "https://api.twitch.tv/helix/eventsub/subscriptions";
288 | const result = try client.fetch(.{
289 | .method = .POST,
290 | .headers = .{
291 | .content_type = .{ .override = "application/json" },
292 | },
293 | .extra_headers = headers,
294 | .location = .{ .url = url },
295 | .payload = body,
296 | });
297 |
298 | log.debug("sub request reply: name: {s} code: {}", .{
299 | event_name,
300 | result.status,
301 | });
302 | }
303 | };
304 |
305 | pub fn deinit(self: *Network) void {
306 | _ = self;
307 | // // Try to grab the reconnecting flag
308 | // while (@atomicRmw(bool, &self._atomic_reconnecting, .Xchg, true, .SeqCst)) {
309 | // std.time.sleep(10 * std.time.ns_per_ms);
310 | // }
311 |
312 | // // Now we can kill the connection and nobody will try to reconnect
313 | // std.posix.shutdown(self.socket.handle, .both) catch |err| {
314 | // log.debug("shutdown failed, err: {}", .{err});
315 | // };
316 | // self.socket.close();
317 | }
318 |
319 | fn ircHandler(self: *Network) void {
320 | self.writer_lock.lock();
321 | while (true) {
322 | var retries: usize = 0;
323 | while (true) : (retries += 1) {
324 | switch (retries) {
325 | 0...1 => {},
326 | else => {
327 | const t: usize = @min(10, 2 * retries);
328 | std.time.sleep(t * std.time.ns_per_s);
329 | },
330 | }
331 | self.socket = connect(
332 | self.gpa,
333 | self.auth.twitch.login,
334 | self.auth.twitch.token,
335 | ) catch |reconnect_err| {
336 | log.debug("reconnect attempt #{} failed: {s}", .{
337 | retries,
338 | @errorName(reconnect_err),
339 | });
340 | continue;
341 | };
342 |
343 | log.debug("reconnected!", .{});
344 | break;
345 | }
346 |
347 | self.writer_lock.unlock();
348 |
349 | self.receiveIrcMessages() catch |err| {
350 | log.debug("reconnecting after network error: {s}", .{@errorName(err)});
351 |
352 | self.writer_lock.lock();
353 | std.posix.shutdown(self.socket.handle, .both) catch |sherr| {
354 | log.debug("reader thread shutdown failed err: {}", .{sherr});
355 | };
356 | self.socket.close();
357 | };
358 | }
359 | }
360 |
361 | fn receiveIrcMessages(self: *Network) !void {
362 | while (true) {
363 | const data = data: {
364 | const r = self.socket.reader();
365 | const d = try r.readUntilDelimiterAlloc(self.gpa, '\n', 4096);
366 | if (d.len >= 1 and d[d.len - 1] == '\r') {
367 | break :data d[0 .. d.len - 1];
368 | }
369 |
370 | break :data d;
371 | };
372 |
373 | log.debug("receiveMessages succeded", .{});
374 |
375 | const p = irc_parser.parseMessage(data, self.gpa, &self.tz) catch |err| {
376 | log.debug("parsing error: [{}]", .{err});
377 | continue;
378 | };
379 | switch (p) {
380 | .ping => {
381 | try self.send(.pong);
382 | },
383 | .clear => |c| {
384 | self.ch.postEvent(GlobalEventUnion{ .network = .{ .clear = c } });
385 | },
386 | .message => |msg| {
387 | switch (msg.kind) {
388 | else => {},
389 | .chat => |c| {
390 | self.emote_cache.fetch(c.emotes) catch |err| {
391 | log.debug("fetching error: [{}]", .{err});
392 | continue;
393 | };
394 | },
395 | .resub => |c| {
396 | self.emote_cache.fetch(c.resub_message_emotes) catch |err| {
397 | log.debug("fetching error: [{}]", .{err});
398 | continue;
399 | };
400 | },
401 | }
402 |
403 | self.ch.postEvent(GlobalEventUnion{ .network = .{ .message = msg } });
404 |
405 | // Hack: when receiving resub events, we generate a fake chat message
406 | // to display the resub message. In the future this should be
407 | // dropped in favor of actually representing properly the resub.
408 | // Also this message is pointing to data that "belongs" to another
409 | // message. Kind of a bad idea.
410 | switch (msg.kind) {
411 | .resub => |r| {
412 | if (r.resub_message.len > 0) {
413 | self.ch.postEvent(GlobalEventUnion{
414 | .network = .{
415 | .message = Chat.Message{
416 | .login_name = msg.login_name,
417 | .time = msg.time,
418 | .kind = .{
419 | .chat = .{
420 | .display_name = r.display_name,
421 | .text = r.resub_message,
422 | .sub_months = r.count,
423 | .is_founder = false, // std.mem.eql(u8, sub_badge.name, "founder"),
424 | .emotes = r.resub_message_emotes,
425 | .is_mod = false, // is_mod,
426 | .is_highlighted = true,
427 | },
428 | },
429 | },
430 | },
431 | });
432 | }
433 | },
434 | else => {},
435 | }
436 | },
437 | }
438 | }
439 | }
440 |
441 | // Public interface for sending commands (messages, bans, ...)
442 | pub fn sendCommand(self: *Network, cmd: UserCommand) void {
443 | self.send(Command{ .user = cmd }) catch {
444 | std.posix.shutdown(self.socket.handle, .both) catch |err| {
445 | log.debug("shutdown failed, err: {}", .{err});
446 | @panic("");
447 | };
448 | };
449 | }
450 |
451 | fn send(self: *Network, cmd: Command) !void {
452 | self.writer_lock.lock();
453 | defer self.writer_lock.unlock();
454 |
455 | const w = self.socket.writer();
456 | switch (cmd) {
457 | .pong => {
458 | log.debug("PONG!", .{});
459 | try w.print("PONG :tmi.twitch.tv\n", .{});
460 | },
461 | .user => |uc| {
462 | switch (uc) {
463 | .message => |msg| {
464 | log.debug("SEND MESSAGE!", .{});
465 | try w.print("PRIVMSG #{s} :{s}\n", .{
466 | self.auth.twitch.login,
467 | msg,
468 | });
469 | },
470 | }
471 | },
472 | }
473 | }
474 |
475 | pub fn connect(gpa: std.mem.Allocator, name: []const u8, token: []const u8) !std.net.Stream {
476 | var socket = if (options.local)
477 | try std.net.tcpConnectToHost(gpa, "localhost", 6667)
478 | else
479 | try std.net.tcpConnectToHost(gpa, "irc.chat.twitch.tv", 6667);
480 |
481 | errdefer socket.close();
482 |
483 | const oua = if (options.local) "##SECRET##" else blk: {
484 | var it = std.mem.tokenizeScalar(u8, token, ' ');
485 | _ = it.next().?;
486 | break :blk it.next().?;
487 | };
488 |
489 | try socket.writer().print(
490 | \\PASS oauth:{0s}
491 | \\NICK {1s}
492 | \\CAP REQ :twitch.tv/tags
493 | \\CAP REQ :twitch.tv/commands
494 | \\JOIN #{1s}
495 | \\
496 | , .{ oua, name });
497 |
498 | // TODO: read what we got back, instead of assuming that
499 | // all went well just because the bytes were shipped.
500 |
501 | return socket;
502 | }
503 |
--------------------------------------------------------------------------------
/src/network/twitch/irc_parser.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const zeit = @import("zeit");
3 | const Chat = @import("../../Chat.zig");
4 |
5 | const ParseResult = union(enum) {
6 | ping,
7 | clear: ?[]const u8,
8 | message: Chat.Message,
9 | };
10 |
11 | const log = std.log.scoped(.parser);
12 |
13 | pub fn parseMessage(data: []u8, alloc: std.mem.Allocator, tz: *zeit.TimeZone) !ParseResult {
14 | log.debug("data:\n{s}\n", .{data});
15 | if (data.len == 0) return error.NoData;
16 |
17 | // Basic message structure:
18 | // <@metadata >: :
19 | //
20 | // Metadata and trailing are optional. Metadata starts with `@` and ends with a space.
21 | // Prefix is missing from PINGs, present otherwise, commands can have zero params.
22 |
23 | var remaining_data: []const u8 = data;
24 |
25 | const metadata: []const u8 = blk: {
26 | if (remaining_data[0] == '@') {
27 | // Message has metadata
28 | const end = std.mem.indexOf(u8, data, " :") orelse return error.NoChunks;
29 | const m = remaining_data[1..end]; // Skip the `@`
30 | remaining_data = remaining_data[end + 1 ..]; // Leave the colon there to unify the two cases
31 |
32 | break :blk m;
33 | }
34 |
35 | // Message has no metadata
36 | break :blk "";
37 | };
38 | log.debug("metadata: [{s}]", .{metadata});
39 |
40 | // Prefix
41 | const prefix = blk: {
42 | if (remaining_data[0] == ':') {
43 | // Message has prefix
44 | const end = std.mem.indexOf(u8, remaining_data, " ") orelse return error.NoCommand;
45 | const p = remaining_data[1..end]; // Skip the colon
46 | remaining_data = remaining_data[end + 1 ..];
47 |
48 | break :blk p;
49 | }
50 | // Message has no prefix
51 | break :blk "";
52 | };
53 | log.debug("prefix: [{s}]", .{prefix});
54 |
55 | // Command and arguments
56 | const cmd_and_args = blk: {
57 | if (std.mem.indexOf(u8, remaining_data, " :")) |end| {
58 | // Message has trailer
59 | const cmd_and_args = remaining_data[0..end];
60 | remaining_data = remaining_data[end + 2 ..]; // Skip the entire separator
61 |
62 | break :blk cmd_and_args;
63 | }
64 | // Message has no trailer
65 | const cmd_and_args = remaining_data;
66 | remaining_data = "";
67 | break :blk cmd_and_args;
68 | };
69 | log.debug("cmd and args: [{s}]", .{cmd_and_args});
70 |
71 | // Trailer
72 | const trailer = remaining_data[0..]; // Empty string if no trailer
73 | log.debug("trailer: [{s}]", .{trailer});
74 |
75 | var cmd_and_args_it = std.mem.tokenizeScalar(u8, cmd_and_args, ' ');
76 | const cmd = cmd_and_args_it.next().?; // Calling the iterator once should never fail
77 |
78 | // Prepare fields common to multiple msg types
79 | const now_utc = try zeit.instant(.{});
80 | const now = now_utc.in(tz).time();
81 |
82 | var time: [5]u8 = undefined;
83 | _ = std.fmt.bufPrint(&time, "{d:0>2}:{d:0>2}", .{
84 | now.hour,
85 | now.minute,
86 | }) catch unreachable; // we know we have the space
87 |
88 | // Switch over all possible message types
89 | if (std.mem.eql(u8, cmd, "PRIVMSG")) {
90 | // @badge-info=;
91 | // badges=;
92 | // client-nonce=69fcc90179a36691a27dcf8f91a706a9;
93 | // color=;
94 | // display-name=SebastianKeller_;
95 | // emotes=;
96 | // flags=;
97 | // id=77bcc67d-2941-4c9d-a281-f83f9cc4fad4;
98 | // mod=0;
99 | // room-id=102701971;
100 | // subscriber=0;
101 | // tmi-sent-ts=1633534241992;
102 | // turbo=0;
103 | // user-id=79632778;
104 | // user-type= :sebastiankeller_!sebastiankeller_@sebastiankeller_.tmi.twitch.tv
105 | // PRIVMSG #kristoff_it :im not, ban me
106 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
107 | "badge-info", // 0
108 | "display-name", // 1
109 | "emotes", // 2
110 | "mod", // 3
111 | });
112 |
113 | const sub_badge: Badge = if (meta[0].len > 0)
114 | try parseBadge(meta[0])
115 | else
116 | Badge{ .name = "", .count = 0 };
117 | const display_name = meta[1];
118 | const emotes = try parseEmotes(meta[2], alloc);
119 | const is_mod = std.mem.eql(u8, meta[3], "1");
120 | const highlight_pos = std.mem.indexOf(u8, metadata, "msg-id=highlighted-message;");
121 |
122 | // Parse the proper login name, which is similar to the display name
123 | // but not exactly the same and it's needed for cleaning up messages
124 | // when banning somebody.
125 | const login_name = prefix[0..(std.mem.indexOfScalar(u8, prefix, '!') orelse 0)];
126 |
127 | return ParseResult{
128 | .message = Chat.Message{
129 | .login_name = login_name,
130 | .time = time,
131 | .kind = .{
132 | .chat = .{
133 | .text = trailer,
134 | .display_name = display_name,
135 | .sub_months = sub_badge.count,
136 | .is_founder = std.mem.eql(u8, sub_badge.name, "founder"),
137 | .emotes = emotes,
138 | .is_mod = is_mod,
139 | .is_highlighted = highlight_pos != null,
140 | },
141 | },
142 | },
143 | };
144 | } else if (std.mem.eql(u8, cmd, "CLEARCHAT")) {
145 | // @ban-duration=600;
146 | // room-id=102701971;
147 | // target-user-id=137180345;
148 | // tmi-sent-ts=1625379632217 :tmi.twitch.tv CLEARCHAT #kristoff_it :soul_serpent
149 | return ParseResult{
150 | .clear = if (trailer.len > 0) trailer else null,
151 | };
152 | } else if (std.mem.eql(u8, cmd, "USERNOTICE")) {
153 | // Welcome to a new world of pain.
154 | // Here's another great protocol idea from Twitch:
155 | // Hidden deep inside the metadata there's the `msg-id` field,
156 | // which, in the case of USERNOTICE is not a unique id, but
157 | // a tag that identifies the event type among the following:
158 | //
159 | // sub, resub, subgift, anonsubgift, submysterygift,
160 | // giftpaidupgrade, rewardgift, anongiftpaidupgrade,
161 | // raid, unraid, ritual, bitsbadgetier
162 | //
163 | // If you read already other comments in this file you
164 | // probably know where this is going: each type has
165 | // different fields present, which makes our linear
166 | // scan strategy less applicable.
167 | // The solution in this case is to look twice: once to
168 | // get the message type and a second time to grab all the
169 | // fields we need.
170 | //
171 | // One might be tempted at this point to really implement
172 | // the sorted version of this algorithm NotLikeThis
173 | const msg_type = (try parseMetaSubsetLinear(metadata, [1][]const u8{"msg-id"}))[0];
174 |
175 | if (std.mem.eql(u8, msg_type, "raid")) {
176 | // @badge-info=;
177 | // badges=;
178 | // color=#5F9EA0;
179 | // display-name=togglebit;
180 | // emotes=;
181 | // flags=;
182 | // id=20d2355b-92d6-4262-a5d5-c0ef7ccb8bad;
183 | // login=togglebit;
184 | // mod=0;
185 | // msg-id=raid;
186 | // msg-param-displayName=togglebit;
187 | // msg-param-login=togglebit;
188 | // msg-param-profileImageURL=https://static-cdn.jtvnw.net/jtv_user_pictures/0bb9c502-ab5d-4440-9c9d-14e5260ebf86-profile_image-70x70.png;
189 | // msg-param-viewerCount=126;
190 | // room-id=102701971;
191 | // subscriber=0;
192 | // system-msg=126\sraiders\sfrom\stogglebit\shave\sjoined!;
193 | // tmi-sent-ts=1619015565551;
194 | // user-id=474725923;
195 | // user-type= :tmi.twitch.tv USERNOTICE #kristoff_it
196 |
197 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
198 | "display-name", // 0
199 | "login", // 1
200 | "msg-param-profileImageURL", // 2
201 | "msg-param-viewerCount", // 3
202 | });
203 |
204 | const count = try std.fmt.parseInt(usize, meta[3], 10);
205 | return ParseResult{
206 | .message = Chat.Message{
207 | .login_name = meta[1],
208 | .time = time,
209 | .kind = .{
210 | .raid = .{
211 | .display_name = meta[0],
212 | .profile_picture_url = meta[2],
213 | .count = count,
214 | },
215 | },
216 | },
217 | };
218 | }
219 | if (std.mem.eql(u8, msg_type, "submysterygift")) {
220 | // @badge-info=founder/1;
221 | // badges=founder/0;
222 | // color=;
223 | // display-name=kristoff_it;
224 | // emotes=;
225 | // flags=;
226 | // id=47f6274d-970c-4f2e-ab10-6cf1474a0813;
227 | // login=kristoff_it;
228 | // mod=0;
229 | // msg-id=submysterygift;
230 | // msg-param-mass-gift-count=5;
231 | // msg-param-origin-id=d0\sf0\s99\s5b\s67\s87\s9d\s6e\s79\s92\se9\s25\sbf\s75\s40\s82\se0\s9b\sea\s2e;
232 | // msg-param-sender-count=5;
233 | // msg-param-sub-plan=1000;
234 | // room-id=180859114;
235 | // subscriber=1;
236 | // system-msg=kristoff_it\sis\sgifting\s5\sTier\s1\sSubs\sto\smattknite's\scommunity!\sThey've\sgifted\sa\stotal\sof\s5\sin\sthe\schannel!;
237 | // tmi-sent-ts=1609457534121;
238 | // user-id=102701971;
239 | // user-type= :tmi.twitch.tv USERNOTICE #mattknite
240 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
241 | "display-name", // 0
242 | "login", // 1
243 | "msg-param-mass-gift-count", // 2
244 | "msg-param-sub-plan", // 3
245 | });
246 |
247 | const count = try std.fmt.parseInt(usize, meta[2], 10);
248 | const tier = try parseSubTier(meta[3]);
249 |
250 | return ParseResult{
251 | .message = Chat.Message{
252 | .login_name = meta[1],
253 | .time = time,
254 | .kind = .{
255 | .sub_mistery_gift = .{
256 | .display_name = meta[0],
257 | .count = count,
258 | .tier = tier,
259 | },
260 | },
261 | },
262 | };
263 | } else if (std.mem.eql(u8, msg_type, "subgift")) {
264 | // @badge-info=founder/1;
265 | // badges=founder/0;
266 | // color=;
267 | // display-name=kristoff_it;
268 | // emotes=;
269 | // flags=;
270 | // id=b35bbd66-50e7-4b77-831c-fab505906551;
271 | // login=kristoff_it;
272 | // mod=0;
273 | // msg-id=subgift;
274 | // msg-param-gift-months=1;
275 | // msg-param-months=1;
276 | // msg-param-origin-id=da\s39\sa3\see\s5e\s6b\s4b\s0d\s32\s55\sbf\sef\s95\s60\s18\s90\saf\sd8\s07\s09;
277 | // msg-param-recipient-display-name=g_w1;
278 | // msg-param-recipient-id=203259404;
279 | // msg-param-recipient-user-name=g_w1;
280 | // msg-param-sender-count=0;
281 | // msg-param-sub-plan-name=Channel\sSubscription\s(mattknite);
282 | // msg-param-sub-plan=1000;
283 | // room-id=180859114;
284 | // subscriber=1;
285 | // system-msg=kristoff_it\sgifted\sa\sTier\s1\ssub\sto\sg_w1!;
286 | // tmi-sent-ts=1609457535209;
287 | // user-id=102701971;
288 | // user-type= :tmi.twitch.tv USERNOTICE #mattknite
289 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
290 | "display-name", // 0
291 | "login", // 1
292 | "msg-param-gift-months", // 2
293 | "msg-param-recipient-display-name", // 3
294 | "msg-param-recipient-user-name", // 4
295 | "msg-param-sub-plan", // 5
296 | });
297 |
298 | const months = try std.fmt.parseInt(usize, meta[2], 10);
299 | const tier = try parseSubTier(meta[5]);
300 |
301 | return ParseResult{
302 | .message = Chat.Message{
303 | .login_name = meta[1],
304 | .time = time,
305 | .kind = .{
306 | .sub_gift = .{
307 | .sender_display_name = meta[0],
308 | .months = months,
309 | .tier = tier,
310 | .recipient_display_name = meta[3],
311 | .recipient_login_name = meta[4],
312 | },
313 | },
314 | },
315 | };
316 | } else if (std.mem.eql(u8, msg_type, "sub")) {
317 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
318 | "display-name", // 0
319 | "login", // 1
320 | "msg-param-sub-plan", // 2
321 | });
322 |
323 | const tier = try parseSubTier(meta[2]);
324 |
325 | return ParseResult{
326 | .message = Chat.Message{
327 | .login_name = meta[1],
328 | .time = time,
329 | .kind = .{
330 | .sub = .{
331 | .display_name = meta[0],
332 | .tier = tier,
333 | },
334 | },
335 | },
336 | };
337 | } else if (std.mem.eql(u8, msg_type, "resub")) {
338 | // **UNRELIABLE** From the spec **UNRELIABLE**
339 | // @badge-info=;
340 | // badges=staff/1,broadcaster/1,turbo/1;
341 | // color=#008000;
342 | // display-name=ronni;
343 | // emotes=;
344 | // id=db25007f-7a18-43eb-9379-80131e44d633;
345 | // login=ronni;
346 | // mod=0;
347 | // msg-id=resub;
348 | // msg-param-cumulative-months=6;
349 | // msg-param-streak-months=2;
350 | // msg-param-should-share-streak=1;
351 | // msg-param-sub-plan=Prime;
352 | // msg-param-sub-plan-name=Prime;
353 | // room-id=1337;subscriber=1;
354 | // system-msg=ronni\shas\ssubscribed\sfor\s6\smonths!;
355 | // tmi-sent-ts=1507246572675;
356 | // turbo=1;
357 | // user-id=1337;
358 | // user-type=staff :tmi.twitch.tv USERNOTICE #dallas :Great stream -- keep it up!
359 | const meta = try parseMetaSubsetLinear(metadata, [_][]const u8{
360 | "display-name", // 0
361 | "emotes", // 1
362 | "login", // 2
363 | "msg-param-cumulative-months", // 3
364 | "msg-param-sub-plan", // 4
365 | });
366 |
367 | const tier = try parseSubTier(meta[4]);
368 | const count = try std.fmt.parseInt(usize, meta[3], 10);
369 | const emotes = try parseEmotes(meta[1], alloc);
370 |
371 | return ParseResult{
372 | .message = Chat.Message{
373 | .login_name = meta[2],
374 | .time = time,
375 | .kind = .{
376 | .resub = .{
377 | .display_name = meta[0],
378 | .count = count,
379 | .tier = tier,
380 | .resub_message = trailer,
381 | .resub_message_emotes = emotes,
382 | },
383 | },
384 | },
385 | };
386 | } else {
387 | return error.UnknownUsernotice;
388 | }
389 |
390 | // } else if (std.mem.eql(u8, cmd, "PING")) {
391 |
392 | // } else if (std.mem.eql(u8, cmd, "PING")) {
393 | // } else if (std.mem.eql(u8, cmd, "PING")) {
394 | } else if (std.mem.eql(u8, cmd, "PING")) {
395 | return .ping;
396 | } else {
397 | return error.UnknownMessage;
398 | }
399 | }
400 |
401 | fn parseSubTier(data: []const u8) !Chat.Message.SubTier {
402 | if (data.len == 0) return error.MissingSubTier;
403 | return switch (data[0]) {
404 | 'P' => .prime,
405 | '1' => .t1,
406 | '2' => .t2,
407 | '3' => .t3,
408 | else => error.BadSubTier,
409 | };
410 | }
411 |
412 | fn parseEmotes(data: []const u8, allocator: std.mem.Allocator) ![]Chat.Message.Emote {
413 | // Small hack: count the dashes to know how many emotes
414 | // are present in the text.
415 | const count = std.mem.count(u8, data, "-");
416 | var emotes = try allocator.alloc(Chat.Message.Emote, count);
417 | errdefer allocator.free(emotes);
418 |
419 | var emote_it = std.mem.tokenizeScalar(u8, data, '/');
420 | var i: usize = 0;
421 | while (emote_it.next()) |e| {
422 | const colon_pos = std.mem.indexOf(u8, e, ":") orelse return error.NoColon;
423 | const emote_id = e[0..colon_pos];
424 |
425 | var pos_it = std.mem.tokenizeScalar(u8, e[colon_pos + 1 ..], ',');
426 | while (pos_it.next()) |pos| : (i += 1) {
427 | var it = std.mem.tokenizeScalar(u8, pos, '-');
428 | const start = blk: {
429 | const str = it.next() orelse return error.NoStart;
430 | break :blk try std.fmt.parseInt(usize, str, 10);
431 | };
432 | const end = blk: {
433 | const str = it.next() orelse return error.NoEnd;
434 | break :blk try std.fmt.parseInt(usize, str, 10);
435 | };
436 |
437 | if (it.rest().len != 0) return error.BadEmote;
438 |
439 | // result.emote_chars += end - start;
440 | emotes[i] = Chat.Message.Emote{
441 | .twitch_id = emote_id,
442 | .start = start,
443 | .end = end,
444 | };
445 | }
446 | }
447 |
448 | // Sort the array by start position
449 | std.mem.sort(Chat.Message.Emote, emotes, {}, Chat.Message.Emote.lessThan);
450 | for (emotes) |em| log.debug("{}", .{em});
451 |
452 | return emotes;
453 | }
454 |
455 | const Badge = struct {
456 | name: []const u8,
457 | count: usize,
458 | };
459 |
460 | fn parseBadge(data: []const u8) !Badge {
461 | var it = std.mem.tokenizeScalar(u8, data, '/');
462 | return Badge{
463 | .name = it.next().?, // first call will not fail
464 | .count = try std.fmt.parseInt(usize, it.rest(), 10),
465 | };
466 | }
467 |
468 | /// `keys` must be an array of strings
469 | fn parseMetaSubsetLinear(meta: []const u8, keys: anytype) ![keys.len][]const u8 {
470 | // Given the starting fact that the Twitch IRC spec sucks ass,
471 | // we have an interesting conundrum on our hands.
472 | // Metadata is a series of key-value pairs (keys being simple names,
473 | // values being occasionally composite) that varies from message
474 | // type to message type. There's a few different message types
475 | // that we care about and each has a different set of kv pairs.
476 | // Unfortunately, as stated above, the Twitch IRC spec does indeed
477 | // suck major ass, and so we can't rely on it blindly, which means
478 | // that we can't just try to decode a struct and expect things to
479 | // go well.
480 | // Also, while we can make some assumptions about the protocol,
481 | // Twitch is going to make changes over time as it tries to refine
482 | // its product (after years of inaction lmao) to please the
483 | // insatiable Bezosaurus Rex that roams Twith's HQ.
484 | // Finally, one extra constraint comes from me: I don't want to
485 | // decode this thing into a hashmap. I would have written bork in
486 | // Perl if I wanted to do things that way.
487 | //
488 | // So, after this inequivocably necessary introduction, here's the
489 | // plan: we assume fields are always presented in the same order
490 | // (within each message type) and expect Twitch to add new fields
491 | // over time (fields that we don't care about in this version, that is).
492 | //
493 | // We expect the caller to provide the `keys` array sorted following
494 | // the same logic and we scan the tag list lineraly expecting to
495 | // match everything as we go. If we scan the full list and discover
496 | // we didn't find all fields, we then fall-back to a "scan everything"
497 | // strategy and print a warning to the logging system.
498 | // This will make the normal case O(n) and the fallback strat O(n^2).
499 | // I could sort and accept a O(nlogn) across the whole board, plus
500 | // maybe a bit of dynamic allocation, but hey go big or go home.
501 | //
502 | // Ah I almost forgot: some fields are present only when enabled, like
503 | // `emote-only` which is present when a message contains only emotes,
504 | // but disappears when there's also non-emote content. GJ Twitch!
505 | var values: [keys.len][]const u8 = undefined;
506 | var it = std.mem.tokenizeScalar(u8, meta, ';');
507 |
508 | // linear scan
509 | const first_miss: usize = outer: for (keys, 0..) |k, i| {
510 | while (it.next()) |kv| {
511 | var kv_it = std.mem.tokenizeScalar(u8, kv, '=');
512 | const meta_k = kv_it.next().?; // First call will always succeed
513 | const meta_v = kv_it.rest();
514 | if (std.mem.eql(u8, k, meta_k)) {
515 | values[i] = meta_v;
516 | continue :outer;
517 | }
518 | }
519 |
520 | // If we reach here we consumed all kv pairs
521 | // and couldn't find our key. Not good!
522 | break :outer i;
523 | } else {
524 | // Success: we found all keys in one go!
525 | return values;
526 | };
527 |
528 | // Fallback to bad search, but first complain about it.
529 | log.debug("Linear scan of metadata failed! Let the maintainers know that Gondor calls for aid!", .{});
530 |
531 | // bad scan
532 | outer: for (keys[first_miss..], 0..) |k, i| {
533 | it = std.mem.tokenizeScalar(u8, meta, ';'); // we now reset every loop
534 | while (it.next()) |kv| {
535 | var kv_it = std.mem.tokenizeScalar(u8, kv, '=');
536 | const meta_k = kv_it.next().?; // First call will always succeed
537 | const meta_v = kv_it.rest();
538 | if (std.mem.eql(u8, k, meta_k)) {
539 | values[i] = meta_v;
540 | continue :outer;
541 | }
542 | }
543 |
544 | // The key is really missing.
545 | return error.MissingKey;
546 | }
547 |
548 | return values;
549 | }
550 |
--------------------------------------------------------------------------------
/src/display.zig:
--------------------------------------------------------------------------------
1 | const std = @import("std");
2 | const builtin = @import("builtin");
3 | const options = @import("build_options");
4 | const vaxis = @import("vaxis");
5 | const main = @import("main.zig");
6 | const url = @import("utils/url.zig");
7 | const Config = @import("Config.zig");
8 | const Chat = @import("Chat.zig");
9 | const Channel = @import("utils/channel.zig").Channel;
10 | const os = std.os;
11 | const posix = std.posix;
12 | const GlobalEventUnion = main.Event;
13 |
14 | const log = std.log.scoped(.display);
15 |
16 | var gpa: std.mem.Allocator = undefined;
17 | var config: Config = undefined;
18 | var loop: *vaxis.Loop(GlobalEventUnion) = undefined;
19 |
20 | var size: Size = .{ .rows = 0, .cols = 0 };
21 | var message_rendering_buffer: std.ArrayListUnmanaged(u8) = .{};
22 | var emote_cache: std.AutoHashMapUnmanaged(u32, void) = .{};
23 | var chat: *Chat = undefined;
24 | var elements: []InteractiveElement = &.{};
25 | var active: InteractiveElement = .none;
26 | var showing_quit_message: ?i64 = null;
27 | var afk: ?Afk = null;
28 |
29 | const Afk = struct {
30 | target_time: i64,
31 | title: []const u8 = "AFK",
32 | reason: []const u8 = "⏰",
33 | };
34 |
35 | const InteractiveElement = union(enum) {
36 | none,
37 | afk,
38 | nick: []const u8,
39 | message: *Chat.Message,
40 |
41 | pub fn eql(lhs: InteractiveElement, rhs: InteractiveElement) bool {
42 | if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) return false;
43 | return switch (lhs) {
44 | .none => true,
45 | .afk => false,
46 | .nick => std.mem.eql(u8, lhs.nick, rhs.nick),
47 | .message => lhs.message == rhs.message,
48 | };
49 | }
50 | };
51 |
52 | pub const Event = union(enum) {
53 | tick,
54 | };
55 |
56 | pub fn setup(
57 | gpa_: std.mem.Allocator,
58 | loop_: *vaxis.Loop(GlobalEventUnion),
59 | config_: Config,
60 | chat_: *Chat,
61 | ) !void {
62 | gpa = gpa_;
63 | loop = loop_;
64 | config = config_;
65 | chat = chat_;
66 |
67 | elements = try gpa.alloc(InteractiveElement, size.rows + 1);
68 | const ticker_thread = try std.Thread.spawn(.{}, tick, .{});
69 | ticker_thread.detach();
70 |
71 | try loop.vaxis.setMouseMode(loop.tty.anyWriter(), true);
72 | try loop.tty.anyWriter().writeAll(
73 | // enter alt screen
74 | // "\x1B[s\x1B[?47h\x1B[?1049h" ++
75 | // dislable wrapping mode
76 | "\x1B[?7l" ++
77 | // disable insert mode (replaces text)
78 | // "\x1B[4l" ++
79 | // hide the cursor
80 | "\x1B[?25l"
81 | // ++
82 | // mouse mode
83 | // "\x1B[?1000h",
84 | );
85 | }
86 |
87 | fn tick() void {
88 | while (true) {
89 | _ = loop.tryPostEvent(.{ .display = .tick });
90 | std.time.sleep(250 * std.time.ns_per_ms);
91 | }
92 | }
93 |
94 | pub fn teardown() void {
95 | log.debug("display teardown!", .{});
96 | message_rendering_buffer.deinit(gpa);
97 | }
98 |
99 | fn moveCursor(w: anytype, row: usize, col: usize) !void {
100 | try w.print("\x1B[{};{}H", .{ row, col });
101 | }
102 | const Size = struct {
103 | rows: usize,
104 | cols: usize,
105 | pub fn eql(lhs: Size, rhs: Size) bool {
106 | return lhs.rows == rhs.rows and
107 | lhs.cols == rhs.cols;
108 | }
109 | };
110 |
111 | pub fn sizeChanged(new: Size) bool {
112 | log.debug("size changed! {} {}", .{ new, size });
113 |
114 | if (new.eql(size)) return false;
115 |
116 | if (new.rows > elements.len) {
117 | elements = gpa.realloc(elements, new.rows + 1) catch
118 | @panic("oom");
119 | }
120 | size = new;
121 | return true;
122 | }
123 |
124 | const window_title_width = window_title.len - 2;
125 | const window_title: []const u8 = blk: {
126 | var v = std.mem.tokenizeScalar(u8, options.version, '.');
127 | const major = v.next().?;
128 | const minor = v.next().?;
129 | const patch = v.next().?;
130 | const dev = v.next() != null;
131 | const more = if (dev or patch[0] != '0') "+" else "";
132 |
133 | break :blk std.fmt.comptimePrint("bork ⚡ v{s}.{s}{s}", .{ major, minor, more });
134 | };
135 |
136 | var placement_id: usize = 0;
137 | const HeadingStyle = enum { nick, arrows, time };
138 | pub fn render() !void {
139 | placement_id = 0;
140 | log.debug("RENDER!\n {?any}", .{chat.last_message});
141 |
142 | var buffered_writer = loop.tty.bufferedWriter();
143 | var w = buffered_writer.writer();
144 |
145 | // enter sync mode
146 | try w.writeAll("\x1B[?2026h");
147 |
148 | // cursor to the top left and clear the screen below
149 | try moveCursor(w, 1, 1);
150 | try w.writeAll("\x1B[0J");
151 | try w.writeAll("\x1B_Ga=d\x1B\\");
152 |
153 | @memset(elements, .none);
154 |
155 | try writeStyle(w, .{ .bg = .blue, .fg = .white });
156 | if (window_title_width <= size.cols) {
157 | const padding = (size.cols -| (window_title_width + 1));
158 | const left_padding = @divFloor(padding, 2);
159 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
160 |
161 | for (0..left_padding) |_| try w.writeAll(" ");
162 | try w.writeAll(window_title);
163 | for (0..right_padding) |_| try w.writeAll(" ");
164 | } else {
165 | switch (size.cols) {
166 | else => try w.writeAll("bork"),
167 | 3 => try w.writeAll("brk"),
168 | 2 => try w.writeAll("bk"),
169 | 1 => try w.writeAll("b"),
170 | 0 => {},
171 | }
172 | }
173 |
174 | if (size.rows > 1) {
175 | try moveCursor(w, size.rows, 1);
176 |
177 | const last_is_bottom = chat.last_message == chat.bottom_message;
178 | if (showing_quit_message) |timeout| {
179 | const now = std.time.timestamp();
180 | if (timeout <= now) showing_quit_message = null;
181 | }
182 | if (showing_quit_message != null) {
183 | const msg = "run `bork quit`";
184 | try writeStyle(w, .{ .bg = .white, .fg = .red });
185 | const padding = (size.cols -| msg.len);
186 | const left_padding = @divFloor(padding, 2);
187 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
188 | for (0..left_padding) |_| try w.writeAll(" ");
189 | try w.writeAll(msg);
190 | for (0..right_padding) |_| try w.writeAll(" ");
191 | } else if (chat.disconnected) {
192 | const msg = "DISCONNECTED";
193 | try writeStyle(w, .{ .bg = .red, .fg = .black });
194 | const padding = (size.cols -| msg.len);
195 | const left_padding = @divFloor(padding, 2);
196 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
197 | for (0..left_padding) |_| try w.writeAll(" ");
198 | try w.writeAll(msg);
199 | for (0..right_padding) |_| try w.writeAll(" ");
200 | } else if (last_is_bottom and chat.scroll_offset == 0) {
201 | for (0..size.cols) |_| try w.writeAll(" ");
202 | } else {
203 | const msg = "DETACHED";
204 | try writeStyle(w, .{ .bg = .yellow, .fg = .black });
205 | const padding = (size.cols -| msg.len);
206 | const left_padding = @divFloor(padding, 2);
207 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
208 | for (0..left_padding) |_| try w.writeAll(" ");
209 | try w.writeAll(msg);
210 | for (0..right_padding) |_| try w.writeAll(" ");
211 | }
212 | }
213 |
214 | try writeStyle(w, .{});
215 |
216 | var row: usize = size.rows;
217 |
218 | // afk message
219 | if (afk) |a| blk: {
220 | if (row < 7) break :blk;
221 |
222 | row -|= 5;
223 | try moveCursor(w, row, 1);
224 |
225 | // top line
226 | try w.writeAll("╔");
227 | for (0..size.cols) |_| try w.writeAll("═");
228 | try w.writeAll("╗\r\n");
229 |
230 | // central lines
231 | try w.writeAll("║");
232 | {
233 | const width = strWidth(a.title);
234 | const padding = (size.cols -| width);
235 | const left_padding = @divFloor(padding, 2);
236 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
237 | for (0..left_padding) |_| try w.writeAll(" ");
238 | try w.print("{s}", .{a.title});
239 | for (0..right_padding) |_| try w.writeAll(" ");
240 | }
241 | try w.writeAll("║\r\n");
242 | try w.writeAll("║");
243 | {
244 | const now = std.time.timestamp();
245 | const remaining = @max(a.target_time - now, 0);
246 | var timer: [9]u8 = undefined;
247 | {
248 | const cd = @as(usize, @intCast(remaining));
249 | const h = @divTrunc(cd, 60 * 60);
250 | const m = @divTrunc(@mod(cd, 60 * 60), 60);
251 | const s = @mod(cd, 60);
252 | _ = std.fmt.bufPrint(&timer, "{:0>2}h{:0>2}m{:0>2}s", .{
253 | h, m, s,
254 | }) catch unreachable; // we know we have the space
255 | }
256 | const width = timer.len + 8;
257 | const padding = (size.cols -| width);
258 | const left_padding = @divFloor(padding, 2);
259 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
260 | for (0..left_padding) |_| try w.writeAll(" ");
261 | try w.print("--- {s} ---", .{timer});
262 | for (0..right_padding) |_| try w.writeAll(" ");
263 | }
264 | try w.writeAll("║\r\n");
265 | try w.writeAll("║");
266 | {
267 | const width = strWidth(a.reason);
268 | const padding = (size.cols -| width);
269 | const left_padding = @divFloor(padding, 2);
270 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
271 | for (0..left_padding) |_| try w.writeAll(" ");
272 | try w.print("{s}", .{a.reason});
273 | for (0..right_padding) |_| try w.writeAll(" ");
274 | }
275 | try w.writeAll("║\r\n");
276 |
277 | // bottom line
278 | try w.writeAll("╚");
279 | for (0..size.cols) |_| try w.writeAll("═");
280 | try w.writeAll("╝\r\n");
281 | for (row..row + 5) |idx| elements[idx] = .afk;
282 | }
283 |
284 | var current_message = chat.bottom_message;
285 | var scroll_offset = chat.scroll_offset;
286 | while (current_message) |msg| : (current_message = msg.prev) {
287 | if (row == 1) break;
288 |
289 | const heading_style: HeadingStyle = if (msg.prev) |p| blk: {
290 | if (std.mem.eql(u8, msg.login_name, p.login_name)) {
291 | if (std.mem.eql(u8, &msg.time, &p.time)) {
292 | break :blk .arrows;
293 | } else break :blk .time;
294 | } else break :blk .nick;
295 | } else .nick;
296 |
297 | const info = try renderMessage(size.cols, msg, heading_style);
298 |
299 | var msg_bytes = info.bytes;
300 | var msg_rows = info.rows;
301 | log.debug("scroll_offset {}, rows {}", .{ scroll_offset, msg_rows });
302 |
303 | if (scroll_offset >= info.rows) {
304 | const change = chat.scrollBottomMessage(.up);
305 | if (change) {
306 | log.debug("change", .{});
307 | chat.scroll_offset -|= @intCast(info.rows);
308 | scroll_offset -|= @intCast(info.rows);
309 | continue;
310 | } else {
311 | log.debug("no change", .{});
312 | chat.scroll_offset = @intCast(info.rows -| 1);
313 | scroll_offset = chat.scroll_offset;
314 | }
315 | }
316 |
317 | if (scroll_offset > 0) {
318 | var it = std.mem.splitScalar(u8, info.bytes, '\n');
319 | const skip = info.rows -| @as(usize, @intCast(scroll_offset));
320 | for (0..skip) |_| _ = it.next();
321 | msg_bytes = info.bytes[0..it.index.?];
322 | msg_rows -|= @intCast(scroll_offset);
323 | scroll_offset = 0;
324 | } else if (chat.scroll_offset < 0) {
325 | var it = std.mem.splitScalar(u8, info.bytes, '\n');
326 | const keep = @min(info.rows, @as(usize, @intCast(-scroll_offset)));
327 | msg_bytes.len = 0;
328 | for (0..keep) |_| msg_bytes.len += it.next().?.len;
329 | chat.scroll_offset = @intCast(msg_rows -| keep);
330 | msg_rows = keep;
331 | scroll_offset = 0;
332 | }
333 |
334 | if (msg_rows + 1 >= row) {
335 | var it = std.mem.splitScalar(u8, msg_bytes, '\n');
336 | const skip = (msg_rows + 2) -| row;
337 | row = 2;
338 | for (0..skip) |_| _ = it.next();
339 | try moveCursor(w, 2, 1);
340 | try w.writeAll(it.rest());
341 | for (0..msg_rows -| skip) |idx| {
342 | elements[row + idx] = .{ .message = msg };
343 | }
344 | break;
345 | } else {
346 | row = row -| msg_rows;
347 | try moveCursor(w, row, 1);
348 | try w.writeAll(msg_bytes);
349 | if (heading_style == .nick and msg.kind == .chat) {
350 | log.debug(" elements.len = {} row = {} name = {s}", .{
351 | elements.len,
352 | row,
353 | msg.login_name,
354 | });
355 | elements[row] = .{ .nick = msg.login_name };
356 | } else {
357 | elements[row] = .{ .message = msg };
358 | }
359 | for (0..msg_rows -| 1) |idx| {
360 | elements[row + 1 + idx] = .{ .message = msg };
361 | }
362 | }
363 | }
364 |
365 | // exit sync mode
366 | try w.writeAll("\x1B[?2026l");
367 | try buffered_writer.flush();
368 | }
369 |
370 | const RenderInfo = struct {
371 | rows: usize,
372 | bytes: []const u8,
373 | };
374 |
375 | fn renderMessage(
376 | cols: usize,
377 | msg: *Chat.Message,
378 | heading_style: HeadingStyle,
379 | ) !RenderInfo {
380 | message_rendering_buffer.clearRetainingCapacity();
381 | const w = message_rendering_buffer.writer(gpa);
382 | const nick_selected = switch (active) {
383 | .nick => |n| std.mem.eql(u8, n, msg.login_name),
384 | else => false,
385 | };
386 | switch (msg.kind) {
387 | .chat => |c| {
388 | // Async emote image data transmission
389 | for (c.emotes) |e| {
390 | const img = e.img_data orelse {
391 | // TODO: display placeholder or something
392 | continue;
393 | };
394 | const entry = try emote_cache.getOrPut(gpa, e.idx);
395 | if (entry.found_existing) continue;
396 |
397 | log.debug("uploading emote!", .{});
398 |
399 | if (img.len <= 4096) {
400 | try w.print(
401 | "\x1b_Gf=100,t=d,a=t,i={d};{s}\x1b\\",
402 | .{ e.idx, img },
403 | );
404 | } else {
405 | var cur: usize = 4096;
406 |
407 | // send first chunk
408 | try w.print(
409 | "\x1b_Gf=100,i={d},m=1;{s}\x1b\\",
410 | .{ e.idx, img[0..cur] },
411 | );
412 |
413 | // send remaining chunks
414 | while (cur < img.len) : (cur += 4096) {
415 | const end = @min(cur + 4096, img.len);
416 | const m = if (end == img.len) "0" else "1";
417 |
418 | // _Gs=100,v=30,m=1;\
419 | // _Gm=1;\
420 | // _Gm=0;\
421 | try w.print(
422 | "\x1b_Gm={s};{s}\x1b\\",
423 | .{ m, img[cur..end] },
424 | );
425 | }
426 | }
427 | }
428 |
429 | switch (heading_style) {
430 | .nick => {
431 | try writeStyle(w, .{ .weight = .bold });
432 | try w.print("{s} ", .{&msg.time});
433 | if (nick_selected) try writeStyle(w, .{
434 | .weight = .bold,
435 | .fg = .yellow,
436 | .reverse = true,
437 | });
438 | try w.print("«{s}»", .{c.display_name});
439 | try writeStyle(w, .{});
440 | try w.print("\r\n ", .{});
441 | },
442 |
443 | .time => {
444 | try writeStyle(w, .{ .weight = .feint });
445 | try w.print("{s} ", .{&msg.time});
446 | try writeStyle(w, .{});
447 | },
448 |
449 | .arrows => {
450 | try writeStyle(w, .{ .fg = .magenta });
451 | try w.print(" >> ", .{});
452 | try writeStyle(w, .{});
453 | },
454 | }
455 | const hl = c.is_highlighted or switch (active) {
456 | .message => |m| m == msg,
457 | .nick => nick_selected,
458 | else => false,
459 | };
460 |
461 | const body_rows = try printWrap(
462 | cols,
463 | w,
464 | c.text,
465 | c.emotes,
466 | hl,
467 | );
468 | var rows = body_rows;
469 | if (heading_style == .nick) rows += 1;
470 |
471 | return .{
472 | .rows = rows,
473 | .bytes = message_rendering_buffer.items,
474 | };
475 | },
476 |
477 | inline .charity,
478 | .follow,
479 | .raid,
480 | .sub,
481 | .resub,
482 | .sub_gift,
483 | .sub_mistery_gift,
484 | => |x, tag| {
485 | try writeStyle(w, .{
486 | .reverse = true,
487 | .weight = .bold,
488 | });
489 |
490 | // Top line
491 | {
492 | const fmt = "«{s}»";
493 | const args = switch (tag) {
494 | .sub_gift => .{x.sender_display_name},
495 | else => .{x.display_name},
496 | };
497 | const width = strWidth(args[0]) + 2;
498 | const padding = (size.cols -| width);
499 | const left_padding = @divFloor(padding, 2);
500 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
501 | for (0..left_padding) |_| try w.writeAll(" ");
502 | try w.print(fmt, args);
503 | for (0..right_padding) |_| try w.writeAll(" ");
504 | }
505 |
506 | try w.writeAll("\r\n");
507 | try writeStyle(w, .{
508 | .reverse = true,
509 | .weight = .bold,
510 | });
511 |
512 | // Bottom line
513 | {
514 | const emoji = switch (tag) {
515 | .raid => "🚨",
516 | .sub_gift, .sub_mistery_gift => "🎁",
517 | .charity => "💝",
518 | else => "🎉",
519 | };
520 | // const emoji_width = comptime dw.strWidth(emoji) catch unreachable;
521 | const emoji_width = 2;
522 |
523 | const fmt = switch (tag) {
524 | .charity => " {s} charity donation! ",
525 | .follow => " Is now a follower! ",
526 | .raid => " Raiding with {} people! ",
527 | .sub => " Is now a {s} subscriber! ",
528 | .resub => " Resubbed at {s}! ",
529 | .sub_gift => " Gifted a {s} sub to «{s}»! ",
530 | .sub_mistery_gift => " Gifted x{} {s} Subs! ",
531 | else => unreachable,
532 | };
533 | const args = switch (tag) {
534 | .charity => .{x.amount},
535 | .follow => .{},
536 | .raid => .{x.count},
537 | .sub => .{x.tier.name()},
538 | .resub => .{x.tier.name()},
539 | .sub_gift => .{
540 | x.tier.name(),
541 | x.recipient_display_name,
542 | },
543 | .sub_mistery_gift => .{ x.count, x.tier.name() },
544 | else => unreachable,
545 | };
546 |
547 | const width = switch (tag) {
548 | .sub_gift => std.fmt.count(fmt, .{
549 | x.tier.name(),
550 | "",
551 | }) + strWidth(x.recipient_display_name),
552 | else => std.fmt.count(fmt, args) + (emoji_width * 2),
553 | };
554 | const padding = (size.cols -| width);
555 | const left_padding = @divFloor(padding, 2);
556 | const right_padding = std.math.divCeil(usize, padding, 2) catch unreachable;
557 | for (0..left_padding) |_| try w.writeAll(" ");
558 | try w.writeAll(emoji);
559 | try w.print(fmt, args);
560 | try w.writeAll(emoji);
561 | for (0..right_padding) |_| try w.writeAll(" ");
562 | }
563 | try writeStyle(w, .{});
564 | return .{
565 | .rows = 2,
566 | .bytes = message_rendering_buffer.items,
567 | };
568 | },
569 | .line => {
570 | return .{ .rows = 0, .bytes = &.{} };
571 | },
572 | }
573 | }
574 |
575 | fn printWrap(
576 | cols: usize,
577 | w: std.ArrayListUnmanaged(u8).Writer,
578 | text: []const u8,
579 | emotes: []const Chat.Message.Emote,
580 | // message is highlighted
581 | hl: bool,
582 | ) !usize {
583 | var it = std.mem.tokenizeScalar(u8, text, ' ');
584 | var current_col: usize = 6;
585 | var total_rows: usize = 1;
586 | if (hl) try writeStyle(w, .{ .reverse = true });
587 | var emote_array_idx: usize = 0;
588 | var cp: usize = 0;
589 | while (it.next()) |word| : (cp += 1) {
590 | cp += try std.unicode.utf8CountCodepoints(word);
591 |
592 | const word_width: usize = @intCast(strWidth(word));
593 |
594 | if (loop.vaxis.caps.kitty_graphics and emote_array_idx < emotes.len and
595 | emotes[emote_array_idx].end == cp - 1)
596 | {
597 | const emote_idx = emotes[emote_array_idx].idx;
598 | log.debug("rendering emote {s} ({})", .{ word, emote_idx });
599 | emote_array_idx += 1;
600 | current_col += 2;
601 |
602 | if (current_col >= cols) {
603 | if (hl) try writeStyle(w, .{});
604 | try w.writeAll("\r\n ");
605 | if (hl) try writeStyle(w, .{ .reverse = true });
606 | current_col = 6 + 2;
607 | total_rows += 1;
608 | }
609 | try w.print(
610 | "\x1b_Gf=100,t=d,a=p,r=1,c=2,i={d},p={};\x1b\\",
611 | .{ emote_idx, placement_id },
612 | );
613 | placement_id += 1;
614 | } else if (word_width >= cols - 6) {
615 | // a link or a very big word
616 | const is_link = url.sense(word);
617 | if (is_link) {
618 | var start: usize = 0;
619 | var end: usize = word.len;
620 | const link = blk: {
621 | if (word[0] == '(') {
622 | start = 1;
623 | if (word[word.len - 1] == ')') {
624 | end = word.len - 1;
625 | }
626 | }
627 |
628 | break :blk word[start..end];
629 | };
630 |
631 | if (start != 0) {
632 | if (current_col >= cols) {
633 | if (hl) try writeStyle(w, .{});
634 | try w.writeAll("\r\n ");
635 | if (hl) try writeStyle(w, .{ .reverse = true });
636 | current_col = 6;
637 | total_rows += 1;
638 | }
639 | try w.writeAll("(");
640 | current_col += 1;
641 | }
642 |
643 | var git = loop.vaxis.unicode.graphemeIterator(link);
644 | var url_is_off = true;
645 | while (git.next()) |gh| {
646 | const bytes = gh.bytes(link);
647 | const remaining = cols -| current_col;
648 | const grapheme_cols = strWidth(bytes);
649 | if (grapheme_cols > remaining) {
650 | if (!url_is_off) {
651 | url_is_off = true;
652 | try w.writeAll("\x1b]8;;\x1b\\");
653 | }
654 | if (hl) {
655 | for (0..remaining) |_| try w.writeAll(" ");
656 | try writeStyle(w, .{});
657 | }
658 | try w.writeAll("\r\n ");
659 | if (hl) try writeStyle(w, .{ .reverse = true });
660 | current_col = 6;
661 | total_rows += 1;
662 | }
663 |
664 | if (url_is_off) {
665 | url_is_off = false;
666 | try w.print(
667 | "\x1b]8;id={};{s}\x1b\\",
668 | .{
669 | std.hash.Crc32.hash(link),
670 | link,
671 | },
672 | );
673 | }
674 |
675 | try w.writeAll(bytes);
676 | current_col += grapheme_cols;
677 | }
678 |
679 | if (!url_is_off) {
680 | try w.writeAll("\x1b]8;;\x1b\\");
681 | }
682 |
683 | if (end != word.len) {
684 | if (current_col >= cols) {
685 | if (hl) try writeStyle(w, .{});
686 | try w.writeAll("\r\n ");
687 | if (hl) try writeStyle(w, .{ .reverse = true });
688 | current_col = 6;
689 | total_rows += 1;
690 | }
691 | try w.writeAll(")");
692 | current_col += 1;
693 | }
694 | } else {
695 | var git = loop.vaxis.unicode.graphemeIterator(word);
696 | while (git.next()) |gh| {
697 | const bytes = gh.bytes(word);
698 | const remaining = cols -| current_col;
699 | const grapheme_cols = strWidth(bytes);
700 | if (grapheme_cols > remaining) {
701 | if (hl) {
702 | for (0..remaining) |_| try w.writeAll(" ");
703 | try writeStyle(w, .{});
704 | }
705 | try w.writeAll("\r\n ");
706 | if (hl) try writeStyle(w, .{ .reverse = true });
707 | current_col = 6;
708 | total_rows += 1;
709 | }
710 |
711 | try w.writeAll(bytes);
712 | current_col += grapheme_cols;
713 | }
714 | }
715 | } else if (word_width <= cols -| current_col) {
716 | // word fits in this row
717 | try w.writeAll(word);
718 | current_col += word_width;
719 | } else {
720 | // word fits the width (i.e. it shouldn't be broken up)
721 | // but it doesn't fit, let's add a line for it.
722 | total_rows += 1;
723 |
724 | if (hl) {
725 | for (0..(cols -| current_col)) |_| try w.writeAll(" ");
726 | try writeStyle(w, .{});
727 | }
728 | try w.writeAll("\r\n ");
729 | if (hl) try writeStyle(w, .{ .reverse = true });
730 | try w.writeAll(word);
731 | current_col = 6 + word_width;
732 | }
733 |
734 | if (current_col < cols) {
735 | try w.writeAll(" ");
736 | current_col += 1;
737 | }
738 | }
739 |
740 | if (hl) {
741 | for (0..(cols -| current_col)) |_| try w.writeAll(" ");
742 | try writeStyle(w, .{});
743 | }
744 |
745 | return total_rows;
746 | }
747 | pub fn setAfkMessage(
748 | target_time: i64,
749 | reason: []const u8,
750 | title: []const u8,
751 | ) !void {
752 | log.debug("afk: {d}, {s}", .{ target_time, reason });
753 |
754 | afk = .{ .target_time = target_time };
755 |
756 | if (title.len > 0) afk.?.title = title;
757 | if (reason.len > 0) afk.?.reason = reason;
758 | }
759 |
760 | pub fn showCtrlCMessage() !bool {
761 | log.debug("show ctrlc message", .{});
762 | showing_quit_message = std.time.timestamp() + 3;
763 | return true;
764 | }
765 |
766 | pub fn handleClick(row: usize, col: usize) !bool {
767 | log.debug("click {},{}!", .{ row, col });
768 |
769 | if (row > size.rows or col > size.cols) {
770 | log.debug("ignoring out of bounds click", .{});
771 | return false;
772 | }
773 |
774 | var new = elements[row];
775 |
776 | switch (new) {
777 | .none => {},
778 | .afk => {
779 | active = .none;
780 | afk = null;
781 | return true;
782 | },
783 | .nick => |n| {
784 | if (col < 6 or col > 6 + 1 + strWidth(n) + 1) {
785 | new = .none;
786 | }
787 | },
788 | .message => {
789 | if (col < 6) new = .none;
790 | },
791 | }
792 |
793 | if (new == .none and active == .none) {
794 | return false;
795 | }
796 |
797 | if (active.eql(new)) {
798 | active = .none;
799 | } else {
800 | active = new;
801 | }
802 |
803 | log.debug("new active element: {any}", .{active});
804 | return true;
805 | }
806 |
807 | pub fn prepareMessage(m: Chat.Message) !*Chat.Message {
808 | const result = try gpa.create(Chat.Message);
809 | result.* = m;
810 | return result;
811 | }
812 |
813 | pub fn clearActiveInteraction(c: ?[]const u8) void {
814 | _ = c;
815 | }
816 |
817 | pub fn wantTick() bool {
818 | return afk != null or showing_quit_message != null;
819 | }
820 |
821 | pub fn panic() void {
822 | // if (global_term) |t| {
823 | // t.currently_rendering = false;
824 | // t.deinit();
825 | // }
826 | }
827 |
828 | pub fn strWidth(str: []const u8) u16 {
829 | return vaxis.gwidth.gwidth(str, loop.vaxis.caps.unicode, &loop.vaxis.unicode.width_data);
830 | }
831 |
832 | pub const Style = struct {
833 | weight: enum { none, bold, normal, feint } = .none,
834 | italics: bool = false,
835 | underline: bool = false,
836 | reverse: bool = false,
837 | fg: Color = .none,
838 | bg: Color = .none,
839 |
840 | pub const Color = enum {
841 | none,
842 | black,
843 | red,
844 | green,
845 | yellow,
846 | blue,
847 | magenta,
848 | cyan,
849 | white,
850 | };
851 | };
852 |
853 | pub fn writeStyle(w: anytype, comptime style: Style) !void {
854 | try w.writeAll("\x1B[0"); // always clear
855 |
856 | switch (style.weight) {
857 | .none => {},
858 | .bold => try w.writeAll(";1"),
859 | .normal => try w.writeAll(";22"),
860 | .feint => try w.writeAll(";2"),
861 | }
862 |
863 | switch (style.fg) {
864 | .none => {},
865 | .black => try w.writeAll(";30"),
866 | .red => try w.writeAll(";31"),
867 | .green => try w.writeAll(";32"),
868 | .yellow => try w.writeAll(";33"),
869 | .blue => try w.writeAll(";34"),
870 | .magenta => try w.writeAll(";35"),
871 | .cyan => try w.writeAll(";36"),
872 | .white => try w.writeAll(";37"),
873 | }
874 |
875 | switch (style.bg) {
876 | .none => {},
877 | .black => try w.writeAll(";40"),
878 | .red => try w.writeAll(";41"),
879 | .green => try w.writeAll(";42"),
880 | .yellow => try w.writeAll(";43"),
881 | .blue => try w.writeAll(";44"),
882 | .magenta => try w.writeAll(";45"),
883 | .cyan => try w.writeAll(";46"),
884 | .white => try w.writeAll(";47"),
885 | }
886 |
887 | if (style.italics) try w.writeAll(";3");
888 | if (style.underline) try w.writeAll(";4");
889 | if (style.reverse) try w.writeAll(";7");
890 |
891 | try w.writeAll("m");
892 | }
893 |
894 | fn parseEvent(
895 | r: std.io.BufferedReader(4096, std.fs.File.Reader).Reader,
896 | ) !Event {
897 | var state: enum {
898 | start,
899 | escape,
900 | event,
901 | } = .start;
902 | while (true) {
903 | const b = try r.readByte();
904 | switch (state) {
905 | .start => switch (b) {
906 | else => {},
907 | '\x1b' => state = .escape,
908 | '\x03' => return .ctrl_c,
909 | },
910 |
911 | .escape => switch (b) {
912 | else => state = .start,
913 | '[', 'O' => state = .event,
914 | '\x1b' => {},
915 | },
916 | .event => switch (b) {
917 | else => state = .start,
918 | 'A' => return .up,
919 | 'B' => return .down,
920 | 'C' => return .right,
921 | 'D' => return .left,
922 | '5', '6' => {
923 | _ = try r.readByte();
924 | switch (b) {
925 | '5' => return .page_up,
926 | '6' => return .page_down,
927 | else => unreachable,
928 | }
929 | },
930 | 'M' => {
931 | const button = try r.readByte();
932 | switch (button) {
933 | else => state = .start,
934 | '`' => return .wheel_up,
935 | 'a' => return .wheel_down,
936 | ' ' => {
937 | const col = try r.readByte();
938 | const row = try r.readByte();
939 | return .{
940 | .left_click = .{
941 | .row = row - 31,
942 | .col = col - 31,
943 | },
944 | };
945 | },
946 | }
947 | },
948 | },
949 | }
950 | }
951 | }
952 |
--------------------------------------------------------------------------------