def load(irc, source, args): """<plugin name>. Loads a plugin from the plugin folder.""" irc.checkAuthenticated(source, allowOper=False) try: name = args[0] except IndexError: irc.reply("Error: Not enough arguments. Needs 1: plugin name.") return if name in world.plugins: irc.reply("Error: %r is already loaded." % name) return log.info('(%s) Loading plugin %r for %s', irc.name, name, irc.getHostmask(source)) try: world.plugins[name] = pl = utils.loadPlugin(name) except ImportError as e: if str(e) == ('No module named %r' % name): log.exception( 'Failed to load plugin %r: The plugin could not be found.', name) else: log.exception('Failed to load plugin %r: ImportError.', name) raise else: if hasattr(pl, 'main'): log.debug('Calling main() function of plugin %r', pl) pl.main(irc) irc.reply("Loaded plugin %r." % name)
def load(irc, source, args): """<plugin name>. Loads a plugin from the plugin folder.""" # Note: reload capability is acceptable here, because all it actually does is call # load after unload. permissions.checkPermissions(irc, source, ['core.load', 'core.reload']) try: name = args[0] except IndexError: irc.reply("Error: Not enough arguments. Needs 1: plugin name.") return if name in world.plugins: irc.reply("Error: %r is already loaded." % name) return log.info('(%s) Loading plugin %r for %s', irc.name, name, irc.getHostmask(source)) try: world.plugins[name] = pl = utils.loadPlugin(name) except ImportError as e: if str(e) == ('No module named %r' % name): log.exception('Failed to load plugin %r: The plugin could not be found.', name) else: log.exception('Failed to load plugin %r: ImportError.', name) raise else: if hasattr(pl, 'main'): log.debug('Calling main() function of plugin %r', pl) pl.main(irc=irc) irc.reply("Loaded plugin %r." % name)
def rehash(): """Rehashes the PyLink daemon.""" log.info('Reloading PyLink configuration...') old_conf = conf.conf.copy() fname = conf.fname new_conf = conf.load_conf(fname, errors_fatal=False, logger=log) conf.conf = new_conf # Reset any file logger options. _stop_file_loggers() files = new_conf['logging'].get('files') if files: for filename, config in files.items(): _make_file_logger(filename, config.get('loglevel')) log.debug('rehash: updating console log level') world.console_handler.setLevel(_get_console_log_level()) login._make_cryptcontext() # refresh password hashing settings for network, ircobj in world.networkobjects.copy().items(): # Server was removed from the config file, disconnect them. log.debug('rehash: checking if %r is in new conf still.', network) if network not in new_conf['servers']: log.debug( 'rehash: removing connection to %r (removed from config).', network) remove_network(ircobj) else: # XXX: we should really just add abstraction to Irc to update config settings... ircobj.serverdata = new_conf['servers'][network] ircobj.autoconnect_active_multiplier = 1 # Clear the IRC object's channel loggers and replace them with # new ones by re-running log_setup(). while ircobj.loghandlers: log.removeHandler(ircobj.loghandlers.pop()) ircobj.log_setup() utils._reset_module_dirs() for network, sdata in new_conf['servers'].items(): # Connect any new networks or disconnected networks if they aren't already. if network not in world.networkobjects: try: proto = utils._get_protocol_module(sdata['protocol']) # API note: 2.0.x style of starting network connections world.networkobjects[network] = newirc = proto.Class(network) newirc.connect() except: log.exception( 'Failed to initialize network %r, skipping it...', network) log.info('Finished reloading PyLink configuration.')
def _process_conns(): """Main loop which processes connected sockets.""" while not world.shutting_down.is_set(): for socketkey, mask in selector.select(timeout=SELECT_TIMEOUT): irc = socketkey.data try: if mask & selectors.EVENT_READ and not irc._aborted.is_set(): irc._run_irc() except: log.exception('Error in select driver loop:') continue
def _get_webhook_fields(self, user): """ Returns a dict of Relay substitution fields for the given User object. This attempts to find the original user via Relay if the .remote metadata field is set. The output includes all keys provided in User.get_fields(), plus the following: netname: The full network name of the network 'user' belongs to nettag: The short network tag of the network 'user' belongs to avatar: The URL to the user's avatar (str), or None if no avatar is specified """ # Try to lookup the remote user data via relay metadata if hasattr(user, 'remote'): remotenet, remoteuid = user.remote try: netobj = world.networkobjects[remotenet] user = netobj.users[remoteuid] except LookupError: netobj = user._irc fields = user.get_fields() fields['netname'] = netobj.get_full_network_name() fields['nettag'] = netobj.name default_avatar_url = self.serverdata.get('default_avatar_url') avatar = None # XXX: we'll have a more rigorous matching system later on if user.services_account in self.serverdata.get('avatars', {}): avatar_url = self.serverdata['avatars'][user.services_account] p = urllib.parse.urlparse(avatar_url) log.debug('(%s) Got raw avatar URL %s for user %s', self.name, avatar_url, user) if p.scheme == 'gravatar' and libgravatar: # gravatar:[email protected] try: g = libgravatar.Gravatar(p.path) log.debug('(%s) Using Gravatar email %s for user %s', self.name, p.path, user) avatar = g.get_image(use_ssl=True) except: log.exception('Failed to obtain Gravatar image for user %s/%s', user, p.path) elif p.scheme in ('http', 'https'): # a direct image link avatar = avatar_url else: log.warning('(%s) Unknown avatar URI %s for user %s', self.name, avatar_url, user) elif default_avatar_url: log.debug('(%s) Avatar not defined for user %s; using default avatar %s', self.name, user, default_avatar_url) avatar = default_avatar_url else: log.debug('(%s) Avatar not defined for user %s; using default webhook avatar', self.name, user) fields['avatar'] = avatar return fields
def _remove_pid(): pidfile = "%s.pid" % conf.confname if world._should_remove_pid: # Remove our pid file. log.info("Removing PID file %r.", pidfile) try: os.remove(pidfile) except OSError: log.exception("Failed to remove PID file %r, ignoring..." % pidfile) else: log.debug( 'Not removing PID file %s as world._should_remove_pid is False.' % pidfile)
def _kill_plugins(irc=None): log.info("Shutting down plugins.") for name, plugin in world.plugins.items(): # Before closing connections, tell all plugins to shutdown cleanly first. if hasattr(plugin, 'die'): log.debug( 'coremods.control: Running die() on plugin %s due to shutdown.', name) try: plugin.die(irc) except: # But don't allow it to crash the server. log.exception( 'coremods.control: Error occurred in die() of plugin %s, skipping...', name)
def rehash(irc, source, args): """takes no arguments. Reloads the configuration file for PyLink, (dis)connecting added/removed networks. Note: plugins must be manually reloaded.""" permissions.checkPermissions(irc, source, ['core.rehash']) try: control._rehash() except Exception as e: # Something went wrong, abort. log.exception("Error REHASHing config: ") irc.reply("Error loading configuration file: %s: %s" % (type(e).__name__, e)) return else: irc.reply("Done.")
def account(irc, host, uid): """ $account exttarget handler. The following forms are supported, with groups separated by a literal colon. Account matching is case insensitive, while network name matching IS case sensitive. $account -> Returns True (a match) if the target is registered. $account:accountname -> Returns True if the target's account name matches the one given, and the target is connected to the local network.. $account:accountname:netname -> Returns True if both the target's account name and origin network name match the ones given. $account:*:netname -> Matches all logged in users on the given network. """ userobj = irc.users[uid] homenet = irc.name if hasattr(userobj, 'remote'): # User is a PyLink Relay pseudoclient. Use their real services account on their # origin network. homenet, realuid = userobj.remote log.debug( '(%s) exttargets.account: Changing UID of relay client %s to %s/%s', irc.name, uid, homenet, realuid) try: userobj = world.networkobjects[homenet].users[realuid] except KeyError: # User lookup failed. Bail and return False. log.exception('(%s) exttargets.account: KeyError finding %s/%s:', irc.name, homenet, realuid) return False slogin = irc.toLower(userobj.services_account) # Split the given exttarget host into parts, so we know how many to look for. groups = list(map(irc.toLower, host.split(':'))) log.debug('(%s) exttargets.account: groups to match: %s', irc.name, groups) if len(groups) == 1: # First scenario. Return True if user is logged in. return bool(slogin) elif len(groups) == 2: # Second scenario. Return True if the user's account matches the one given. return slogin == groups[1] and homenet == irc.name else: # Third or fourth scenario. If there are more than 3 groups, the rest are ignored. # In other words: Return True if the user is logged in, the query matches either '*' or the # user's login, and the user is connected on the network requested. return slogin and (groups[1] in ('*', slogin)) and (homenet == groups[2])
def handle_nick(self, numeric, command, args): """Handles NICK changes, and legacy NICK introductions from pre-4.0 servers.""" if self.mixed_link and len(args) > 2: # Handle legacy NICK introduction here. # I don't want to rewrite all the user introduction stuff, so I'll just reorder the arguments # so that handle_uid can handle this instead. # But since legacy nicks don't have any UIDs attached, we'll have to store the users # internally by their nicks. In other words, we need to convert from this: # <- NICK Global 3 1456843578 services novernet.com services.novernet.com 0 +ioS * :Global Noticer # & nick hopcount timestamp username hostname server service-identifier-token :realname # With NICKIP and VHP enabled: # <- NICK GL32 2 1470699865 gl localhost unreal32.midnight.vpn GL +iowx hidden-1C620195 AAAAAAAAAAAAAAAAAAAAAQ== :realname # to this: # <- :001 UID GL 0 1441306929 gl localhost 0018S7901 0 +iowx * hidden-1C620195 fwAAAQ== :realname log.debug('(%s) got legacy NICK args: %s', self.irc.name, ' '.join(args)) new_args = args[:] # Clone the old args list servername = new_args[5].lower( ) # Get the name of the users' server. # Fake a UID and put it where it belongs in the new-style UID command. fake_uid = '%s@%s' % (args[0], self.legacy_nickcount) self.legacy_nickcount += 1 new_args[5] = fake_uid # This adds a dummy cloaked host (equal the real host) to put the displayed host in the # right position. As long as the VHP capability is respected, this will propagate +x cloaked # hosts from UnrealIRCd 3.2 users. Otherwise, +x host cloaking won't work! new_args.insert(-2, args[4]) log.debug('(%s) translating legacy NICK args to: %s', self.irc.name, ' '.join(new_args)) return self.handle_uid(servername, 'UID_LEGACY', new_args) else: # Normal NICK change, just let ts6_common handle it. # :70MAAAAAA NICK GL-devel 1434744242 try: return super().handle_nick(numeric, command, args) except KeyError: log.exception( '(%s) Malformed NICK command received. If you are linking PyLink to a ' 'mixed UnrealIRCd 3.2/4.0 network, enable the mixed_link option in the ' 'server config and restart your PyLink daemon.', self.irc.name) self.irc.disconnect()
def _shutdown(irc=None): """Shuts down the Pylink daemon.""" for name, plugin in world.plugins.items(): # Before closing connections, tell all plugins to shutdown cleanly first. if hasattr(plugin, 'die'): log.debug( 'coremods.control: Running die() on plugin %s due to shutdown.', name) try: plugin.die(irc) except: # But don't allow it to crash the server. log.exception( 'coremods.control: Error occurred in die() of plugin %s, skipping...', name) for ircobj in world.networkobjects.copy().values(): # Disconnect all our networks. remove_network(ircobj)
def _check_connection(irc, args): ip = args['ip'] try: result = sshbl.scan(ip) except: log.exception("SSHBL scan errored:") return threshold = irc.get_service_option('sshbl', 'threshold', default=0) reason = irc.get_service_option('sshbl', 'reason', "Your host runs an SSH daemon commonly used by spammer IPs. Consider upgrading your machines " "or contacting network staff for an exemption.") if result: _, port, blacklisted, score = result if not blacklisted: return log.info("sshbl: caught IP %s:%s (%s/%s) with score %s", ip, port, irc.name, args['nick'], score) if args['uid'] in irc.users: irc.kill(irc.pseudoclient.uid, args['uid'], reason)
def _update_user_status(self, guild, uid, presence): """Handles a Discord presence update.""" pylink_netobj = self.protocol._children.get(guild.id) if pylink_netobj: try: u = pylink_netobj.users[uid] except KeyError: log.exception('(%s) _update_user_status: could not fetch user %s', self.protocol.name, uid) return # It seems that presence updates are not sent at all for offline users, so they # turn into an unset field in disco. I guess this makes sense for saving bandwidth? if presence: status = presence.status else: status = DiscordStatus.OFFLINE if status != DiscordStatus.ONLINE: awaymsg = self.status_mapping.get(status.value, 'Unknown Status') else: awaymsg = '' now_invisible = None if not pylink_netobj.join_offline_users: if status in (DiscordStatus.OFFLINE, DiscordStatus.INVISIBLE): # If we are hiding offline users, set a special flag for relay to quit the user. log.debug('(%s) Hiding user %s/%s from relay channels as they are offline', pylink_netobj.name, uid, pylink_netobj.get_friendly_name(uid)) now_invisible = True u._invisible = True elif (u.away in (self.status_mapping['INVISIBLE'], self.status_mapping['OFFLINE'])): # User was previously offline - burst them now. log.debug('(%s) Rejoining user %s/%s from as they are now online', pylink_netobj.name, uid, pylink_netobj.get_friendly_name(uid)) now_invisible = False u._invisible = False u.away = awaymsg pylink_netobj.call_hooks([uid, 'AWAY', {'text': awaymsg, 'now_invisible': now_invisible}])
def masskill(irc, source, args, use_regex=False): """<banmask / exttarget> [<kill/ban reason>] [--akill/ak] [--force-kb/-f] [--include-opers/-o] Kills all users matching the given PyLink banmask. The --akill option can also be given to convert kills to akills, which expire after 7 days. For relay users, attempts to kill are forwarded as a kickban to every channel where the calling user meets claim requirements to set a ban (i.e. this is true if you are opped, if your network is in claim list, etc.; see "help CLAIM" for more specific rules). This can also be extended to all shared channels the user is in using the --force-kb option (we hope this feature is only used for good). By default, this command will ignore opers. This behaviour can be suppressed using the --include-opers option. To properly kill abusers on another network, combine this command with the 'remote' command in the 'networks' plugin and adjust your banmasks accordingly.""" permissions.check_permissions(irc, source, ['opercmds.masskill']) args = masskill_parser.parse_args(args) if args.force_kb: permissions.check_permissions(irc, source, ['opercmds.masskill.force']) reason = ' '.join(args.reason) results = killed = 0 userlist_func = irc.match_all_re if use_regex else irc.match_all seen_users = set() for uid in userlist_func(args.banmask): userobj = irc.users[uid] if irc.is_oper(uid) and not args.include_opers: irc.reply('Skipping killing \x02%s\x02 because they are opered.' % userobj.nick) continue elif irc.get_service_bot(uid): irc.reply( 'Skipping killing \x02%s\x02 because it is a service client.' % userobj.nick) continue relay = world.plugins.get('relay') if relay and hasattr(userobj, 'remote'): # For relay users, forward kill attempts as kickban because we don't want networks k-lining each others' users. bans = [irc.make_channel_ban(uid)] for channel in userobj.channels.copy( ): # Look in which channels the user appears to be in locally if (args.force_kb or relay.check_claim(irc, channel, source)): irc.mode(irc.pseudoclient.uid, channel, bans) irc.kick(irc.pseudoclient.uid, channel, uid, reason) # XXX: code duplication with massban. try: irc.call_hooks([ irc.pseudoclient.uid, 'OPERCMDS_MASSKILL_BAN', { 'target': channel, 'modes': bans, 'parse_as': 'MODE' } ]) irc.call_hooks([ irc.pseudoclient.uid, 'OPERCMDS_MASSKILL_KICK', { 'channel': channel, 'target': uid, 'text': reason, 'parse_as': 'KICK' } ]) except: log.exception( '(%s) Failed to send process massban hook; some kickbans may have not ' 'been sent to plugins / relay networks!', irc.name) if uid not in seen_users: # Don't count users multiple times on different channels killed += 1 else: irc.reply( "Not kicking \x02%s\x02 from \x02%s\x02 because you don't have CLAIM access. If this is " "another network's channel, ask someone to op you or use the --force-kb option." % (userobj.nick, channel)) else: if args.akill: # TODO: configurable length via strings such as "2w3d5h6m3s" - though month and minute clash this way? if not (userobj.realhost or userobj.ip): irc.reply( "Skipping akill on %s because PyLink doesn't know the real host." % irc.get_hostmask(uid)) continue irc.set_server_ban(irc.pseudoclient.uid, 604800, host=userobj.realhost or userobj.ip or userobj.host, reason=reason) else: irc.kill(irc.pseudoclient.uid, uid, reason) try: irc.call_hooks([ irc.pseudoclient.uid, 'OPERCMDS_MASSKILL', { 'target': uid, 'parse_as': 'KILL', 'userdata': userobj, 'text': reason } ]) except: log.exception( '(%s) Failed to send process massban hook; some kickbans may have not ' 'been sent to plugins / relay networks!', irc.name) killed += 1 results += 1 seen_users.add(uid) else: log.info('(%s) Ran masskill%s for %s (%s/%s user(s) removed)', irc.name, 're' if use_regex else '', irc.get_hostmask(source), killed, results) irc.reply('Masskilled %s/%s users.' % (killed, results))
def massban(irc, source, args, use_regex=False): """<channel> <banmask / exttarget> [<kick reason>] [--quiet/-q] [--force/-f] [--include-opers/-o] Applies (i.e. kicks affected users) the given PyLink banmask on the specified channel. The --quiet option can also be given to mass-mute the given user on networks where this is supported (currently ts6, unreal, and inspircd). No kicks will be sent in this case. By default, this command will ignore opers. This behaviour can be suppressed using the --include-opers option. Relay CLAIM checking is used on Relay channels if it is enabled; use the --force option to override this if needed.""" permissions.check_permissions(irc, source, ['opercmds.massban']) args = massban_parser.parse_args(args) reason = ' '.join(args.reason) if args.force: permissions.check_permissions(irc, source, ['opercmds.massban.force']) if args.channel not in irc.channels: irc.error("Unknown channel %r." % args.channel) return elif 'relay' in world.plugins and (not world.plugins['relay'].check_claim( irc, args.channel, source)) and (not args.force): irc.error( "You do not have access to set bans in %s. Ask someone to op you or use the --force option." % args.channel) return results = 0 userlist_func = irc.match_all_re if use_regex else irc.match_all for uid in userlist_func(args.banmask, channel=args.channel): if irc.is_oper(uid) and not args.include_opers: irc.reply('Skipping banning \x02%s\x02 because they are opered.' % irc.users[uid].nick) continue elif irc.get_service_bot(uid): irc.reply( 'Skipping banning \x02%s\x02 because it is a service client.' % irc.users[uid].nick) continue # Remove the target's access before banning them. bans = [('-%s' % irc.cmodes[prefix], uid) for prefix in irc.channels[args.channel].get_prefix_modes(uid) if prefix in irc.cmodes] # Then, add the actual ban. bans += [ irc.make_channel_ban(uid, ban_type='quiet' if args.quiet else 'ban') ] irc.mode(irc.pseudoclient.uid, args.channel, bans) try: irc.call_hooks([ irc.pseudoclient.uid, 'OPERCMDS_MASSBAN', { 'target': args.channel, 'modes': bans, 'parse_as': 'MODE' } ]) except: log.exception( '(%s) Failed to send process massban hook; some bans may have not ' 'been sent to plugins / relay networks!', irc.name) if not args.quiet: irc.kick(irc.pseudoclient.uid, args.channel, uid, reason) # XXX: this better not be blocking... try: irc.call_hooks([ irc.pseudoclient.uid, 'OPERCMDS_MASSKICK', { 'channel': args.channel, 'target': uid, 'text': reason, 'parse_as': 'KICK' } ]) except: log.exception( '(%s) Failed to send process massban hook; some kicks may have not ' 'been sent to plugins / relay networks!', irc.name) results += 1 else: irc.reply('Banned %s users on %r.' % (results, args.channel)) log.info('(%s) Ran massban%s for %s on %s (%s user(s) removed)', irc.name, 're' if use_regex else '', irc.get_hostmask(source), args.channel, results)
def on_message(self, event: events.MessageCreate): message = event.message subserver = None target = None # If the bot is the one sending the message, don't do anything if message.author.id == self.me.id: return elif message.webhook_id: # Ignore messages from other webhooks for now... return text = message.content if not message.guild: # This is a DM. target = self.me.id if message.author.id not in self._dm_channels: self._dm_channels[message.author.id] = message.channel common_guilds = self._find_common_guilds(message.author.id) # If we are on multiple guilds, force the sender to choose a server to send from, since # every PyLink event needs to be associated with a subserver. if len(common_guilds) > 1: log.debug('discord: received DM from %s/%s - forcing guild name disambiguation', message.author.id, message.author) fail = False guild_id = None try: netname, text = text.split(' ', 1) except ValueError: fail = True else: if netname not in world.networkobjects: fail = True else: guild_id = world.networkobjects[netname].sid # Unrecognized guild or not one in common if guild_id not in self.protocol._children or \ guild_id not in common_guilds: fail = True if fail: # Build a list of common server *names* common_servers = [nwobj.name for gid, nwobj in self.protocol._children.items() if gid in common_guilds] try: message.channel.send_message( "To DM me, please prefix your messages with a guild name so I know where to " "process your messages: **<guild name> <command> <args>**\n" "Guilds we have in common: **%s**" % ', '.join(common_servers) ) except: log.exception("(%s) Could not send message to user %s", self.name, message.author) return else: log.debug('discord: using guild %s/%s for DM from %s/%s', world.networkobjects[netname].sid, netname, message.author.id, message.author) subserver = guild_id elif common_guilds: # We should be on at least one guild, right? subserver = common_guilds[0] log.debug('discord: using guild %s for DM from %s', subserver, message.author.id) else: log.debug('discord: ignoring message from user %s/%s since we are not in any common guilds', message.author.id, message.author) return else: subserver = message.guild.id target = message.channel.id def format_user_mentions(u): # Try to find the user's guild nick, falling back to the user if that fails if message.guild and u.id in message.guild.members: return '@' + message.guild.members[u.id].name else: return '@' + str(u) # Translate mention IDs to their names text = message.replace_mentions(user_replace=format_user_mentions, role_replace=lambda r: '@' + str(r), channel_replace=str) if not subserver: return pylink_netobj = self.protocol._children[subserver] author = message.author.id def _send(text): for line in text.split('\n'): # Relay multiline messages as such pylink_netobj.call_hooks([author, 'PRIVMSG', {'target': target, 'text': line}]) _send(text) # For attachments, just send the link for attachment in message.attachments.values(): _send(attachment.url)
def _main(): conf.load_conf(args.config) from pylinkirc.log import log from pylinkirc import classes, utils, coremods, selectdriver # Write and check for an existing PID file unless specifically told not to. if not args.no_pid: pidfile = '%s.pid' % conf.confname pid_exists = False pid = None if os.path.exists(pidfile): try: with open(pidfile) as f: pid = int(f.read()) except OSError: log.exception("Could not read PID file %s:", pidfile) else: pid_exists = True if psutil is not None and os.name == 'posix': # FIXME: Haven't tested this on other platforms, so not turning it on by default. try: proc = psutil.Process(pid) except psutil.NoSuchProcess: # Process doesn't exist! pid_exists = False log.info( "Ignoring stale PID %s from PID file %r: no such process exists.", pid, pidfile) else: # This PID got reused for something that isn't us? if not any('pylink' in arg.lower() for arg in proc.cmdline()): log.info( "Ignoring stale PID %s from PID file %r: process command line %r is not us", pid, pidfile, proc.cmdline()) pid_exists = False if pid and pid_exists: if args.rehash: os.kill(pid, signal.SIGUSR1) log.info('OK, rehashed PyLink instance %s (config %r)', pid, args.config) sys.exit() elif args.stop or args.restart: # Handle --stop and --restart options os.kill(pid, signal.SIGTERM) log.info( "Waiting for PyLink instance %s (config %r) to stop...", pid, args.config) while os.path.exists(pidfile): # XXX: this is ugly, but os.waitpid() only works on non-child processes on Windows time.sleep(0.2) log.info("Successfully killed PID %s for config %r.", pid, args.config) if args.stop: sys.exit() else: log.error("PID file %r exists; aborting!", pidfile) if psutil is None: log.error( "If PyLink didn't shut down cleanly last time it ran, or you're upgrading " "from PyLink < 1.1-dev, delete %r and start the server again.", pidfile) if os.name == 'posix': log.error( "Alternatively, you can install psutil for Python 3 (pip3 install psutil), " "which will allow this launcher to detect stale PID files and ignore them." ) sys.exit(1) elif args.stop or args.restart or args.rehash: # XXX: also repetitive # --stop and --restart should take care of stale PIDs. if pid: world._should_remove_pid = True log.error( 'Cannot stop/rehash PyLink: no process with PID %s exists.', pid) else: log.error( 'Cannot stop/rehash PyLink: PID file %r does not exist or cannot be read.', pidfile) sys.exit(1) world._should_remove_pid = True log.info('PyLink %s starting...', __version__) world.daemon = args.daemonize if args.daemonize: if args.no_pid: print( 'ERROR: Combining --no-pid and --daemonize is not supported.') sys.exit(1) elif os.name != 'posix': print( 'ERROR: Daemonization is not supported outside POSIX systems.') sys.exit(1) else: log.info('Forking into the background.') log.removeHandler(world.console_handler) # Adapted from https://stackoverflow.com/questions/5975124/ if os.fork(): # Fork and exit the parent process. os._exit(0) os.setsid() # Decouple from our lovely terminal if os.fork(): # Fork again to prevent starting zombie apocalypses. os._exit(0) else: # For foreground sessions, set the terminal window title. # See https://bbs.archlinux.org/viewtopic.php?id=85567 & # https://stackoverflow.com/questions/7387276/ if os.name == 'nt': import ctypes ctypes.windll.kernel32.SetConsoleTitleW("PyLink %s" % __version__) elif os.name == 'posix': sys.stdout.write("\x1b]2;PyLink %s\x07" % __version__) if not args.no_pid: # Write the PID file only after forking. with open(pidfile, 'w') as f: f.write(str(os.getpid())) # Load configured plugins to_load = conf.conf['plugins'] utils._reset_module_dirs() for plugin in to_load: try: world.plugins[plugin] = pl = utils._load_plugin(plugin) except Exception as e: log.exception('Failed to load plugin %r: %s: %s', plugin, type(e).__name__, str(e)) else: if hasattr(pl, 'main'): log.debug('Calling main() function of plugin %r', pl) pl.main() # Initialize all the networks one by one for network, sdata in conf.conf['servers'].items(): try: protoname = sdata['protocol'] except (KeyError, TypeError): log.error( "(%s) Configuration error: No protocol module specified, aborting.", network) else: # Fetch the correct protocol module. try: proto = utils._get_protocol_module(protoname) # Create and connect the network. world.networkobjects[network] = irc = proto.Class(network) log.debug('Connecting to network %r', network) irc.connect() except: log.exception( '(%s) Failed to connect to network %r, skipping it...', network, network) continue world.started.set() log.info("Loaded plugins: %s", ', '.join(sorted(world.plugins.keys()))) selectdriver.start()
def _message_builder(self): """ Discord message queue handler. Also supports virtual users via webhooks. """ def _send(sender, channel, pylink_target, message_parts): """ Wrapper to send a joined message. """ text = '\n'.join(message_parts) # Handle the case when the sender is not the PyLink client (sender != None) # For channels, use either virtual webhook users or CLIENTBOT_MESSAGE forwarding (relay_clientbot). if sender: user_fields = self._get_webhook_fields(sender) if channel.guild: # This message belongs to a channel netobj = self._children[channel.guild.id] # Note: skip webhook sending for messages that contain only spaces, as that fails with # 50006 "Cannot send an empty message" errors if netobj.serverdata.get('use_webhooks') and text.strip(): user_format = netobj.serverdata.get('webhook_user_format', "$nick @ $netname") tmpl = string.Template(user_format) webhook_fake_username = tmpl.safe_substitute(self._get_webhook_fields(sender)) try: webhook = self._get_webhook(channel) webhook.execute(content=text[:self.MAX_MESSAGE_SIZE], username=webhook_fake_username, avatar_url=user_fields['avatar']) except APIException as e: if e.code == 10015 and channel.id in self.webhooks: log.info("(%s) Invalidating webhook %s for channel %s due to Unknown Webhook error (10015)", self.name, self.webhooks[channel.id], channel) del self.webhooks[channel.id] elif e.code == 50013: # Prevent spamming errors: disable webhooks we don't have the right permissions log.warning("(%s) Disabling webhooks on guild %s/%s due to insufficient permissions (50013). Rehash to re-enable.", self.name, channel.guild.id, channel.guild.name) self.serverdata.update( {'guilds': {channel.guild.id: {'use_webhooks': False} } }) else: log.error("(%s) Caught API exception when sending webhook message to channel %s: %s/%s", self.name, channel, e.response.status_code, e.code) log.debug("(%s) APIException full traceback:", self.name, exc_info=True) except: log.exception("(%s) Failed to send webhook message to channel %s", self.name, channel) else: return for line in message_parts: netobj.call_hooks([sender.uid, 'CLIENTBOT_MESSAGE', {'target': pylink_target, 'text': line}]) return else: # This is a forwarded PM - prefix the message with its sender info. pm_format = self.serverdata.get('pm_format', "Message from $nick @ $netname: $text") user_fields['text'] = text text = string.Template(pm_format).safe_substitute(user_fields) try: channel.send_message(text[:self.MAX_MESSAGE_SIZE]) except Exception as e: log.exception("(%s) Could not send message to channel %s (pylink_target=%s)", self.name, channel, pylink_target) joined_messages = collections.defaultdict(collections.deque) while not self._aborted.is_set(): try: # message is an instance of QueuedMessage (defined in this file) message = self.message_queue.get(timeout=BATCH_DELAY) message.text = utils.strip_irc_formatting(message.text) if not self.serverdata.get('allow_mention_everyone', False): message.text = message.text.replace('@here', '@ here') message.text = message.text.replace('@everyone', '@ everyone') # First, buffer messages by channel joined_messages[message.channel].append(message) except queue.Empty: # Then process them together when we run out of things in the queue for channel, messages in joined_messages.items(): next_message = [] length = 0 current_sender = None # We group messages here to avoid being throttled as often. In short, we want to send a message when: # 1) The virtual sender (for webhook purposes) changes # 2) We reach the message limit for one batch (2000 chars) # 3) We run out of messages at the end while messages: message = messages.popleft() next_message.append(message.text) length += len(message.text) if message.sender != current_sender or length >= self.MAX_MESSAGE_SIZE: current_sender = message.sender _send(current_sender, channel, message.pylink_target, next_message) next_message.clear() length = 0 # The last batch if next_message: _send(current_sender, channel, message.pylink_target, next_message) joined_messages.clear() except Exception: log.exception("Exception in message queueing thread:")
def _send(sender, channel, pylink_target, message_parts): """ Wrapper to send a joined message. """ text = '\n'.join(message_parts) # Handle the case when the sender is not the PyLink client (sender != None) # For channels, use either virtual webhook users or CLIENTBOT_MESSAGE forwarding (relay_clientbot). if sender: user_fields = self._get_webhook_fields(sender) if channel.guild: # This message belongs to a channel netobj = self._children[channel.guild.id] # Note: skip webhook sending for messages that contain only spaces, as that fails with # 50006 "Cannot send an empty message" errors if netobj.serverdata.get('use_webhooks') and text.strip(): user_format = netobj.serverdata.get('webhook_user_format', "$nick @ $netname") tmpl = string.Template(user_format) webhook_fake_username = tmpl.safe_substitute(self._get_webhook_fields(sender)) try: webhook = self._get_webhook(channel) webhook.execute(content=text[:self.MAX_MESSAGE_SIZE], username=webhook_fake_username, avatar_url=user_fields['avatar']) except APIException as e: if e.code == 10015 and channel.id in self.webhooks: log.info("(%s) Invalidating webhook %s for channel %s due to Unknown Webhook error (10015)", self.name, self.webhooks[channel.id], channel) del self.webhooks[channel.id] elif e.code == 50013: # Prevent spamming errors: disable webhooks we don't have the right permissions log.warning("(%s) Disabling webhooks on guild %s/%s due to insufficient permissions (50013). Rehash to re-enable.", self.name, channel.guild.id, channel.guild.name) self.serverdata.update( {'guilds': {channel.guild.id: {'use_webhooks': False} } }) else: log.error("(%s) Caught API exception when sending webhook message to channel %s: %s/%s", self.name, channel, e.response.status_code, e.code) log.debug("(%s) APIException full traceback:", self.name, exc_info=True) except: log.exception("(%s) Failed to send webhook message to channel %s", self.name, channel) else: return for line in message_parts: netobj.call_hooks([sender.uid, 'CLIENTBOT_MESSAGE', {'target': pylink_target, 'text': line}]) return else: # This is a forwarded PM - prefix the message with its sender info. pm_format = self.serverdata.get('pm_format', "Message from $nick @ $netname: $text") user_fields['text'] = text text = string.Template(pm_format).safe_substitute(user_fields) try: channel.send_message(text[:self.MAX_MESSAGE_SIZE]) except Exception as e: log.exception("(%s) Could not send message to channel %s (pylink_target=%s)", self.name, channel, pylink_target)
def unload(irc, source, args): """<plugin name>. Unloads a currently loaded plugin.""" permissions.checkPermissions(irc, source, ['core.unload', 'core.reload']) try: name = args[0] except IndexError: irc.reply("Error: Not enough arguments. Needs 1: plugin name.") return # Since we're using absolute imports in 0.9.x+, the module name differs from the actual plugin # name. modulename = utils.PLUGIN_PREFIX + name if name in world.plugins: log.info('(%s) Unloading plugin %r for %s', irc.name, name, irc.getHostmask(source)) pl = world.plugins[name] log.debug('sys.getrefcount of plugin %s is %s', pl, sys.getrefcount(pl)) # Remove any command functions defined by the plugin. for cmdname, cmdfuncs in world.services['pylink'].commands.copy().items(): log.debug('cmdname=%s, cmdfuncs=%s', cmdname, cmdfuncs) for cmdfunc in cmdfuncs: log.debug('__module__ of cmdfunc %s is %s', cmdfunc, cmdfunc.__module__) if cmdfunc.__module__ == modulename: log.debug("Removing %s from world.services['pylink'].commands[%s]", cmdfunc, cmdname) world.services['pylink'].commands[cmdname].remove(cmdfunc) # If the cmdfunc list is empty, remove it. if not cmdfuncs: log.debug("Removing world.services['pylink'].commands[%s] (it's empty now)", cmdname) del world.services['pylink'].commands[cmdname] # Remove any command hooks set by the plugin. for hookname, hookfuncs in world.hooks.copy().items(): for hookfunc in hookfuncs: if hookfunc.__module__ == modulename: world.hooks[hookname].remove(hookfunc) # If the hookfuncs list is empty, remove it. if not hookfuncs: del world.hooks[hookname] # Call the die() function in the plugin, if present. if hasattr(pl, 'die'): try: pl.die(irc=irc) except: # But don't allow it to crash the server. log.exception('(%s) Error occurred in die() of plugin %s, skipping...', irc.name, pl) # Delete it from memory (hopefully). del world.plugins[name] for n in (name, modulename): if n in sys.modules: del sys.modules[n] if n in globals(): del globals()[n] # Garbage collect. gc.collect() irc.reply("Unloaded plugin %r." % name) return True # We succeeded, make it clear (this status is used by reload() below) else: irc.reply("Unknown plugin %r." % name)
def main(): import argparse parser = argparse.ArgumentParser(description='Starts an instance of PyLink IRC Services.') parser.add_argument('config', help='specifies the path to the config file (defaults to pylink.yml)', nargs='?', default='pylink.yml') parser.add_argument("-v", "--version", help="displays the program version and exits", action='store_true') parser.add_argument("-c", "--check-pid", help="no-op; kept for compatibility with PyLink <= 1.2.x", action='store_true') parser.add_argument("-n", "--no-pid", help="skips generating and checking PID files", action='store_true') parser.add_argument("-r", "--restart", help="restarts the PyLink instance with the given config file", action='store_true') parser.add_argument("-s", "--stop", help="stops the PyLink instance with the given config file", action='store_true') parser.add_argument("-R", "--rehash", help="rehashes the PyLink instance with the given config file", action='store_true') parser.add_argument("-d", "--daemonize", help="[experimental] daemonizes the PyLink instance on POSIX systems", action='store_true') args = parser.parse_args() if args.version: # Display version and exit print('PyLink %s (in VCS: %s)' % (__version__, real_version)) sys.exit() # XXX: repetitive elif args.no_pid and (args.restart or args.stop or args.rehash): print('ERROR: --no-pid cannot be combined with --restart or --stop') sys.exit(1) elif args.rehash and os.name != 'posix': print('ERROR: Rehashing via the command line is not supported outside Unix.') sys.exit(1) # FIXME: we can't pass logging on to conf until we set up the config... conf.loadConf(args.config) from pylinkirc.log import log from pylinkirc import classes, utils, coremods # Write and check for an existing PID file unless specifically told not to. if not args.no_pid: pidfile = '%s.pid' % conf.confname has_pid = False pid = None if os.path.exists(pidfile): has_pid = True if psutil is not None and os.name == 'posix': # FIXME: Haven't tested this on other platforms, so not turning it on by default. with open(pidfile) as f: try: pid = int(f.read()) proc = psutil.Process(pid) except psutil.NoSuchProcess: # Process doesn't exist! has_pid = False log.info("Ignoring stale PID %s from PID file %r: no such process exists.", pid, pidfile) else: # This PID got reused for something that isn't us? if not any('pylink' in arg.lower() for arg in proc.cmdline()): log.info("Ignoring stale PID %s from PID file %r: process command line %r is not us", pid, pidfile, proc.cmdline()) has_pid = False if has_pid: if args.rehash: os.kill(pid, signal.SIGUSR1) log.info('OK, rehashed PyLink instance %s (config %r)', pid, args.config) sys.exit() elif args.stop or args.restart: # Handle --stop and --restart options os.kill(pid, signal.SIGTERM) log.info("Waiting for PyLink instance %s (config %r) to stop...", pid, args.config) while os.path.exists(pidfile): # XXX: this is ugly, but os.waitpid() only works on non-child processes on Windows time.sleep(0.2) log.info("Successfully killed PID %s for config %r.", pid, args.config) if args.stop: sys.exit() else: log.error("PID file %r exists; aborting!", pidfile) if psutil is None: log.error("If PyLink didn't shut down cleanly last time it ran, or you're upgrading " "from PyLink < 1.1-dev, delete %r and start the server again.", pidfile) if os.name == 'posix': log.error("Alternatively, you can install psutil for Python 3 (pip3 install psutil), " "which will allow this launcher to detect stale PID files and ignore them.") sys.exit(1) elif args.stop or args.restart or args.rehash: # XXX: also repetitive # --stop and --restart should take care of stale PIDs. if pid: world._should_remove_pid = True log.error('Cannot stop/rehash PyLink: no process with PID %s exists.', pid) else: log.error('Cannot stop/rehash PyLink: PID file %r does not exist.', pidfile) sys.exit(1) world._should_remove_pid = True log.info('PyLink %s starting...', __version__) world.daemon = args.daemonize if args.daemonize: if args.no_pid: print('ERROR: Combining --no-pid and --daemonize is not supported.') sys.exit(1) elif os.name != 'posix': print('ERROR: Daemonization is not supported outside POSIX systems.') sys.exit(1) else: log.info('Forking into the background.') log.removeHandler(world.console_handler) # Adapted from https://stackoverflow.com/questions/5975124/ if os.fork(): # Fork and exit the parent process. os._exit(0) os.setsid() # Decouple from our lovely terminal if os.fork(): # Fork again to prevent starting zombie apocalypses. os._exit(0) else: # For foreground sessions, set the terminal window title. # See https://bbs.archlinux.org/viewtopic.php?id=85567 & # https://stackoverflow.com/questions/7387276/ if os.name == 'nt': import ctypes ctypes.windll.kernel32.SetConsoleTitleW("PyLink %s" % __version__) elif os.name == 'posix': sys.stdout.write("\x1b]2;PyLink %s\x07" % __version__) # Write the PID file only after forking. with open(pidfile, 'w') as f: f.write(str(os.getpid())) # Load configured plugins to_load = conf.conf['plugins'] utils.resetModuleDirs() for plugin in to_load: try: world.plugins[plugin] = pl = utils.loadPlugin(plugin) except Exception as e: log.exception('Failed to load plugin %r: %s: %s', plugin, type(e).__name__, str(e)) else: if hasattr(pl, 'main'): log.debug('Calling main() function of plugin %r', pl) pl.main() # Initialize all the networks one by one for network, sdata in conf.conf['servers'].items(): try: protoname = sdata['protocol'] except (KeyError, TypeError): log.error("(%s) Configuration error: No protocol module specified, aborting.", network) else: # Fetch the correct protocol module. try: proto = utils.getProtocolModule(protoname) # Create and connect the network. log.debug('Connecting to network %r', network) world.networkobjects[network] = classes.Irc(network, proto, conf.conf) except: log.exception('(%s) Failed to connect to network %r, skipping it...', network, network) continue world.started.set() log.info("Loaded plugins: %s", ', '.join(sorted(world.plugins.keys()))) from pylinkirc import coremods coremods.permissions.resetPermissions() # Future note: this is moved to run on import in 2.0