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: _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 _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 HangoutsLockHook(immp.Hook): """ Hook to enforce the history and link-join settings in Hangouts. """ schema = immp.Schema({ immp.Optional("history", dict): { str: bool }, immp.Optional("linkjoin", dict): { str: bool } }) @property def channels(self): try: return { key: { self.host.channels[label]: setting for label, setting in mapping.items() } for key, mapping in self.config.items() } except KeyError as e: raise immp.HookError("No channel named '{}'".format(e.args[0])) async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if sent != source or not isinstance(sent.channel.plug, HangoutsPlug): return conv = sent.channel.plug._convs.get(sent.channel.source) if isinstance(sent.raw, hangups.OTREvent): setting = HISTORY.get(self.channels["history"].get(sent.channel)) if setting is None: return if setting != sent.raw.new_otr_status: request = hangouts_pb2.ModifyOTRStatusRequest( request_header=sent.channel.plug._client. get_request_header(), event_request_header=conv._get_event_request_header(), otr_status=setting) await sent.channel.plug._client.modify_otr_status(request) elif isinstance(sent.raw, hangups.GroupLinkSharingModificationEvent): setting = LINK_JOIN.get(self.channels["linkjoin"].get( sent.channel)) if setting is None: return if setting != sent.raw.new_status: request = hangouts_pb2.SetGroupLinkSharingEnabledRequest( request_header=sent.channel.plug._client. get_request_header(), event_request_header=conv._get_event_request_header(), group_link_sharing_status=setting) await sent.channel.plug._client.set_group_link_sharing_enabled( request)
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 }))
class AutoRespondHook(immp.Hook): """ Basic text responses for given trigger words and phrases. """ schema = immp.Schema({ "groups": [str], immp.Optional("responses", dict): { str: str } }) group = immp.Group.MergedProperty("groups") def __init__(self, name, config, host): super().__init__(name, config, host) self._sent = [] @command("ar-add", parser=CommandParser.shlex) async def add(self, msg, match, response): """ Add a new trigger / response pair. """ text = "Updated" if match in self.responses else "Added" self.responses[match] = response await msg.channel.send(immp.Message(text="{} {}".format(TICK, text))) @command("ar-remove", parser=CommandParser.shlex) async def remove(self, msg, match): """ Remove an existing trigger. """ if match in self.responses: del self.responses[match] text = "{} Removed".format(TICK) else: text = "{} No such response".format(CROSS) await msg.channel.send(immp.Message(text=text)) async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if not primary or not await self.group.has_channel(sent.channel): return # Skip our own response messages. if (sent.channel, sent.id) in self._sent: return text = str(source.text) for regex, response in self.config["responses"].items(): match = re.search(regex, text, re.I) if match: log.debug("Matched regex %r in channel: %r", match, sent.channel) response = response.format(*match.groups()) for id_ in await sent.channel.send(immp.Message(text=response) ): self._sent.append((sent.channel, id_))
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 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 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 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 WhoIsHook(immp.Hook): """ Hook to provide generic lookup of user profiles across one or more identity providers. """ schema = immp.Schema({ "identities": [str], immp.Optional("public", False): bool }) _identities = immp.ConfigProperty([IdentityProvider]) @command("who", parser=CommandParser.none) async def who(self, msg, name): """ Recall a known identity and all of its links. """ if self.config["public"]: providers = self._identities else: tasks = (provider.identity_from_user(msg.user) for provider in self._identities) providers = [ identity.provider for identity in await gather(*tasks) if identity ] if providers: if name[0].mention: user = name[0].mention tasks = (provider.identity_from_user(user) for provider in providers) else: tasks = (provider.identity_from_name(str(name)) for provider in providers) identities = list(filter(None, await gather(*tasks))) links = { link for identity in identities for link in identity.links } if links: text = name.clone() for segment in text: segment.bold = True text.append(immp.Segment(" may appear as:")) for user in sorted(links, key=lambda user: user.plug.network_name): text.append(immp.Segment("\n")) text.append( immp.Segment("({}) ".format(user.plug.network_name))) if user.link: text.append( immp.Segment(user.real_name or user.username, link=user.link)) elif user.real_name and user.username: text.append( immp.Segment("{} [{}]".format( user.real_name, user.username))) else: text.append( immp.Segment(user.real_name or user.username)) else: text = "{} Name not in use".format(CROSS) else: text = "{} Not identified".format(CROSS) await msg.channel.send(immp.Message(text=text))
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 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 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 HangoutsPlug(immp.HTTPOpenable, immp.Plug): """ Plug for `Google Hangouts <https://hangouts.google.com>`_. """ schema = immp.Schema({"cookie": str, immp.Optional("read", True): bool}) network_name = "Hangouts" @property def network_id(self): return "hangouts:{}".format(self._bot_user) if self._bot_user else None def __init__(self, name, config, host): super().__init__(name, config, host) self._client = self._looped = None self._starting = Condition() self._closing = False self._users = self._convs = self._bot_user = None async def _loop(self): while True: try: await self._client.connect() except CancelledError: log.debug("Cancel request for plug %r loop", self.name) return except Exception as e: log.debug("Unexpected client disconnect: %r", e) if self._closing: return log.debug("Reconnecting in 3 seconds") await sleep(3) async def _connect(self): log.debug("Retrieving users and conversations") self._users, self._convs = await hangups.build_user_conversation_list( self._client) self._convs.on_event.add_observer(self._event) resp = await self._client.get_self_info( hangouts_pb2.GetSelfInfoRequest( request_header=self._client.get_request_header())) self._bot_user = resp.self_entity.id.chat_id async with self._starting: self._starting.notify_all() async def _event(self, event): try: sent = await HangoutsMessage.from_event(self, event) except NotImplementedError: log.warn("Skipping unimplemented %r event type", event.__class__.__name__) else: log.debug("Queueing new message event") self.queue(sent) if self.config["read"]: await self._convs.get(event.conversation_id ).update_read_timestamp() async def start(self): await super().start() self._closing = False self._client = hangups.Client( hangups.get_auth_stdin(self.config["cookie"])) self._client.on_connect.add_observer(self._connect) log.debug("Connecting client") self._looped = ensure_future(self._loop()) async with self._starting: # Block until users and conversations are loaded. await self._starting.wait() log.debug("Listening for events") async def stop(self): await super().stop() self._closing = True if self._client: log.debug("Requesting client disconnect") await self._client.disconnect() self._client = None if self._looped: self._looped.cancel() self._looped = None self._bot_user = None async def user_from_id(self, id_): user = self._users.get_user( hangups.user.UserID(chat_id=id_, gaia_id=id_)) if user: return HangoutsUser.from_user(self, user) request = hangouts_pb2.GetEntityByIdRequest( request_header=self._client.get_request_header(), batch_lookup_spec=[hangouts_pb2.EntityLookupSpec(gaia_id=id_)]) response = await self._client.get_entity_by_id(request) if response.entity: return HangoutsUser.from_entity(self, response.entity) else: return None async def user_is_system(self, user): return user.id == self._bot_user def _filter_channels(self, type_): convs = self._convs.get_all(include_archived=True) return (immp.Channel(self, conv.id_) for conv in convs if conv._conversation.type == type_) async def public_channels(self): return list(self._filter_channels( hangouts_pb2.CONVERSATION_TYPE_GROUP)) async def private_channels(self): return list( self._filter_channels(hangouts_pb2.CONVERSATION_TYPE_ONE_TO_ONE)) async def channel_for_user(self, user): for channel in self._filter_channels( hangouts_pb2.CONVERSATION_TYPE_ONE_TO_ONE): if any(part.id == user.id for part in await channel.members()): return channel request = hangouts_pb2.CreateConversationRequest( request_header=self._client.get_request_header(), type=hangouts_pb2.CONVERSATION_TYPE_ONE_TO_ONE, client_generated_id=self._client.get_client_generated_id(), invitee_id=[hangouts_pb2.InviteeID(gaia_id=user.id)]) response = await self._client.create_conversation(request) return immp.Channel(self, response.conversation.conversation_id.id) async def channel_is_private(self, channel): try: conv = self._convs.get(channel.source) except KeyError: return False else: return conv._conversation.type == hangouts_pb2.CONVERSATION_TYPE_ONE_TO_ONE async def channel_title(self, channel): try: return self._convs.get(channel.source).name except KeyError: return None async def channel_link(self, channel): return "https://hangouts.google.com/chat/{}".format(channel.source) async def channel_rename(self, channel, title): try: conv = self._convs.get(channel.source) except KeyError: return None else: if not conv.name == title: await conv.rename(title) async def channel_members(self, channel): try: conv = self._convs.get(channel.source) except KeyError: return None else: return [HangoutsUser.from_user(self, user) for user in conv.users] async def channel_invite(self, channel, user): try: conv = self._convs.get(channel.source) except KeyError: return request = hangouts_pb2.AddUserRequest( request_header=self._client.get_request_header(), event_request_header=conv._get_event_request_header(), invitee_id=[hangouts_pb2.InviteeID(gaia_id=user.id)]) await self._client.add_user(request) async def channel_remove(self, channel, user): try: conv = self._convs.get(channel.source) except KeyError: return request = hangouts_pb2.RemoveUserRequest( request_header=self._client.get_request_header(), event_request_header=conv._get_event_request_header(), participant_id=hangouts_pb2.ParticipantId(gaia_id=user.id)) await self._client.remove_user(request) async def _next_batch(self, conv, before_id): # Conversation.get_events() should, if the target is the oldest message in the current # batch, fetch the next whole batch and return that, or else return everything before the # target. However, at the end of the message history, it sometimes returns an arbitrary # batch instead. Return fetched messages from Conversation.events directly instead. ids = [event.id_ for event in conv.events] if before_id not in ids: return None if ids[0] == before_id: # Target is the oldest message cached, so there may be more -- try for another batch. await conv.get_events(before_id) ids = [event.id_ for event in conv.events] # Take all events older than the target. events = conv.events[:ids.index(before_id)] return [ await HangoutsMessage.from_event(self, event) for event in events ] async def channel_history(self, channel, before=None): try: conv = self._convs.get(channel.source) except KeyError: return [] if not conv.events: return [] if not before: if len(conv.events) == 1: # Only the initial message cached, try to fetch a first batch. await conv.get_events(conv.events[0].id_) # Return all cached events. return [ await HangoutsMessage.from_event(self, event) for event in conv.events ] ids = [event.id_ for event in conv.events] if before.id in ids: return await self._next_batch(conv, before.id) # Hangouts has no way to query for an event by ID, only by timestamp. Instead, we'll try a # few times to retrieve it further down the message history. for i in range(10): log.debug("Fetching batch %i of events to find %r", i + 1, before.id) events = await conv.get_events(conv.events[0].id_) ids = [event.id_ for event in events] if not ids: # No further messages, we've hit the end of the message history. return [] elif before.id in ids: return await self._next_batch(conv, before.id) # Maxed out on attempts but didn't find the requested message. return [] async def _get_event(self, receipt): try: conv = self._convs.get(receipt.channel.source) except KeyError: return None ids = [event.id_ for event in conv.events] try: return conv.get_event(receipt.id) except KeyError: pass # Hangouts has no way to query for an event by ID, only by timestamp. Instead, we'll try a # few times to retrieve it further down the message history. for i in range(10): log.debug("Fetching batch %i of events to find %r", i + 1, receipt.id) events = await conv.get_events(conv.events[0].id_) ids = [event.id_ for event in events] if not ids: # No further messages, we've hit the end of the message history. return [] elif receipt.id in ids: return events[ids.index(receipt.id)] # Maxed out on attempts but didn't find the requested message. return None async def get_message(self, receipt): # We have the message reference but not the content. event = await self._get_event(receipt) if not event: return None sent = await HangoutsMessage.from_event(self, event) # As we only use this for rendering the message again, we shouldn't add a second # layer of authorship if we originally sent the message being retrieved. if sent.user.id == self._bot_user: sent.user = None return sent async def _upload(self, attach): async with (await attach.get_content(self.session)) as img_content: # Hangups expects a file-like object with a synchronous read() method. # NB. The whole file is read into memory by Hangups anyway. # Filename must be present, else Hangups will try (and fail) to read the path. photo = await self._client.upload_image( BytesIO(await img_content.read()), filename=attach.title or "image.png") return hangouts_pb2.ExistingMedia(photo=hangouts_pb2.Photo( photo_id=photo)) @classmethod def _serialise(cls, segments): output = [] for segment in segments: output += HangoutsSegment.to_segments(segment) return [segment.serialize() for segment in output] def _request(self, conv, segments=None, media=None, place=None): return hangouts_pb2.SendChatMessageRequest( request_header=self._client.get_request_header(), event_request_header=conv._get_event_request_header(), message_content=hangouts_pb2.MessageContent( segment=segments) if segments else None, existing_media=media, location=hangouts_pb2.Location(place=place) if place else None) async def _requests(self, conv, msg): uploads = [] images = [] places = [] for attach in msg.attachments: if isinstance(attach, immp.File) and attach.type in (immp.File.Type.image, immp.File.Type.video): uploads.append(self._upload(attach)) elif isinstance(attach, immp.Location): places.append(HangoutsLocation.to_place(attach)) if uploads: images = await gather(*uploads) requests = [] if msg.text or msg.reply_to: render = msg.render(link_name=False, edit=msg.edited, quote_reply=True) segments = self._serialise(render) media = None if len(images) == 1: # Attach the only image to the message text. media = images.pop() requests.append(self._request(conv, segments, media)) if images: segments = [] if msg.user: label = immp.Message(user=msg.user, text="sent an image", action=True) segments = self._serialise(label.render(link_name=False)) # Send any additional media items in their own separate messages. for media in images: requests.append(self._request(conv, segments, media)) if places: # Send each location separately. for place in places: requests.append(self._request(conv, place=place)) # Include a label only if we haven't sent a text message earlier. if msg.user and not msg.text: label = immp.Message(user=msg.user, text="sent a location", action=True) segments = self._serialise(label.render(link_name=False)) requests.append(self._request(conv, segments)) return requests async def put(self, channel, msg): conv = self._convs.get(channel.source) # Attempt to find sources for referenced messages. clone = copy(msg) clone.reply_to = await self.resolve_message(clone.reply_to) requests = [] for attach in clone.attachments: # Generate requests for attached messages first. if isinstance(attach, immp.Message): requests += await self._requests( conv, await self.resolve_message(attach)) own_requests = await self._requests(conv, clone) if requests and not own_requests: # Forwarding a message but no content to show who forwarded it. info = immp.Message(user=clone.user, action=True, text="forwarded a message") own_requests = await self._requests(conv, info) requests += own_requests events = [] for request in requests: events.append(await self._client.send_chat_message(request)) return [event.created_event.event_id for event in events]
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 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)
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 MentionsHook(_AlertHookBase): """ Hook to send mention alerts via private channels. """ schema = immp.Schema( { immp.Optional("usernames", True): bool, immp.Optional("real-names", False): bool, immp.Optional("ambiguous", False): bool }, _AlertHookBase.schema) @staticmethod def _clean(text): return re.sub(r"\W", "", text).lower() if text else None def match(self, mention, members): """ Identify users relevant to a mention. Args: mention (str): Raw mention text, e.g. ``@fred``. members (.User list): List of members in the channel where the mention took place. Returns: .User set: All applicable members to be notified. """ name = self._clean(mention) real_matches = set() real_partials = set() for member in members: if self.config["usernames"] and self._clean( member.username) == name: # Assume usernames are unique, only match the corresponding user. return {member} if self.config["real-names"]: real = self._clean(member.real_name) if real == name: real_matches.add(member) if real.startswith(name): real_partials.add(member) if real_matches: # Assume multiple identical real names is unlikely. # If it's the same person with two users, they both get mentioned. return real_matches elif len(real_partials) == 1 or self.config["ambiguous"]: # Return a single partial match if it exists. # Only allow multiple partials if enabled, else ignore the mention. return real_partials else: return set() async def before_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if not primary or not source.text or await sent.channel.is_private(): return sent try: _, members = await self._get_members(sent) except _Skip: return sent for match in re.finditer(r"@\S+", str(source.text)): mention = match.group(0) matches = self.match(mention, members) if len(matches) == 1: target = next(iter(matches)) log.debug("Exact match for mention %r: %r", mention, target) text = sent.text[match.start():match.end():True] for segment in text: segment.mention = target sent.text = (sent.text[:match.start():True] + text + sent.text[match.end()::True]) return sent async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if not primary or not source.text or await sent.channel.is_private(): return try: _, members = await self._get_members(sent) except _Skip: return mentioned = set() for mention in re.findall(r"@\S+", str(source.text)): matches = self.match(mention, members) if matches: log.debug("Mention %r applies: %r", mention, matches) mentioned.update(matches) else: log.debug("Mention %r doesn't apply", mention) for segment in source.text: if segment.mention and segment.mention in members: log.debug("Segment mention %r applies: %r", segment.text, segment.mention) mentioned.add(segment.mention) if not mentioned: return text = immp.RichText() if source.user: text.append( immp.Segment(source.user.real_name or source.user.username, bold=True), immp.Segment(" mentioned you")) else: text.append(immp.Segment("You were mentioned")) title = await sent.channel.title() link = await sent.channel.link() if title: text.append(immp.Segment(" in "), immp.Segment(title, italic=True)) text.append(immp.Segment(":\n")) text += source.text if source.user and source.user.link: text.append(immp.Segment("\n"), immp.Segment("Go to user", link=source.user.link)) if link: text.append(immp.Segment("\n"), immp.Segment("Go to channel", link=link)) tasks = [] for member in mentioned: if member == source.user: continue private = await sent.channel.plug.channel_for_user(member) if private: tasks.append(private.send(immp.Message(text=text))) if tasks: await wait(tasks)
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 CommandHook(immp.Hook): """ Generic command handler for other hooks. Hooks should wrap command handler methods with the :func:`command` decorator, which will be picked up by any instances of this class configured to include that hook. """ schema = immp.Schema({ "prefix": [str], immp.Optional("return-errors", False): bool, immp.Optional("sets", dict): { str: { str: [str] } }, "mapping": { str: { immp.Optional("groups", list): [str], immp.Optional("hooks", list): [str], immp.Optional("identify", dict): { str: [str] }, immp.Optional("sets", list): [str] } } }) def __init__(self, name, config, host): super().__init__(name, config, host) # Avoiding circular dependency between commands and sync -- use the full path to populate # that attribute path in the global `immp` import for later (so unused here). import immp.hook.sync # noqa def discover(self, hook): """ Inspect a :class:`.Hook` instance, scanning its attributes for commands. Returns: (str, .BoundCommand) dict: Commands provided by this hook, keyed by name. """ if hook.state != immp.OpenState.active: return {} attrs = [getattr(hook, attr) for attr in dir(hook)] cmds = { cmd.name: cmd for cmd in attrs if isinstance(cmd, BoundCommand) and isinstance(cmd.cmd, FullCommand) } if isinstance(hook, DynamicCommands): cmds.update({cmd.name: cmd for cmd in hook.commands()}) return cmds def _mapping_cmds(self, mapping, channel, user, private): cmdgroup = set() for name in mapping["hooks"]: cmdgroup.update(set(self.discover(self.host.hooks[name]).values())) for label in mapping["sets"]: for name, cmdset in self.config["sets"][label].items(): discovered = self.discover(self.host.hooks[name]) cmdgroup.update(set(discovered[cmd] for cmd in cmdset)) return { cmd for cmd in cmdgroup if cmd.applicable(channel, user, private) } async def commands(self, channel, user): """ Retrieve all commands, and filter against the mappings. Args: channel (.Channel): Source channel where the command will be executed. user (.User): Author of the message to trigger the command. Returns: (str, .BoundCommand) dict: Commands provided by all hooks, in this channel for this user, keyed by name. """ log.debug("Collecting commands for %r in %r", user, channel) if isinstance(channel, immp.Plug): # Look for commands for a generic channel. plug = channel channel = immp.Channel(plug, "") private = False else: plug = None private = await channel.is_private() mappings = [] identities = {} for label, mapping in self.config["mapping"].items(): providers = mapping["identify"] if providers: for name, roles in providers.items(): if name not in identities: try: provider = self.host.hooks[name] identities[ name] = await provider.identity_from_user(user) except Exception: log.exception( "Exception retrieving identity from %r for map %r", name, label) identities[name] = None continue if not identities[name]: continue elif not roles or set(roles).intersection( identities[name].roles): log.debug("Identified %r as %r for map %r", user, identities[name], label) break else: log.debug("Could not identify %r for map %r, skipping", user, label) continue for name in mapping["groups"]: group = self.host.groups[name] if plug and group.has_plug(plug, "anywhere", "named"): mappings.append(mapping) elif not plug and await group.has_channel(channel): mappings.append(mapping) cmds = set() for mapping in mappings: cmds.update(self._mapping_cmds(mapping, channel, user, private)) mapped = {cmd.name: cmd for cmd in cmds} if len(cmds) > len(mapped): # Mapping by name silently overwrote at least one command with a duplicate name. raise immp.ConfigError( "Multiple applicable commands with the same name") return mapped @command("help", sync_aware=True) async def help(self, msg, command=None): """ List all available commands in this channel, or show help about a single command. """ if await msg.channel.is_private(): current = None private = msg.channel else: current = msg.channel private = await msg.user.private_channel() parts = defaultdict(dict) if current: parts[current] = await self.commands(current, msg.user) if private: parts[private] = await self.commands(private, msg.user) for name in parts[private]: parts[current].pop(name, None) parts[None] = await self.commands(msg.channel.plug, msg.user) for name in parts[None]: if private: parts[private].pop(name, None) if current: parts[current].pop(name, None) full = dict(parts[None]) full.update(parts[current]) full.update(parts[private]) if command: try: cmd = full[command] except KeyError: text = "\N{CROSS MARK} No such command" else: text = immp.RichText([immp.Segment(cmd.name, bold=True)]) if cmd.spec: text.append(immp.Segment(" {}".format(cmd.spec))) if cmd.doc: text.append(immp.Segment(":", bold=True), immp.Segment("\n"), *immp.RichText.unraw(cmd.doc, self.host)) else: titles = {None: [immp.Segment("Global commands", bold=True)]} if private: titles[private] = [immp.Segment("Private commands", bold=True)] if current: titles[current] = [ immp.Segment("Commands for ", bold=True), immp.Segment(await current.title(), bold=True, italic=True) ] text = immp.RichText() for channel, cmds in parts.items(): if not cmds: continue if text: text.append(immp.Segment("\n")) text.append(*titles[channel]) for name, cmd in sorted(cmds.items()): text.append(immp.Segment("\n- {}".format(name))) if cmd.spec: text.append( immp.Segment(" {}".format(cmd.spec), italic=True)) await msg.channel.send(immp.Message(text=text)) async def on_receive(self, sent, source, primary): await super().on_receive(sent, source, primary) if not primary or not sent.user or not sent.text or sent != source: return plain = str(sent.text) raw = None for prefix in self.config["prefix"]: if plain.lower().startswith(prefix): raw = plain[len(prefix):].split(maxsplit=1) break if not raw: return # Sync integration: exclude native channels of syncs from command execution. if isinstance(sent.channel.plug, immp.hook.sync.SyncPlug): log.debug("Suppressing command in virtual sync channel: %r", sent.channel) return synced = immp.hook.sync.SyncPlug.any_sync(self.host, sent.channel) if synced: log.debug("Mapping command channel: %r -> %r", sent.channel, synced) name = raw[0].lower() trailing = sent.text[-len(raw[1])::True] if len(raw) == 2 else None cmds = await self.commands(sent.channel, sent.user) try: cmd = cmds[name] except KeyError: log.debug("No matches for command name %r in %r", name, sent.channel) return else: log.debug("Matched command in %r: %r", sent.channel, cmd) try: args = cmd.parse(trailing) cmd.valid(*args) except ValueError: # Invalid number of arguments passed, return the command usage. await self.help(sent, name) return if synced and not cmd.sync_aware: msg = copy(sent) msg.channel = synced else: msg = sent try: log.debug("Executing command: %r %r", sent.channel, sent.text) await cmd(msg, *args) except BadUsage: await self.help(sent, name) except Exception as e: log.exception("Exception whilst running command: %r", sent.text) if self.config["return-errors"]: text = ": ".join(filter(None, (e.__class__.__name__, str(e)))) await sent.channel.send( immp.Message(text="\N{WARNING SIGN} {}".format(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("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