A virtual currency betting bot for Twitch chat. https://ddark.net/better
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

788 lines
26 KiB

#pragma warning (disable: 4996) // This function or variable may be unsafe (strcpy, sprintf, ...)
#pragma warning (disable: 4200) // nonstandard extension used: zero-sized array in struct/union
#include <winsock2.h>
#include <ws2tcpip.h>
#include <cassert>
#include <cstdio>
#include <cerrno>
#define STBDS_REALLOC(c,p,s) BETTER_REALLOC(p, s, CODE_LOCATION)
#define STBDS_FREE(c,p) BETTER_FREE(p)
#include <stb_ds.h>
#include <stb_sprintf.h>
extern "C"
{
#include <cregex.h>
}
#include "better.h"
#include "better_App.h"
#include "better_irc.h"
#include "better_ChatEntry.h"
#include "better_bets.h"
#include "better_func.h"
#include "better_alloc.h"
static DWORD _irc_main(App* app)
{
addrinfo* result;
addrinfo hints;
ZeroMemory(&hints, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_protocol = IPPROTO_TCP;
// Resolve the server address and port
i32 res = getaddrinfo("irc.chat.twitch.tv", "6667", &hints, &result);
if (res != 0)
{
if (!PostMessage(app->main_wnd, BETTER_WM_DNS_FAILED, (WPARAM)app, res))
{
return GetLastError();
}
return 0;
}
if (!PostMessage(app->main_wnd, BETTER_WM_DNS_COMPLETE, (WPARAM)app, (LPARAM)result))
{
return GetLastError();
}
return 0;
}
bool irc_init(App* app)
{
add_log(app, LOGLEVEL_DEBUG, "Initializing Winsock and compiling regular expressions.");
i32 res = WSAStartup(MAKEWORD(2,2), &app->wsa_data);
if (res != 0)
{
add_log(app, LOGLEVEL_DEVERROR, "WSAStartup failed: %i", res);
return false;
}
const char* re_error = NULL;
cregex_node_t* re_node = cregex_parse("(^|\n)(:([^!@ \r\n]+)[^ ]* )?([^ \r\n]+)(( [^: \r\n]+)*)( :([^\r\n]+))?\r\n");
if (!re_node)
{
add_log(app, LOGLEVEL_DEVERROR, "regex parsing failed (what the fuck ddarknut) (how did you let this happen) (like actually what the fuck are you doing)");
return false;
}
else
{
app->irc_re = cregex_compile_node(re_node);
cregex_parse_free(re_node);
if(!app->irc_re)
{
add_log(app, LOGLEVEL_DEVERROR, "regex compilation failed (what the fuck ddarknut) (how did you let this happen) (like actually what the fuck are you doing)");
return false;
}
}
return true;
}
void irc_cleanup(App* app)
{
add_log(app, LOGLEVEL_DEBUG, "Cleaning up Winsock and other irc stuff.");
if (app->sock != INVALID_SOCKET)
{
closesocket(app->sock);
app->sock = INVALID_SOCKET;
}
WSACleanup();
cregex_compile_free(app->irc_re);
}
bool irc_connect(App* app)
{
if (app->settings.channel[0] == '\0')
{
add_log(app, LOGLEVEL_USERERROR, "Cannot start connection: Channel name is empty.");
return false;
}
if (app->settings.username[0] == '\0')
{
add_log(app, LOGLEVEL_USERERROR, "Cannot start connection: Username is empty.");
return false;
}
if (!app->settings.oauth_token_is_present)
{
add_log(app, LOGLEVEL_USERERROR, "Cannot start connection: OAuth token is empty.");
return false;
}
if (dns_thread_running(app))
{
add_log(app, LOGLEVEL_USERERROR, "Cannot start connection: Still waiting on another DNS request.");
return false;
}
add_log(app, LOGLEVEL_INFO, "Sending DNS request...");
app->dns_req_thread = CreateThread(NULL,
0,
(LPTHREAD_START_ROUTINE)_irc_main,
app,
0,
&app->dns_req_thread_id);
if (!app->dns_req_thread)
{
add_log(app, LOGLEVEL_DEVERROR, "CreateThread failed: %d", GetLastError());
abort();
}
return true;
}
void irc_disconnect(App* app)
{
add_log(app, LOGLEVEL_INFO, "Disconnecting from Twitch.");
if (app->sock != INVALID_SOCKET)
closesocket(app->sock);
app->sock = INVALID_SOCKET;
app->joined_channel = false;
}
void irc_schedule_reconnect(App* app)
{
if (!SetTimer(app->main_wnd, TID_ALLOW_AUTO_RECONNECT, (UINT)MIN_RECONNECT_INTERVAL, NULL))
add_log(app, LOGLEVEL_DEVERROR, "SetTimer failed: %d", GetLastError());
}
void irc_timed_reconnect(App* app)
{
if (app->allow_auto_reconnect)
{
add_log(app, LOGLEVEL_INFO, "Attempting to reconnect...");
if (app->sock != INVALID_SOCKET)
closesocket(app->sock);
app->sock = INVALID_SOCKET;
app->joined_channel = false;
irc_connect(app);
}
else
{
irc_disconnect(app);
add_log(app, LOGLEVEL_WARN, "Stopping automatic reconnect because it's too soon after the last reconnect.");
}
}
bool dns_thread_running(App* app)
{
if (!app->dns_req_thread)
return false;
DWORD exit_code;
if (!GetExitCodeThread(app->dns_req_thread, &exit_code))
{
add_log(app, LOGLEVEL_DEVERROR, "GetExitCodeThread failed: %d", GetLastError());
abort();
}
if (exit_code == STILL_ACTIVE)
return true;
if (exit_code != 0)
{
add_log(app, LOGLEVEL_DEVERROR, "DNS thread failed with exit code: %d", exit_code);
abort();
}
return false;
}
void irc_on_dns_complete(App* app, addrinfo* result)
{
add_log(app, LOGLEVEL_INFO, "DNS request complete.");
// Attempt to connect to the first address returned by
// the call to getaddrinfo
addrinfo* ptr=result;
// Create a SOCKET for connecting to server
app->sock = socket(ptr->ai_family, ptr->ai_socktype, ptr->ai_protocol);
if (app->sock == INVALID_SOCKET)
{
add_log(app, LOGLEVEL_DEVERROR, "Failed to create socket: %ld", WSAGetLastError());
abort();
}
app->allow_auto_reconnect = false;
if (!SetTimer(app->main_wnd, TID_ALLOW_AUTO_RECONNECT, MIN_RECONNECT_INTERVAL, NULL))
add_log(app, LOGLEVEL_DEVERROR, "SetTimer failed: %d", GetLastError());
i32 res = WSAAsyncSelect(app->sock,
app->main_wnd,
BETTER_WM_SOCK_MSG,
FD_CONNECT);
assert(res != SOCKET_ERROR);
res = connect(app->sock, ptr->ai_addr, (int)ptr->ai_addrlen);
if (res == SOCKET_ERROR)
{
i32 err = WSAGetLastError();
if (err != WSAEWOULDBLOCK)
{
add_log(app, LOGLEVEL_DEVERROR, "connect failed: %i", err);
abort();
}
}
else assert(0 && "Made a blocking call to connect");
}
void irc_on_dns_failed(App* app, DWORD getaddrinfo_error)
{
add_log(app, LOGLEVEL_USERERROR, "DNS request failed. Are you connected to the internet? (getaddrinfo returned %d)", getaddrinfo_error);
}
void irc_on_connect(App* app)
{
if (app->sock == INVALID_SOCKET)
{
add_log(app, LOGLEVEL_DEVERROR, "Unable to connect to Twitch.");
return;
}
add_log(app, LOGLEVEL_INFO, "Connected to Twitch.");
// TODO: Should really try the next address returned by getaddrinfo (ptr->ai_next) if the connect call failed. But for this simple example we just free the resources returned by getaddrinfo and print an error message
// freeaddrinfo(result);
if (!SetTimer(app->main_wnd, TID_PRIVMSG_READY, get_privmsg_interval(app), NULL))
add_log(app, LOGLEVEL_DEVERROR, "SetTimer failed: %d", GetLastError());
char* sendbuf = (char*) BETTER_ALLOC(SEND_BUFLEN+1, "irc_on_connect_sendbuf");
if (app->settings.username[0] == '\0')
{
add_log(app, LOGLEVEL_USERERROR, "Can't log in: Username is empty.");
BETTER_FREE(sendbuf);
irc_disconnect(app);
}
else if (!app->settings.oauth_token_is_present)
{
add_log(app, LOGLEVEL_USERERROR, "Can't log in: OAuth token is empty.");
BETTER_FREE(sendbuf);
irc_disconnect(app);
}
else
{
if (!CryptUnprotectMemory(app->settings.token, sizeof(app->settings.token), CRYPTPROTECTMEMORY_SAME_PROCESS))
{
add_log(app, LOGLEVEL_DEVERROR, "CryptUnprotectMemory failed: %i", GetLastError());
SecureZeroMemory(app->settings.token, sizeof(app->settings.token));
app->settings.oauth_token_is_present = false;
return;
}
sprintf(sendbuf, "CAP REQ :twitch.tv/membership\r\nPASS %s\r\nNICK %s\r\n", app->settings.token, app->settings.username);
if (!CryptProtectMemory(app->settings.token, sizeof(app->settings.token), CRYPTPROTECTMEMORY_SAME_PROCESS))
{
add_log(app, LOGLEVEL_DEVERROR, "CryptProtectMemory failed: %i", GetLastError());
SecureZeroMemory(app->settings.token, sizeof(app->settings.token));
app->settings.oauth_token_is_present = false;
return;
}
add_log(app, LOGLEVEL_INFO, "Logging in as \"%s\"...", app->settings.username);
irc_queue_write(app, sendbuf, false);
}
}
void irc_send_buffer(App* app, char* buf)
{
// printf("<%s", buf);
i32 res = send(app->sock, buf, (i32)strlen(buf), 0);
if (res == SOCKET_ERROR)
{
i32 err = WSAGetLastError();
if (err != WSAEWOULDBLOCK)
{
add_log(app, LOGLEVEL_DEVERROR, "Lost connection to Twitch. (send returned %i)", WSAGetLastError());
irc_timed_reconnect(app);
}
}
}
void irc_on_write(App* app)
{
if (app->sock == INVALID_SOCKET)
return;
while (app->write_queue.count > 0)
{
char* buf = app->write_queue.pop_front();
irc_send_buffer(app, buf);
SecureZeroMemory(buf, strlen(buf));
BETTER_FREE(buf);
}
while (app->privmsg_queue.count > 0)
{
if (!app->privmsg_ready)
{
// TODO: Instead of ignoring the FD_WRITE event and immediately ask for a new one here, we can just wait with calling WSAAsyncSelect until we are *actually* ready to send a message. That way, we aren't constantly receiving FD_WRITE events and not acting on them even though privmsg_queue isn't empty.
i32 res = WSAAsyncSelect(app->sock,
app->main_wnd,
BETTER_WM_SOCK_MSG,
FD_WRITE | FD_READ | FD_CLOSE);
assert(res != SOCKET_ERROR);
break;
}
app->privmsg_ready = false;
if (!SetTimer(app->main_wnd, TID_PRIVMSG_READY, get_privmsg_interval(app), NULL))
add_log(app, LOGLEVEL_DEVERROR, "SetTimer failed: %d", GetLastError());
char* buf = app->privmsg_queue.pop_front();
irc_send_buffer(app, buf);
SecureZeroMemory(buf, strlen(buf));
BETTER_FREE(buf);
}
}
// Allocates, copies, null terminates, and returns the string starting at `start` and ending at `end`
static char* extract_str(const char* start, const char* end)
{
assert(end >= start);
usize len = end - start;
char* str = (char*)BETTER_ALLOC(len+1, "extract_str");
memcpy(str, start, len);
str[len] = '\0';
return str;
};
// Assumes `buf` is null terminated.
static const char* parse_messages(App* app, char* const buf)
{
const char* next = buf;
struct {const char *start, *end;} matches[9] = {};
while (cregex_program_run(app->irc_re, next, (const char**)matches, 18) > 0)
{
if (next == buf && matches[0].start != buf)
{
// First match is not at the beginning of the buffer.
// NOTE: If we reach this, there's something wrong with the part of `irc_on_read_or_close` that skips the rest of discarded messages...
add_log(app, LOGLEVEL_DEVERROR, "Discarding prepended data: \"%s\"", buf);
}
IrcMessage msg = {};
// Prefix/nickname
if (matches[3].start)
msg.name = extract_str(matches[3].start, matches[3].end);
// Command
if (matches[4].start)
msg.command = extract_str(matches[4].start, matches[4].end);
// // Params (except trailing)
if (matches[5].start)
{
const char *s = matches[5].start, *e = s;
while (*s == ' ' && s < matches[5].end)
{
++s;
for (e = s; e < matches[5].end && *e != ' '; ++e);
arrput(msg.params, extract_str(s, e));
s = e;
}
}
// Trailing param
if (matches[8].start)
arrput(msg.params, extract_str(matches[8].start, matches[8].end));
app->read_queue.push_back_growing(msg);
next = matches[0].end;
}
return next;
}
void irc_on_read_or_close(App* app)
{
static char partial_msg[RECV_BUFLEN+1] = "";
static bool discarded = false;
while(true)
{
char buf[2*RECV_BUFLEN+1];
i32 bytes = recv(app->sock, buf+RECV_BUFLEN, RECV_BUFLEN, 0);
if (bytes > 0)
{
// static FILE* file = fopen("raw_chatlog1.log", "rb");
// bytes = (i32)fread(buf+RECV_BUFLEN, sizeof(char), RECV_BUFLEN, file);
if (bytes == RECV_BUFLEN)
add_log(app, LOGLEVEL_DEBUG, "Received a max-length byte chunk.");
// Null terminate the data we received.
(buf + RECV_BUFLEN)[bytes] = '\0';
if (discarded)
{
// Skip discarded message (see comment below where `discarded` is set)
add_log(app, LOGLEVEL_DEVERROR, "Discarding data (followup): \"%s\"", buf + RECV_BUFLEN);
const char* start = buf + RECV_BUFLEN;
while (*start != '\0')
if (*start++ == '\n') break;
memmove(buf + RECV_BUFLEN, start, (buf + 2*RECV_BUFLEN + 1) - start);
discarded = false;
}
// Find where we should start matching messages based on previously received data.
usize partial_size = strlen(partial_msg);
char* messages_begin = buf + RECV_BUFLEN - partial_size;
assert(messages_begin >= buf);
// Prepend previously received data.
if (partial_size > 0)
memcpy(messages_begin, partial_msg, partial_size);
// Parse messages and get back a position where potentially unrecognized data starts.
const char* rest = parse_messages(app, messages_begin);
usize rest_len = strlen(rest);
if (rest_len > RECV_BUFLEN)
{
add_log(app, LOGLEVEL_DEVERROR, "Discarding data (irc message is TOO DAMN BIG): \"%s\"", rest);
// The remaining data after parsing is too big to keep, so we discard a message here by looking for the start of the next message and copying that to be prepended to future data.
// NOTE: This should usually not happen, as long as RECV_BUFLEN is bigger than the length of any irc message sent by Twitch.
const char* p = rest + rest_len - RECV_BUFLEN;
while (*p != '\0')
if (*p++ == '\n') break;
if (p == rest + rest_len)
{
// There was no start of a new message within the remaining data -- in this case we will get the next part of the message we are discarding on the next call to `recv`. Clearly that data will be useless and we want to skip it -- this flag helps us remember that.
discarded = true;
*partial_msg = '\0';
}
else strcpy(partial_msg, p);
}
else
{
strcpy(partial_msg, rest);
}
}
else if (bytes == 0)
{
add_log(app, LOGLEVEL_WARN, "Connection closed by the server. (Empty payload)");
irc_timed_reconnect(app);
break;
}
else
{
i32 err = WSAGetLastError();
switch (err)
{
case WSAEWOULDBLOCK: break; // Do nothing -- this "error" is expected!
case WSAECONNRESET:
{
// The server unexpectedly reset the connection.
add_log(app, LOGLEVEL_WARN, "Lost connection to Twitch. (WSAECONNRESET)");
irc_timed_reconnect(app);
} break;
default:
{
add_log(app, LOGLEVEL_DEVERROR, "Connection failed. (recv returned %i)", err);
irc_timed_reconnect(app);
}
}
break;
}
}
}
bool is_alphabetic(char c)
{
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
}
static void default_message_handler(App* app, IrcMessage* msg)
{
add_log(app, LOGLEVEL_DEBUG, "Unhandled message (%s) (%s)", msg->name, msg->command);
printf("%s :: ", msg->name);
printf("%s (", msg->command);
for (int i = 0; i < arrlen(msg->params); ++i)
printf("\"%s\", ", msg->params[i]);
printf(")\n");
IrcMessage_free_all(msg);
}
void irc_handle_message(App* app, IrcMessage* msg)
{
i32 numeric = atoi(msg->command);
if (numeric)
{
switch (numeric)
{
case 1: // Welcome message
{
make_lower(app->settings.channel);
make_lower(app->settings.username);
if (arrlen(msg->params) < 1)
{
add_log(app, LOGLEVEL_DEVERROR, "Expected at least one parameter with welcome message.");
irc_disconnect(app);
}
else if (_stricmp(app->settings.username, msg->params[0]) != 0)
{
add_log(app, LOGLEVEL_USERERROR, "Login failed: Username did not match the OAuth token's owner.", app->settings.channel);
irc_disconnect(app);
}
else
{
add_log(app, LOGLEVEL_INFO, "Login successful. Joining channel \"%s\"...", app->settings.channel);
i32 max_len = 8 + CHANNEL_NAME_MAX;
char* join_msg = (char*)BETTER_ALLOC(max_len+1, "join_msg_buffer");
sprintf(join_msg, "JOIN #%s\r\n", app->settings.channel);
join_msg[max_len] = '\0';
irc_queue_write(app, join_msg, false);
}
IrcMessage_free_all(msg);
} break;
case 353: // namelist
{
if (arrlen(msg->params) >= 4 &&
_stricmp(msg->params[2]+1, app->settings.channel) == 0)
{
const char *s = msg->params[3], *e = s;
while (*e)
{
for (e = s; *e && *e != ' '; ++e);
char* name = extract_str(s, e);
if (shgeti(app->users, name) == -1)
add_user(app, name, app->settings.starting_points);
BETTER_FREE(name);
s = e+1;
}
}
IrcMessage_free_all(msg);
} break;
default: default_message_handler(app, msg); break;
}
}
else
{
if (strcmp(msg->command, "PRIVMSG") == 0)
{
char* name = msg->name;
char* msg_content = arrpop(msg->params);
// Add to chat log
{
ChatEntry* chat_entry = app->chat_buffer.push_back_discarding({});
// Move ownership of strings to `chat_entry`
chat_entry->name = name;
chat_entry->msg = msg_content;
}
// Free everything from the IRC message except name and trailing
// param. The ChatEntry now owns those strings.
{
IrcMessage_free_params(msg);
BETTER_FREE(msg->command);
msg = NULL;
}
// Get or add the user
UserInfo* user = shget(app->users, name);
if (!user)
user = add_user(app, name, app->settings.starting_points);
//printf("'%s' ", msg_content);
if (msg_content[0] == app->settings.command_prefix[0])
{
char command[CHAT_COMMAND_MAX+1];
char* read = msg_content + 1;
// Extract command
{
char* write;
for (write = command;
write < command + CHAT_COMMAND_MAX && *read && *read != ' ';
++read, ++write)
*write = *read;
*write = '\0';
for (; *read == ' '; ++read);
}
if (strlen(command) > 0)
{
if (_stricmp(command, app->settings.points_name) == 0)
{
//////////////////////
// FEEDBACK COMMAND //
//////////////////////
if (!app->point_feedback_queue.contains(user))
app->point_feedback_queue.push_back_growing(user);
}
else if (bets_status(app) != BETS_STATUS_CLOSED &&
(_stricmp(command, "bet") == 0 ||
_stricmp(command, "bets") == 0))
{
/////////////////
// BET COMMAND //
/////////////////
// Extract amount parameter
char amount_param[CHAT_PARAM_MAX+1];
{
char* write;
for (write = amount_param;
write < amount_param + CHAT_PARAM_MAX && *read && *read != ' ';
++read, ++write)
*write = *read;
*write = '\0';
for (; *read == ' '; ++read);
}
if (strlen(amount_param) > 0)
{
// Extract trailing option parameter
char option_param[CHAT_PARAM_MAX+1];
{
char* write;
for (write = option_param;
write < option_param + CHAT_PARAM_MAX && *read;
++read, ++write)
*write = *read;
*write = '\0';
}
if (strlen(option_param) > 0)
{
char* end;
// Interpret option either as number (index) or a string (name)
i32 option = strtol(option_param, &end, 10);
if (errno == ERANGE || end == option_param)
{
errno = 0;
option = -1;
for (int i = 0; i < arrlen(app->bet_options); ++i)
{
make_lower(option_param);
if (_stricmp(option_param, app->bet_options[i].option_name) == 0)
{
option = i;
break;
}
}
}
else option -= 1; // When users refer to options by number, they are 1-indexed. Make it 0-indexed.
if (option >= 0)
{
if (_stricmp(amount_param, "all") == 0)
register_max_bet(app, name, option);
else
{
i64 amount = strtoll(amount_param, &end, 10);
if (errno != ERANGE && end != amount_param)
{
errno = 0;
register_bet(app, name, amount, option);
}
}
}
}
}
}
}
}
}
else if (strcmp(msg->command, "JOIN") == 0)
{
if (!app->joined_channel && _stricmp(msg->name, app->settings.username) == 0)
{
add_log(app, LOGLEVEL_INFO, "Join successful.");
app->joined_channel = true;
}
if (shgeti(app->users, msg->name) == -1)
add_user(app, msg->name, app->settings.starting_points);
IrcMessage_free_all(msg);
}
else if (strcmp(msg->command, "PING") == 0)
{
// TODO: Right now we always call free on messages when they're removed from the write queue. Therefore we always have to allocate them with malloc, even if they could be static like in this case. Probably should fix this.
char* rpl_s = "PONG :tmi.twitch.tv\r\n";
char* rpl = (char*) BETTER_ALLOC(strlen(rpl_s)+1, "pong_write_buffer");
strcpy(rpl, rpl_s);
irc_queue_write(app, rpl, false);
IrcMessage_free_all(msg);
}
else if (strcmp(msg->command, "NOTICE") == 0)
{
if (arrlen(msg->params) >= 2)
add_log(app, LOGLEVEL_WARN, "Server notice: %s", msg->params[1]);
IrcMessage_free_all(msg);
}
else
{
default_message_handler(app, msg);
}
}
}
void irc_queue_write(App* app, char* msg, bool is_privmsg)
{
if (app->sock == INVALID_SOCKET)
{
SecureZeroMemory(msg, strlen(msg));
BETTER_FREE(msg);
return;
}
if (is_privmsg)
app->privmsg_queue.push_back_growing(msg);
else
app->write_queue.push_back_growing(msg);
i32 res = WSAAsyncSelect(app->sock,
app->main_wnd,
BETTER_WM_SOCK_MSG,
FD_WRITE | FD_READ | FD_CLOSE);
assert(res != SOCKET_ERROR);
}