class _Schema: _openable = { "path": str, immp.Optional("enabled", True): bool, immp.Optional("config", dict): dict } _plugs = {str: _openable} _hooks = { str: immp.Schema({immp.Optional("priority"): immp.Nullable(int)}, _openable) } _channels = {str: {"plug": str, "source": str}} _logging = {immp.Optional("disable_existing_loggers", False): bool} config = immp.Schema({ immp.Optional("path", list): [str], immp.Optional("plugs", dict): _plugs, immp.Optional("channels", dict): _channels, immp.Optional("groups", dict): { str: dict }, immp.Optional("hooks", dict): _hooks, immp.Optional("logging"): immp.Nullable(_logging) })
class ShellHook(immp.ResourceHook): """ Hook to start a Python shell when a message is received. """ schema = immp.Schema({ immp.Optional("all", False): bool, immp.Optional("console"): immp.Nullable("ptpython") }) def __init__(self, name, config, host): super().__init__(name, config, host) if self.config["console"] == "ptpython": if ptpython: log.debug("Using ptpython console") self.console = self._ptpython else: raise immp.PlugError("'ptpython' module not installed") else: log.debug("Using native console") self.console = self._code @staticmethod def _ptpython(local): ptpython.repl.embed(globals(), local) @staticmethod def _code(local): code.interact(local=dict(globals(), **local)) async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if sent.channel in self.host.channels or self.config["all"]: log.debug("Entering console: %r", sent) self.console(locals())
class _Schema: config = immp.Schema({"token": str, immp.Optional("bot", True): bool, immp.Optional("webhooks", dict): {str: str}, immp.Optional("playing"): immp.Nullable(str)}) webhook = immp.Schema(immp.Any({"code": int, "message": str}, {"id": str}))
class ForwardHook(_SyncHookBase): """ Hook to propagate messages from a source channel to one or more destination channels. """ schema = immp.Schema({immp.Optional("users"): immp.Nullable([str])}, _SyncHookBase.schema) @property def _channels(self): try: return {self.host.channels[key]: tuple(self.host.channels[label] for label in value) for key, value in self.config["channels"].items()} except KeyError as e: raise immp.ConfigError("No such channel '{}'".format(repr(e.args[0]))) async def send(self, channel, msg): """ Send a message to all channels in this sync. Args: channel (.Channel): Source channel that defines the underlying forwarding channels to send to. msg (.Message): External message to push. """ queue = [] clone = msg.clone() await self._alter_recurse(clone, self._alter_name) for synced in self._channels[channel]: local = clone.clone() await self._alter_recurse(local, self._alter_identities, synced) queue.append(self._send(synced, local)) # Send all the messages in parallel. await gather(*queue) def _accept(self, msg): if not super()._accept(msg): return False if self.config["users"] is not None: if not msg.user or msg.user.id not in self.config["users"]: log.debug("Not syncing message from non-whitelisted user: %r", msg.user.id) return False return True async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if primary and sent.channel in self._channels and self._accept(source): await self.send(sent.channel, source)
class _Schema: config = immp.Schema({ "route": str, immp.Optional("secret"): immp.Nullable(str) }) event = immp.Schema({ "repository": { "full_name": str }, "sender": { "id": int, "login": str, "avatar_url": str, "html_url": str } })
class IRCPlug(immp.Plug): """ Plug for an IRC server. """ schema = immp.Schema({ "server": { "host": str, "port": int, immp.Optional("ssl", False): bool, immp.Optional("password"): immp.Nullable(str) }, "user": { "nick": str, "real-name": str }, immp.Optional("quit", "Disconnecting"): str, immp.Optional("accept-invites", False): bool, immp.Optional("puppet", False): bool, immp.Optional("puppet-prefix", ""): str }) def __init__(self, name, config, host): super().__init__(name, config, host) self._client = None # Don't yield messages for initial self-joins. self._joins = set() # Maintain puppet clients by nick for cleaner sending. self._puppets = {} @property def network_name(self): if self._client and self._client.network: network = self._client.network else: network = self.config["server"]["host"] return "{} IRC".format(network) @property def network_id(self): return "irc:{}".format(self.config["server"]["host"]) async def start(self): self._client = IRCClient(self, self.config["server"]["host"], self.config["server"]["port"], self.config["server"]["ssl"], self.config["user"]["nick"], self.config["server"]["password"], "immp", self.config["user"]["real-name"], True) await self._client.connect() for channel in self.host.channels.values(): if channel.plug == self and channel.source.startswith("#"): self._joins.add(channel.source) await self._client.join(channel.source) async def stop(self): if self._client: await self._client.disconnect(self.config["quit"]) self._client = None for client in self._puppets.values(): await client.disconnect(self.config["quit"]) self._puppets.clear() async def user_from_id(self, id_): nick = id_.split("!", 1)[0] user = await self.user_from_username(nick) if user: return user else: return IRCUser.from_id(self, id_) async def user_from_username(self, username): try: return self._client.users[username] except KeyError: pass for user in await self._client.who(username): if user.username == username: return user return None async def user_is_system(self, user): if user.id == self._client.nickmask: return True for client in self._puppets.values(): if user.id == client.nickmask: return True else: return False async def public_channels(self): try: raw = await self._client.list() except IRCTryAgain: return None channels = (line.args[1] for line in raw) return [immp.Channel(self, channel) for channel in channels] async def private_channels(self): try: raw = await self._client.names() except IRCTryAgain: return None names = set() for line in raw: names.update( name.lstrip(self._client.prefixes) for name in line.args[3].split()) return [ immp.Channel(self, name) for name in names if name != self.config["user"]["nick"] ] async def channel_for_user(self, user): return immp.Channel(self, user.username) async def channel_is_private(self, channel): return not channel.source.startswith(tuple(self._client.types)) async def channel_title(self, channel): return channel.source async def channel_members(self, channel): try: nicks = self._client.members[channel.source] except KeyError: members = list(await self._client.who(channel.source)) else: members = [await self.user_from_username(nick) for nick in nicks] if await channel.is_private( ) and members[0].id != self._client.nickmask: members.append(await self.user_from_id(self._client.nickmask)) return members async def channel_invite(self, channel, user): self._client.invite(channel.source, user.username) async def channel_remove(self, channel, user): self._client.kick(channel.source, user.username) async def _handle(self, line): if line.command in ("JOIN", "PART", "KICK", "PRIVMSG"): sent = await IRCMessage.from_line(self, line) if sent.joined and sent.joined[0].id == self._client.nickmask: if sent.channel.source in self._joins: self._joins.remove(sent.channel.source) return puppets = [client.nickmask for client in self._puppets.values()] # Don't yield messages sent by puppets, or for puppet kicks. if sent.user.id in puppets: pass elif sent.joined and all(user.id in puppets for user in sent.joined): pass elif sent.left and all(user.id in puppets for user in sent.left): pass else: self.queue(sent) elif line.command == "INVITE" and self.config["accept-invites"]: await self._client.join(line.args[1]) @classmethod def _lines(cls, rich, user, action, edited): if not rich: return [] elif not isinstance(rich, immp.RichText): rich = immp.RichText([immp.Segment(rich)]) lines = [] for text in IRCRichText.to_formatted(rich).split("\n"): if user: template = "* {} {}" if action else "<{}> {}" text = template.format(user.username or user.real_name, text) if edited: text = "[edit] {}".format(text) if not user and action: text = "\x01ACTION {}\x01".format(text) lines.append(text) return lines async def _puppet(self, user): username = user.username or user.real_name nick = self.config["puppet-prefix"] + "-".join(username.split()) try: puppet = self._puppets[user] except KeyError: pass else: log.debug("Reusing puppet %r for user %r", puppet.nickmask, user) if puppet.nick.rstrip("_") != nick: puppet.nick = nick return puppet if user.plug and user.plug.network_id == self.network_id: for puppet in self._puppets.values(): if user.id == puppet.nickmask: log.debug("Matched nickmask with puppet %r", user.id) return puppet log.debug("Adding puppet %r for user %r", nick, user) real_name = user.real_name or user.username if user.plug: real_name = "{} ({})".format(real_name, user.plug.network_name) puppet = IRCClient(self, self.config["server"]["host"], self.config["server"]["port"], self.config["server"]["ssl"], nick, self.config["server"]["password"], "immp", real_name) self._puppets[user] = puppet await puppet.connect() return puppet async def put(self, channel, msg): user = None if self.config["puppet"] else msg.user lines = [] if msg.text: lines += self._lines(msg.text, user, msg.action, msg.edited) for attach in msg.attachments: if isinstance(attach, immp.File): text = "uploaded a file{}".format( ": {}".format(attach) if str(attach) else "") lines += self._lines(text, user, True, msg.edited) elif isinstance(attach, immp.Location): text = "shared a location: {}".format(attach) lines += self._lines(text, user, True, msg.edited) elif isinstance(attach, immp.Message) and attach.text: lines += self._lines(attach.text, attach.user, attach.action, attach.edited) ids = [] if self.config["puppet"] and msg.user: client = await self._puppet(msg.user) if not await channel.is_private(): await client.join(channel.source) else: client = self._client for text in lines: line = client.send(channel.source, text) sent = await IRCMessage.from_line(self, line) self.queue(sent) ids.append(sent.id) return ids
class ForwardHook(_SyncHookBase): """ Hook to propagate messages from a source channel to one or more destination channels. """ schema = immp.Schema( { immp.Optional("users"): immp.Nullable([str]), immp.Optional("groups", dict): { str: [str] } }, _SyncHookBase.schema) _channels = immp.ConfigProperty({immp.Channel: [immp.Channel]}) _groups = immp.ConfigProperty({immp.Group: [immp.Channel]}) async def _targets(self, channel): targets = set() if channel in self._channels: targets.update(self._channels[channel]) for group, channels in self._groups.items(): if await group.has_channel(channel): targets.update(channels) return targets async def send(self, msg, channels): """ Send a message to all channels in this forwarding group. Args: msg (.Message): External message to push. channels (.Channel list): Set of target channels to forward the message to. """ queue = [] clone = msg.clone() await self._alter_recurse(clone, self._alter_name) for synced in channels: local = clone.clone() await self._alter_recurse(local, self._alter_identities, synced) queue.append(self._send(synced, local)) # Send all the messages in parallel. await gather(*queue) def _accept(self, msg, id_): if not super()._accept(msg, id_): return False if self.config["users"] is not None: if not msg.user or msg.user.id not in self.config["users"]: log.debug("Not syncing message from non-whitelisted user: %r", msg.user.id) return False return True async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if not primary or not self._accept(source, sent.id): return targets = await self._targets(sent.channel) if targets: await self.send(source, targets)
class LocalIdentityHook(immp.Hook, AccessPredicate, IdentityProvider): """ Hook for managing physical users with multiple logical links across different plugs. This effectively provides self-service identities, as opposed to being provided externally. """ schema = immp.Schema({ immp.Optional("instance"): immp.Nullable(int), "plugs": [str], immp.Optional("multiple", True): bool }) _plugs = immp.ConfigProperty([immp.Plug]) def __init__(self, name, config, host): super().__init__(name, config, host) self.db = None async def start(self): if not self.config["instance"]: # Find a non-conflicting number and assign it. codes = { hook.config["instance"] for hook in self.host.hooks.values() if isinstance(hook, self.__class__) } code = 1 while code in codes: code += 1 log.debug("Assigning instance code %d to hook %r", code, self.name) self.config["instance"] = code self.db = self.host.resources[DatabaseHook].db self.db.create_tables([IdentityGroup, IdentityLink, IdentityRole], safe=True) def get(self, name): """ Retrieve the identity group using the given name. Args: name (str): Existing name to query. Returns: .IdentityGroup: Linked identity, or ``None`` if not linked. """ try: return IdentityGroup.select_links().where( IdentityGroup.name == name).get() except IdentityGroup.DoesNotExist: return None def find(self, user): """ Retrieve the identity that contains the given user, if one exists. Args: user (.User): Existing user to query. Returns: .IdentityGroup: Linked identity, or ``None`` if not linked. """ if not user or user.plug not in self._plugs: return None try: return (IdentityGroup.select_links().where( IdentityGroup.instance == self.config["instance"], IdentityLink.network == user.plug.network_id, IdentityLink.user == user.id).get()) except IdentityGroup.DoesNotExist: return None async def channel_access(self, channel, user): return bool( IdentityLink.get(network=user.plug.network_id, user=user.id)) async def identity_from_name(self, name): group = self.get(name) return await group.to_identity(self.host, self) if group else None async def identity_from_user(self, user): group = self.find(user) return await group.to_identity(self.host, self) if group else None def _test(self, channel, user): return channel.plug in self._plugs @command("id-add", scope=CommandScope.private, test=_test) async def add(self, msg, name, pwd): """ Create a new identity, or link to an existing one from a second user. """ if not msg.user or msg.user.plug not in self._plugs: return if self.find(msg.user): text = "{} Already identified".format(CROSS) else: pwd = IdentityGroup.hash(pwd) exists = False try: group = IdentityGroup.get(instance=self.config["instance"], name=name) exists = True except IdentityGroup.DoesNotExist: group = IdentityGroup.create(instance=self.config["instance"], name=name, pwd=pwd) if exists and not group.pwd == pwd: text = "{} Password incorrect".format(CROSS) elif not self.config["multiple"] and any( link.network == msg.user.plug.network_id for link in group.links): text = "{} Already identified on {}".format( CROSS, msg.user.plug.network_name) else: IdentityLink.create(group=group, network=msg.user.plug.network_id, user=msg.user.id) text = "{} {}".format(TICK, "Added" if exists else "Claimed") await msg.channel.send(immp.Message(text=text)) @command("id-rename", scope=CommandScope.private, test=_test) async def rename(self, msg, name): """ Rename the current identity. """ if not msg.user: return group = self.find(msg.user) if not group: text = "{} Not identified".format(CROSS) elif group.name == name: text = "{} No change".format(TICK) elif IdentityGroup.select().where( IdentityGroup.instance == self.config["instance"], IdentityGroup.name == name).exists(): text = "{} Name already in use".format(CROSS) else: group.name = name group.save() text = "{} Claimed".format(TICK) await msg.channel.send(immp.Message(text=text)) @command("id-password", scope=CommandScope.private, test=_test) async def password(self, msg, pwd): """ Update the password for the current identity. """ if not msg.user: return group = self.find(msg.user) if not group: text = "{} Not identified".format(CROSS) else: group.pwd = IdentityGroup.hash(pwd) group.save() text = "{} Changed".format(TICK) await msg.channel.send(immp.Message(text=text)) @command("id-reset", scope=CommandScope.private, test=_test) async def reset(self, msg): """ Delete the current identity and all linked users. """ if not msg.user: return group = self.find(msg.user) if not group: text = "{} Not identified".format(CROSS) else: group.delete_instance() text = "{} Reset".format(TICK) await msg.channel.send(immp.Message(text=text)) @command("id-role", scope=CommandScope.private, role=CommandRole.admin, test=_test) async def role(self, msg, name, role=None): """ List roles assigned to an identity, or add/remove a given role. """ try: group = IdentityGroup.get(instance=self.config["instance"], name=name) except IdentityGroup.DoesNotExist: text = "{} Name not registered".format(CROSS) else: if role: count = IdentityRole.delete().where( IdentityRole.group == group, IdentityRole.role == role).execute() if count: text = "{} Removed".format(TICK) else: IdentityRole.create(group=group, role=role) text = "{} Added".format(TICK) else: roles = IdentityRole.select().where( IdentityRole.group == group) if roles: labels = [role.role for role in roles] text = "Roles for {}: {}".format(name, ", ".join(labels)) else: text = "No roles for {}.".format(name) await msg.channel.send(immp.Message(text=text))
class IRCPlug(immp.Plug): """ Plug for an IRC server. """ schema = immp.Schema({"server": {"host": str, "port": int, immp.Optional("ssl", False): bool, immp.Optional("password"): immp.Nullable(str)}, "user": {"nick": str, "real-name": str}, immp.Optional("perform", list): [str], immp.Optional("quit", "Disconnecting"): str, immp.Optional("accept-invites", False): bool, immp.Optional("colour-nicks", False): bool, immp.Optional("quote-reply-to", True): bool, immp.Optional("puppet", False): bool, immp.Optional("puppet-prefix", ""): str, immp.Optional("send-delay", 0.5): float}) def __init__(self, name, config, host): super().__init__(name, config, host) self._client = None # Don't yield messages for initial self-joins. self._joins = set() # Maintain puppet clients by nick for cleaner sending. self._puppets = {} # Queue multiple outgoing messages in quick succession and insert delays between them. self._delay_lock = DelayLock(lambda: self.config["send-delay"]) # Cache the last message in each channel, to suppress reply quoting when directly below. self._last_msgs = {} @property def network_name(self): if self._client and self._client.network: network = self._client.network else: network = self.config["server"]["host"] return "{} IRC".format(network) @property def network_id(self): return "irc:{}".format(self.config["server"]["host"]) async def start(self): await super().start() self._client = IRCClient(self, self.config["server"]["host"], self.config["server"]["port"], self.config["server"]["ssl"], self.config["user"]["nick"], self.config["server"]["password"], "immp", self.config["user"]["real-name"], self._connected, self._handle) await self._client.connect() async def stop(self): await super().stop() if self._client: await self._client.disconnect(self.config["quit"]) self._client = None for client in self._puppets.values(): await client.disconnect(self.config["quit"]) self._puppets.clear() def get_user(self, nick): return self._client.users.get(nick) async def user_from_id(self, id_): nick = id_.split("!", 1)[0] user = await self.user_from_username(nick) return user or IRCUser.from_id(self, id_) async def user_from_username(self, username): user = self.get_user(username) if user: return user for client in self._puppets.values(): if username == client.nick: return IRCUser.from_id(self, client.nickmask, client.name) else: return await self._client.whois(username) async def user_is_system(self, user): if user.id == self._client.nickmask: return True for client in self._puppets.values(): if user.id == client.nickmask: return True else: return False async def public_channels(self): try: raw = await self._client.list() except IRCTryAgain: return None channels = (line.args[1] for line in raw) return [immp.Channel(self, channel) for channel in channels] async def private_channels(self): try: raw = await self._client.names() except IRCTryAgain: return None names = set(self._client.users) for line in raw: names.update(name.lstrip(self._client.prefixes) for name in line.args[3].split()) names.discard(self._client.nick) for client in self._puppets.values(): names.discard(client.nick) return [immp.Channel(self, name) for name in names] async def channel_for_user(self, user): return immp.Channel(self, user.username) async def channel_is_private(self, channel): return not channel.source.startswith(tuple(self._client.types)) async def channel_title(self, channel): return channel.source async def channel_members(self, channel): if self._client.closing: return None try: nicks = self._client.members[channel.source] except KeyError: members = list(await self._client.who(channel.source)) else: members = [await self.user_from_username(nick) for nick in nicks] if await channel.is_private() and members[0].id != self._client.nickmask: members.append(await self.user_from_id(self._client.nickmask)) return members async def channel_invite(self, channel, user): await self._client.invite(channel.source, user.username) async def channel_remove(self, channel, user): await self._client.kick(channel.source, user.username) async def _connected(self): for perform in self.config["perform"]: self._client._write(Line.parse(perform)) for channel in self.host.channels.values(): if channel.plug == self and channel.source.startswith("#"): self._joins.add(channel.source) await self._client.join(channel.source) async def _handle(self, line): if line.command in ("JOIN", "PART", "KICK", "PRIVMSG"): sent = await IRCMessage.from_line(self, line) # Suppress initial joins and final parts. if sent.joined and sent.joined[0].id == self._client.nickmask: if sent.channel.source in self._joins: self._joins.remove(sent.channel.source) return elif sent.left and sent.left[0].id == self._client.nickmask and self._client.closing: return puppets = [client.nickmask for client in self._puppets.values()] # Don't yield messages sent by puppets, or for puppet kicks. if sent.user.id in puppets: pass elif sent.joined and all(user.id in puppets for user in sent.joined): pass elif sent.left and all(user.id in puppets for user in sent.left): pass else: self.queue(sent) self._last_msgs[sent.channel] = [sent] elif line.command == "INVITE" and self.config["accept-invites"]: await self._client.join(line.args[1]) def _inline(self, rich): # Take an excerpt of some message text, merging multiple lines into one. if not rich: return rich inlined = rich.clone() for segment in inlined: segment.text = segment.text.replace("\n", " ") return inlined.trim(160) def _author_name(self, user): name = user.username or user.real_name if self.config["colour-nicks"]: name = IRCSegment._coloured(IRCUser.nick_colour(name), name) return name def _author_template(self, user=None, action=False, edited=False, reply=None, quoter=None): prefix = [] suffix = [] if user: prefix.append(("* {} " if action else "<{}> ").format(self._author_name(user))) if quoter: if isinstance(quoter, immp.User): prefix.append("<{}> ".format(self._author_name(quoter))) prefix.append("[") suffix.append("]") if edited: prefix.append("[edit] ") if reply: prefix.append("{} ".format(ARROW if self.config["quote-reply-to"] else ARROW[1:])) if action and not user and not quoter: prefix.append("\x01ACTION ") suffix.append("\x01") return "{}{{}}{}".format("".join(reversed(prefix)), "".join(suffix)).strip() def _lines(self, rich, user=None, action=False, edited=False, reply=None, quoter=None): if not rich: return [] elif not isinstance(rich, immp.RichText): rich = immp.RichText([immp.Segment(rich)]) template = self._author_template(user, action, edited, reply, quoter) lines = [] # Line length isn't well defined (generally 512 bytes for the entire wire line), so set a # conservative length limit to allow for long channel names and formatting characters. for line in chain(*(chunk.lines() for chunk in rich.chunked(360))): text = IRCRichText.to_formatted(line) lines.append(template.format(text)) return lines async def _puppet(self, user, create=True): username = user.username or user.real_name nick = self.config["puppet-prefix"] + "-".join(username.split()) try: puppet = self._puppets[user] except KeyError: if not create: return None else: log.debug("Reusing puppet %r for user %r", puppet, user) if puppet.nick.rstrip("_") != nick: await puppet.set_nick(nick) return puppet if user.plug and user.plug.network_id == self.network_id: for puppet in self._puppets.values(): if user.id == puppet.nickmask: log.debug("Matched nickmask with puppet %r", user.id) return puppet log.debug("Adding puppet %r for user %r", nick, user) real_name = user.real_name or user.username if user.plug: real_name = "{} ({}{})".format(real_name, user.plug.network_name, ": {}".format(user.id) if user.id else "") puppet = IRCClient(self, self.config["server"]["host"], self.config["server"]["port"], self.config["server"]["ssl"], nick, self.config["server"]["password"], "immp", real_name) self._puppets[user] = puppet await puppet.connect() return puppet async def put(self, channel, msg): user = None if self.config["puppet"] else msg.user lines = [] if isinstance(msg.reply_to, immp.Message) and msg.reply_to.text: if not self.config["quote-reply-to"]: pass elif msg.reply_to not in self._last_msgs.get(channel, []): lines.append(self._lines(self._inline(msg.reply_to.text), msg.reply_to.user, msg.reply_to.action, msg.edited, None, True)[0]) if msg.text: lines += self._lines(msg.text, user, msg.action, msg.edited, msg.reply_to) for attach in msg.attachments: if isinstance(attach, immp.File): text = "uploaded a file{}".format(": {}".format(attach) if str(attach) else "") lines += self._lines(text, user, True, msg.edited, msg.reply_to) elif isinstance(attach, immp.Location): text = "shared a location: {}".format(attach) lines += self._lines(text, user, True, msg.edited, msg.reply_to) elif isinstance(attach, immp.Message) and attach.text: lines += self._lines(attach.text, attach.user, attach.action, attach.edited, msg.reply_to, user) arrowed = False for i, line in enumerate(lines): if arrowed: lines[i] = line.replace(ARROW, " ") elif ARROW in line: arrowed = True receipts = [] if self.config["puppet"] and msg.user: client = await self._puppet(msg.user) if not await channel.is_private(): await client.join(channel.source) else: client = self._client for text in lines: async with self._delay_lock: line = await client.send(channel.source, text) sent = await IRCMessage.from_line(self, line) self.queue(sent) receipts.append(sent) self._last_msgs[channel] = receipts if self.config["puppet"]: for member in msg.joined: puppet = await self._puppet(member, False) if puppet: ensure_future(puppet.join(channel.source)) for member in msg.left: puppet = await self._puppet(member, False) if puppet: ensure_future(puppet.part(channel.source)) return receipts
class ChannelAccessHook(immp.Hook, AccessPredicate): """ Hook for controlling membership of, and joins to, secure channels. """ schema = immp.Schema({immp.Optional("hooks", dict): {str: immp.Nullable([str])}, immp.Optional("exclude", dict): {str: [str]}, immp.Optional("joins", True): bool, immp.Optional("startup", False): bool, immp.Optional("passive", False): bool, immp.Optional("default", True): bool}) hooks = immp.ConfigProperty({AccessPredicate: [immp.Channel]}) @property def channels(self): inverse = defaultdict(list) for hook, channels in self.hooks.items(): if not channels: continue for channel in channels: inverse[channel].append(hook) return inverse # This hook acts as an example predicate to block all access. async def channel_access_multi(self, channels, users): return [], list(product(channels, users)) async def channel_access(self, channel, user): return False async def verify(self, members=None): """ Perform verification of each user in each channel, for all configured access predicates. Users who are denied access by any predicate will be removed, unless passive mode is set. Args: members ((.Channel, .User set) dict): Mapping from target channels to a subset of users pending verification. If ``None`` is given for a channel's set of users, all members present in the channel will be verified. If ``members`` itself is ``None``, access checks will be run against all configured channels. """ everywhere = set() grouped = {} for hook, scope in self.hooks.items(): interested = await hook.access_channels() if scope and interested: log.debug("Hook %r using scope and own list", hook) wanted = set(interested).intersection(scope) elif scope or interested: log.debug("Hook %r using %s", hook, "scope" if scope else "own list") wanted = set(scope or interested) else: log.warning("Hook %r has no declared channels for access control", hook) continue if members is not None: wanted.intersection_update(members) if wanted: everywhere.update(wanted) grouped[hook] = wanted else: log.debug("Skipping hook %r as member filter doesn't overlap", hook) targets = defaultdict(set) members = members or {} for channel in everywhere: users = members.get(channel) try: current = await channel.members() except Exception: log.warning("Failed to retrieve members for channel %r", channel, exc_info=True) continue for user in users or current or (): if current and user not in current: log.debug("Skipping non-member user %r", user) elif user.id in self.config["exclude"].get(user.plug.name, []): log.debug("Skipping excluded user %r", user) elif await user.is_system(): log.debug("Skipping system user %r", user) else: targets[channel].add(user) hooks = [] tasks = [] for hook, channels in grouped.items(): known = {channel: users for channel, users in targets.items() if users} log.debug("Requesting decisions from %r: %r", hook, set(known)) hooks.append(hook) tasks.append(ensure_future(hook.channel_access_multi(known))) allowed = set() denied = set() for hook, result in zip(hooks, await gather(*tasks, return_exceptions=True)): if isinstance(result, Exception): log.warning("Failed to verify channel access with hook %r", hook.name, exc_info=result) continue hook_allowed, hook_denied = result allowed.update(hook_allowed) if hook_denied: log.debug("Hook %r denied %d user-channel pair(s)", hook.name, len(hook_denied)) denied.update(hook_denied) removals = defaultdict(set) for channel, users in targets.items(): for user in users: pair = (channel, user) if pair in denied: allow = False elif pair in allowed: allow = True else: allow = self.config["default"] if allow: log.debug("Allowing access to %r for %r", channel, user) else: log.debug("Denying access to %r for %r", channel, user) removals[channel].add(user) active = not self.config["passive"] for channel, refused in removals.items(): log.debug("%s %d user(s) from %r: %r", "Removing" if active else "Would remove", len(refused), channel, refused) if active: await channel.remove_multi(refused) async def _startup_check(self): log.debug("Running startup access checks") await self.verify() log.debug("Finished startup access checks") def on_ready(self): if self.config["startup"]: ensure_future(self._startup_check()) async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if self.config["joins"] and primary and sent == source and source.joined: await self.verify({sent.channel: source.joined})
class _SyncHookBase(immp.Hook): schema = immp.Schema({"channels": {str: [str]}, immp.Optional("joins", False): bool, immp.Optional("renames", False): bool, immp.Optional("identities"): immp.Nullable(str), immp.Optional("reset-author", False): bool, immp.Optional("name-format"): immp.Nullable(str), immp.Optional("strip-name-emoji", False): bool}) _identities = immp.ConfigProperty(IdentityProvider) def _accept(self, msg): if not self.config["joins"] and (msg.joined or msg.left): log.debug("Not syncing join/part message: %r", msg.id) return False if not self.config["renames"] and msg.title: log.debug("Not syncing rename message: %r", msg.id) return False return True def _replace_recurse(self, msg, func, *args): # Switch out entire messages for copies or replacements. if msg.reply_to: msg.reply_to = func(msg.reply_to, *args) attachments = [] for attach in msg.attachments: if isinstance(attach, immp.Message): attachments.append(func(attach, *args)) else: attachments.append(attach) msg.attachments = attachments return msg async def _alter_recurse(self, msg, func, *args): # Alter properties on existing cloned message objects. await func(msg, *args) if msg.reply_to: await func(msg.reply_to, *args) for attach in msg.attachments: if isinstance(attach, immp.Message): await func(attach, *args) async def _rename_user(self, user, channel): # Use name-format or identities to render a suitable author real name. renamed = name = identity = None if not self.config["reset-author"]: renamed = user if self._identities: try: identity = await self._identities.identity_from_user(user) except Exception as e: log.warning("Failed to retrieve identity information for %r", user, exc_info=e) if self.config["name-format"]: if not Template: raise immp.PlugError("'jinja2' module not installed") title = await channel.title() if channel else None context = {"user": user, "identity": identity, "channel": title} name = Template(self.config["name-format"]).render(**context) if not name and self.config["reset-author"]: user = None elif self.config["reset-author"]: user = None elif identity: name = "{} ({})".format(user.real_name or user.username, identity.name) elif self.config["strip-name-emoji"] and user: name = user.real_name or user.username if not name: return user if self.config["strip-name-emoji"]: if not EMOJI_REGEX: raise immp.PlugError("'emoji' module not installed") name = EMOJI_REGEX.sub(_emoji_replace, name).strip() if renamed: log.debug("Replacing real name: %r -> %r", renamed.real_name, name) renamed = copy(renamed) renamed.real_name = name else: log.debug("Adding real name: %r", name) renamed = immp.User(real_name=name) return renamed async def _alter_name(self, msg): channel = msg.channel if isinstance(msg, immp.Receipt) else None msg.user = await self._rename_user(msg.user, channel) async def _alter_identities(self, msg, channel): # Replace mentions for identified users in the target channel. if not msg.text: return msg.text = msg.text.clone() for segment in msg.text: user = segment.mention if not user or user.plug == channel.plug: # No mention or already matches plug, nothing to do. continue identity = None if self.config["identities"]: try: identity = await self._identities.identity_from_user(user) except Exception as e: log.warning("Failed to retrieve identity information for %r", user, exc_info=e) # Try to find an identity corresponding to the target plug. links = identity.links if identity else [] for user in links: if user.plug == channel.plug: log.debug("Replacing mention: %r -> %r", user, user) segment.mention = user break else: # Fallback case: replace mention with a link to the user's profile. if user.link: log.debug("Adding fallback mention link: %r -> %r", user, user.link) segment.link = user.link else: log.debug("Removing foreign mention: %r", user) segment.mention = None # Perform name substitution on the mention text. if self.config["name-format"]: at = "@" if segment.text.startswith("@") else "" renamed = await self._rename_user(user, channel) segment.text = "{}{}".format(at, renamed.real_name) async def _send(self, channel, msg): try: ids = await channel.send(msg) log.debug("Synced IDs in %r: %r", channel, ids) return (channel, ids) except Exception: log.exception("Failed to relay message to channel: %r", channel) return (channel, [])
class _Schema: config = immp.Schema({ "route": str, immp.Optional("secret"): immp.Nullable(str), immp.Optional("ignore", list): [str] }) _linked = {"html_url": str} _sender = {"id": int, "login": str, "avatar_url": str} _repo = {"full_name": str} _project = {"name": str, "number": int, **_linked} _card = {"note": str, "url": str} _release = { "tag": str, immp.Optional("name"): immp.Nullable(str), **_linked } _issue = {"number": int, "title": str, **_linked} _pull = {immp.Optional("merged", False): bool, **_issue} _fork = {"full_name": str, **_linked} _page = {"action": str, "title": str, **_linked} push = immp.Schema({ "ref": str, "after": str, "compare": str, immp.Optional("created", False): bool, immp.Optional("deleted", False): bool, immp.Optional("commits", list): [{ "id": str, "message": str }] }) event = immp.Schema({ "sender": _sender, immp.Optional("organization"): immp.Nullable(_sender), immp.Optional("repository"): immp.Nullable(_repo), immp.Optional("project"): immp.Nullable(_project), immp.Optional("project_card"): immp.Nullable(_card), immp.Optional("release"): immp.Nullable(_release), immp.Optional("issue"): immp.Nullable(_issue), immp.Optional("pull_request"): immp.Nullable(_pull), immp.Optional("review"): immp.Nullable(_linked), immp.Optional("forkee"): immp.Nullable(_fork), immp.Optional("pages"): immp.Nullable([_page]) })
class AsyncShellHook(immp.ResourceHook): """ Hook to launch an asynchonous console alongside a :class:`.Host` instance. Attributes: buffer (collections.deque): Queue of recent messages, the length defined by the ``buffer`` config entry. last ((.SentMessage, .Message) tuple): Most recent message received from a connected plug. """ schema = immp.Schema({ "bind": immp.Any(str, int), immp.Optional("buffer"): immp.Nullable(int) }) def __init__(self, name, config, host): super().__init__(name, config, host) if not aioconsole: raise immp.PlugError("'aioconsole' module not installed") self.buffer = None self._server = None @property def last(self): return self.buffer[-1] if self.buffer else None async def start(self): await super().start() if self.config["buffer"] is not None: self.buffer = deque(maxlen=self.config["buffer"] or None) if isinstance(self.config["bind"], str): log.debug("Launching console on socket %s", self.config["bind"]) bind = {"path": self.config["bind"]} else: log.debug("Launching console on port %d", self.config["bind"]) bind = {"port": self.config["bind"]} self._server = await aioconsole.start_interactive_server( factory=self._factory, **bind) async def stop(self): await super().stop() self.buffer = None if self._server: log.debug("Stopping console server") self._server.close() self._server = None @staticmethod def _pprint(console, obj): console.print(pformat(obj)) def _factory(self, streams=None): context = {"host": self.host, "shell": self, "immp": immp} console = aioconsole.AsynchronousConsole(locals=context, streams=streams) context["pprint"] = partial(self._pprint, console) return console async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if self.buffer is not None: self.buffer.append((sent, source))
class _SyncHookBase(immp.Hook): _override_config = { immp.Optional("joins", immp.Optional.MISSING): bool, immp.Optional("renames", immp.Optional.MISSING): bool, immp.Optional("reset-author", immp.Optional.MISSING): bool, immp.Optional("name-format", immp.Optional.MISSING): immp.Nullable(str), immp.Optional("strip-name-emoji", immp.Optional.MISSING): bool } schema = immp.Schema({ "channels": { str: [str] }, immp.Optional("plugs", dict): { str: _override_config }, immp.Optional("joins", False): bool, immp.Optional("renames", False): bool, immp.Optional("identities"): immp.Nullable(str), immp.Optional("reset-author", False): bool, immp.Optional("name-format"): immp.Nullable(str), immp.Optional("strip-name-emoji", False): bool }) _identities = immp.ConfigProperty(IdentityProvider) def _plug_config(self, channel): keys = tuple( immp.Optional.unwrap(key)[0] for key in self._override_config) config = {key: self.config[key] for key in keys} if channel and channel.plug: override = self.config["plugs"].get(channel.plug.name) or {} config.update( {key: override[key] for key in keys if key in override}) return config def _accept(self, msg, id_): config = self._plug_config( msg.channel if isinstance(msg, immp.Receipt) else None) if not config["joins"] and (msg.joined or msg.left): log.debug("Not syncing join/part message: %r", id_) return False if not config["renames"] and msg.title: log.debug("Not syncing rename message: %r", id_) return False return True async def _replace_recurse(self, msg, func, *args): # Switch out entire messages for copies or replacements. if msg.reply_to: msg.reply_to = await func(msg.reply_to, *args) attachments = [] for attach in msg.attachments: if isinstance(attach, immp.Message): attachments.append(await func(attach, *args)) else: attachments.append(attach) msg.attachments = attachments return msg async def _alter_recurse(self, msg, func, *args): # Alter properties on existing cloned message objects. await func(msg, *args) if msg.reply_to: await func(msg.reply_to, *args) for attach in msg.attachments: if isinstance(attach, immp.Message): await func(attach, *args) async def _rename_user(self, user, channel): config = self._plug_config(channel) # Use name-format or identities to render a suitable author real name. base = (user.real_name or user.username) if user else None name = None identity = None force = False if user and self._identities: try: identity = await self._identities.identity_from_user(user) except Exception: log.warning("Failed to retrieve identity information for %r", user, exc_info=True) if config["name-format"]: if not Template: raise immp.PlugError("'jinja2' module not installed") title = await channel.title() if channel else None context = {"user": user, "identity": identity, "channel": title} try: name = Template(config["name-format"]).render(**context) except TemplateError: log.warning("Bad name format template", exc_info=True) else: # Remove the user's username, so that this name is always used. force = True elif identity: name = "{} ({})".format(base, identity.name) if config["strip-name-emoji"]: if not EMOJI_REGEX: raise immp.PlugError("'emoji' module not installed") current = name or base if current: name = EMOJI_REGEX.sub(_emoji_replace, current).strip() if not name: return user elif config["reset-author"] or not user: log.debug("Creating unlinked user with real name: %r", name) return immp.User(real_name=name, suggested=(user.suggested if user else False)) else: log.debug("Copying user with new real name: %r -> %r", user, name) return immp.User(id_=user.id, plug=user.plug, real_name=name, username=(None if force else user.username), avatar=user.avatar, link=user.link, suggested=user.suggested) async def _alter_name(self, msg): channel = msg.channel if isinstance(msg, immp.Receipt) else None msg.user = await self._rename_user(msg.user, channel) async def _alter_identities(self, msg, channel): # Replace mentions for identified users in the target channel. if not msg.text: return msg.text = msg.text.clone() for segment in msg.text: user = segment.mention if not user or user.plug == channel.plug: # No mention or already matches plug, nothing to do. continue identity = None if self.config["identities"]: try: identity = await self._identities.identity_from_user(user) except Exception as e: log.warning( "Failed to retrieve identity information for %r", user, exc_info=e) # Try to find an identity corresponding to the target plug. links = identity.links if identity else [] for user in links: if user.plug == channel.plug: log.debug("Replacing mention: %r -> %r", user, user) segment.mention = user break # Perform name substitution on the mention text. config = self._plug_config(channel) if config["name-format"]: at = "@" if segment.text.startswith("@") else "" renamed = await self._rename_user(user, channel) segment.text = "{}{}".format(at, renamed.real_name) async def _send(self, channel, msg): try: receipts = await channel.send(msg) log.debug("Synced IDs in %r: %r", channel, [receipt.id for receipt in receipts]) return (channel, receipts) except Exception: log.exception("Failed to relay message to channel: %r", channel) return (channel, [])
class _Schema: image_sizes = ("original", "512", "192", "72", "48", "32", "24") _images = { immp.Optional("image_{}".format(size)): immp.Nullable(str) for size in image_sizes } config = immp.Schema({ "token": str, immp.Optional("fallback-name", "IMMP"): str, immp.Optional("fallback-image"): immp.Nullable(str), immp.Optional("thread-broadcast", False): bool }) team = immp.Schema({ "id": str, "name": str, "domain": str, "prefs": { immp.Optional("display_real_names", False): bool, str: immp.Any() } }) user = immp.Schema({ "id": str, "name": str, "profile": { immp.Optional("real_name"): immp.Nullable(str), immp.Optional("bot_id"): immp.Nullable(str), **_images } }) bot = immp.Schema({ "id": str, "app_id": str, "name": str, "icons": _images }) channel = immp.Schema({"id": str, "name": str}) direct = immp.Schema({"id": str, "user": str}) _shares = {str: [{"ts": str}]} file = immp.Schema( immp.Any( { "id": str, "name": immp.Nullable(str), "pretty_type": str, "url_private": str, immp.Optional("mode"): immp.Nullable(str), immp.Optional("shares", dict): { immp.Optional("public", dict): _shares, immp.Optional("private", dict): _shares } }, { "id": str, "mode": "tombstone" })) attachment = immp.Schema({ immp.Optional("fallback"): immp.Nullable(str), immp.Optional("title"): immp.Nullable(str), immp.Optional("image_url"): immp.Nullable(str), immp.Optional("is_msg_unfurl", False): bool }) msg_unfurl = immp.Schema({"channel_id": str, "ts": str}, attachment) _base_msg = immp.Schema({ "ts": str, "type": "message", immp.Optional("hidden", False): bool, immp.Optional("channel"): immp.Nullable(str), immp.Optional("edited", dict): { immp.Optional("user"): immp.Nullable(str) }, immp.Optional("thread_ts"): immp.Nullable(str), immp.Optional("replies", list): [{ "ts": str }], immp.Optional("files", list): [file], immp.Optional("attachments", list): [attachment], immp.Optional("is_ephemeral", False): bool }) _plain_msg = immp.Schema( { immp.Optional("user"): immp.Nullable(str), immp.Optional("bot_id"): immp.Nullable(str), immp.Optional("username"): immp.Nullable(str), immp.Optional("icons", dict): dict, "text": str }, _base_msg) message = immp.Schema( immp.Any( immp.Schema({"subtype": "file_comment"}, _base_msg), immp.Schema({"subtype": "message_changed"}, _base_msg), immp.Schema({ "subtype": "message_deleted", "deleted_ts": str }, _base_msg), immp.Schema( { "subtype": immp.Any("channel_name", "group_name"), "name": str }, _plain_msg), immp.Schema({immp.Optional("subtype"): immp.Nullable(str)}, _plain_msg))) # Circular references to embedded messages. message.raw.choices[1].raw.update({ "message": message, "previous_message": message }) event = immp.Schema( immp.Any( message, { "type": "team_pref_change", "name": "str", "value": immp.Any() }, { "type": immp.Any("team_join", "user_change"), "user": user }, { "type": immp.Any("channel_created", "channel_joined", "channel_rename", "group_created", "group_joined", "group_rename"), "channel": { "id": str, "name": str } }, { "type": "im_created", "channel": { "id": str } }, { "type": immp.Any("member_joined_channel", "member_left_channel"), "user": str, "channel": str }, { "type": "message", immp.Optional("subtype"): immp.Nullable(str) }, {"type": str})) def _api(nested={}): return immp.Schema( immp.Any( { "ok": True, immp.Optional("response_metadata", dict): { immp.Optional("next_cursor", ""): str }, **nested }, { "ok": False, "error": str })) rtm = _api({ "url": str, "self": { "id": str }, "team": { "id": str, "name": str, "domain": str }, "users": [user], "channels": [channel], "groups": [channel], "ims": [direct], "bots": [{ "id": str, "deleted": bool }] }) im_open = _api({"channel": direct}) members = _api({"members": [str]}) post = _api({"ts": str}) upload = _api({"file": file}) history = _api({"messages": [message]}) api = _api()
class WebHook(immp.ResourceHook): """ Hook that provides a generic webserver, which other hooks can bind routes to. Attributes: app (aiohttp.web.Application): Web application instance, used to add new routes. """ schema = immp.Schema( immp.Any({ immp.Optional("host"): immp.Nullable(str), "port": int }, {"path": str})) def __init__(self, name, config, host): super().__init__(name, config, host) self.app = web.Application() if aiohttp_jinja2: # Empty mapping by default, other hooks can add to this via add_loader(). self._loader = PrefixLoader({}) self._jinja = aiohttp_jinja2.setup(self.app, loader=self._loader) self._jinja.filters["json"] = json.dumps self._jinja.globals["immp"] = immp self._jinja.globals["host"] = self.host self._runner = web.AppRunner(self.app) self._site = None self._contexts = {} def context(self, prefix, module, path=None, env=None): """ Retrieve a context for the current module. Args: prefix (str): URL prefix acting as the base path. module (str): Dotted module name of the Python module using this context. Callers should use :data:`__name__` from the root of their module. path (str): Base path of the module, needed for static routes. Callers should use ``os.path.dirname(__file__)`` from the root of their module. env (dict): Additional variables to make available in the Jinja context. See :attr:`.WebContext.env` for details. Returns: .WebContext: Linked context instance for that module. """ self._contexts[module] = WebContext(self, prefix, module, path, env) return self._contexts[module] def add_loader(self, module): """ Register a Jinja2 package loader for the given module. Args: module (str): Module name to register. """ if not aiohttp_jinja2: raise immp.HookError("Loaders require Jinja2 and aiohttp_jinja2") self._loader.mapping[module] = PackageLoader(module) def add_route(self, *args, **kwargs): """ Equivalent to :meth:`aiohttp.web.UrlDispatcher.add_route`. """ return self.app.router.add_route(*args, **kwargs) def add_static(self, *args, **kwargs): """ Equivalent to :meth:`aiohttp.web.UrlDispatcher.add_static`. """ return self.app.router.add_static(*args, **kwargs) async def start(self): await super().start() await self._runner.setup() if "path" in self.config: log.debug("Starting server on socket %s", self.config["path"]) self._site = web.UnixSite(self._runner, self.config["path"]) else: log.debug("Starting server on host %s:%d", self.config["host"], self.config["port"]) self._site = web.TCPSite(self._runner, self.config["host"], self.config["port"]) await self._site.start() async def stop(self): await super().stop() if self._site: log.debug("Stopping server") await self._runner.cleanup() self._site = None
class SyncHook(_SyncHookBase): """ Hook to propagate messages between two or more channels. Attributes: plug (.SyncPlug): Virtual plug for this sync, if configured. """ schema = immp.Schema({immp.Optional("joins", True): bool, immp.Optional("renames", True): bool, immp.Optional("plug"): immp.Nullable(str), immp.Optional("titles", dict): {str: str}}, _SyncHookBase.schema) def __init__(self, name, config, host): super().__init__(name, config, host) self.db = None # Message cache, stores IDs of all synced messages by channel. self._cache = SyncCache(self) # Hook lock, to put a hold on retrieving messages whilst a send is in progress. self._lock = BoundedSemaphore() # Add a virtual plug to the host, for external subscribers. if self.config["plug"]: log.debug("Creating virtual plug: %r", self.config["plug"]) self.plug = SyncPlug(self.config["plug"], self, host) host.add_plug(self.plug) for label in self.config["channels"]: host.add_channel(label, immp.Channel(self.plug, label)) else: self.plug = None @property def channels(self): try: return {virtual: [self.host.channels[label] for label in labels] for virtual, labels in self.config["channels"].items()} except KeyError as e: raise immp.ConfigError("No channel {} on host".format(repr(e.args[0]))) from None def label_for_channel(self, channel): labels = [] for label, channels in self.channels.items(): if channel in channels: labels.append(label) if not labels: raise immp.ConfigError("Channel {} not bridged".format(repr(channel))) elif len(labels) > 1: raise immp.ConfigError("Channel {} defined more than once".format(repr(channel))) else: return labels[0] async def start(self): try: self.db = self.host.resources[DatabaseHook].db except KeyError: pass else: self.db.create_tables([SyncBackRef], safe=True) async def stop(self): self.db = None def _test(self, channel, user): return any(channel in channels for channels in self.channels.values()) @command("sync-members", test=_test) async def members(self, msg): """ List all members of the current conversation, across all channels. """ members = defaultdict(list) missing = False for synced in self.channels[msg.channel.source]: local = (await synced.members()) if local: members[synced.plug.network_name] += local else: missing = True if not members: return text = immp.RichText([immp.Segment("Members of this conversation:")]) for network in sorted(members): text.append(immp.Segment("\n{}".format(network), bold=True)) for member in sorted(members[network], key=lambda member: member.real_name or member.username): name = member.real_name or member.username text.append(immp.Segment("\n")) if member.link: text.append(immp.Segment(name, link=member.link)) elif member.real_name and member.username: text.append(immp.Segment("{} [{}]".format(name, member.username))) else: text.append(immp.Segment(name)) if missing: text.append(immp.Segment("\n"), immp.Segment("(list may be incomplete)")) await msg.channel.send(immp.Message(user=immp.User(real_name="Sync"), text=text)) @command("sync-list", test=_test) async def list(self, msg): """ List all channels connected to this conversation. """ text = immp.RichText([immp.Segment("Channels in this sync:")]) for synced in self.channels[msg.channel.source]: text.append(immp.Segment("\n{}".format(synced.plug.network_name))) title = await synced.title() if title: text.append(immp.Segment(": {}".format(title))) await msg.channel.send(immp.Message(user=immp.User(real_name="Sync"), text=text)) async def send(self, label, msg, origin=None, ref=None): """ Send a message to all channels in this sync. Args: label (str): Bridge that defines the underlying synced channels to send to. msg (.Message): External message to push. This should be the source copy when syncing a message from another channel. origin (.Receipt): Raw message that triggered this sync; if set and part of the sync, it will be skipped (used to avoid retransmitting a message we just received). This should be the plug-native copy of a message when syncing from another channel. ref (.SyncRef): Existing sync reference, if message has been partially synced. """ base = immp.Message(text=msg.text, user=msg.user, edited=msg.edited, action=msg.action, reply_to=msg.reply_to, joined=msg.joined, left=msg.left, title=msg.title, attachments=msg.attachments, raw=msg) queue = [] for synced in self.channels[label]: if origin and synced == origin.channel: continue elif ref and ref.ids[synced]: log.debug("Skipping already-synced target channel %r: %r", synced, ref) continue local = base.clone() self._replace_recurse(local, self._replace_ref, synced) await self._alter_recurse(local, self._alter_identities, synced) await self._alter_recurse(local, self._alter_name) queue.append(self._send(synced, local)) # Just like with plugs, when sending a new (external) message to all channels in a sync, we # need to wait for all plugs to complete and have their IDs cached before processing any # further messages. async with self._lock: ids = dict(await gather(*queue)) if ref: ref.ids.update(ids) else: ref = SyncRef(ids, source=msg, origin=origin) self._cache.add(ref) # Push a copy of the message to the sync channel, if running. if self.plug: sent = immp.SentMessage(id_=ref.key, channel=immp.Channel(self.plug, label), text=msg.text, user=msg.user, action=msg.action, reply_to=msg.reply_to, joined=msg.joined, left=msg.left, title=msg.title, attachments=msg.attachments, raw=msg) self.plug.queue(sent) return ref async def delete(self, ref, sent=None): queue = [] for channel, ids in ref.ids.items(): for id_ in ids: if not (sent and sent.channel == channel and sent.id == id_): queue.append(immp.Receipt(id_, channel).delete()) if queue: await gather(*queue) def _replace_ref(self, msg, channel): if not isinstance(msg, immp.Receipt): log.debug("Not replacing non-receipt message: %r", msg) return msg base = None if isinstance(msg, immp.SentMessage): base = immp.Message(text=msg.text, user=msg.user, action=msg.action, reply_to=msg.reply_to, joined=msg.joined, left=msg.left, title=msg.title, attachments=msg.attachments, raw=msg.raw) try: ref = self._cache[msg] except KeyError: log.debug("No match for source message: %r", msg) return base # Given message was a resync of the source message from a synced channel. if ref.ids.get(channel): log.debug("Found reference to previously synced message: %r", ref.key) at = ref.source.at if isinstance(ref.source, immp.Receipt) else None best = ref.source or msg return immp.SentMessage(id_=ref.ids[channel][0], channel=channel, at=at, text=best.text, user=best.user, action=best.action, reply_to=best.reply_to, joined=best.joined, left=best.left, title=best.title, attachments=best.attachments, raw=best.raw) elif channel.plug == msg.channel.plug: log.debug("Referenced message has origin plug, not modifying: %r", msg) return msg else: log.debug("Origin message not referenced in the target channel: %r", msg) return base async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) try: label = self.label_for_channel(sent.channel) except immp.ConfigError: return async with self._lock: # No critical section here, just wait for any pending messages to be sent. pass ref = None try: ref = self._cache[sent] except KeyError: if sent.deleted: log.debug("Ignoring deleted message not in sync cache: %r", sent) return else: log.debug("Incoming message not in sync cache: %r", sent) else: if sent.deleted: log.debug("Incoming message is a delete, needs sync: %r", sent) await self.delete(ref) return elif (sent.edited and not ref.revisions) or ref.revision(sent): log.debug("Incoming message is an update, needs sync: %r", sent) elif all(ref.ids[channel] for channel in self.channels[label]): log.debug("Incoming message already synced: %r", sent) return else: log.debug("Incoming message partially synced: %r", sent) if not self._accept(source): return log.debug("Sending message to synced channel %r: %r", label, sent.id) await self.send(label, source, sent, ref)