├── .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 | \\<TIMER> the countdown timer, eg: '1h25m' or '500s' 170 | \\<MSG> 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 = "<html><body><h1>Success! You can now return to bork</h1></body></html>"; 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 <command> --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 >:<prefix> <command> <params> :<trailing> 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 | // <ESC>_Gs=100,v=30,m=1;<encoded pixel data first chunk><ESC>\ 419 | // <ESC>_Gm=1;<encoded pixel data second chunk><ESC>\ 420 | // <ESC>_Gm=0;<encoded pixel data last chunk><ESC>\ 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 | --------------------------------------------------------------------------------