pinhook 1.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pinhook/__init__.py ADDED
File without changes
pinhook/__version__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '1.11.0'
pinhook/bans.py ADDED
@@ -0,0 +1,56 @@
1
+ import fnmatch
2
+ import json
3
+ import os
4
+
5
+ import irc.strings
6
+
7
+ # ban and op entries can be one of:
8
+ # a nick troll
9
+ # a hostmask *!*@203.0.113.7 (* and ? are wildcards)
10
+ # a services account $a:troll
11
+
12
+
13
+ def is_account(entry):
14
+ return entry.startswith('$a:')
15
+
16
+
17
+ def is_mask(entry):
18
+ return '!' in entry or '@' in entry
19
+
20
+
21
+ def matches(entry, nick, user=None, host=None, account=None):
22
+ # irc lowercasing also turns [ ] into { }, so nicks can't break the wildcard match
23
+ entry = irc.strings.lower(entry)
24
+ if is_account(entry):
25
+ return bool(account) and irc.strings.lower(account) == entry[len('$a:'):]
26
+ if is_mask(entry):
27
+ mask = irc.strings.lower('{}!{}@{}'.format(nick, user or '', host or ''))
28
+ return fnmatch.fnmatchcase(mask, entry)
29
+ return irc.strings.lower(nick) == entry
30
+
31
+
32
+ def matches_any(entries, nick, user=None, host=None, account=None):
33
+ return any(matches(e, nick, user, host, account) for e in entries)
34
+
35
+
36
+ def load(path):
37
+ if not path or not os.path.exists(path):
38
+ return []
39
+ with open(path) as f:
40
+ try:
41
+ entries = json.load(f)
42
+ except ValueError as e:
43
+ raise ValueError("couldn't read ban file {}: {}".format(path, e))
44
+ if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries):
45
+ raise ValueError('ban file {} should be a list of ban entries'.format(path))
46
+ return entries
47
+
48
+
49
+ def save(path, entries):
50
+ if not path:
51
+ return
52
+ # write to a temp file first so a crash can't leave a half written ban file
53
+ tmp = path + '.tmp'
54
+ with open(tmp, 'w') as f:
55
+ json.dump(entries, f, indent=2)
56
+ os.replace(tmp, path)
pinhook/bot.py ADDED
@@ -0,0 +1,578 @@
1
+ from collections import OrderedDict
2
+ from datetime import datetime, timezone
3
+ import difflib
4
+ import functools
5
+ import logging
6
+ import socket
7
+ import ssl
8
+ import sys
9
+ import time
10
+
11
+ from . import bans
12
+ from . import log
13
+ from . import plugin
14
+
15
+ import irc.bot
16
+
17
+
18
+ irc.client.ServerConnection.buffer_class.errors = 'replace'
19
+
20
+
21
+ class Bot(irc.bot.SingleServerIRCBot):
22
+ internal_commands = {
23
+ 'join': 'join a channel',
24
+ 'quit': 'force the bot to quit',
25
+ 'reload': 'force bot to reload all plugins',
26
+ 'enable': 'enable a plugin',
27
+ 'disable': 'disable a plugin',
28
+ 'op': 'add a user as bot operator',
29
+ 'deop': 'remove a user as bot operator',
30
+ 'ops': 'list all ops',
31
+ 'ban': 'ban a user from using the bot, by account or host when possible',
32
+ 'unban': 'remove bot ban for user',
33
+ 'banlist': 'list current bans'
34
+ }
35
+ # keyword options Bot understands, subclasses can add their own
36
+ options = {
37
+ 'port', 'ops', 'plugin_dir', 'ssl_required', 'ssl_verify', 'ns_pass',
38
+ 'nickserv', 'log_level', 'log_file', 'server_pass', 'cmd_prefix',
39
+ 'use_prefix_for_plugins', 'disable_help', 'banned_users', 'bot_mode',
40
+ 'ban_file', 'require_account',
41
+ }
42
+ log_levels = {
43
+ 'critical': logging.CRITICAL,
44
+ 'error': logging.ERROR,
45
+ 'warning': logging.WARNING,
46
+ 'warn': logging.WARNING,
47
+ 'info': logging.INFO,
48
+ 'debug': logging.DEBUG,
49
+ }
50
+ # set once the server agrees to tag messages with the sender's account
51
+ account_tag = False
52
+ _negotiating_caps = False
53
+
54
+ def __init__(self, channels, nickname, server, **kwargs):
55
+ self.port = kwargs.get('port', 6667)
56
+ self.ops = kwargs.get('ops', [])
57
+ self.plugin_dir = kwargs.get('plugin_dir', 'plugins')
58
+ self.ssl_required = kwargs.get('ssl_required', False)
59
+ self.ssl_verify = kwargs.get('ssl_verify', True)
60
+ self.ns_pass = kwargs.get('ns_pass', None)
61
+ self.nickserv = kwargs.get('nickserv', 'NickServ')
62
+ self.log_level = kwargs.get('log_level', 'info')
63
+ self.log_file = kwargs.get('log_file', None)
64
+ self.server_pass = kwargs.get('server_pass', None)
65
+ self.cmd_prefix = kwargs.get('cmd_prefix', '!')
66
+ self.use_prefix_for_plugins = kwargs.get('use_prefix_for_plugins', False)
67
+ self.disable_help = kwargs.get('disable_help', False)
68
+ self.bot_mode = kwargs.get('bot_mode', True)
69
+ self.require_account = kwargs.get('require_account', False)
70
+ factory = self.ssl_factory(server) if self.ssl_required else irc.connection.Factory()
71
+ irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname, connect_factory=self.cap_factory(factory))
72
+ self.chanlist = channels
73
+ self.bot_nick = nickname
74
+ self.start_logging()
75
+ self.warn_unknown_options(kwargs)
76
+ self.setup_bans(nickname, kwargs)
77
+ self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()}
78
+ plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix, logger=self.logger)
79
+
80
+ class Message:
81
+ def __init__(self, bot, channel, nick, user, botnick, ops, logger, action, privmsg, notice, msg_type, cmd=None, arg=None, text=None, nick_list=None, is_op=False):
82
+ self.bot = bot
83
+ self.datetime = datetime.now(timezone.utc)
84
+ self.timestamp = self.datetime.timestamp()
85
+ self.channel = channel
86
+ self.nick = nick
87
+ self.user = user
88
+ self.nick_list = nick_list
89
+ self.botnick = botnick
90
+ self.ops = ops
91
+ self.is_op = is_op
92
+ self.logger = logger
93
+ self.action = action
94
+ self.privmsg = privmsg
95
+ self.notice = notice
96
+ self.msg_type = msg_type
97
+ if cmd:
98
+ self.cmd = cmd
99
+ self.arg = arg
100
+ if text != None:
101
+ self.text = text
102
+ if cmd is None and text is None:
103
+ raise TypeError('missing cmd or text parameter')
104
+
105
+ def connect(self, *args, **kwargs):
106
+ # the irc library quietly retries failed connections, so say why it failed
107
+ try:
108
+ super().connect(*args, **kwargs)
109
+ except irc.client.ServerConnectionError as e:
110
+ self.logger.error(self.connection_error_message(args[0], args[1], e))
111
+ raise
112
+
113
+ def connection_error_message(self, host, port, error):
114
+ cause = error.__cause__
115
+ start = "couldn't connect to {}:{}: ".format(host, port)
116
+ end = ' (will keep retrying every 1-5 minutes)'
117
+ if isinstance(cause, ssl.SSLCertVerificationError):
118
+ start += "the server's ssl certificate failed verification ({}). ".format(cause.verify_message.rstrip('.'))
119
+ # openssl codes for hostname and ip address mismatch, and expired
120
+ if cause.verify_code in (62, 64):
121
+ return start + 'Set server to the name the certificate was issued for, not an IP address or alias' + end
122
+ if cause.verify_code == 10:
123
+ return start + 'This needs fixing on the server' + end
124
+ return start + 'If this server uses a self-signed certificate, set ssl_verify to false' + end
125
+ if isinstance(cause, ssl.SSLError):
126
+ return (start + 'ssl handshake failed ({}). '
127
+ "Check {} is the server's ssl port, usually 6697".format(getattr(cause, 'reason', None) or cause, port) + end)
128
+ if isinstance(cause, socket.gaierror):
129
+ return start + 'server name not found, check the server setting' + end
130
+ if isinstance(cause, ConnectionRefusedError):
131
+ return start + 'connection refused, check the server and port settings' + end
132
+ return start + str(cause or error) + end
133
+
134
+ def warn_unknown_options(self, kwargs):
135
+ for key in sorted(set(kwargs) - self.options):
136
+ close = difflib.get_close_matches(key, self.options, n=1)
137
+ hint = ", did you mean '{}'?".format(close[0]) if close else ''
138
+ self.logger.warning("unknown option '{}' ignored{}".format(key, hint))
139
+
140
+ def setup_bans(self, nickname, kwargs):
141
+ self.ban_file = kwargs.get('ban_file', '{}.bans.json'.format(nickname)) or None
142
+ self.config_bans = list(kwargs.get('banned_users', []))
143
+ self.banned_users = list(self.config_bans)
144
+ saved = bans.load(self.ban_file)
145
+ for entry in saved:
146
+ if not self.nick_in(entry, self.banned_users):
147
+ self.banned_users.append(entry)
148
+ if saved:
149
+ self.logger.info('loaded {} bans from {}'.format(len(saved), self.ban_file))
150
+ # recent nicks and who they are, so !ban troll can ban their account or host
151
+ self.seen = OrderedDict()
152
+
153
+ def save_bans(self):
154
+ # bans from the config stay in the config, the file keeps the ones made with !ban
155
+ bans.save(self.ban_file, [b for b in self.banned_users if not self.nick_in(b, self.config_bans)])
156
+
157
+ def remember(self, nick, user, host, account):
158
+ key = irc.strings.lower(nick)
159
+ self.seen[key] = (nick, user, host, account)
160
+ self.seen.move_to_end(key)
161
+ while len(self.seen) > 1000:
162
+ self.seen.popitem(last=False)
163
+
164
+ def ban_entry(self, target):
165
+ # a nick on its own is easy to change, so ban their account if they're
166
+ # logged in, or their user and host if not
167
+ if bans.is_account(target) or bans.is_mask(target):
168
+ return target, target
169
+ seen = self.seen.get(irc.strings.lower(target))
170
+ if not seen:
171
+ return target, "{} (nick only, they haven't spoken since the bot started)".format(target)
172
+ nick, user, host, account = seen
173
+ if account:
174
+ return '$a:' + account, '{} (account {})'.format(target, account)
175
+ entry = '*!*{}@{}'.format((user or '').lstrip('~'), host)
176
+ return entry, '{} ({})'.format(target, entry)
177
+
178
+ def remove_bans(self, target):
179
+ # the entry itself, plus anything banning the person last seen as target
180
+ seen = self.seen.get(irc.strings.lower(target))
181
+ removed = [b for b in self.banned_users if self.nick_in(b, [target]) or (seen and bans.matches(b, *seen))]
182
+ self.banned_users = [b for b in self.banned_users if b not in removed]
183
+ return removed
184
+
185
+ @staticmethod
186
+ def account_from_tags(tags):
187
+ for tag in tags or []:
188
+ if tag['key'] == 'account' and tag['value'] not in (None, '*'):
189
+ return tag['value']
190
+ return None
191
+
192
+ def cap_factory(self, factory):
193
+ # ask what the server supports before the irc library sends NICK and USER,
194
+ # some servers (like libera) only allow it before registration
195
+ def connect(address):
196
+ sock = factory(address)
197
+ sock.send(b'CAP LS 302\r\n')
198
+ self._negotiating_caps = True
199
+ self._server_caps = set()
200
+ self.account_tag = False
201
+ return sock
202
+ return connect
203
+
204
+ def on_cap(self, c, e):
205
+ if not self._negotiating_caps:
206
+ return
207
+ subcommand = e.arguments[0]
208
+ if subcommand == 'LS':
209
+ self._server_caps.update(cap.split('=')[0] for cap in e.arguments[-1].split())
210
+ # a * means the list carries on in another line
211
+ if len(e.arguments) > 2 and e.arguments[1] == '*':
212
+ return
213
+ if 'account-tag' in self._server_caps:
214
+ c.cap('REQ', 'account-tag')
215
+ return
216
+ elif subcommand in ('ACK', 'NAK'):
217
+ self.account_tag = subcommand == 'ACK' and 'account-tag' in e.arguments[-1].split()
218
+ else:
219
+ return
220
+ # registration waits until we say we're done
221
+ self._negotiating_caps = False
222
+ c.cap('END')
223
+
224
+ def ssl_factory(self, server):
225
+ context = ssl.create_default_context()
226
+ if not self.ssl_verify:
227
+ context.check_hostname = False
228
+ context.verify_mode = ssl.CERT_NONE
229
+ wrapper = functools.partial(context.wrap_socket, server_hostname=server)
230
+ return irc.connection.Factory(wrapper=wrapper)
231
+
232
+ @staticmethod
233
+ def nick_in(nick, nicks):
234
+ # irc nicks are case insensitive, so Troll and troll are the same person
235
+ nick = irc.strings.lower(nick)
236
+ return any(irc.strings.lower(n) == nick for n in nicks)
237
+
238
+ def start_logging(self):
239
+ self.logger = log.get_bot_logger(self.bot_nick)
240
+ log_level = str(self.log_level).lower()
241
+ if log_level == 'off':
242
+ self.logger.disabled = True
243
+ return
244
+ if log_level not in self.log_levels:
245
+ raise ValueError("log_level must be one of: off, {}".format(', '.join(self.log_levels)))
246
+ self.logger.disabled = False
247
+ if self.log_file:
248
+ log.set_log_file(self.log_file, self.logger)
249
+ else:
250
+ log.set_log_file('{}.log'.format(self.bot_nick), self.logger)
251
+ self.logger.setLevel(self.log_levels[log_level])
252
+ self.logger.info('Logging started!')
253
+
254
+ def on_welcome(self, c, e):
255
+ if self.require_account and not self.account_tag:
256
+ self.logger.error("require_account is on but this server doesn't tell the bot who is logged in (account-tag), so the bot will ignore everyone")
257
+ if self.ns_pass:
258
+ self.logger.info('identifying with nickserv')
259
+ c.privmsg(self.nickserv, 'identify {}'.format(self.ns_pass))
260
+ for channel in self.chanlist:
261
+ self.logger.info('joining channel {}'.format(channel.split()[0]))
262
+ c.join(*channel.split())
263
+
264
+ def on_nicknameinuse(self, c, e):
265
+ # servers call us * until we're registered, after that a failed
266
+ # nick change should just keep the nick we already have
267
+ if e.target != '*':
268
+ return
269
+ new_nick = e.arguments[0] + '_'
270
+ self.logger.warning('nickname {} in use, trying {}'.format(e.arguments[0], new_nick))
271
+ c.nick(new_nick)
272
+
273
+ def on_featurelist(self, c, e):
274
+ # servers that support bot mode advertise its letter as BOT=<mode>
275
+ # https://ircv3.net/specs/extensions/bot-mode
276
+ if not self.bot_mode:
277
+ return
278
+ for feature in e.arguments:
279
+ if feature.startswith('BOT='):
280
+ mode = feature[len('BOT='):]
281
+ self.logger.info('setting bot mode +{}'.format(mode))
282
+ c.mode(c.get_nickname(), '+' + mode)
283
+
284
+ def on_pubmsg(self, c, e):
285
+ self.process_event(c, e)
286
+
287
+ def on_privmsg(self, c, e):
288
+ self.process_event(c, e)
289
+
290
+ def on_action(self, c, e):
291
+ self.process_event(c, e)
292
+
293
+ def call_help(self, nick, op):
294
+ cmds = {k:v.help_text for k,v in plugin.cmds.items() if not (plugin.cmds[k].ops or plugin.cmds[k].hide) }
295
+ cmds.update({self.cmd_prefix + 'help': 'returns this output to private message'})
296
+ if op:
297
+ cmds.update({k:v.help_text for k,v in plugin.cmds.items() if plugin.cmds[k].ops})
298
+ cmds.update({k:v for k,v in self.internal_commands.items()})
299
+ helpout = OrderedDict(sorted(cmds.items()))
300
+ for h in helpout:
301
+ self.connection.privmsg(nick, '{} -- {}'.format(h, helpout[h]))
302
+ time.sleep(.1)
303
+ self.connection.privmsg(nick, 'List of listeners: {}'.format(', '.join([l for l in plugin.lstnrs])))
304
+ return None
305
+
306
+ def call_internal_commands(self, channel, nick, cmd, text, arg, c, op=None):
307
+ if not cmd.startswith(self.cmd_prefix):
308
+ return None
309
+ else:
310
+ cmd = cmd[len(self.cmd_prefix):]
311
+ output = None
312
+ if op is None:
313
+ op = self.nick_in(nick, self.ops)
314
+ if cmd == 'join' and op:
315
+ try:
316
+ c.join(*arg.split())
317
+ self.logger.info('joining {} per request of {}'.format(arg, nick))
318
+ output = plugin.message('{}: joined {}'.format(nick, arg.split()[0]))
319
+ except:
320
+ self.logger.exception('issue with join command: {}join #channel <channel key>'.format(self.cmd_prefix))
321
+ elif cmd == 'quit' and op:
322
+ self.logger.info('quitting per request of {}'.format(nick))
323
+ if not arg:
324
+ arg = "See y'all later!"
325
+ c.quit(arg)
326
+ sys.exit()
327
+ elif cmd == 'help' and not self.disable_help:
328
+ self.call_help(nick, op)
329
+ elif cmd == 'reload' and op:
330
+ self.logger.info('reloading plugins per request of {}'.format(nick))
331
+ plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix, logger=self.logger)
332
+ output = plugin.message('Plugins reloaded')
333
+ elif cmd == 'enable' and op:
334
+ if arg in plugin.plugins:
335
+ if plugin.plugins[arg].enabled:
336
+ output = plugin.message("{}: '{}' already enabled".format(nick, arg))
337
+ else:
338
+ plugin.plugins[arg].enable()
339
+ output = plugin.message("{}: '{}' enabled!".format(nick, arg))
340
+ else:
341
+ output = plugin.message("{}: '{}' not found".format(nick, arg))
342
+ elif cmd == 'disable' and op:
343
+ if arg in plugin.plugins:
344
+ if not plugin.plugins[arg].enabled:
345
+ output = plugin.message("{}: '{}' already disabled".format(nick, arg))
346
+ else:
347
+ plugin.plugins[arg].disable()
348
+ output = plugin.message("{}: '{}' disabled!".format(nick, arg))
349
+ else:
350
+ output = plugin.message("{}: '{}' not found".format(nick, arg))
351
+ elif cmd in ('op', 'deop', 'ban', 'unban') and op and not arg.split():
352
+ output = plugin.message('{}: usage: {}{} <nick>'.format(nick, self.cmd_prefix, cmd))
353
+ elif cmd == 'op' and op:
354
+ for o in arg.split():
355
+ if not self.nick_in(o, self.ops):
356
+ self.ops.append(o)
357
+ output = plugin.message('{}: {} added as op'.format(nick, arg))
358
+ elif cmd == 'deop' and op:
359
+ for o in arg.split():
360
+ self.ops = [i for i in self.ops if not self.nick_in(i, [o])]
361
+ output = plugin.message('{}: {} removed as op'.format(nick, arg))
362
+ elif cmd == 'ops' and op:
363
+ output = plugin.message('current ops: {}'.format(', '.join(self.ops)))
364
+ elif cmd == 'ban' and op:
365
+ banned = []
366
+ for target in arg.split():
367
+ entry, description = self.ban_entry(target)
368
+ if not self.nick_in(entry, self.banned_users):
369
+ self.banned_users.append(entry)
370
+ banned.append(description)
371
+ self.save_bans()
372
+ output = plugin.message('{}: banned {}'.format(nick, ', '.join(banned)))
373
+ elif cmd == 'unban' and op:
374
+ removed = []
375
+ for target in arg.split():
376
+ removed += self.remove_bans(target)
377
+ self.save_bans()
378
+ if not removed:
379
+ output = plugin.message('{}: no ban found for {}'.format(nick, arg))
380
+ else:
381
+ reply = '{}: removed ban on {}'.format(nick, ', '.join(removed))
382
+ from_config = [b for b in removed if self.nick_in(b, self.config_bans)]
383
+ if from_config:
384
+ reply += " ({} is in the config file, so it'll be back after a restart)".format(', '.join(from_config))
385
+ output = plugin.message(reply)
386
+ elif cmd == 'banlist' and op:
387
+ output = plugin.message('currently banned: {}'.format(', '.join(self.banned_users) or 'nobody'))
388
+ return output
389
+
390
+ def check_output(self, output):
391
+ if output and not isinstance(output, plugin.Output):
392
+ raise TypeError('plugins must return message() or action(), got {}'.format(type(output).__name__))
393
+ return output
394
+
395
+ def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, user, arg, msg_type, botnick, op=None):
396
+ output = None
397
+ if op is None:
398
+ op = self.nick_in(nick, self.ops)
399
+ if cmd in plugin.cmds:
400
+ try:
401
+ if plugin.cmds[cmd].ops and not op:
402
+ if plugin.cmds[cmd].ops_msg:
403
+ output = plugin.message(plugin.cmds[cmd].ops_msg)
404
+ elif plugin.cmds[cmd].enabled:
405
+ self.logger.debug('executing {}'.format(cmd))
406
+ output = self.check_output(plugin.cmds[cmd].run(self.Message(
407
+ bot=self,
408
+ channel=chan,
409
+ cmd=cmd,
410
+ nick_list=nick_list,
411
+ nick=nick,
412
+ user=user,
413
+ arg=arg,
414
+ privmsg=privmsg,
415
+ action=action,
416
+ notice=notice,
417
+ botnick=botnick,
418
+ ops=self.ops,
419
+ logger=self.logger,
420
+ msg_type=msg_type,
421
+ is_op=op
422
+ )))
423
+ except Exception:
424
+ self.logger.exception('issue with command {}'.format(cmd))
425
+ else:
426
+ for lstnr in plugin.lstnrs:
427
+ if plugin.lstnrs[lstnr].enabled:
428
+ try:
429
+ self.logger.debug('whispering to listener: {}'.format(lstnr))
430
+ listen_output = self.check_output(plugin.lstnrs[lstnr].run(self.Message(
431
+ bot=self,
432
+ channel=chan,
433
+ text=text,
434
+ nick_list=nick_list,
435
+ nick=nick,
436
+ user=user,
437
+ privmsg=privmsg,
438
+ action=action,
439
+ notice=notice,
440
+ botnick=botnick,
441
+ ops=self.ops,
442
+ logger=self.logger,
443
+ msg_type=msg_type,
444
+ is_op=op
445
+ )))
446
+ if listen_output:
447
+ output = listen_output
448
+ except Exception:
449
+ self.logger.exception('issue with listener {}'.format(lstnr))
450
+ if output:
451
+ self.logger.debug(f'returning output: {output.msg}')
452
+ return output
453
+
454
+ def process_event(self, c, e):
455
+ nick = e.source.nick
456
+ user = e.source.user
457
+ host = e.source.host
458
+ account = self.account_from_tags(e.tags)
459
+ self.remember(nick, user, host, account)
460
+ if bans.matches_any(self.banned_users, nick, user, host, account):
461
+ self.logger.debug('ignoring banned user {}'.format(nick))
462
+ return
463
+ botnick = c.get_nickname()
464
+ if irc.strings.lower(nick) == irc.strings.lower(botnick):
465
+ return
466
+ if self.require_account and not account:
467
+ self.logger.debug('ignoring {}, not logged in to an account'.format(nick))
468
+ return
469
+ op = bans.matches_any(self.ops, nick, user, host, account)
470
+ if e.arguments:
471
+ text = e.arguments[0]
472
+ else:
473
+ text = ''
474
+ if e.type == 'privmsg' or e.type == 'pubmsg':
475
+ msg_type = 'message'
476
+ else:
477
+ msg_type = e.type
478
+ if not irc.client.is_channel(e.target):
479
+ chan = nick
480
+ nick_list = [nick]
481
+ else:
482
+ chan = e.target
483
+ nick_list = list(self.channels[chan].users())
484
+ if e.type == 'action':
485
+ cmd = ''
486
+ else:
487
+ cmd = text.split(' ')[0]
488
+ self.logger.debug(
489
+ 'Message info: channel: {}, nick: {}, cmd: {}, text: {}'.format(chan, nick, cmd, text)
490
+ )
491
+ if len(text.split(' ')) > 1:
492
+ arg = ''.join([i + ' ' for i in text.split(' ')[1:]]).strip()
493
+ else:
494
+ arg = ''
495
+ output = self.call_internal_commands(chan, nick, cmd, text, arg, c, op=op)
496
+ if not output:
497
+ plugin_info = {
498
+ 'chan': chan,
499
+ 'cmd': cmd,
500
+ 'text': text,
501
+ 'nick_list': nick_list,
502
+ 'nick': nick,
503
+ 'user': user,
504
+ 'arg': arg,
505
+ 'privmsg': c.privmsg,
506
+ 'action': c.action,
507
+ 'notice': c.notice,
508
+ 'msg_type': msg_type,
509
+ 'botnick': botnick,
510
+ 'op': op
511
+ }
512
+ output = self.call_plugins(**plugin_info)
513
+ if output:
514
+ self.logger.debug(f'sending output: {output.msg}')
515
+ self.process_output(c, chan, output)
516
+
517
+ def process_output(self, c, chan, output):
518
+ if not output.msg:
519
+ return
520
+ for msg in output.msg:
521
+ if output.msg_type == plugin.OutputType.Message:
522
+ self.logger.debug('output message: {}'.format(msg))
523
+ try:
524
+ c.privmsg(chan, msg)
525
+ except irc.client.MessageTooLong:
526
+ self.logger.error('output message too long: {}'.format(msg))
527
+ break
528
+ elif output.msg_type == plugin.OutputType.Action:
529
+ self.logger.debug('output action: {}'.format(msg))
530
+ try:
531
+ c.action(chan, msg)
532
+ except irc.client.MessageTooLong:
533
+ self.logger.error('output message too long: {}'.format(msg))
534
+ break
535
+ else:
536
+ self.logger.warning("Unsupported output type '{}'".format(output.msg_type))
537
+ time.sleep(.5)
538
+
539
+
540
+ class TwitchBot(Bot):
541
+ server = 'irc.chat.twitch.tv'
542
+ options = {
543
+ 'port', 'ops', 'plugin_dir', 'log_level', 'log_file', 'cmd_prefix',
544
+ 'use_prefix_for_plugins', 'disable_help', 'banned_users', 'bot_mode',
545
+ 'ban_file',
546
+ }
547
+
548
+ def __init__(self, nickname, channel, token, **kwargs):
549
+ self.port = kwargs.get('port', 6697)
550
+ self.ssl_verify = True
551
+ self.ops = kwargs.get('ops', [])
552
+ self.plugin_dir = kwargs.get('plugin_dir', 'plugins')
553
+ self.log_level = kwargs.get('log_level', 'info')
554
+ self.log_file = kwargs.get('log_file', None)
555
+ self.server_pass = kwargs.get('server_pass', None)
556
+ self.cmd_prefix = kwargs.get('cmd_prefix', '!')
557
+ self.use_prefix_for_plugins = kwargs.get('use_prefix_for_plugins', False)
558
+ self.disable_help = kwargs.get('disable_help', False)
559
+ self.bot_mode = kwargs.get('bot_mode', True)
560
+ self.require_account = False
561
+ self.bot_nick = nickname
562
+ self.start_logging()
563
+ self.warn_unknown_options(kwargs)
564
+ self.setup_bans(nickname, kwargs)
565
+ self.channel = channel
566
+ self.logger.info('Joining Twitch Server')
567
+ # always use tls, the oauth token is a password
568
+ irc.bot.SingleServerIRCBot.__init__(self, [(self.server, self.port, 'oauth:'+token)], nickname, nickname, connect_factory=self.ssl_factory(self.server))
569
+ self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()}
570
+ plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix, logger=self.logger)
571
+
572
+ def on_welcome(self, c, e):
573
+ self.logger.info('requesting permissions')
574
+ c.cap('REQ', ':twitch.tv/membership')
575
+ c.cap('REQ', ':twitch.tv/tags')
576
+ c.cap('REQ', ':twitch.tv/commands')
577
+ self.logger.info('Joining channel ' + self.channel)
578
+ c.join(self.channel)
pinhook/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ import click
2
+ from .bot import Bot
3
+ from marshmallow import Schema, fields, validate, INCLUDE, ValidationError
4
+
5
+ class Config(Schema):
6
+ nickname = fields.Str(required=True)
7
+ channels = fields.List(fields.Str(), required=True)
8
+ server = fields.Str(required=True)
9
+ port = fields.Int()
10
+ ops = fields.List(fields.Str())
11
+ ssl_required = fields.Bool()
12
+ ssl_verify = fields.Bool()
13
+ plugin_dir = fields.Str()
14
+ ns_pass = fields.Str()
15
+ nickserv = fields.Str()
16
+ log_level = fields.Str(validate=validate.OneOf(['off'] + list(Bot.log_levels)))
17
+ log_file = fields.Str()
18
+ server_pass = fields.Str()
19
+ cmd_prefix = fields.Str()
20
+ use_prefix_for_plugins = fields.Bool()
21
+ disable_help = fields.Bool()
22
+ banned_users = fields.List(fields.Str())
23
+ bot_mode = fields.Bool()
24
+ ban_file = fields.Str(allow_none=True)
25
+ require_account = fields.Bool()
26
+
27
+ class Meta:
28
+ unknown = INCLUDE
29
+
30
+ def check_conf(schema, data, name):
31
+ try:
32
+ return schema.load(data)
33
+ except ValidationError as e:
34
+ problems = [' {}: {}'.format(key, ' '.join(msgs) if isinstance(msgs, list) else msgs) for key, msgs in sorted(e.messages.items())]
35
+ raise click.ClickException('{} has problems:\n{}'.format(name, '\n'.join(problems)))
36
+
37
+ def read_conf(config, conf_format):
38
+ schema = Config()
39
+ if not conf_format:
40
+ if config.name.endswith('.json'):
41
+ conf_format = 'json'
42
+ elif config.name.endswith(('.yaml', '.yml')):
43
+ conf_format = 'yaml'
44
+ elif config.name.endswith(('.toml', '.tml')):
45
+ conf_format = 'toml'
46
+ else:
47
+ raise click.ClickException('Could not detect file format, please supply using --format option')
48
+ if conf_format == 'json':
49
+ import json
50
+ to_json = json.loads(config.read())
51
+ output = check_conf(schema, to_json, config.name)
52
+ elif conf_format == 'yaml':
53
+ try:
54
+ import yaml
55
+ except ImportError:
56
+ raise click.ClickException('yaml not installed, please use `pip3 install pinhook[yaml]` to install')
57
+ else:
58
+ to_yaml = yaml.load(config.read(), Loader=yaml.FullLoader)
59
+ output = check_conf(schema, to_yaml, config.name)
60
+ elif conf_format == 'toml':
61
+ try:
62
+ # built in from python 3.11
63
+ import tomllib
64
+ except ImportError:
65
+ try:
66
+ import toml
67
+ except ImportError:
68
+ raise click.ClickException('toml not installed, please use `pip3 install pinhook[toml]` to install')
69
+ to_toml = toml.load(config.name)
70
+ else:
71
+ to_toml = tomllib.load(config)
72
+ output = check_conf(schema, to_toml, config.name)
73
+ return output
74
+
75
+ @click.command()
76
+ @click.argument('config', type=click.File('rb'))
77
+ @click.option('--format', '-f', 'conf_format', type=click.Choice(['json', 'yaml', 'toml']))
78
+ def cli(config, conf_format):
79
+ config = read_conf(config, conf_format)
80
+ try:
81
+ bot = Bot(**config)
82
+ except ValueError as e:
83
+ # a bad log_level or ban file, say what's wrong without a traceback
84
+ raise click.ClickException(str(e))
85
+ bot.start()
86
+
pinhook/log.py ADDED
@@ -0,0 +1,24 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger('bot')
4
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(name)s - %(module)s - %(message)s')
5
+ # Set console logger
6
+ streamhandler = logging.StreamHandler()
7
+ streamhandler.setFormatter(formatter)
8
+ logger.addHandler(streamhandler)
9
+
10
+ def get_bot_logger(nickname):
11
+ # each bot gets its own child of the 'bot' logger so it can have its own
12
+ # level and log file, console output still goes through the parent
13
+ bot_logger = logger.getChild(nickname)
14
+ # a new bot with the same nick replaces the old one's log file
15
+ for handler in list(bot_logger.handlers):
16
+ bot_logger.removeHandler(handler)
17
+ handler.close()
18
+ return bot_logger
19
+
20
+ def set_log_file(filename, target=logger):
21
+ # Set file logger
22
+ filehandler = logging.FileHandler(filename)
23
+ filehandler.setFormatter(formatter)
24
+ target.addHandler(filehandler)
pinhook/plugin.py ADDED
@@ -0,0 +1,183 @@
1
+ from enum import Enum
2
+ from functools import wraps
3
+ import importlib.util
4
+ import os
5
+ import sys
6
+
7
+ from .log import logger
8
+
9
+ plugins = {}
10
+ cmds = {}
11
+ lstnrs = {}
12
+
13
+ class OutputType(Enum):
14
+ Message = 'message'
15
+ Action = 'action'
16
+
17
+
18
+ class Output:
19
+ def __init__(self, msg_type, msg):
20
+ self.msg_type = msg_type
21
+ self.msg = self.sanitize(msg)
22
+
23
+ def sanitize(self, msg):
24
+ if isinstance(msg, str):
25
+ return msg.splitlines()
26
+ if isinstance(msg, (list, tuple)):
27
+ # items in a list can have newlines too
28
+ return [line for m in msg for line in str(m).splitlines()]
29
+ return msg
30
+
31
+
32
+ class _BasePlugin:
33
+ enabled = True
34
+ logger = logger
35
+
36
+ def enable(self):
37
+ self.enabled = True
38
+
39
+ def disable(self):
40
+ self.enabled = False
41
+
42
+
43
+ class Listener(_BasePlugin):
44
+ def __init__(self, name, run=None):
45
+ self.name = name
46
+ if run:
47
+ self.run = run
48
+ self._add_listener()
49
+
50
+ def __str__(self):
51
+ return self.name
52
+
53
+ def run(self):
54
+ pass
55
+
56
+ def _add_listener(self):
57
+ lstnrs[self.name] = self
58
+ plugins[self.name] = self
59
+
60
+
61
+ class Command(_BasePlugin):
62
+ def __init__(self, cmd, **kwargs):
63
+ self.cmd = cmd
64
+ self.help_text = kwargs.get('help_text', 'N/A')
65
+ self.ops = kwargs.get('ops', False)
66
+ self.ops_msg = kwargs.get('ops_msg', '')
67
+ self.hide = kwargs.get('hide', False)
68
+ self.run = kwargs.get('run', self.run)
69
+ self._add_command()
70
+
71
+ def __str__(self):
72
+ return self.cmd
73
+
74
+ def run(self, msg):
75
+ pass
76
+
77
+ def _enable_ops(self, ops_msg):
78
+ self.ops = True
79
+ self.ops_msg = ops_msg
80
+
81
+ def _update_plugin(self, **kwargs):
82
+ self.help_text = kwargs.get('help_text', 'N/A')
83
+ self.run = kwargs.get('run', self.run)
84
+
85
+ def _add_command(self):
86
+ cmds[self.cmd] = self
87
+ plugins[self.cmd] = self
88
+
89
+
90
+ def action(msg):
91
+ return Output(OutputType.Action, msg)
92
+
93
+ def message(msg):
94
+ return Output(OutputType.Message, msg)
95
+
96
+ def _add_command(command, help_text, func, ops=False, ops_msg='', hide=False):
97
+ if command not in cmds:
98
+ Command(command, help_text=help_text, ops=ops, ops_msg=ops_msg, hide=hide, run=func)
99
+ else:
100
+ cmds[command]._update_plugin(help_text=help_text, run=func)
101
+
102
+ def _ops_plugin(command, ops_msg, func):
103
+ if command not in cmds:
104
+ Command(command, ops=True, ops_msg=ops_msg)
105
+ else:
106
+ cmds[command]._enable_ops(ops_msg)
107
+
108
+ def _add_listener(name, func):
109
+ Listener(name, run=func)
110
+
111
+ def clear_plugins():
112
+ cmds.clear()
113
+ lstnrs.clear()
114
+ plugins.clear()
115
+
116
+ def load_plugins(plugin_dir, use_prefix=False, cmd_prefix='!', logger=logger):
117
+ # i'm not sure why i need this but i do
118
+ global cmds
119
+ global plugins
120
+ global lstnrs
121
+ #check for all the disabled plugins so that we don't re-enable them
122
+ disabled_plugins = [i for i in plugins if not plugins[i].enabled]
123
+ logger.debug(disabled_plugins)
124
+ # clear plugin list to ensure no old plugins remain
125
+ logger.info('clearing plugin cache')
126
+ clear_plugins()
127
+ # ensure plugin folder exists
128
+ logger.info('checking plugin directory')
129
+ if not os.path.exists(plugin_dir):
130
+ logger.info('plugin directory {} not found, creating'.format(plugin_dir))
131
+ os.makedirs(plugin_dir)
132
+ # load all plugins
133
+ for m in os.listdir(plugin_dir):
134
+ if m.endswith('.py'):
135
+ try:
136
+ name = m[:-3]
137
+ logger.info('loading plugin {}'.format(name))
138
+ # load under our own name so a plugin called json.py can't replace the real json module
139
+ spec = importlib.util.spec_from_file_location('pinhook_plugins.' + name, os.path.join(plugin_dir, m))
140
+ module = importlib.util.module_from_spec(spec)
141
+ sys.modules[spec.name] = module
142
+ spec.loader.exec_module(module)
143
+ except Exception:
144
+ logger.exception('could not load plugin')
145
+ # gather all commands and listeners
146
+ if use_prefix: # use prefixes if needed
147
+ cmds = {cmd_prefix + k: v for k,v in cmds.items()}
148
+ for p in plugins:
149
+ if p in disabled_plugins:
150
+ plugins[p].disable()
151
+ for cmd in cmds:
152
+ logger.debug('adding command {}'.format(cmd))
153
+ for lstnr in lstnrs:
154
+ logger.debug('adding listener {}'.format(lstnr))
155
+
156
+ def command(command, help_text='N/A', ops=False, ops_msg='', hide=False):
157
+ @wraps(command)
158
+ def register_for_command(func):
159
+ _add_command(command, help_text, func, ops=ops, ops_msg=ops_msg, hide=hide)
160
+ return func
161
+ return register_for_command
162
+
163
+ def register(command, help_text='N/A'):
164
+ logger.warning('@register decorator has been deprecated in favor of @command. This will cause errors in future versions.')
165
+ @wraps(command)
166
+ def register_for_command(func):
167
+ _add_command(command, help_text, func)
168
+ return func
169
+ return register_for_command
170
+
171
+ def listener(name):
172
+ def register_as_listener(func):
173
+ _add_listener(name, func)
174
+ return func
175
+ return register_as_listener
176
+
177
+ def ops(command, msg=None):
178
+ logger.warning('use of the @ops decorator has been deprecated in favor of using the @command decorator with the ops and ops_msg options. Use will cause errors in future versions.')
179
+ @wraps(command)
180
+ def register_ops_command(func):
181
+ _ops_plugin(command, msg, func)
182
+ return func
183
+ return register_ops_command
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: pinhook
3
+ Version: 1.11.0
4
+ Summary: a pluggable irc bot framework in python
5
+ Home-page: https://github.com/archangelic/pinhook
6
+ Author: M. Hancock
7
+ Author-email: mhancock@archangelic.space
8
+ License: MIT
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Requires-Python: >=3.8
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: irc
24
+ Requires-Dist: click
25
+ Requires-Dist: marshmallow
26
+ Provides-Extra: toml
27
+ Requires-Dist: toml; python_version < "3.11" and extra == "toml"
28
+ Provides-Extra: yaml
29
+ Requires-Dist: pyyaml; extra == "yaml"
30
+ Dynamic: author
31
+ Dynamic: author-email
32
+ Dynamic: classifier
33
+ Dynamic: description
34
+ Dynamic: description-content-type
35
+ Dynamic: home-page
36
+ Dynamic: license
37
+ Dynamic: license-file
38
+ Dynamic: provides-extra
39
+ Dynamic: requires-dist
40
+ Dynamic: requires-python
41
+ Dynamic: summary
42
+
43
+
44
+ # pinhook
45
+
46
+ [![Supported Python versions](https://img.shields.io/pypi/pyversions/pinhook.svg)](https://pypi.org/project/pinhook) [![Package License](https://img.shields.io/pypi/l/pinhook.svg)](https://github.com/archangelic/pinhook/blob/master/LICENSE) [![PyPI package format](https://img.shields.io/pypi/format/pinhook.svg)](https://pypi.org/project/pinhook) [![Package development status](https://img.shields.io/pypi/status/pinhook.svg)](https://pypi.org/project/pinhook) [![With love from tilde.town](https://img.shields.io/badge/with%20love%20from-tilde%20town-e0b0ff.svg)](https://tilde.town)
47
+
48
+ The pluggable python framework for IRC bots and Twitch bots
49
+
50
+ **Documentation: https://archangelic.github.io/pinhook/**
51
+
52
+ ## Installation
53
+
54
+ ``` bash
55
+ pip install pinhook
56
+ ```
57
+
58
+ ## Quick start
59
+
60
+ Make a `plugins` folder with this in `plugins/hello.py`:
61
+
62
+ ```python
63
+ import pinhook.plugin
64
+
65
+ @pinhook.plugin.command('!hello', help_text='says hello back')
66
+ def hello(msg):
67
+ return pinhook.plugin.message('hi {}!'.format(msg.nick))
68
+ ```
69
+
70
+ Then start a bot, from a python file:
71
+
72
+ ```python
73
+ from pinhook.bot import Bot
74
+
75
+ bot = Bot(
76
+ channels=['#foo'],
77
+ nickname='ph-bot',
78
+ server='irc.libera.chat',
79
+ port=6697,
80
+ ssl_required=True,
81
+ )
82
+ bot.start()
83
+ ```
84
+
85
+ or from a config file, with `pinhook config.yaml`:
86
+
87
+ ```yaml
88
+ nickname: "ph-bot"
89
+ server: "irc.libera.chat"
90
+ port: 6697
91
+ ssl_required: true
92
+ channels:
93
+ - "#foo"
94
+ ```
95
+
96
+ Say `!hello` in `#foo` and the bot answers.
97
+
98
+ ## Learn more
99
+
100
+ * [Getting started](https://archangelic.github.io/pinhook/latest/getting-started/)
101
+ * [Options](https://archangelic.github.io/pinhook/latest/options/)
102
+ * [Built-in commands](https://archangelic.github.io/pinhook/latest/commands/)
103
+ * [Writing plugins](https://archangelic.github.io/pinhook/latest/plugins/)
104
+ * [Bans and ops](https://archangelic.github.io/pinhook/latest/bans-and-ops/)
105
+ * [Twitch bots](https://archangelic.github.io/pinhook/latest/twitch/)
106
+ * [Troubleshooting](https://archangelic.github.io/pinhook/latest/troubleshooting/)
107
+ * [Upgrading to 1.11](https://archangelic.github.io/pinhook/latest/upgrading/)
108
+
109
+ ## Examples
110
+
111
+ There are some basic examples in the `examples` directory in this repository.
112
+
113
+ Here is a list of live bots using pinhook:
114
+
115
+ * [pinhook-tilde](https://github.com/archangelic/pinhook-tilde) - fun bot for tilde.town
116
+ * [adminbot](https://github.com/tildetown/adminbot) - admin helper bot for tilde.town, featuring some of the ways you can change the Bot class to suit your needs
@@ -0,0 +1,13 @@
1
+ pinhook/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pinhook/__version__.py,sha256=glAkwZsi0Hbv-R1Jbe-2qECv-6AWHNGvlRS_cgbvCTU,23
3
+ pinhook/bans.py,sha256=vyIrHN4ejANac9fd3aXvcmF81Ki9zOf0px8I7vU8MNs,1704
4
+ pinhook/bot.py,sha256=5Uty6gSxC1EUptfwkOhWeGxLF5geSXENOrIS1_NUGKw,25804
5
+ pinhook/cli.py,sha256=WAjIlWqZK_5f_kQfNQtMvpVjJFkvIXmU4lXPfILjn5U,3176
6
+ pinhook/log.py,sha256=BQGFAia0fXsOBzBI4cD4QMdOx99rEewExCxg71tjczw,892
7
+ pinhook/plugin.py,sha256=_pz2dRiurZROeGS3QRmyw44pVGJFaNRh39pKaBibz50,5498
8
+ pinhook-1.11.0.dist-info/licenses/LICENSE,sha256=K_nYx_j4vZZEkXPmnBlsg9sKMBvwXtU3n-98ITmQ5lo,1067
9
+ pinhook-1.11.0.dist-info/METADATA,sha256=o8HGwPlOIXtrGJaUU5xO0C-7TBqUO_fgR6WLuWrjBtE,3803
10
+ pinhook-1.11.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ pinhook-1.11.0.dist-info/entry_points.txt,sha256=cntGw5Ri3rD8RStghG4nJYXQhAvnHG9qZyi3_1Euglk,44
12
+ pinhook-1.11.0.dist-info/top_level.txt,sha256=beywy3YHJ9gxBRkXCzfJHp6CRoUawHfts_qkYMNqWms,8
13
+ pinhook-1.11.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pinhook = pinhook.cli:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2017 M. Hancock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, 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,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pinhook