├── .gitignore ├── README.beam.md ├── README.md ├── fakespaghettilogger.py ├── spaghettilogger_test.py └── spaghettilogger.py /.gitignore: -------------------------------------------------------------------------------- 1 | # intellij 2 | *.iml 3 | .idea/ 4 | 5 | 6 | __pycache__ 7 | .py[co] 8 | 9 | *~ 10 | -------------------------------------------------------------------------------- /README.beam.md: -------------------------------------------------------------------------------- 1 | Fake Spaghetti Logger 2 | ===================== 3 | 4 | Quick Start 5 | =========== 6 | 7 | Requires 8 | 9 | * [Python](https://www.python.org/) 3.3+ 10 | * [TornadoWeb](http://www.tornadoweb.org/) Python library version 4.3+ 11 | 12 | You can install the required Python libraries using Pip. Assuming Linux system: 13 | 14 | pip3 install tornado 15 | 16 | Add the `--user` option to not use sudo. 17 | 18 | Then run 19 | 20 | python3 fakespaghettilogger.py CHANNEL_ID_NUM LOGGING_DIR 21 | 22 | * where `CHANNEL_ID_NUM` is the numeric ID of the channel (not the channel name). 23 | * and `LOGGING_DIR` is the directory name of logs to be placed. 24 | 25 | 26 | Operation 27 | ========= 28 | 29 | The logger will place logs under directories for each channel and log to files named by date. It will reconnect automatically. 30 | 31 | The chat logger will save the original WebSocket message received. 32 | 33 | Only 1 channel is supported per instance because their stream does not include channel IDs for parts/joins. 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Spaghetti Logger 2 | ================ 3 | 4 | Spaghetti Logger is a Twitch.tv chat logger. 5 | 6 | Fake Spaghetti Logger is a Beam.pro chat logger. See README.beam.md for instructions for Beam. 7 | 8 | 9 | Quick Start 10 | =========== 11 | 12 | Requires 13 | 14 | * [Python](https://www.python.org/) 3.3+ 15 | * [irc](https://pypi.python.org/pypi/irc) Python library version 15+ 16 | 17 | You can install the required Python libraries using Pip. Assuming Linux system: 18 | 19 | pip3 install irc 20 | 21 | Add the `--user` option to not use sudo. 22 | 23 | Then run 24 | 25 | python3 spaghettilogger.py CHANNELS_FILE LOGGING_DIR 26 | 27 | * where `CHANNELS_FILE` is the filename of a text file containing IRC channel names one per line (include the `#` symbol) 28 | * and `LOGGING_DIR` is the directory name of logs to be placed. 29 | 30 | 31 | Operation 32 | ========= 33 | 34 | The logger will place logs under directories for each channel and log to files named by date. It will reconnect automatically. 35 | 36 | The logger will check the channels file for modification every 30 seconds. It will join and part the channels as needed. 37 | 38 | The logger will log the following 39 | 40 | * PRIVMSG with tags 41 | * NOTICE 42 | * JOIN 43 | * PART 44 | * MOD 45 | * CLEARCHAT 46 | * USERNOTICE 47 | 48 | 49 | For group chats, the logger needs to be logged in. Check https://chatdepot.twitch.tv/room_memberships?oauth_token=OAUTH_TOKEN_HERE for IP addresses and channel name. Use `--nickname` and `--oauth-path OAUTH_FILENAME` where `OAUTH_FILENAME` is a text file containing your oauth token. 50 | 51 | For details of the IRC protocol, see https://dev.twitch.tv/docs/v5/guides/irc/ . 52 | 53 | In the event the IRC address changes, use `--host` option when running the logger. 54 | 55 | 56 | Credits 57 | ======= 58 | 59 | Copyright 2015-2018 Christopher Foo. License: GPLv3 60 | -------------------------------------------------------------------------------- /fakespaghettilogger.py: -------------------------------------------------------------------------------- 1 | # This is free and unencumbered software released into the public domain. 2 | 3 | # Anyone is free to copy, modify, publish, use, compile, sell, or 4 | # distribute this software, either in source code form or as a compiled 5 | # binary, for any purpose, commercial or non-commercial, and by any 6 | # means. 7 | 8 | # In jurisdictions that recognize copyright laws, the author or authors 9 | # of this software dedicate any and all copyright interest in the 10 | # software to the public domain. We make this dedication for the benefit 11 | # of the public at large and to the detriment of our heirs and 12 | # successors. We intend this dedication to be an overt act of 13 | # relinquishment in perpetuity of all present and future rights to this 14 | # software under copyright law. 15 | 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | # OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | # ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | # OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | # For more information, please refer to 25 | 26 | import argparse 27 | import datetime 28 | import json 29 | import logging 30 | import os 31 | import sys 32 | 33 | import time 34 | import tornado.gen 35 | import tornado.websocket 36 | import tornado.ioloop 37 | 38 | from spaghettilogger import LineWriter, RECONNECT_MIN_INTERVAL, \ 39 | RECONNECT_SUCCESS_THRESHOLD, RECONNECT_MAX_INTERVAL 40 | 41 | _logger = logging.getLogger(__name__) 42 | 43 | __version__ = '1.0.1' 44 | 45 | 46 | class Client(object): 47 | def __init__(self, url, channel_id, log_dir): 48 | self._writer = LineWriter(log_dir, str(channel_id), encoding='utf-8', 49 | encoding_errors='replace') 50 | self._url = url 51 | self._channel_id = channel_id 52 | 53 | def run(self): 54 | tornado.ioloop.IOLoop.current().run_sync(self._run) 55 | 56 | @tornado.gen.coroutine 57 | def _run(self): 58 | sleep_time = RECONNECT_MIN_INTERVAL 59 | 60 | while True: 61 | start_time = time.time() 62 | try: 63 | yield self._run_session() 64 | except tornado.websocket.WebSocketError: 65 | _logger.exception('Websocket error') 66 | 67 | end_time = time.time() 68 | 69 | if end_time - start_time < RECONNECT_SUCCESS_THRESHOLD: 70 | sleep_time *= 2 71 | sleep_time = min(sleep_time, RECONNECT_MAX_INTERVAL) 72 | else: 73 | sleep_time = RECONNECT_MIN_INTERVAL 74 | 75 | _logger.info("Sleeping for %s seconds", sleep_time) 76 | 77 | yield tornado.gen.sleep(sleep_time) 78 | 79 | @tornado.gen.coroutine 80 | def _run_session(self): 81 | conn = yield tornado.websocket.websocket_connect(self._url) 82 | 83 | _logger.info("Join channel %s", self._channel_id) 84 | 85 | self._write_line('logstart {}'.format(self._channel_id), 86 | internal=True) 87 | 88 | conn.write_message(json.dumps({ 89 | "type": "method", 90 | "method": "auth", 91 | "arguments": [self._channel_id], 92 | "id": 1 93 | })) 94 | 95 | while True: 96 | msg = yield conn.read_message() 97 | 98 | if msg is None: 99 | break 100 | 101 | self._write_line(msg) 102 | 103 | def _write_line(self, msg, internal=False): 104 | if internal: 105 | prefix = '# ' 106 | else: 107 | prefix = '' 108 | 109 | writer = self._writer 110 | line = '{prefix}{date} {text}'.format( 111 | prefix=prefix, 112 | date=datetime.datetime.utcnow().isoformat(), 113 | text=msg 114 | ) 115 | writer.write_line(line) 116 | 117 | 118 | def main(): 119 | arg_parser = argparse.ArgumentParser() 120 | arg_parser.add_argument('channel_id', type=int) 121 | arg_parser.add_argument('log_dir') 122 | arg_parser.add_argument('--url', default='wss://chat2-dal07.beam.pro:443') 123 | 124 | args = arg_parser.parse_args() 125 | 126 | logging.basicConfig(level=logging.INFO) 127 | 128 | if not os.path.isdir(args.log_dir): 129 | sys.exit('log dir provided is not a directory.') 130 | 131 | _logger.info('Starting websocket client.') 132 | 133 | channel_ids = [] 134 | 135 | client = Client(args.url, args.channel_id, args.log_dir) 136 | client.run() 137 | 138 | _logger.info('Stopped websocket client.') 139 | 140 | if __name__ == '__main__': 141 | main() 142 | -------------------------------------------------------------------------------- /spaghettilogger_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import glob 3 | import io 4 | import os 5 | import socketserver 6 | import tempfile 7 | import threading 8 | import unittest 9 | 10 | from spaghettilogger import ChatLogger, Client 11 | 12 | 13 | class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): 14 | pass 15 | 16 | 17 | class TestLogger(unittest.TestCase): 18 | def test_logger(self): 19 | thread_event = threading.Event() 20 | 21 | class Handler(socketserver.StreamRequestHandler): 22 | def handle(self): 23 | nick = self.rfile.readline() 24 | assert nick.startswith(b'NICK'), nick 25 | 26 | user = self.rfile.readline() 27 | assert user.startswith(b'USER'), user 28 | 29 | self.wfile.write( 30 | b':tmi.twitch.tv 001 twitch_username :Welcome, GLHF!\n' 31 | b':tmi.twitch.tv 002 twitch_username :Your host is tmi.twitch.tv\n' 32 | b':tmi.twitch.tv 003 twitch_username :This server is rather new\n' 33 | b':tmi.twitch.tv 004 twitch_username :-\n' 34 | b':tmi.twitch.tv 375 twitch_username :-\n' 35 | b':tmi.twitch.tv 372 twitch_username :You are in a maze of twisty passages, all alike.\n' 36 | b':tmi.twitch.tv 376 twitch_username :>\n' 37 | ) 38 | 39 | for dummy in range(3): 40 | caps = self.rfile.readline() 41 | assert caps.startswith(b'CAP'), caps 42 | 43 | join = self.rfile.readline() 44 | assert join.startswith(b'JOIN'), join 45 | 46 | self.wfile.write(b':twitch_username!twitch_username@twitch_username.tmi.twitch.tv JOIN #test_channel\n') 47 | self.wfile.write(b':justinfan28394!justinfan28394@justinfan28394.tmi.twitch.tv JOIN #test_channel\n') 48 | self.wfile.write(b':twitch_username!twitch_username@twitch_username.tmi.twitch.tv PART #test_channel\n') 49 | self.wfile.write(b':jtv MODE #test_channel +o operator_user\n') 50 | self.wfile.write(b'@msg-id=slow_off :tmi.twitch.tv NOTICE #test_channel :This room is no longer in slow mode.\n') 51 | self.wfile.write(b'@ban-duration=600;ban-reason= :tmi.twitch.tv CLEARCHAT #test_channel :naughty_user\n') 52 | self.wfile.write(b':tmi.twitch.tv CLEARCHAT #test_channel\n') 53 | self.wfile.write('@badges=;color=;display-name=Naughty_User;emotes=;mod=0;room-id=1337;subscriber=0;turbo=1;user-id=1337;user-type= :naughty_user!naughty_user@naughty_user.tmi.twitch.tv PRIVMSG #test_channel :☺\n'.encode('utf8')) 54 | self.wfile.write(b'@badges=;color=;display-name=Naughty_User;emotes=;mod=0;room-id=1337;subscriber=0;turbo=1;user-id=1337;user-type= :naughty_user!naughty_user@naughty_user.tmi.twitch.tv PRIVMSG #test_channel :\x01ACTION OneHand\x01\n') 55 | self.wfile.write(b'@badges=global_mod/1,turbo/1;color=#0D4200;display-name=TWITCH_UserNaME;emotes=25:0-4,12-16/1902:6-10;mod=0;room-id=1337;subscriber=0;turbo=1;user-id=1337;user-type=global_mod :twitch_username!twitch_username@twitch_username.tmi.twitch.tv PRIVMSG #test_channel :Kappa Keepo Kappa\n') 56 | self.wfile.write(b'@badges=staff/1,broadcaster/1,turbo/1;color=#008000;display-name=TWITCH_UserName;emotes=;mod=0;msg-id=resub;msg-param-months=6;room-id=1337;subscriber=1;system-msg=TWITCH_UserName\shas\ssubscribed\sfor\s6\smonths!;login=twitch_username;turbo=1;user-id=1337;user-type=staff :tmi.twitch.tv USERNOTICE #test_channel :Great stream -- keep it up!\n') 57 | self.wfile.write(b'@login=ronni;target-msg-id=abc-123-def :tmi.twitch.tv CLEARMSG #test_channel :HeyGuys\n') 58 | 59 | thread_event.set() 60 | 61 | server = ThreadedTCPServer(('localhost', 0), Handler) 62 | port = server.server_address[1] 63 | 64 | with tempfile.TemporaryDirectory() as temp_dir: 65 | log_dir = os.path.join(temp_dir, 'logs') 66 | 67 | os.mkdir(log_dir) 68 | 69 | channels_file_path = os.path.join(temp_dir, 'channels.txt') 70 | 71 | with open(channels_file_path, 'w') as file: 72 | file.write('#test_channel\n') 73 | 74 | chat_logger = ChatLogger(log_dir) 75 | client = Client(chat_logger, channels_file_path) 76 | 77 | client.autoconnect('localhost', port, 'justinfan28394') 78 | 79 | server_thread = threading.Thread(target=server.serve_forever) 80 | server_thread.daemon = True 81 | server_thread.start() 82 | 83 | client_thread = threading.Thread(target=client.reactor.process_forever) 84 | client_thread.daemon = True 85 | client_thread.start() 86 | 87 | thread_event.wait(30) 88 | 89 | server.shutdown() 90 | server.server_close() 91 | 92 | client.stop() 93 | 94 | data = io.StringIO() 95 | paths = sorted(glob.glob(temp_dir + '/logs/#test_channel/*.log')) 96 | 97 | for path in paths: 98 | print('reading', path) 99 | with open(path) as file: 100 | data.write(file.read()) 101 | 102 | log_file_data = data.getvalue() 103 | 104 | print(log_file_data) 105 | 106 | self.assertRegex(log_file_data, r'# {}.* logstart'.format(datetime.datetime.utcnow().year)) 107 | self.assertIn('join justinfan28394', log_file_data) 108 | self.assertIn('part twitch_username', log_file_data) 109 | self.assertIn('mode jtv +o operator_user', log_file_data) 110 | self.assertRegex(log_file_data, r'notice .* :This room is no longer in slow mode\.') 111 | self.assertIn('clearchat ban-duration=600;ban-reason= :naughty_user', log_file_data) 112 | self.assertIn('clearchat :', log_file_data) 113 | self.assertIn('☺', log_file_data) 114 | self.assertIn('\x01ACTION OneHand\x01', log_file_data) 115 | self.assertIn('TWITCH_UserNaME', log_file_data) 116 | self.assertIn('Kappa Keepo', log_file_data) 117 | self.assertRegex(log_file_data, r'usernotice .*msg-param-months.* :Great stream') 118 | self.assertIn('clearmsg login=ronni;target-msg-id=abc-123-def :HeyGuys', log_file_data) 119 | -------------------------------------------------------------------------------- /spaghettilogger.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | '''Twitch.tv Chat Logger''' 3 | # Copyright 2015-2018 Christopher Foo. License: GPLv3 4 | 5 | import argparse 6 | import datetime 7 | import logging 8 | import os.path 9 | import random 10 | import sys 11 | import signal 12 | import time 13 | 14 | import functools 15 | from itertools import zip_longest 16 | 17 | import irc.client 18 | import irc.ctcp 19 | import irc.strings 20 | import irc.message 21 | 22 | 23 | _logger = logging.getLogger(__name__) 24 | 25 | __version__ = '1.2' 26 | 27 | 28 | class LineWriter(object): 29 | def __init__(self, log_dir, channel_name, encoding='latin-1', 30 | encoding_errors=None): 31 | self._log_dir = log_dir 32 | self._channel_name = channel_name 33 | self._file = None 34 | self._previous_date = None 35 | self._encoding = encoding 36 | self._encoding_errors = encoding_errors 37 | 38 | channel_dir = os.path.join(log_dir, channel_name) 39 | 40 | if not os.path.exists(channel_dir): 41 | os.mkdir(channel_dir) 42 | 43 | def write_line(self, line): 44 | current_date = datetime.datetime.utcnow().date() 45 | 46 | if self._previous_date != current_date: 47 | if self._file: 48 | self._file.close() 49 | 50 | path = os.path.join( 51 | self._log_dir, 52 | self._channel_name, 53 | current_date.isoformat() + '.log' 54 | ) 55 | 56 | self._file = open(path, 'a', encoding=self._encoding, 57 | errors=self._encoding_errors) 58 | 59 | assert '\n' not in line, line 60 | assert '\r' not in line, line 61 | self._file.write(line) 62 | self._file.write('\n') 63 | self._file.flush() 64 | 65 | def close(self): 66 | if self._file: 67 | self._file.close() 68 | self._file = None 69 | 70 | 71 | class ChatLogger(object): 72 | def __init__(self, log_directory): 73 | self._log_directory = log_directory 74 | self._channels = [] 75 | self._writers = {} 76 | 77 | def add_channel(self, channel): 78 | if channel not in self._writers: 79 | self._writers[channel] = LineWriter(self._log_directory, channel) 80 | self._write_line(channel, 'logstart {}'.format(channel), 81 | internal=True) 82 | 83 | def remove_channel(self, channel): 84 | if channel in self._writers: 85 | self._write_line(channel, 'logend {}'.format(channel), 86 | internal=True) 87 | self._writers[channel].close() 88 | del self._writers[channel] 89 | 90 | def log_message(self, nick, channel, message, tags=None): 91 | self._write_line( 92 | channel, 93 | 'privmsg {tags} :{nick} :{message}'.format( 94 | tags=tags if tags else '', 95 | nick=nick, 96 | message=message 97 | ) 98 | ) 99 | 100 | def log_mode(self, nick, channel, args): 101 | self._write_line( 102 | channel, 103 | 'mode {} {}'.format(nick, ' '.join(args)) 104 | ) 105 | 106 | def log_notice(self, channel, msg, tags=None): 107 | self._write_line( 108 | channel, 109 | 'notice {tags} :{msg}'.format( 110 | msg=msg, 111 | tags=tags if tags else '' 112 | ) 113 | ) 114 | 115 | def log_usernotice(self, channel, msg=None, tags=None): 116 | self._write_line( 117 | channel, 118 | 'usernotice {tags} :{msg}'.format( 119 | msg=msg if msg else '', 120 | tags=tags if tags else '' 121 | ) 122 | ) 123 | 124 | def log_clearchat(self, channel, nick=None, tags=None): 125 | self._write_line( 126 | channel, 127 | 'clearchat {tags} :{nick}'.format( 128 | nick=nick if nick else '', 129 | tags=tags if tags else '' 130 | ) 131 | ) 132 | 133 | def log_clearmsg(self, channel, message=None, tags=None): 134 | self._write_line( 135 | channel, 136 | 'clearmsg {tags} :{message}'.format( 137 | message=message if message else '', 138 | tags=tags if tags else '' 139 | ) 140 | ) 141 | 142 | def log_join(self, channel, nick): 143 | self._write_line( 144 | channel, 145 | 'join {}'.format(nick) 146 | ) 147 | 148 | def log_part(self, channel, nick): 149 | self._write_line( 150 | channel, 151 | 'part {}'.format(nick) 152 | ) 153 | 154 | def _write_line(self, channel, text, internal=False): 155 | if channel not in self._writers: 156 | _logger.warning('Discarded message to channel %s when not joined.', 157 | channel) 158 | return 159 | 160 | if internal: 161 | prefix = '# ' 162 | else: 163 | prefix = '' 164 | 165 | writer = self._writers[channel] 166 | line = '{prefix}{date} {text}'.format( 167 | prefix=prefix, 168 | date=datetime.datetime.utcnow().isoformat(), 169 | text=text 170 | ) 171 | writer.write_line(line) 172 | 173 | def stop(self): 174 | for channel in tuple(self._writers.keys()): 175 | self.remove_channel(channel) 176 | 177 | RECONNECT_SUCCESS_THRESHOLD = 60 178 | RECONNECT_MIN_INTERVAL = 2 179 | RECONNECT_MAX_INTERVAL = 300 180 | KEEP_ALIVE = 60 181 | IRC_RATE_LIMIT = (20 - 0.1) / 30 182 | FILE_POLL_INTERVAL = 30 183 | 184 | 185 | class ListWrapper(list): 186 | __slots__ = ('raw', ) 187 | 188 | 189 | class Client(irc.client.SimpleIRCClient): 190 | def __init__(self, chat_logger: ChatLogger, channels_file): 191 | super().__init__() 192 | self._chat_logger = chat_logger 193 | self._channels_file = channels_file 194 | self._channels_file_timestamp = 0 195 | self._channels = [] 196 | self._joined_channels = set() 197 | self._running = True 198 | self._reconnect_time = RECONNECT_MIN_INTERVAL 199 | self._last_connect = 0 200 | 201 | irc.client.ServerConnection.buffer_class.encoding = 'latin-1' 202 | 203 | self.reactor.scheduler.execute_every(FILE_POLL_INTERVAL, self._load_channels) 204 | self.reactor.scheduler.execute_every(KEEP_ALIVE, self._keep_alive) 205 | 206 | # Monkey patch to include raw tags so we don't have to serialize it 207 | # again 208 | original_from_group = irc.message.Tag.from_group 209 | 210 | def new_from_group(text): 211 | result = original_from_group(text) 212 | if result: 213 | result = ListWrapper(result) 214 | result.raw = text 215 | return result 216 | 217 | irc.message.Tag.from_group = new_from_group 218 | 219 | # Monkey patch to preserve messages such as /me 220 | # Fortunately, Twitch does not require CTCP replies 221 | irc.ctcp.dequote = lambda msg: [msg] 222 | 223 | def autoconnect(self, *args, **kwargs): 224 | self.connection.set_rate_limit(float('+inf')) 225 | try: 226 | if args: 227 | self.connect(*args, **kwargs) 228 | else: 229 | self.connection.reconnect() 230 | except irc.client.ServerConnectionError: 231 | _logger.exception('Connect failed.') 232 | self._schedule_reconnect() 233 | 234 | def _schedule_reconnect(self): 235 | time_now = time.time() 236 | 237 | if not self._last_connect or time_now - self._last_connect < RECONNECT_SUCCESS_THRESHOLD: 238 | self._reconnect_time *= 2 239 | self._reconnect_time = min(RECONNECT_MAX_INTERVAL, 240 | self._reconnect_time) 241 | else: 242 | self._reconnect_time = RECONNECT_MIN_INTERVAL 243 | 244 | _logger.info('Reconnecting in %s seconds.', self._reconnect_time) 245 | self.reactor.scheduler.execute_after(self._reconnect_time, 246 | self.autoconnect) 247 | 248 | def stop(self): 249 | self._running = False 250 | self.reactor.disconnect_all() 251 | self._chat_logger.stop() 252 | 253 | def on_welcome(self, connection, event): 254 | _logger.info('Logged in to server.') 255 | self.connection.cap('REQ', 'twitch.tv/membership') 256 | self.connection.cap('REQ', 'twitch.tv/commands') 257 | self.connection.cap('REQ', 'twitch.tv/tags') 258 | self.connection.set_rate_limit(IRC_RATE_LIMIT) 259 | self._load_channels(force_reload=True) 260 | self._last_connect = time.time() 261 | 262 | def on_disconnect(self, connection, event): 263 | _logger.info('Disconnected!') 264 | self._chat_logger.stop() 265 | 266 | if self._running: 267 | self._joined_channels.clear() 268 | self._last_connect = 0 269 | self._schedule_reconnect() 270 | 271 | def on_join(self, connection, event): 272 | client_nick = self.connection.get_nickname() 273 | nick = irc.strings.lower(event.source.nick) 274 | channel = irc.strings.lower(event.target) 275 | 276 | if nick == client_nick: 277 | _logger.info('Joined %s', channel) 278 | self._joined_channels.add(event.target) 279 | 280 | self._chat_logger.log_join(channel, nick) 281 | 282 | def on_part(self, connection, event): 283 | client_nick = self.connection.get_nickname() 284 | nick = irc.strings.lower(event.source.nick) 285 | channel = irc.strings.lower(event.target) 286 | 287 | if nick == client_nick and channel in self._joined_channels: 288 | _logger.info('Parted %s', channel) 289 | self._joined_channels.remove(channel) 290 | 291 | self._chat_logger.log_part(channel, nick) 292 | 293 | def on_pubmsg(self, connection, event): 294 | channel = irc.strings.lower(event.target) 295 | 296 | if hasattr(event.source, 'nick'): 297 | nick = irc.strings.lower(event.source.nick) 298 | 299 | self._chat_logger.log_message( 300 | nick, 301 | channel, 302 | event.arguments[0], 303 | event.tags.raw if event.tags else None) 304 | 305 | def on_mode(self, connection, event): 306 | nick = irc.strings.lower(event.source.nick) 307 | channel = irc.strings.lower(event.target) 308 | 309 | self._chat_logger.log_mode(nick, channel, event.arguments) 310 | 311 | def on_pubnotice(self, connection, event): 312 | channel = irc.strings.lower(event.target) 313 | 314 | self._chat_logger.log_notice( 315 | channel, 316 | event.arguments[0], 317 | event.tags.raw if event.tags else None 318 | ) 319 | 320 | def on_clearchat(self, connection, event): 321 | channel = irc.strings.lower(event.target) 322 | nick = event.arguments[0] if event.arguments else None 323 | tags = event.tags.raw if event.tags else None 324 | 325 | self._chat_logger.log_clearchat(channel, nick, tags=tags) 326 | 327 | def on_clearmsg(self, connection, event): 328 | channel = irc.strings.lower(event.target) 329 | message = event.arguments[0] if event.arguments else None 330 | tags = event.tags.raw if event.tags else None 331 | 332 | self._chat_logger.log_clearmsg(channel, message, tags=tags) 333 | 334 | def on_usernotice(self, connection, event): 335 | channel = irc.strings.lower(event.target) 336 | msg = event.arguments[0] if event.arguments else None 337 | 338 | self._chat_logger.log_usernotice( 339 | channel, 340 | msg, 341 | event.tags.raw if event.tags else None 342 | ) 343 | 344 | def _join_new_channels(self): 345 | new_channels = frozenset(self._channels) - self._joined_channels 346 | join_channels = tuple( 347 | channel for channel in self._channels if channel in new_channels 348 | ) 349 | join_multi = tuple( 350 | ','.join(channel for channel in group if channel) for group in grouper(join_channels, 25) 351 | ) 352 | 353 | for channel in join_channels: 354 | _logger.info('Channel to join: %s', channel) 355 | self._chat_logger.add_channel(channel) 356 | 357 | for index, channel in enumerate(join_multi): 358 | _logger.info('Joining %s', channel) 359 | 360 | if hasattr(self.connection.send_raw, 'max_rate'): 361 | # Give the Reactor loop a chance to process incoming 362 | # messages especially on server connect 363 | self.reactor.scheduler.execute_after( 364 | 1 / self.connection.send_raw.max_rate * index, 365 | functools.partial(self.connection.join, channel) 366 | ) 367 | else: 368 | self.connection.join(channel) 369 | 370 | def _part_old_channels(self): 371 | old_channels = self._joined_channels - frozenset(self._channels) 372 | 373 | for channel in old_channels: 374 | _logger.info('Parting %s', channel) 375 | self.connection.part(channel) 376 | self._chat_logger.remove_channel(channel) 377 | 378 | def _load_channels(self, force_reload=False): 379 | new_time = os.path.getmtime(self._channels_file) 380 | 381 | if not force_reload and new_time == self._channels_file_timestamp: 382 | return 383 | 384 | self._channels_file_timestamp = new_time 385 | 386 | self._channels = [] 387 | 388 | with open(self._channels_file, 'r') as file: 389 | for channel in file: 390 | channel = channel.strip() 391 | 392 | if not channel: 393 | continue 394 | 395 | channel = irc.strings.lower(channel) 396 | self._channels.append(channel) 397 | 398 | if self.connection.is_connected(): 399 | self._join_new_channels() 400 | self._part_old_channels() 401 | 402 | def _keep_alive(self): 403 | if self.connection.is_connected(): 404 | self.connection.ping('keep-alive') 405 | 406 | 407 | def grouper(iterable, n, fillvalue=None): 408 | "Collect data into fixed-length chunks or blocks" 409 | # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" 410 | args = [iter(iterable)] * n 411 | return zip_longest(*args, fillvalue=fillvalue) 412 | 413 | 414 | def main(): 415 | arg_parser = argparse.ArgumentParser() 416 | arg_parser.add_argument('channels_file') 417 | arg_parser.add_argument('log_dir') 418 | arg_parser.add_argument('--host', default='irc.twitch.tv') 419 | arg_parser.add_argument('--port', type=int, default=6667) 420 | arg_parser.add_argument('--nickname') 421 | arg_parser.add_argument('--oauth-file') 422 | 423 | args = arg_parser.parse_args() 424 | 425 | logging.basicConfig(level=logging.INFO) 426 | 427 | if not os.path.isfile(args.channels_file): 428 | sys.exit('channels file provided is not a file.') 429 | 430 | if not os.path.isdir(args.log_dir): 431 | sys.exit('log dir provided is not a directory.') 432 | 433 | if args.oauth_file: 434 | with open(args.oauth_file, 'r') as file: 435 | password = 'oauth:' + file.read().strip() 436 | else: 437 | password = None 438 | 439 | _logger.info('Starting IRC client.') 440 | 441 | chat_logger = ChatLogger(args.log_dir) 442 | client = Client(chat_logger, args.channels_file) 443 | running = True 444 | nickname = args.nickname or 'justinfan{}'.format(random.randint(0, 9000000)) 445 | 446 | def stop(dummy1, dummy2): 447 | nonlocal running 448 | running = False 449 | 450 | client.autoconnect(args.host, args.port, nickname, password=password) 451 | 452 | signal.signal(signal.SIGINT, stop) 453 | signal.signal(signal.SIGTERM, stop) 454 | 455 | while running: 456 | client.reactor.process_once(0.2) 457 | 458 | client.stop() 459 | 460 | _logger.info('Stopped IRC client.') 461 | 462 | if __name__ == '__main__': 463 | main() 464 | --------------------------------------------------------------------------------