TimberBot – blob

You can use Git to clone the repository via the web URL. Download snapshot (zip)
Add Linux documentation
[TimberBot] / twitch.py
1 #!/usr/bin/env python3
2 # -*- coding: utf-8 -*-
4 # SPDX-License-Identifier: GPL-3.0-or-later
5 # This file is part of TimberBot. <https://fietkau.software/timberbot>
6 # Copyright (C) 2014-2022 Julian Fietkau
8 import datetime
9 import errno
10 import os
11 import time
12 import websocket
14 class ConnectorTwitch():
16     def __init__(self, config, verbose_print):
17         self.config = config
18         self.verbose_print = verbose_print
20         self.connection_attempts = 0
21         self.last_message = None
22         self.status = 'disconnected'
24         self.ws = None
26         self.sent_timestamps = []
27         self.scheduled_update_intervals = 0
29     def ws_send(self, msg):
30         self.ws.send(bytes(msg, 'utf-8'))
32     def connect(self):
33         self.ws = websocket.WebSocket()
34         self.ws.connect('wss://irc-ws.chat.twitch.tv:443')
36         self.ws_send('PASS ' + self.config['password'] + '\r\n')
37         self.ws_send('NICK ' + self.config['nick'] + '\r\n')
38         self.ws_send('CAP REQ :twitch.tv/commands twitch.tv/membership twitch.tv/tags' + '\r\n')
39         self.ws_send('JOIN #' + self.config['channel'].lower() + '\r\n')
41         self.status = 'connected'
42         self.connection_attempts = 0
44     def disconnect(self):
45         self.status = 'disconnected'
47     def split_message(self, message, max_message_length):
48         messages = []
49         words = message.split(' ')
50         current_word = ''
51         while current_word != None:
52             current_message = ''
53             while current_word != None and len(current_message) + 1 + len(current_word) <= max_message_length - 6:
54                 current_message = current_message + ' ' + current_word
55                 current_message = current_message.strip()
56                 if len(words) > 0:
57                     current_word = words.pop(0)
58                 else:
59                     current_word = None
60             messages.append(current_message)
61         counted_messages = []
62         message_number = 1
63         for msg in messages:
64             counted_messages.append(msg + ' [' + str(message_number) + '/' + str(len(messages)) + ']')
65             message_number = message_number + 1
66         return counted_messages
68     def send(self, message):
69         max_message_length = 350
70         if len(message) > max_message_length:
71             messages = self.split_message(message, max_message_length)
72         else:
73             messages = [message]
74         for msg in messages:
75             self.socket_send(msg)
77     def reply(self, user, message):
78         self.send(user + ': ' + message)
80     def special_action(self, action_type, params):
81         if action_type == 'timeout':
82             if 'user' in params:
83                 command = '/timeout ' + params['user'].lower()
84                 if 'duration' in params:
85                     command += ' ' + str(params['duration'])
86                 self.send(command)
87             else:
88                 raise RuntimeError('Special action "' + str(action_type) + '" requires parameter "' + 'user' + '".')
89         elif action_type in ['ban', 'unban', 'mod', 'unmod', 'host']:
90             if 'user' in params:
91                 self.send('/' + action_type + ' ' + params['user'].lower())
92             else:
93                 raise RuntimeError('Special action "' + str(action_type) + '" requires parameter "' + 'user' + '".')
94         elif action_type in ['slow', 'slowoff', 'subscribers', 'subscribersoff', 'clear', 'r9kbeta', 'r9kbetaoff', 'unhost']:
95             self.send('/' + action_type)
96         elif action_type == 'me':
97             if 'message' in params:
98                 self.send('/me ' + params['message'])
99             else:
100                 raise RuntimeError('Special action "' + str(action_type) + '" requires parameter "' + 'message' + '".')
101         else:
102             raise RuntimeError('Special action "' + str(action_type) + '" is unavailable on this chat service.')
104     def socket_send(self, message):
105         if self.ws == None:
106             return
107         while len(self.sent_timestamps) > 0 and self.sent_timestamps[0] + datetime.timedelta(seconds = 30) < datetime.datetime.utcnow():
108             self.sent_timestamps.pop(0)
109         if len(self.sent_timestamps) < 20: # ensures we do not send >20 msgs per 30 seconds.
110             msg = 'PRIVMSG #' + self.config['channel'].lower() + ' :' + message
111             if self.verbose_print:
112                 print('[' + self.config['channel'] + '][SEND] ' + msg)
113             self.ws_send(msg + '\r\n')
114             time.sleep(0.5)
115             self.sent_timestamps.append(datetime.datetime.utcnow())
116         else:
117             raise IOError('Message queue full, outgoing message skipped: ' + message)
119     def socket_recv(self):
120         lines = []
121         if self.ws == None:
122             if self.connection_attempts < 10:
123                 try:
124                     self.connect()
125                 except TypeError:
126                     pass
127             elif self.connection_attempts >= 10:
128                 self.status = 'disconnected'
129                 return
130         data = self.ws.recv()
131         lines = data.splitlines()
132         return lines
134     def parse_line(self, line):
135         if self.verbose_print:
136             print('[' + self.config['channel'] + '][RECV] ' + line)
138         if line.find('PING') == 0: # respond to server PING
139             self.ws_send('PONG' + line[4:])
140             return None
142         if line[0] == ':':
143             without_tags = line[1:]
144         elif line.find(' :') > -1:
145             without_tags = line[line.index(' :')+2:]
146         else:
147             return None
148         user = without_tags.split(':')[0].split('!')[0]
149         if len(user) > 0 and user[0] in ['@', '+', '!', '%']:
150             user = user[1:]
151         message = None
152         if without_tags.find(':') > -1:
153             message = without_tags[without_tags.index(':')+1:]
155         params = {}
157         if ' ' in user or line.find('PRIVMSG') == -1:
158             if user.find(' 353 ') >= 0:
159                 return ['NAMES', None, message.split(), params]
160             full_prefix = line.split(':')[1]
161             if full_prefix.find(' JOIN ') >= 0:
162                 if user.lower() != self.config['nick'].lower():
163                     return ['JOIN', user, None, params]
164             if full_prefix.find(' PART ') >= 0:
165                 if user.lower() != self.config['nick'].lower():
166                     return ['PART', user, None, params]
167         else:
168             if ord(message[0]) == 1 and ord(message[-1]) == 1:
169                 message = message[1:-1]
170                 if message[0:6] == 'ACTION':
171                     message = message[6:]
172                     if message[0] == ' ':
173                         message = message[1:]
174                     return ['ME', user, message, params]
175             else:
176                 return ['MSG', user, message, params]
178     def poll(self):
179         if self.status == 'disconnected':
180             print('Disconnecting')
181             if self.ws != None:
182                 print('Disconnecting inner')
183                 self.ws_send('PART #' + self.config['channel'].lower() + '\r\n')
184                 self.ws_send('QUIT :' + self.config['nick'] + ' out!\r\n')
185                 print('1')
186                 self.ws.close()
187                 print('2')
188                 self.ws = None
189                 print('Disconnected inner')
190             print('Disconnected')
191             return
192         lines = self.socket_recv()
194         if self.ws == None:
195             return
197         self.scheduled_update_intervals += 1
198         if self.scheduled_update_intervals % 20 == 0:
199             self.ws_send('JOIN #' + self.config['channel'].lower() + '\r\n')
201         result = []
203         for line in lines:
205             parsed = self.parse_line(line)
207             if parsed == None or parsed[1] == 'jtv':
208                 continue
210             tags = {}
211             if line[0] == '@': # IRCv3 twitch tags
212                 tag_str = line[1:line.index(' :')]
213                 for tag in tag_str.split(';'):
214                     split_tag = tag.split('=')
215                     tag_value = split_tag[1]
216                     tags[split_tag[0]] = split_tag[1]
217             if 'display-name' in tags and len(tags['display-name']) > 0:
218                 parsed[1] = tags['display-name']
219             params = {}
220             for tag in tags:
221                 params['twitch:' + tag] = tags[tag]
222             params['mod'] = False
223             if 'user-type' in tags and len(tags['user-type']) > 0:
224                 params['mod'] = True
225             # It seems that the Twitch broadcaster does not have user-type set to anything.
226             if parsed[1] != None and parsed[1].lower() == self.config['channel'].lower():
227                 params['mod'] = True
229             parsed[3] = parsed[3].copy()
230             parsed[3].update(params)
232             result.append(parsed)
234         return result