TimberBot – blob
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