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 +0 -0
- pinhook/__version__.py +1 -0
- pinhook/bans.py +56 -0
- pinhook/bot.py +578 -0
- pinhook/cli.py +86 -0
- pinhook/log.py +24 -0
- pinhook/plugin.py +183 -0
- pinhook-1.11.0.dist-info/METADATA +116 -0
- pinhook-1.11.0.dist-info/RECORD +13 -0
- pinhook-1.11.0.dist-info/WHEEL +5 -0
- pinhook-1.11.0.dist-info/entry_points.txt +2 -0
- pinhook-1.11.0.dist-info/licenses/LICENSE +21 -0
- pinhook-1.11.0.dist-info/top_level.txt +1 -0
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
|
+
[](https://pypi.org/project/pinhook) [](https://github.com/archangelic/pinhook/blob/master/LICENSE) [](https://pypi.org/project/pinhook) [](https://pypi.org/project/pinhook) [](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,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
|