def join(self, client, channel): """Joins a PyLink client to a channel.""" # InspIRCd doesn't distinguish between burst joins and regular joins, # so what we're actually doing here is sending FJOIN from the server, # on behalf of the clients that are joining. channel = self.irc.toLower(channel) server = self.irc.isInternalClient(client) if not server: log.error( '(%s) Error trying to join %r to %r (no such client exists)', self.irc.name, client, channel) raise LookupError('No such PyLink client exists.') # Strip out list-modes, they shouldn't be ever sent in FJOIN. modes = [ m for m in self.irc.channels[channel].modes if m[0] not in self.irc.cmodes['*A'] ] self._send( server, "FJOIN {channel} {ts} {modes} :,{uid}".format( ts=self.irc.channels[channel].ts, uid=client, channel=channel, modes=self.irc.joinModes(modes))) self.irc.channels[channel].users.add(client) self.irc.users[client].channels.add(channel)
def message(self, source, target, text, notice=False): """Sends messages to the target.""" if target in self.virtual_parent.client.state.users: try: discord_target = self.bot_plugin._dm_channels[target] log.debug('(%s) Found DM channel for %s: %s', self.name, target, discord_target) except KeyError: u = self.virtual_parent.client.state.users[target] discord_target = self.bot_plugin._dm_channels[target] = u.open_dm() log.debug('(%s) Creating new DM channel for %s: %s', self.name, target, discord_target) elif target in self.channels: discord_target = self.channels[target].discord_channel else: log.error('(%s) Could not find message target for %s', self.name, target) return if text.startswith('\x01ACTION '): # Mangle IRC CTCP actions # TODO: possibly allow toggling between IRC style actions (* nick abcd) and Discord style (italicize the text) text = '\x1d%s' % text[8:-1] elif text.startswith('\x01'): return # Drop other CTCPs sourceobj = None if self.pseudoclient and self.pseudoclient.uid != source: sourceobj = self.users.get(source) message_data = QueuedMessage(discord_target, target, text, sender=sourceobj, is_notice=notice) self.virtual_parent.message_queue.put_nowait(message_data)
def on_member_add(self, event: events.GuildMemberAdd, *args, **kwargs): log.info('(%s) got GuildMemberAdd event for guild %s/%s: %s', self.protocol.name, event.guild.id, event.guild.name, event.member) try: pylink_netobj = self.protocol._children[event.guild.id] except KeyError: log.error("(%s) Could not burst user %s as the parent network object does not exist", self.protocol.name, event.member) return self._burst_new_client(event.guild, event.member, pylink_netobj)
def on_server_update(self, event: events.GuildUpdate, *args, **kwargs): log.info('(%s) got GuildUpdate event for guild %s/%s', self.protocol.name, event.guild.id, event.guild.name) try: pylink_netobj = self.protocol._children[event.guild.id] except KeyError: log.error("(%s) Could not update guild %s/%s as the corresponding network object does not exist", self.protocol.name, event.guild.id, event.guild.name) return else: pylink_netobj._guild_name = event.guild.name
def on_member_chunk(self, event: events.GuildMembersChunk, *args, **kwargs): log.debug('(%s) got GuildMembersChunk event for guild %s/%s: %s', self.protocol.name, event.guild.id, event.guild.name, event.members) try: pylink_netobj = self.protocol._children[event.guild.id] except KeyError: log.error("(%s) Could not burst users %s as the parent network object does not exist", self.protocol.name, event.members) return for member in event.members: self._burst_new_client(event.guild, member, pylink_netobj)
def join(self, client, channel): """Joins a PyLink client to a channel.""" # JOIN: # parameters: channelTS, channel, '+' (a plus sign) if not self.is_internal_client(client): log.error('(%s) Error trying to join %r to %r (no such client exists)', self.name, client, channel) raise LookupError('No such PyLink client exists.') self._send_with_prefix(client, "JOIN {ts} {channel} +".format(ts=self._channels[channel].ts, channel=channel)) self._channels[channel].users.add(client) self.users[client].channels.add(channel)
def handle_kill(irc, numeric, command, args): """ Tracks kills against PyLink clients. If too many are received, automatically disconnects from the network. """ if killcache.setdefault(irc.name, 1) >= 5: log.error('(%s) servprotect: Too many kills received, aborting!', irc.name) irc.disconnect() log.debug('(%s) servprotect: Incrementing killcache by 1', irc.name) killcache[irc.name] += 1
def handle_save(irc, numeric, command, args): """ Tracks SAVEs (nick collision) against PyLink clients. If too many are received, automatically disconnects from the network. """ if savecache.setdefault(irc.name, 0) >= 5: log.error('(%s) servprotect: Too many nick collisions, aborting!', irc.name) irc.disconnect() log.debug('(%s) servprotect: Incrementing savecache by 1', irc.name) savecache[irc.name] += 1
def part(self, client, channel, reason=None): """Sends a part from a PyLink client.""" if not self.is_internal_client(client): log.error( '(%s) Error trying to part %r from %r (no such client exists)', self.name, client, channel) raise LookupError('No such PyLink client exists.') msg = "PART %s" % channel if reason: msg += " :%s" % reason self._send_with_prefix(client, msg) self.handle_part(client, 'PART', [channel])
def handle_kill(irc, numeric, command, args): """ Tracks kills against PyLink clients. If too many are received, automatically disconnects from the network. """ if (args['userdata'] and irc.isInternalServer(args['userdata'].server)) or irc.isInternalClient(args['target']): if killcache.setdefault(irc.name, 1) >= length: log.error('(%s) servprotect: Too many kills received, aborting!', irc.name) irc.disconnect() log.debug('(%s) servprotect: Incrementing killcache by 1', irc.name) killcache[irc.name] += 1
def _process_hooks(self): """Loop to process incoming hook data.""" while not self._aborted.is_set(): data = self._hooks_queue.get() if data is None: log.debug('(%s) Stopping queue thread due to getting None as item', self.name) break elif self not in world.networkobjects.values(): log.debug('(%s) Stopping stale queue thread; no longer matches world.networkobjects', self.name) break subserver, data = data if subserver not in world.networkobjects: log.error('(%s) Not queuing hook for subserver %r no longer in networks list.', self.name, subserver) elif subserver in self._children: self._children[subserver].call_hooks(data)
def setnextid(irc, source, args): """<channel> <ID> Sets the next quote ID in <channel> to <ID>. This command should only be used if quotes were deleted after a certain number, as overwriting quotes is not supported.""" permissions.checkPermissions(irc, source, ["quotes.admin"]) options = sni_parser.parse_args(args) if not options.int: log.error("Did not receive a ID to set.") else: ins = engine.execute( dbcd.update().where(dbcd.c.channel == options.channel).values( next_id=options.int)).rowcount if ins > 0: reply( irc, "Set {}'s next id to {}".format(options.channel, options.int)) else: error(irc, "An error occured.")
def qadd(irc, source, args): """<quote text> Adds a quote to the bots database.""" options = qadd_parser.parse_args(args) ourquote = " ".join(options.quote) if irc.called_in == source: if irc.checkAuthenticated(source, allowAuthed=True): reply("Please see 'addquote' for arbitrary adding.", notice=True, private=True) else: error( irc, "quotes must be sent in using a channel, not a 1to1 message.") s = select([dbcd.c.next_id]).where(dbcd.c.channel == irc.called_in) try: result = engine.execute(s).fetchone()[0] except exc.OperationalError as e: log.error("OperationalError Occured:") log.error("Exception Details: %s" % e) error(irc, "Stale Database Connection, Please try again.") error( irc, "If you've already tried once or twice, please forward this error to an admin, who may, or may not already know." ) return next_id = result channel = irc.called_in ins = dbq.insert().values(id=next_id, channel=irc.called_in, quote=ourquote, added_by=irc.getHostmask(source)) engine.execute(ins) reply(irc, "Done. Quote #%s added." % next_id) new_nextid = int(next_id) + 1 updated = engine.execute(dbcd.update().where( dbcd.c.channel == channel).values(next_id=new_nextid))
def message(self, source, target, text, notice=False): """Sends messages to the target.""" if target in self.users: discord_target = self.users[target].discord_user.user.open_dm() elif target in self.channels: discord_target = self.channels[target].discord_channel else: log.error('(%s) Could not find message target for %s', self.name, target) return message_data = {'target': discord_target, 'sender': source} if self.pseudoclient and self.pseudoclient.uid == source: message_data['text'] = I2DFormatter().format(text) self.virtual_parent.message_queue.put_nowait(message_data) return self.call_hooks([ source, 'CLIENTBOT_MESSAGE', { 'target': target, 'is_notice': notice, 'text': text } ])
def on_member_update(self, event: GuildMemberUpdate, *args, **kwargs): log.info('(%s) got GuildMemberUpdate event for guild %s/%s: %s', self.protocol.name, event.guild.id, event.guild.name, event.member) try: pylink_netobj = self.protocol._children[event.guild.id] except KeyError: log.error( "(%s) Could not update user %s as the parent network object does not exist", self.protocol.name, event.member) return uid = event.member.id pylink_user = pylink_netobj.users.get(uid) if not pylink_user: self._burst_new_client(event.guild, event.member, pylink_netobj) return # Handle NICK changes oldnick = pylink_user.nick if pylink_user.nick != event.member.name: pylink_user.nick = event.member.name pylink_netobj.call_hooks([ uid, 'NICK', { 'newnick': event.member.name, 'oldnick': oldnick } ]) # Relay permission changes as modes for channel in event.guild.channels.values(): if channel.type == ChannelType.GUILD_TEXT: self._update_channel_presence(event.guild, channel, event.member, relay_modes=True)
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 _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 _punish(irc, target, channel, punishment, reason): """Punishes the target user. This function returns True if the user was successfully punished.""" if target not in irc.users: log.warning("(%s) antispam: got target %r that isn't a user?", irc.name, target) return False elif irc.is_oper(target): log.debug("(%s) antispam: refusing to punish oper %s/%s", irc.name, target, irc.get_friendly_name(target)) return False target_nick = irc.get_friendly_name(target) if channel: c = irc.channels[channel] exempt_level = irc.get_service_option('antispam', 'exempt_level', DEFAULT_EXEMPT_OPTION).lower() if exempt_level not in EXEMPT_OPTIONS: log.error('(%s) Antispam exempt %r is not a valid setting, ' 'falling back to defaults; accepted settings include: %s', irc.name, exempt_level, ', '.join(EXEMPT_OPTIONS)) exempt_level = DEFAULT_EXEMPT_OPTION if exempt_level == 'voice' and c.is_voice_plus(target): log.debug("(%s) antispam: refusing to punish voiced and above %s/%s", irc.name, target, target_nick) return False elif exempt_level == 'halfop' and c.is_halfop_plus(target): log.debug("(%s) antispam: refusing to punish halfop and above %s/%s", irc.name, target, target_nick) return False elif exempt_level == 'op' and c.is_op_plus(target): log.debug("(%s) antispam: refusing to punish op and above %s/%s", irc.name, target, target_nick) return False my_uid = sbot.uids.get(irc.name) # XXX workaround for single-bot protocols like Clientbot if irc.pseudoclient and not irc.has_cap('can-spawn-clients'): my_uid = irc.pseudoclient.uid bans = set() log.debug('(%s) antispam: got %r as punishment for %s/%s', irc.name, punishment, target, irc.get_friendly_name(target)) def _ban(): bans.add(irc.make_channel_ban(target)) def _quiet(): bans.add(irc.make_channel_ban(target, ban_type='quiet')) def _kick(): irc.kick(my_uid, channel, target, reason) irc.call_hooks([my_uid, 'ANTISPAM_KICK', {'channel': channel, 'text': reason, 'target': target, 'parse_as': 'KICK'}]) def _kill(): if target not in irc.users: log.debug('(%s) antispam: not killing %s/%s; they already left', irc.name, target, irc.get_friendly_name(target)) return userdata = irc.users[target] irc.kill(my_uid, target, reason) irc.call_hooks([my_uid, 'ANTISPAM_KILL', {'target': target, 'text': reason, 'userdata': userdata, 'parse_as': 'KILL'}]) kill = False successful_punishments = 0 for action in set(punishment.split('+')): if action not in PUNISH_OPTIONS: log.error('(%s) Antispam punishment %r is not a valid setting; ' 'accepted settings include: %s OR any combination of ' 'these joined together with a "+".', irc.name, punishment, ', '.join(PUNISH_OPTIONS)) return elif action == 'block': # We only need to increment this for this function to return True successful_punishments += 1 elif action == 'kill': kill = True # Delay kills so that the user data doesn't disappear. # XXX factorize these blocks elif action == 'kick' and channel: try: _kick() except NotImplementedError: log.warning("(%s) antispam: Kicks are not supported on this network, skipping; " "target was %s/%s", irc.name, target_nick, channel) else: successful_punishments += 1 elif action == 'ban' and channel: try: _ban() except (ValueError, NotImplementedError): log.warning("(%s) antispam: Bans are not supported on this network, skipping; " "target was %s/%s", irc.name, target_nick, channel) else: successful_punishments += 1 elif action == 'quiet' and channel: try: _quiet() except (ValueError, NotImplementedError): log.warning("(%s) antispam: Quiet is not supported on this network, skipping; " "target was %s/%s", irc.name, target_nick, channel) else: successful_punishments += 1 if bans: # Set all bans at once to prevent spam irc.mode(my_uid, channel, bans) irc.call_hooks([my_uid, 'ANTISPAM_BAN', {'target': channel, 'modes': bans, 'parse_as': 'MODE'}]) if kill: try: _kill() except NotImplementedError: log.warning("(%s) antispam: Kills are not supported on this network, skipping; " "target was %s/%s", irc.name, target_nick, channel) else: successful_punishments += 1 if not successful_punishments: log.warning('(%s) antispam: Failed to punish %s with %r, target was %s', irc.name, target_nick, punishment, channel or 'a PM') return bool(successful_punishments)
def handle_join(irc, source, command, args): """ killonjoin JOIN listener. """ # Ignore our own clients and other Ulines if irc.is_privileged_service(source) or irc.is_internal_server(source): return badchans = irc.serverdata.get('badchans') if not badchans: return elif not isinstance(badchans, list): log.error("(%s) badchans: the 'badchans' option must be a list of strings, not a %s", irc.name, type(badchans)) return use_kline = irc.get_service_option('badchans', 'use_kline', False) kline_duration = irc.get_service_option('badchans', 'kline_duration', DEFAULT_BAN_DURATION) try: kline_duration = utils.parse_duration(kline_duration) except ValueError: log.warning('(%s) badchans: invalid kline duration %s', irc.name, kline_duration, exc_info=True) kline_duration = DEFAULT_BAN_DURATION channel = args['channel'] for badchan in badchans: if irc.match_text(badchan, channel): asm_uid = None # Try to kill from the antispam service if available if 'antispam' in world.services: asm_uid = world.services['antispam'].uids.get(irc.name) for user in args['users']: try: ip = irc.users[user].ip ipa = ipaddress.ip_address(ip) except (KeyError, ValueError): log.error("(%s) badchans: could not obtain IP of user %s", irc.name, user) continue nuh = irc.get_hostmask(user) exempt_hosts = set(conf.conf.get('badchans', {}).get('exempt_hosts', [])) | \ set(irc.serverdata.get('badchans_exempt_hosts', [])) if not ipa.is_global: irc.msg(user, "Warning: %s kills unopered users, but non-public addresses are exempt." % channel, notice=True, source=asm_uid or irc.pseudoclient.uid) continue if exempt_hosts: skip = False for glob in exempt_hosts: if irc.match_host(glob, user): log.info("(%s) badchans: ignoring exempt user %s on %s (%s)", irc.name, nuh, channel, ip) irc.msg(user, "Warning: %s kills unopered users, but your host is exempt." % channel, notice=True, source=asm_uid or irc.pseudoclient.uid) skip = True break if skip: continue if irc.is_oper(user): irc.msg(user, "Warning: %s kills unopered users!" % channel, notice=True, source=asm_uid or irc.pseudoclient.uid) else: log.info('(%s) badchans: punishing user %s (server: %s) for joining channel %s', irc.name, nuh, irc.get_friendly_name(irc.get_server(user)), channel) if use_kline: irc.set_server_ban(asm_uid or irc.sid, kline_duration, host=ip, reason=REASON) else: irc.kill(asm_uid or irc.sid, user, REASON) if ip not in seen_ips: dronebl_key = irc.get_service_option('badchans', 'dronebl_key') if dronebl_key: log.info('(%s) badchans: submitting IP %s (%s) to DroneBL', irc.name, ip, nuh) pool.submit(_submit_dronebl, irc, ip, dronebl_key, nuh) dnsblim_key = irc.get_service_option('badchans', 'dnsblim_key') if dnsblim_key: log.info('(%s) badchans: submitting IP %s (%s) to DNSBL.im', irc.name, ip, nuh) pool.submit(_submit_dnsblim, irc, ip, dnsblim_key, nuh) seen_ips[ip] = time.time() else: log.debug('(%s) badchans: ignoring already submitted IP %s', irc.name, ip)
def _update_channel_presence(self, guild, channel, member=None, *, relay_modes=False): """ Updates channel presence & IRC modes for the given member, or all guild members if not given. """ if channel.type == ChannelType.GUILD_CATEGORY: # XXX: there doesn't seem to be an easier way to get this. Fortunately, there usually # aren't too many channels in one guild... for subchannel in guild.channels.values(): if subchannel.parent_id == channel.id: log.debug( '(%s) _update_channel_presence: checking channel %s/%s in category %s/%s', self.protocol.name, subchannel.id, subchannel, channel.id, channel) self._update_channel_presence(guild, subchannel, member=member, relay_modes=relay_modes) return elif channel.type != ChannelType.GUILD_TEXT: log.debug( '(%s) _update_channel_presence: ignoring non-text channel %s/%s', self.protocol.name, channel.id, channel) return modes = [] users_joined = [] try: pylink_netobj = self.protocol._children[guild.id] except KeyError: log.error( "(%s) Could not update channel %s(%s)/%s as the parent network object does not exist", self.protocol.name, guild.id, guild.name, str(channel)) return # Create a new channel if not present try: pylink_channel = pylink_netobj.channels[channel.id] pylink_channel.name = str(channel) except KeyError: pylink_channel = pylink_netobj.channels[channel.id] = Channel( self, name=str(channel)) pylink_channel.discord_id = channel.id pylink_channel.discord_channel = channel if member is None: members = guild.members.values() else: members = [member] for member in members: uid = member.id try: pylink_user = pylink_netobj.users[uid] except KeyError: log.error( "(%s) Could not update user %s(%s)/%s as the user object does not exist", self.protocol.name, guild.id, guild.name, uid) continue channel_permissions = channel.get_permissions(member) has_perm = channel_permissions.can(Permissions.read_messages) log.debug( 'discord: checking if member %s/%s has permission read_messages on %s/%s: %s', member.id, member, channel.id, channel, has_perm) #log.debug('discord: channel permissions are %s', str(channel_permissions.to_dict())) if has_perm: if uid not in pylink_channel.users: log.debug('discord: joining member %s to %s/%s', member, channel.id, channel) pylink_user.channels.add(channel.id) pylink_channel.users.add(uid) users_joined.append(uid) for irc_mode, discord_permission in self.irc_discord_perm_mapping.items( ): prefixlist = pylink_channel.prefixmodes[ irc_mode] # channel.prefixmodes['op'] etc. # If the user now has the permission but not the associated mode, add it to the mode list has_op_perm = channel_permissions.can(discord_permission) if has_op_perm: modes.append( ('+%s' % pylink_netobj.cmodes[irc_mode], uid)) if irc_mode == 'op': # Stop adding lesser modes once we find an op; this reflects IRC services # which tend to set +ao, +o, ... instead of +ohv, +aohv break elif (not has_op_perm) and uid in prefixlist: modes.append( ('-%s' % pylink_netobj.cmodes[irc_mode], uid)) elif uid in pylink_channel.users and not has_perm: log.debug('discord: parting member %s from %s/%s', member, channel.id, channel) pylink_user.channels.discard(channel.id) pylink_channel.remove_user(uid) # We send KICK from a server to prevent triggering antiflood mechanisms... pylink_netobj.call_hooks([ guild.id, 'KICK', { 'channel': channel.id, 'target': uid, 'text': "User removed from channel" } ]) # Optionally, burst the server owner as IRC owner if self.protocol.serverdata.get('show_owner_status', True) and uid == guild.owner_id: modes.append(('+q', uid)) if modes: pylink_netobj.apply_modes(channel.id, modes) log.debug('(%s) Relaying permission changes on %s/%s as modes: %s', self.protocol.name, member.name, channel, pylink_netobj.join_modes(modes)) if relay_modes: pylink_netobj.call_hooks( [guild.id, 'MODE', { 'target': channel.id, 'modes': modes }]) if users_joined: pylink_netobj.call_hooks([ guild.id, 'JOIN', { 'channel': channel.id, 'users': users_joined, 'modes': [] } ])
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
def _update_channel_presence(self, guild, channel, member=None, *, relay_modes=False): """ Updates channel presence & IRC modes for the given member, or all guild members if not given. """ if channel.type == ChannelType.GUILD_CATEGORY: # XXX: there doesn't seem to be an easier way to get this. Fortunately, there usually # aren't too many channels in one guild... for subchannel in guild.channels.values(): if subchannel.parent_id == channel.id: log.debug('(%s) _update_channel_presence: checking channel %s/%s in category %s/%s', self.protocol.name, subchannel.id, subchannel, channel.id, channel) self._update_channel_presence(guild, subchannel, member=member, relay_modes=relay_modes) return elif channel.type != ChannelType.GUILD_TEXT: log.debug('(%s) _update_channel_presence: ignoring non-text channel %s/%s', self.protocol.name, channel.id, channel) return modes = [] users_joined = [] try: pylink_netobj = self.protocol._children[guild.id] except KeyError: log.error("(%s) Could not update channel %s(%s)/%s as the parent network object does not exist", self.protocol.name, guild.id, guild.name, str(channel)) return # Create a new channel if not present try: pylink_channel = pylink_netobj.channels[channel.id] pylink_channel.name = str(channel) log.debug("(%s) Retrieved channel %s for channel ID %s", self.name, pylink_channel, channel.id) except KeyError: pylink_channel = pylink_netobj.channels[channel.id] = Channel(self, name=str(channel)) log.debug("(%s) Created new channel %s for channel ID %s", self.name, pylink_channel, channel.id) pylink_channel.discord_id = channel.id pylink_channel.discord_channel = channel if member is None: members = guild.members.values() else: members = [member] for member in members: uid = member.id try: pylink_user = pylink_netobj.users[uid] except KeyError: log.error("(%s) Could not update user %s(%s)/%s as the user object does not exist", self.protocol.name, guild.id, guild.name, uid) return channel_permissions = channel.get_permissions(member) has_perm = channel_permissions.can(Permissions.read_messages) log.debug('discord: checking if member %s/%s has permission read_messages on %s/%s: %s', member.id, member, channel.id, channel, has_perm) #log.debug('discord: channel permissions are %s', str(channel_permissions.to_dict())) if has_perm: if uid not in pylink_channel.users: log.debug('discord: adding member %s to %s/%s', member, channel.id, channel) pylink_user.channels.add(channel.id) pylink_channel.users.add(uid) # Hide offline users if join_offline_users is enabled if pylink_netobj.join_offline_users or (member.user.presence and \ member.user.presence.status not in (DiscordStatus.OFFLINE, DiscordStatus.INVISIBLE)): users_joined.append(uid) # Map Discord role IDs to IRC modes # e.g. 1234567890: 'op' # 2345678901: 'voice' role_map = pylink_netobj.serverdata.get('role_mode_map') or {} # Track all modes the user is allowed to have, since multiple roles may map to one mode. entitled_modes = {irc_mode for role_id, irc_mode in role_map.items() if role_id in member.roles} # Optionally burst guild owner as IRC owner (+q) if uid == guild.owner_id and pylink_netobj.serverdata.get('show_owner_status', True): entitled_modes.add('owner') # Grant +qo and +ao instead of only +q and +a if 'owner' in entitled_modes or 'admin' in entitled_modes: entitled_modes.add('op') for mode, prefixlist in pylink_channel.prefixmodes.items(): modechar = pylink_netobj.cmodes.get(mode) if not modechar: continue # New role added if mode in entitled_modes and uid not in prefixlist: modes.append(('+%s' % modechar, uid)) # Matching role removed if mode not in entitled_modes and uid in prefixlist: modes.append(('-%s' % modechar, uid)) elif uid in pylink_channel.users and not has_perm: log.debug('discord: parting member %s from %s/%s', member, channel.id, channel) pylink_user.channels.discard(channel.id) pylink_channel.remove_user(uid) # We send KICK from a server to prevent triggering antiflood mechanisms... pylink_netobj.call_hooks([ guild.id, 'KICK', { 'channel': channel.id, 'target': uid, 'text': "User removed from channel" } ]) # Note: once we've gotten here, it is possible that the channel was removed because the bot # no longer has access to it if channel.id in pylink_netobj.channels: if users_joined: pylink_netobj.call_hooks([ guild.id, 'JOIN', { 'channel': channel.id, 'users': users_joined, 'modes': [] } ]) if modes: pylink_netobj.apply_modes(channel.id, modes) log.debug('(%s) Relaying permission changes on %s/%s as modes: %s', self.protocol.name, member.name, channel, pylink_netobj.join_modes(modes)) if relay_modes: pylink_netobj.call_hooks([guild.id, 'MODE', {'target': channel.id, 'modes': modes}])