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({ "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 _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 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 AlertHookBase(immp.Hook): schema = immp.Schema({"groups": [str]}) group = immp.Group.MergedProperty("groups") async def _get_members(self, msg): # Sync integration: avoid duplicate notifications inside and outside a synced channel. # Commands and excludes should apply to the sync, but notifications are based on the # network-native channel. if isinstance(msg.channel.plug, SyncPlug): # We're in the sync channel, so we've already handled this event in native channels. log.debug("Ignoring sync channel: %r", msg.channel) raise Skip channel = msg.channel synced = SyncPlug.any_sync(self.host, msg.channel) if synced: # We're in the native channel of a sync, use this channel for reading config. log.debug("Translating sync channel: %r -> %r", msg.channel, synced) channel = synced members = [ user for user in (await msg.channel.members()) or [] if self.group.has_plug(user.plug) ] if not members: raise Skip return channel, members
class AsyncDatabaseHook(immp.ResourceHook, _ModelsMixin): """ Hook that provides generic data access to other hooks, backed by :mod:`tortoise`. Because models are in the global scope, they can only be attached to a single database, therefore this hook acts as the single source of truth for obtaining that "global" database. Hooks should register their models to the database connection at startup by calling :meth:`add_models` (obtained from ``host.resources[AsyncDatabaseHook]``), which will create any needed tables the first time models are added. """ schema = immp.Schema({"url": str}) def __init__(self, name, config, host): super().__init__(name, config, host) if not Tortoise: raise immp.PlugError("'tortoise' module not installed") async def start(self): await super().start() log.debug("Opening connection to database") modules = sorted(set(model.__module__ for model in self.models)) log.debug("Registering model modules: %s", ", ".join(modules)) await Tortoise.init(db_url=self.config["url"], modules={"db": modules}) await Tortoise.generate_schemas(safe=True) async def stop(self): await super().stop() log.debug("Closing connection to database") await Tortoise.close_connections()
class DatabaseHook(immp.ResourceHook): """ Hook that provides generic database access to other hooks, backed by :mod:`peewee`. Because models are in the global scope, they can only be attached to a single database, therefore this hook acts as the single source of truth for obtaining a "global" database. Attributes: db (peewee.Database): Connected database instance. """ schema = immp.Schema({"url": str}) def __init__(self, name, config, host): super().__init__(name, config, host) self.db = None async def start(self): log.debug("Opening connection to database") self.db = connect(self.config["url"]) BaseModel._meta.database.initialize(self.db) async def stop(self): if self.db: log.debug("Closing connection to database") self.db.close() self.db = None
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 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)
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 DiscordRoleHook(immp.Hook): """ Hook to assign and unassign Discord roles to and from users. """ schema = immp.Schema({"roles": {str: int}}) def _common(self, msg, name): if name not in self.config["roles"]: raise _NoSuchRole client = msg.channel.plug._client channel = client.get_channel(int(msg.channel.source)) member = channel.guild.get_member(int(msg.user.id)) for role in channel.guild.roles: if role.id == self.config["roles"][name]: return role, member else: raise _NoSuchRole def _test(self, channel, user): return isinstance(channel.plug, DiscordPlug) @command("role", scope=CommandScope.shared, parser=CommandParser.none, test=_test, sync_aware=True) async def role(self, msg, name): try: role, member = self._common(msg, str(name)) except _NoSuchRole: await msg.channel.send(immp.Message(text="No such role")) return else: await member.add_roles(role) await msg.channel.send( immp.Message(text="\N{WHITE HEAVY CHECK MARK} Added")) @command("unrole", scope=CommandScope.shared, parser=CommandParser.none, test=_test, sync_aware=True) async def unrole(self, msg, name): try: role, member = self._common(msg, str(name)) except _NoSuchRole: await msg.channel.send(immp.Message(text="No such role")) return else: await member.remove_roles(role) await msg.channel.send( immp.Message(text="\N{WHITE HEAVY CHECK MARK} Removed"))
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 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 TextCommandHook(immp.Hook, DynamicCommands): """ Command provider to send configured text responses. """ schema = immp.Schema({"commands": {str: str}}) def commands(self): return { self._response.complete(name, name) for name in self.config["commands"] } @command() async def _response(self, name, msg): text = immp.RichText.unraw(self.config["commands"][name], self.host) await msg.channel.send(immp.Message(text=text))
class DatabaseHook(immp.ResourceHook, _ModelsMixin): """ Hook that provides generic data access to other hooks, backed by :ref:`Peewee <peewee:api>`. Because models are in the global scope, they can only be attached to a single database, therefore this hook acts as the single source of truth for obtaining that "global" database. Hooks should subclass :class:`.BaseModel` for their data structures, in order to gain the database connection. At startup, they can register their models to the database connection by calling :meth:`add_models` (obtained from ``host.resources[DatabaseHook]``), which will create any needed tables the first time models are added. Any database types supported by Peewee can be used, though the usual caveats apply: if a hook requires fields specific to a single database type, the system as a whole is effectively locked-in to that type. """ schema = immp.Schema({"url": str}) def __init__(self, name, config, host): super().__init__(name, config, host) warn("DatabaseHook is deprecated, migrate to AsyncDatabaseHook", DeprecationWarning) if not Model: raise immp.PlugError("'peewee' module not installed") self.db = None async def start(self): await super().start() log.debug("Opening connection to database") self.db = connect(self.config["url"]) BaseModel._meta.database.initialize(self.db) if self.models: names = sorted(cls.__name__ for cls in self.models) log.debug("Registering models: %s", ", ".join(names)) self.db.create_tables(self.models, safe=True) async def stop(self): await super().stop() if self.db: log.debug("Closing connection to database") self.db.close() self.db = 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)
class WebUIHook(immp.ResourceHook): """ Hook providing web-based configuration management for a running host instance. """ schema = immp.Schema({"route": str}) def __init__(self, name, config, host): super().__init__(name, config, host) self.ctx = None self._host_version = get_distribution( immp.__name__).version if get_distribution else None self._python_version = ".".join(str(v) for v in sys.version_info[:3]) def on_load(self): log.debug("Registering routes") runner = self.host.resources.get(RunnerHook) self.ctx = self.host.resources[WebHook].context( self.config["route"], __name__, env={ "hook_url_for": self.hook_url_for, "group_summary": self.group_summary, "runner": runner, # `zip` doesn't seem to work. "zipped": zip }) # Home: self.ctx.route("GET", "", self.main, "main.j2", "main") # Add: self.ctx.route("GET", "add", self.noop, "add.j2", "add") self.ctx.route("POST", "add", self.add, "add.j2", "add:post") # Plugs: self.ctx.route("GET", "plug/{name}", self.plug, "plug.j2") self.ctx.route("POST", "plug/{name}/disable", self.plug_disable) self.ctx.route("POST", "plug/{name}/enable", self.plug_enable) self.ctx.route("POST", "plug/{name}/stop", self.plug_stop) self.ctx.route("POST", "plug/{name}/start", self.plug_start) self.ctx.route("POST", "plug/{name}/config", self.plug_config) self.ctx.route("GET", "plug/{name}/channels", self.plug_channels, "plug_channels.j2") self.ctx.route("GET", "plug/{name}/remove", self.plug, "plug_remove.j2", "plug_remove") self.ctx.route("POST", "plug/{name}/remove", self.plug_remove, name="plug_remove:post") # Channels: self.ctx.route("GET", "channel/{name}", self.named_channel, "channel.j2") self.ctx.route("POST", "channel", self.named_channel_add) self.ctx.route("POST", "channel/{name}/remove", self.named_channel_remove) self.ctx.route("GET", "plug/{plug}/channel/{source}", self.channel, "channel.j2") self.ctx.route("POST", "plug/{plug}/channel/{source}/migrate", self.channel_migration) self.ctx.route("POST", "plug/{plug}/channel/{source}/invite", self.channel_invite) self.ctx.route("POST", "plug/{plug}/channel/{source}/kick/{user}", self.channel_kick) # Groups: self.ctx.route("GET", "group/{name}", self.group, "group.j2") self.ctx.route("POST", "group", self.group_add) self.ctx.route("POST", "group/{name}/remove", self.group_remove) self.ctx.route("POST", "group/{name}/config", self.group_config) # Hooks: self.ctx.route("GET", "resource/{cls}", self.hook, "hook.j2", "resource") self.ctx.route("POST", "resource/{cls}/disable", self.hook_disable, name="resource_disable") self.ctx.route("POST", "resource/{cls}/enable", self.hook_enable, name="resource_enable") self.ctx.route("POST", "resource/{cls}/stop", self.hook_stop, name="resource_stop") self.ctx.route("POST", "resource/{cls}/start", self.hook_start, name="resource_start") self.ctx.route("POST", "resource/{cls}/config", self.hook_config, name="resource_config") self.ctx.route("GET", "resource/{cls}/remove", self.hook, "hook_remove.j2", "resource_remove") self.ctx.route("POST", "resource/{cls}/remove", self.hook_remove, name="resource_remove:post") self.ctx.route("GET", "hook/{name}", self.hook, "hook.j2") self.ctx.route("POST", "hook/{name}/disable", self.hook_disable) self.ctx.route("POST", "hook/{name}/enable", self.hook_enable) self.ctx.route("POST", "hook/{name}/stop", self.hook_stop) self.ctx.route("POST", "hook/{name}/start", self.hook_start) self.ctx.route("POST", "hook/{name}/config", self.hook_config) self.ctx.route("GET", "hook/{name}/remove", self.hook, "hook_remove.j2", "hook_remove") self.ctx.route("POST", "hook/{name}/remove", self.hook_remove, name="hook_remove:post") async def noop(self, request): return {} async def main(self, request): loggers = ([("<root>", logging.getLevelName(logging.root.level))] + [(module, logging.getLevelName(logger.level)) for module, logger in sorted( logging.root.manager.loggerDict.items()) if isinstance(logger, logging.Logger) and logger.level != logging.NOTSET]) uptime = None if self.host.started: uptime = datetime.now() - self.host.started # Drop microseconds from the delta (no datetime.replace equivalent for timedelta). uptime = timedelta(days=uptime.days, seconds=uptime.seconds) return { "uptime": uptime, "loggers": loggers, "versions": [("Python", self._python_version), ("IMMP", self._host_version)] } async def add(self, request): post = await request.post() try: path = post["path"] except KeyError: raise web.HTTPBadRequest if not path: raise web.HTTPBadRequest try: cls = immp.resolve_import(path) except ImportError: raise web.HTTPNotFound if "schema" in post: config = post.get("config") or "" doc, doc_html = _render_module_doc(cls) return { "path": path, "config": config, "class": cls, "doc": doc, "doc_html": doc_html, "hook": issubclass(cls, immp.Hook) } try: name = post["name"] except KeyError: raise web.HTTPBadRequest if not name: raise web.HTTPBadRequest elif name in self.host: raise web.HTTPConflict if cls.schema: try: config = json.loads(post["config"]) except (KeyError, ValueError): raise web.HTTPBadRequest else: config = None if not issubclass(cls, (immp.Plug, immp.Hook)): raise web.HTTPNotFound try: inst = cls(name, config, self.host) except immp.Invalid: raise web.HTTPNotAcceptable if issubclass(cls, immp.Plug): self.host.add_plug(inst) raise web.HTTPFound(self.ctx.url_for("plug", name=name)) elif issubclass(cls, immp.Hook): try: priority = int(post["priority"]) if post["priority"] else None except (KeyError, ValueError): raise web.HTTPBadRequest self.host.add_hook(inst, priority) if issubclass(cls, immp.ResourceHook): raise web.HTTPFound(self.ctx.url_for("resource", cls=path)) else: raise web.HTTPFound(self.ctx.url_for("hook", name=name)) def _resolve_plug(self, request): try: return self.host.plugs[request.match_info["name"]] except KeyError: raise web.HTTPNotFound async def plug(self, request): plug = self._resolve_plug(request) name = None source = request.query.get("source") if source: title = await immp.Channel(plug, source).title() name = re.sub(r"[^a-z0-9]+", "-", title, flags=re.I).strip("-") if title else "" doc, doc_html = _render_module_doc(plug.__class__) return { "plug": plug, "doc": doc, "doc_html": doc_html, "add_name": name, "add_source": source, "channels": { name: channel for name, channel in self.host.channels.items() if channel.plug == plug } } async def plug_disable(self, request): plug = self._resolve_plug(request) plug.disable() raise web.HTTPFound(self.ctx.url_for("plug", name=plug.name)) async def plug_enable(self, request): plug = self._resolve_plug(request) plug.enable() raise web.HTTPFound(self.ctx.url_for("plug", name=plug.name)) async def plug_stop(self, request): plug = self._resolve_plug(request) ensure_future(plug.close()) raise web.HTTPFound(self.ctx.url_for("plug", name=plug.name)) async def plug_start(self, request): plug = self._resolve_plug(request) ensure_future(plug.open()) raise web.HTTPFound(self.ctx.url_for("plug", name=plug.name)) async def plug_config(self, request): plug = self._resolve_plug(request) if not plug.schema: raise web.HTTPNotFound post = await request.post() if "config" not in post: raise web.HTTPBadRequest try: config = json.loads(post["config"]) except ValueError: raise web.HTTPNotAcceptable try: plug.config = plug.schema(config) except immp.Invalid: raise web.HTTPNotAcceptable raise web.HTTPFound(self.ctx.url_for("plug", name=plug.name)) async def plug_channels(self, request): plug = self._resolve_plug(request) public, private = await gather(plug.public_channels(), plug.private_channels()) titles = await gather(*(channel.title() for channel in public)) if public else [] all_members = await gather( *(channel.members() for channel in private)) if private else [] users = [] for members in all_members: if not members: users.append([]) continue systems = await gather( *(member.is_system() for member in members)) if members else [] users.append([ member for member, system in zip(members, systems) if not system ]) channels = defaultdict(list) for name, channel in self.host.channels.items(): if channel.plug == plug: channels[channel].append(name) return { "plug": plug, "channels": channels, "public": public, "titles": titles, "private": private, "users": users } async def plug_remove(self, request): plug = self._resolve_plug(request) await plug.stop() self.host.remove_plug(plug.name) raise web.HTTPFound(self.ctx.url_for("main")) def _resolve_channel(self, request): try: if "name" in request.match_info: name = request.match_info["name"] return name, self.host.channels[name] elif "plug" in request.match_info: plug = self.host.plugs[request.match_info["plug"]] return None, immp.Channel(plug, request.match_info["source"]) else: raise web.HTTPBadRequest except KeyError: raise web.HTTPNotFound async def named_channel(self, request): name, channel = self._resolve_channel(request) raise web.HTTPFound( self.ctx.url_for("channel", plug=channel.plug.name, source=channel.source)) async def named_channel_add(self, request): post = await request.post() try: plug = post["plug"] name = post["name"] source = post["source"] except KeyError: raise web.HTTPBadRequest if not (plug and name and source): raise web.HTTPBadRequest if name in self.host: raise web.HTTPConflict if plug not in self.host.plugs: raise web.HTTPNotFound self.host.add_channel(name, immp.Channel(self.host.plugs[plug], source)) raise web.HTTPFound(self.ctx.url_for("plug", name=plug)) async def named_channel_remove(self, request): name, channel = self._resolve_channel(request) self.host.remove_channel(name) raise web.HTTPFound(self.ctx.url_for("plug", name=channel.plug.name)) async def channel(self, request): name, channel = self._resolve_channel(request) private = await channel.is_private() title = await channel.title() link = await channel.link() members = await channel.members() return { "name": name, "channel": channel, "private": private, "title_": title, "link": link, "members": members } async def channel_migration(self, request): _, old = self._resolve_channel(request) post = await request.post() if "name" in post: new = self.host.channels[post["name"]] elif "plug" in post and "source" in post: new = immp.Channel(self.host.plugs[post["plug"]], post["source"]) else: raise web.HTTPBadRequest await self.host.channel_migrate(old, new) raise web.HTTPFound( self.ctx.url_for("channel", plug=old.plug.name, source=old.source)) async def channel_invite(self, request): name, channel = self._resolve_channel(request) if channel.plug.virtual: raise web.HTTPBadRequest post = await request.post() try: id_ = post["user"] except KeyError: raise web.HTTPBadRequest members = await channel.members() if members is None: raise web.HTTPBadRequest elif id_ in (member.id for member in members): raise web.HTTPBadRequest user = await channel.plug.user_from_id(id_) if user is None: raise web.HTTPBadRequest await channel.invite(user) raise web.HTTPFound( self.ctx.url_for("channel", plug=channel.plug.name, source=channel.source)) async def channel_kick(self, request): _, channel = self._resolve_channel(request) if channel.plug.virtual: raise web.HTTPBadRequest id_ = request.match_info["user"] members = await channel.members() if members is None: raise web.HTTPBadRequest elif id_ not in (member.id for member in members): raise web.HTTPBadRequest user = await channel.plug.user_from_id(id_) if user is None: raise web.HTTPBadRequest await channel.remove(user) raise web.HTTPFound( self.ctx.url_for("channel", plug=channel.plug.name, source=channel.source)) def _resolve_group(self, request): try: return self.host.groups[request.match_info["name"]] except KeyError: raise web.HTTPNotFound @staticmethod def group_summary(group): summary = [] for key in ("anywhere", "private", "shared", "named", "channels"): count = len(group.config[key]) if count: summary.append("{} {}".format(count, key)) return ", ".join(summary) if summary else "Empty group" async def group(self, request): group = self._resolve_group(request) return {"group": group} async def group_add(self, request): post = await request.post() try: name = post["name"] except KeyError: raise web.HTTPBadRequest if not name: raise web.HTTPBadRequest if name in self.host: raise web.HTTPConflict self.host.add_group(immp.Group(name, {}, self.host)) raise web.HTTPFound(self.ctx.url_for("group", name=name)) async def group_remove(self, request): group = self._resolve_group(request) self.host.remove_group(group.name) raise web.HTTPFound(self.ctx.url_for("main")) async def group_config(self, request): group = self._resolve_group(request) post = await request.post() if "config" not in post: raise web.HTTPBadRequest try: config = json.loads(post["config"]) except ValueError: raise web.HTTPBadRequest try: group.config = group.schema(config) except immp.Invalid: raise web.HTTPNotAcceptable raise web.HTTPFound(self.ctx.url_for("group", name=group.name)) def _resolve_hook(self, request): if "name" in request.match_info: try: return self.host.hooks[request.match_info["name"]] except KeyError: pass elif "cls" in request.match_info: for cls, hook in self.host.resources.items(): if request.match_info["cls"] == "{}.{}".format( cls.__module__, cls.__name__): return hook raise web.HTTPNotFound def hook_url_for(self, hook, name_=None, **kwargs): if isinstance(hook, immp.ResourceHook): cls = "{}.{}".format(hook.__class__.__module__, hook.__class__.__name__) route = "resource_{}".format(name_) if name_ else "resource" return self.ctx.url_for(route, cls=cls, **kwargs) else: route = "hook_{}".format(name_) if name_ else "hook" return self.ctx.url_for(route, name=hook.name, **kwargs) async def hook(self, request): hook = self._resolve_hook(request) can_stop = not isinstance(hook, (WebHook, WebUIHook)) doc, doc_html = _render_module_doc(hook) return { "hook": hook, "doc": doc, "doc_html": doc_html, "resource": isinstance(hook, immp.ResourceHook), "priority": self.host.priority.get(hook.name), "can_stop": can_stop } async def hook_disable(self, request): hook = self._resolve_hook(request) hook.disable() raise web.HTTPFound(self.hook_url_for(hook, None)) async def hook_enable(self, request): hook = self._resolve_hook(request) hook.enable() raise web.HTTPFound(self.hook_url_for(hook, None)) async def hook_stop(self, request): hook = self._resolve_hook(request) if isinstance(hook, (WebHook, WebUIHook)): # This will hang due to trying to serve this request at the same time. raise web.HTTPBadRequest ensure_future(hook.close()) raise web.HTTPFound(self.hook_url_for(hook, None)) async def hook_start(self, request): hook = self._resolve_hook(request) ensure_future(hook.open()) raise web.HTTPFound(self.hook_url_for(hook, None)) async def hook_config(self, request): hook = self._resolve_hook(request) post = await request.post() try: priority = int(post["priority"]) if post["priority"] else None except (KeyError, ValueError): raise web.HTTPBadRequest self.host.prioritise_hook(hook.name, priority) if hook.schema: if "config" not in post: raise web.HTTPBadRequest try: config = json.loads(post["config"]) except ValueError: raise web.HTTPNotAcceptable try: hook.config = hook.schema(config) except immp.Invalid: raise web.HTTPNotAcceptable raise web.HTTPFound(self.hook_url_for(hook, None)) async def hook_remove(self, request): hook = self._resolve_hook(request) await hook.stop() self.host.remove_hook(hook.name) raise web.HTTPFound(self.ctx.url_for("main"))
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 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 _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 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 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 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 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 _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 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 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 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 _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()