Beispiel #1
0
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)
    })
Beispiel #2
0
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])
    })
Beispiel #3
0
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}))
Beispiel #4
0
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())
Beispiel #5
0
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
Beispiel #6
0
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()
Beispiel #7
0
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
Beispiel #8
0
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
        }
    })
Beispiel #9
0
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)
Beispiel #10
0
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_))
Beispiel #11
0
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"))
Beispiel #12
0
 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
             }))
Beispiel #13
0
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)
Beispiel #14
0
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))
Beispiel #15
0
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
Beispiel #16
0
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)
Beispiel #17
0
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"))
Beispiel #18
0
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)
Beispiel #19
0
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)
Beispiel #20
0
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, [])
Beispiel #21
0
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]
Beispiel #22
0
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
Beispiel #23
0
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))
Beispiel #24
0
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))
Beispiel #25
0
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, [])
Beispiel #26
0
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
Beispiel #27
0
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})
Beispiel #28
0
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))
Beispiel #29
0
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)))
Beispiel #30
0
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()