├── immp
├── core
│ ├── __init__.py
│ ├── error.py
│ ├── stream.py
│ ├── hook.py
│ ├── channel.py
│ ├── plug.py
│ └── util.py
├── __main__.py
├── __init__.py
├── hook
│ ├── textcommand.py
│ ├── webui
│ │ └── templates
│ │ │ ├── hook_remove.j2
│ │ │ ├── macros.j2
│ │ │ ├── plug_remove.j2
│ │ │ ├── group.j2
│ │ │ ├── plug_channels.j2
│ │ │ ├── add.j2
│ │ │ ├── base.j2
│ │ │ ├── hook.j2
│ │ │ ├── channel.j2
│ │ │ ├── plug.j2
│ │ │ └── main.j2
│ ├── alerts
│ │ ├── common.py
│ │ ├── __init__.py
│ │ ├── mentions.py
│ │ └── subscriptions.py
│ ├── discordrole.py
│ ├── hangoutslock.py
│ ├── autorespond.py
│ ├── database.py
│ ├── shell.py
│ ├── runner.py
│ ├── notes.py
│ ├── identity.py
│ ├── web.py
│ ├── access.py
│ └── identitylocal.py
└── plug
│ ├── dummy.py
│ └── github.py
├── .dockerignore
├── requirements.txt
├── Dockerfile
├── LICENSE.txt
├── README.rst
└── setup.py
/immp/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | Dockerfile
3 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiohttp>=3.0.0
2 | aiohttp_jinja2>=1.0.0
3 | aioconsole>=0.1.14
4 | anyconfig>=0.11.1
5 | discord.py>=2.0.0
6 | docutils>=0.16
7 | emoji>=1.6.2
8 | hangups>=0.4.11
9 | jinja2>=2.6
10 | peewee>=3.0.0
11 | ptpython>=2.0.1
12 | ruamel.yaml>=0.15.75
13 | telethon>=1.9.0
14 | tortoise-orm>=0.15.0
15 | uvloop>=0.12.0
16 |
--------------------------------------------------------------------------------
/immp/core/error.py:
--------------------------------------------------------------------------------
1 | class ConfigError(Exception):
2 | """
3 | Error for invalid configuration in a given context.
4 | """
5 |
6 |
7 | class PlugError(Exception):
8 | """
9 | Error for plug-specific problems.
10 | """
11 |
12 |
13 | class HookError(Exception):
14 | """
15 | Error for hook-specific problems.
16 | """
17 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.8
2 |
3 | WORKDIR /usr/src/app
4 |
5 | COPY requirements.txt /usr/src/app/
6 | RUN pip3 install --no-cache-dir -r requirements.txt
7 |
8 | ENV PYTHONPATH /usr/src/app
9 | CMD [ "python3", "-m", "immp", "config.yaml" ]
10 |
11 | ARG uid=1000
12 | ARG gid=1000
13 |
14 | RUN groupadd -g $gid immp
15 | RUN useradd -u $uid -g immp immp
16 |
17 | COPY . /usr/src/app/
18 | RUN pip3 install --no-cache-dir .
19 |
20 | VOLUME /data
21 | WORKDIR /data
22 |
23 | USER immp
24 |
--------------------------------------------------------------------------------
/immp/__main__.py:
--------------------------------------------------------------------------------
1 | from argparse import ArgumentParser
2 |
3 | # Backwards-compatible import route for LocalFilter.
4 | from immp import LocalFilter # noqa
5 | from immp.hook.runner import main
6 |
7 | try:
8 | import uvloop
9 | except ImportError:
10 | uvloop = None
11 |
12 |
13 | def entrypoint():
14 | if uvloop:
15 | uvloop.install()
16 | parser = ArgumentParser(prog="immp", add_help=False)
17 | parser.add_argument("-w", "--write", action="store_true")
18 | parser.add_argument("file", metavar="FILE")
19 | args = parser.parse_args()
20 | main(args.file, args.write)
21 |
22 |
23 | if __name__ == "__main__":
24 | entrypoint()
25 |
--------------------------------------------------------------------------------
/immp/__init__.py:
--------------------------------------------------------------------------------
1 | from .core.channel import Channel, Group
2 | from .core.error import ConfigError, HookError, PlugError
3 | from .core.host import Host
4 | from .core.message import File, Location, Message, Receipt, RichText, Segment, SentMessage, User
5 | from .core.hook import Hook, ResourceHook
6 | from .core.plug import Plug
7 | from .core.schema import (Any, Invalid, JSONSchema, Nullable, Optional, Schema, SchemaError,
8 | Validator, Walker)
9 | from .core.stream import PlugStream
10 | from .core.util import (escape, pretty_str, resolve_import, unescape, ConfigProperty,
11 | Configurable, HTTPOpenable, IDGen, LocalFilter, OpenState, Openable,
12 | Watchable, WatchedDict, WatchedList)
13 |
--------------------------------------------------------------------------------
/immp/hook/textcommand.py:
--------------------------------------------------------------------------------
1 | """
2 | Simple custom commands to send preconfigured text messages to channels.
3 |
4 | Config:
5 | commands ((str, str) dict):
6 | Mapping from command name to rich response text.
7 | """
8 |
9 | import immp
10 | from immp.hook.command import command, DynamicCommands
11 |
12 |
13 | class TextCommandHook(immp.Hook, DynamicCommands):
14 | """
15 | Command provider to send configured text responses.
16 | """
17 |
18 | schema = immp.Schema({"commands": {str: str}})
19 |
20 | def commands(self):
21 | return {self._response.complete(name, name) for name in self.config["commands"]}
22 |
23 | @command()
24 | async def _response(self, name, msg):
25 | text = immp.RichText.unraw(self.config["commands"][name], self.host)
26 | await msg.channel.send(immp.Message(text=text))
27 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/hook_remove.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% set title = "Remove" %}
3 | {% set subtitle = hook.name %}
4 | {% set nav = [("Hook: " + hook.name, hook_url_for(hook))] %}
5 |
6 | {% block body %}
7 |
8 |
9 |
10 |
This will not notify any other hooks with a reference to this hook, nor will it attempt to remove it from their state.
11 |
12 |
13 |
27 |
28 | {% endblock %}
29 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/macros.j2:
--------------------------------------------------------------------------------
1 | {%- macro open_icon(state) -%}
2 | {%- if state == immp.OpenState.disabled -%}
3 | eye-slash
4 | {%- elif state == immp.OpenState.active -%}
5 | circle
6 | {%- elif state == immp.OpenState.inactive -%}
7 | ban
8 | {%- elif state == immp.OpenState.failed -%}
9 | exclamation-triangle
10 | {%- else -%}
11 | hourglass-half
12 | {%- endif -%}
13 | {%- endmacro -%}
14 |
15 | {%- macro open_colour(state) -%}
16 | {%- if state == immp.OpenState.active -%}
17 | success
18 | {%- elif state == immp.OpenState.inactive -%}
19 | danger
20 | {%- elif state == immp.OpenState.failed -%}
21 | warning
22 | {%- endif -%}
23 | {%- endmacro -%}
24 |
25 | {%- macro render_doc(doc, doc_html) -%}
26 | {%- if doc -%}
27 | Documentation
28 | {%- if doc_html -%}
29 |
30 | {{ doc_html | safe }}
31 |
32 | {%- else -%}
33 | {{ doc }}
34 | {%- endif -%}
35 | {%- endif -%}
36 | {%- endmacro -%}
37 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/plug_remove.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% set title = "Remove" %}
3 | {% set subtitle = plug.network_name %}
4 | {% if plug.network_id %}
5 | {% set subtitle = subtitle + " (" + plug.network_id + ")" %}
6 | {% endif %}
7 | {% set nav = [("Plug: " + plug.name, ctx.url_for("plug", name=plug.name))] %}
8 |
9 | {% block body %}
10 |
11 |
12 |
13 |
This will not notify any hooks with a reference to this plug, nor will it attempt to remove it from their state.{% if channels %} {{ channels|length }} named channel{% if channels|length != 1 %}s{% endif %} will also be removed.{% endif %}
14 |
15 |
16 |
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/immp/hook/alerts/common.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import immp
4 | from immp.hook.sync import SyncPlug
5 |
6 |
7 | log = logging.getLogger(__name__)
8 |
9 |
10 | class Skip(Exception):
11 | # Message isn't applicable to the hook.
12 | pass
13 |
14 |
15 | class AlertHookBase(immp.Hook):
16 |
17 | schema = immp.Schema({"groups": [str]})
18 |
19 | group = immp.Group.MergedProperty("groups")
20 |
21 | async def _get_members(self, msg):
22 | # Sync integration: avoid duplicate notifications inside and outside a synced channel.
23 | # Commands and excludes should apply to the sync, but notifications are based on the
24 | # network-native channel.
25 | if isinstance(msg.channel.plug, SyncPlug):
26 | # We're in the sync channel, so we've already handled this event in native channels.
27 | log.debug("Ignoring sync channel: %r", msg.channel)
28 | raise Skip
29 | channel = msg.channel
30 | synced = SyncPlug.any_sync(self.host, msg.channel)
31 | if synced:
32 | # We're in the native channel of a sync, use this channel for reading config.
33 | log.debug("Translating sync channel: %r -> %r", msg.channel, synced)
34 | channel = synced
35 | members = [user for user in (await msg.channel.members()) or []
36 | if self.group.has_plug(user.plug)]
37 | if not members:
38 | raise Skip
39 | return channel, members
40 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2018, Terrance
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | * Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | * Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | * Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/group.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% set title = "Group: " + group.name %}
3 | {% set subtitle = group_summary(group) %}
4 |
5 | {% block body -%}
6 |
7 |
8 |
17 |
18 |
Config
19 | {%- if runner and not runner.writeable %}
20 |
21 |
22 |
A config file is being used, but will not be written to. Changes will only apply to the current session.
23 |
24 |
25 | {%- endif %}
26 |
42 |
43 | {%- endblock %}
44 |
--------------------------------------------------------------------------------
/immp/hook/alerts/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Fallback mention and word highlight support for plugs via private channels.
3 |
4 | Mentions
5 | ~~~~~~~~
6 |
7 | Config:
8 | plugs (str list):
9 | List of plug names to enable mention alerts for.
10 | usernames (bool):
11 | Whether to match network usernames (``True`` by default).
12 | real-names (bool):
13 | Whether to match user's display names (``False`` by default).
14 | ambiguous (bool):
15 | Whether to notify multiple potential users of an ambiguous mention (``False`` by default).
16 |
17 | For networks that don't provide native user mentions, this plug can send users a private message
18 | when mentioned by their username or real name.
19 |
20 | A mention is matched from each ``@`` sign until whitespace is encountered. For real names, spaces
21 | and special characters are ignored, so that e.g. ``@fredbloggs`` will match *Fred Bloggs*.
22 |
23 | Partial mentions are supported, failing any exact matches, by basic prefix search on real names.
24 | For example, ``@fred`` will match *Frederick*, and ``@fredb`` will match *Fred Bloggs*.
25 |
26 | Subscriptions
27 | ~~~~~~~~~~~~~
28 |
29 | Dependencies:
30 | :class:`.AsyncDatabaseHook`
31 |
32 | Config:
33 | plugs (str list):
34 | List of plug names to enable subscription alerts for.
35 |
36 | Commands:
37 | sub-add :
38 | Add a subscription to your trigger list.
39 | sub-remove :
40 | Remove a subscription from your trigger list.
41 | sub-exclude :
42 | Don't trigger a specific subscription in the current public channel.
43 | sub-list:
44 | Show all active subscriptions.
45 |
46 | Allows users to opt in to private message notifications when chosen highlight words are used in a
47 | group conversation.
48 | """
49 |
50 | from .mentions import MentionsHook
51 | from .subscriptions import SubscriptionsHook
52 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | IMMP
2 | ====
3 |
4 | A modular processing platform for instant messages.
5 |
6 | Requirements
7 | ------------
8 |
9 | This project uses the latest and greatest Python features (that is, native asyncio syntax and
10 | asynchronous generators), and therefore requires at least **Python 3.7**.
11 |
12 | Additional modules are required for most plugs and hooks -- consult the docs for each module you
13 | want to use to check its own requirements, or use the included requirements list to install all
14 | possible dependencies for built-in modules.
15 |
16 | Terminology
17 | -----------
18 |
19 | Network
20 | An external service that provides message-based communication.
21 | Message
22 | A unit of data, which can include text, images, attachments, authorship, and so on.
23 | User
24 | An individual or service which can author messages on a network.
25 | Plug
26 | A handler for all communication with an external network, transforming the network’s content
27 | to message objects and back again.
28 | Channel
29 | A single room in an external network – a source of messages, and often a container of users.
30 | Group
31 | A collection of plugs and channels.
32 | Hook
33 | A worker that processes a stream of incoming messages, in whichever way it sees fit.
34 |
35 | Basic usage
36 | -----------
37 |
38 | Prepare a config file in a format of your choosing, e.g. in YAML:
39 |
40 | .. code:: yaml
41 |
42 | plugs:
43 | demo:
44 | path: demo.DemoPlug
45 | config:
46 | api-key: xyzzy
47 |
48 | channels:
49 | foo:
50 | plug: demo
51 | source: 12345
52 | bar:
53 | plug: demo
54 | source: 98765
55 |
56 | hooks:
57 | test:
58 | path: test.TestHook
59 | config:
60 | channels: [foo, bar]
61 | args: [123, 456]
62 |
63 | All labels under the top-level names are effectively free text, and are used to reference from
64 | other sections.
65 |
66 | Then start the built-in runner::
67 |
68 | $ immp config.yaml
69 |
--------------------------------------------------------------------------------
/immp/plug/dummy.py:
--------------------------------------------------------------------------------
1 | """
2 | For testing, a plug with no external network.
3 |
4 | Messages with sequential identifiers are created and posted to the ``dummy`` channel every 10
5 | seconds. Any messages sent to the plug are echoed to this channel as if a network had itself
6 | processed them.
7 | """
8 |
9 | from asyncio import ensure_future, sleep
10 | import logging
11 |
12 | import immp
13 |
14 |
15 | log = logging.getLogger(__name__)
16 |
17 |
18 | class DummyPlug(immp.Plug):
19 | """
20 | Test plug that yields a message every 10 seconds.
21 | """
22 |
23 | network_name = "Dummy"
24 | network_id = "dummy"
25 |
26 | def __init__(self, name, config, host):
27 | super().__init__(name, config, host)
28 | self.counter = immp.IDGen()
29 | self.user = immp.User(id_="dummy", real_name=name)
30 | self.channel = immp.Channel(self, "dummy")
31 | self._task = None
32 |
33 | async def start(self):
34 | await super().start()
35 | self._task = ensure_future(self._timer())
36 |
37 | async def stop(self):
38 | await super().stop()
39 | if self._task:
40 | self._task.cancel()
41 | self._task = None
42 |
43 | async def put(self, channel, msg):
44 | # Make a clone of the message to echo back out of the generator.
45 | clone = immp.SentMessage(id_=self.counter(),
46 | channel=self.channel,
47 | text=msg.text,
48 | user=msg.user,
49 | action=msg.action)
50 | log.debug("Returning message: %r", clone)
51 | self.queue(clone)
52 | return [clone]
53 |
54 | async def _timer(self):
55 | while True:
56 | await sleep(10)
57 | log.debug("Creating next test message")
58 | self.queue(immp.SentMessage(id_=self.counter(),
59 | channel=self.channel,
60 | text="Test",
61 | user=self.user))
62 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from glob import glob
2 | import os.path
3 |
4 | from setuptools import find_namespace_packages, setup
5 |
6 |
7 | README = os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.rst")
8 |
9 |
10 | BASE = "immp"
11 | PACKAGES = [BASE]
12 | DATA = {}
13 | SCRIPTS = glob("bin/*")
14 |
15 | for name in find_namespace_packages(BASE):
16 | pkg = "{}.{}".format(BASE, name)
17 | parent, sub = pkg.rsplit(".", 1)
18 | if parent and sub == "templates":
19 | DATA[parent] = ["{}/**".format(sub)]
20 | else:
21 | PACKAGES.append(pkg)
22 |
23 |
24 | AIOHTTP = "aiohttp>=3.0.0"
25 |
26 |
27 | setup(name="IMMP",
28 | description="A modular processing platform for instant messages.",
29 | long_description=open(README).read(),
30 | long_description_content_type="text/x-rst",
31 | license="BSD 3-Clause License",
32 | platforms=["Any"],
33 | packages=PACKAGES,
34 | package_data=DATA,
35 | scripts=SCRIPTS,
36 | entry_points={"console_scripts": ["immp=immp.__main__:entrypoint"]},
37 | python_requires=">=3.7",
38 | extras_require={"runner": ["anyconfig>=0.11.1",
39 | "ruamel.yaml>=0.15.75"],
40 | "uv": ["uvloop>=0.12.0"],
41 | "db": ["tortoise-orm>=0.15.0"],
42 | "web": [AIOHTTP,
43 | "aiohttp_jinja2>=1.0.0"],
44 | "webui": ["docutils>=0.16"],
45 | "console": ["aioconsole>=0.1.14"],
46 | "sync": ["emoji>=1.2.1",
47 | "jinja2>=2.6"],
48 | "discord": [AIOHTTP,
49 | "discord.py>=2.0.0",
50 | "emoji>=1.6.2"],
51 | "hangouts": [AIOHTTP,
52 | "hangups>=0.4.11"],
53 | "slack": [AIOHTTP,
54 | "emoji>=1.6.2"],
55 | "telegram": [AIOHTTP,
56 | "telethon>=1.9.0"]},
57 | classifiers=["Development Status :: 4 - Beta",
58 | "Intended Audience :: Developers",
59 | "Topic :: Communications :: Chat",
60 | "Topic :: Software Development :: Libraries"])
61 |
--------------------------------------------------------------------------------
/immp/hook/discordrole.py:
--------------------------------------------------------------------------------
1 | """
2 | Self-serve Discord roles for users.
3 |
4 | Dependencies:
5 | :class:`.DiscordPlug`
6 |
7 | Config:
8 | roles ((str, int) dict):
9 | Mapping from user-facing role names to Discord role IDs.
10 |
11 | Commands:
12 | role :
13 | Claim a role of this name.
14 | unrole :
15 | Drop the role of this name.
16 | """
17 |
18 | import immp
19 | from immp.hook.command import CommandParser, CommandScope, command
20 | from immp.plug.discord import DiscordPlug
21 |
22 |
23 | class _NoSuchRole(Exception):
24 | pass
25 |
26 |
27 | class DiscordRoleHook(immp.Hook):
28 | """
29 | Hook to assign and unassign Discord roles to and from users.
30 | """
31 |
32 | schema = immp.Schema({"roles": {str: int}})
33 |
34 | def _common(self, msg, name):
35 | if name not in self.config["roles"]:
36 | raise _NoSuchRole
37 | client = msg.channel.plug._client
38 | channel = client.get_channel(int(msg.channel.source))
39 | member = channel.guild.get_member(int(msg.user.id))
40 | for role in channel.guild.roles:
41 | if role.id == self.config["roles"][name]:
42 | return role, member
43 | else:
44 | raise _NoSuchRole
45 |
46 | def _test(self, channel, user):
47 | return isinstance(channel.plug, DiscordPlug)
48 |
49 | @command("role", scope=CommandScope.shared, parser=CommandParser.none,
50 | test=_test, sync_aware=True)
51 | async def role(self, msg, name):
52 | try:
53 | role, member = self._common(msg, str(name))
54 | except _NoSuchRole:
55 | await msg.channel.send(immp.Message(text="No such role"))
56 | return
57 | else:
58 | await member.add_roles(role)
59 | await msg.channel.send(immp.Message(text="\N{WHITE HEAVY CHECK MARK} Added"))
60 |
61 | @command("unrole", scope=CommandScope.shared, parser=CommandParser.none,
62 | test=_test, sync_aware=True)
63 | async def unrole(self, msg, name):
64 | try:
65 | role, member = self._common(msg, str(name))
66 | except _NoSuchRole:
67 | await msg.channel.send(immp.Message(text="No such role"))
68 | return
69 | else:
70 | await member.remove_roles(role)
71 | await msg.channel.send(immp.Message(text="\N{WHITE HEAVY CHECK MARK} Removed"))
72 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/plug_channels.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% set title = "Channels" %}
3 | {% set nav = [("Plug: " + plug.name, ctx.url_for("plug", name=plug.name))] %}
4 |
5 | {% block body -%}
6 |
7 |
8 |
9 |
Public channels
10 | {%- if public is none %}
11 |
No support for retrieving a list of channels.
12 | {%- elif not public %}
13 |
No channels available.
14 | {%- else %}
15 |
16 |
17 | {%- for channel, name in zipped(public, titles) %}
18 |
19 |
20 | {{ channel.source }}
21 |
22 | {% if name %}{{ name }}{% endif %}
23 |
24 | {% if channels[channel] %}
25 | {{ channels[channel] | join(" ") }}
26 | {% else %}
27 |
28 | Add
29 |
30 | {% endif %}
31 |
32 |
33 | {%- endfor %}
34 |
35 |
36 | {%- endif %}
37 |
38 |
39 |
Private channels
40 | {%- if private is none %}
41 |
No support for retrieving a list of channels.
42 | {%- elif not private %}
43 |
No channels available.
44 | {%- else %}
45 |
46 |
47 | {%- for channel, users in zipped(private, users) %}
48 |
49 |
50 | {{ channel.source }}
51 |
52 | {% for user in users %}{% if not loop.first %}, {% endif %}{{ user.real_name or user.username }}{% endfor %}
53 |
54 | {% if channels[channel] %}
55 | {{ channels[channel] | join(" ") }}
56 | {% else %}
57 |
58 | Add
59 |
60 | {% endif %}
61 |
62 |
63 | {%- endfor %}
64 |
65 |
66 | {%- endif %}
67 |
68 |
69 |
70 | {%- endblock %}
71 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/add.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% from ctx.module + "/macros.j2" import render_doc %}
3 | {% set title = "Add" %}
4 |
5 | {% block body -%}
6 |
7 |
8 | {%- if class is defined %}
9 |
10 |
Path
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
Name
19 |
20 |
21 |
22 |
Identifier available to other hooks, in order to reference this instance in config.
23 |
24 |
25 | {%- if hook %}
26 |
27 |
28 |
Priority
29 |
30 |
31 |
32 |
Optional ordering constraint. If set, this hook will process messages ahead of unordered hooks, and relative to other ordered hooks.
33 |
34 |
35 | {%- endif %}
36 |
37 |
38 |
Config
39 | {%- if class.schema %}
40 |
41 |
42 |
{{ config or "{}" }}
43 |
44 | {%- else %}
45 |
No config required.
46 | {%- endif %}
47 |
48 |
53 | {%- else %}
54 |
55 |
Path
56 |
57 |
58 |
59 |
60 |
61 | Load
62 |
63 |
64 |
Full path to your plug or hook class. External modules must be present on the system path, either installed to a default directory or one added to sys.path.
65 |
66 | {%- endif %}
67 |
68 | {{ render_doc(doc, doc_html) }}
69 |
70 | {%- endblock %}
71 |
--------------------------------------------------------------------------------
/immp/hook/hangoutslock.py:
--------------------------------------------------------------------------------
1 | """
2 | History and link-join setting locks for Hangouts.
3 |
4 | Dependencies:
5 | :class:`.HangoutsPlug`
6 |
7 | Config:
8 | history ((str, bool) dict):
9 | Mapping from channel names to desired conversation history settings -- ``True`` to keep
10 | history enabled, ``False`` to keep it disabled.
11 | linkjoin ((str, bool) dict):
12 | Mapping from channel names to desired link join settings -- ``True`` to enable joining the
13 | hangout via link, ``False`` to disable it.
14 | """
15 |
16 | import hangups
17 | from hangups import hangouts_pb2
18 |
19 | import immp
20 | from immp.plug.hangouts import HangoutsPlug
21 |
22 |
23 | HISTORY = {True: hangouts_pb2.OFF_THE_RECORD_STATUS_ON_THE_RECORD,
24 | False: hangouts_pb2.OFF_THE_RECORD_STATUS_OFF_THE_RECORD}
25 |
26 | LINK_JOIN = {True: hangouts_pb2.GROUP_LINK_SHARING_STATUS_ON,
27 | False: hangouts_pb2.GROUP_LINK_SHARING_STATUS_OFF}
28 |
29 |
30 | class HangoutsLockHook(immp.Hook):
31 | """
32 | Hook to enforce the history and link-join settings in Hangouts.
33 | """
34 |
35 | schema = immp.Schema({immp.Optional("history", dict): {str: bool},
36 | immp.Optional("linkjoin", dict): {str: bool}})
37 |
38 | @property
39 | def channels(self):
40 | try:
41 | return {key: {self.host.channels[label]: setting
42 | for label, setting in mapping.items()}
43 | for key, mapping in self.config.items()}
44 | except KeyError as e:
45 | raise immp.HookError("No channel named '{}'".format(e.args[0]))
46 |
47 | async def on_receive(self, sent, source, primary):
48 | await super().on_receive(sent, source, primary)
49 | if sent != source or not isinstance(sent.channel.plug, HangoutsPlug):
50 | return
51 | conv = sent.channel.plug._convs.get(sent.channel.source)
52 | if isinstance(sent.raw, hangups.OTREvent):
53 | setting = HISTORY.get(self.channels["history"].get(sent.channel))
54 | if setting is None:
55 | return
56 | if setting != sent.raw.new_otr_status:
57 | request = hangouts_pb2.ModifyOTRStatusRequest(
58 | request_header=sent.channel.plug._client.get_request_header(),
59 | event_request_header=conv._get_event_request_header(),
60 | otr_status=setting)
61 | await sent.channel.plug._client.modify_otr_status(request)
62 | elif isinstance(sent.raw, hangups.GroupLinkSharingModificationEvent):
63 | setting = LINK_JOIN.get(self.channels["linkjoin"].get(sent.channel))
64 | if setting is None:
65 | return
66 | if setting != sent.raw.new_status:
67 | request = hangouts_pb2.SetGroupLinkSharingEnabledRequest(
68 | request_header=sent.channel.plug._client.get_request_header(),
69 | event_request_header=conv._get_event_request_header(),
70 | group_link_sharing_status=setting)
71 | await sent.channel.plug._client.set_group_link_sharing_enabled(request)
72 |
--------------------------------------------------------------------------------
/immp/hook/autorespond.py:
--------------------------------------------------------------------------------
1 | """
2 | Basic text request/response handler.
3 |
4 | Config:
5 | groups (str list):
6 | List of groups to process responses in.
7 | responses ((str, str) dict):
8 | Mapping from match regex to response text.
9 |
10 | Commands:
11 | ar-add :
12 | Add a new trigger / response pair.
13 | ar-remove :
14 | Remove an existing trigger.
15 |
16 | This hook will listen for messages in all given channels, for text content that matches any of the
17 | defined regular expressions. On a match, it will answer with the corresponding response. You can
18 | include capture groups in the expression, which are available using positional formatting syntax
19 | (``{0}`` for a specific group, or ``{}`` for each one in turn).
20 |
21 | Because all responses are defined in the config, you'll need to ensure it's saved when making
22 | changes via the add/remove commands.
23 | """
24 |
25 | import logging
26 | import re
27 |
28 | import immp
29 | from immp.hook.command import CommandParser, command
30 |
31 |
32 | CROSS = "\N{CROSS MARK}"
33 | TICK = "\N{WHITE HEAVY CHECK MARK}"
34 |
35 |
36 | log = logging.getLogger(__name__)
37 |
38 |
39 | class AutoRespondHook(immp.Hook):
40 | """
41 | Basic text responses for given trigger words and phrases.
42 | """
43 |
44 | schema = immp.Schema({"groups": [str],
45 | immp.Optional("responses", dict): {str: str}})
46 |
47 | group = immp.Group.MergedProperty("groups")
48 |
49 | def __init__(self, name, config, host):
50 | super().__init__(name, config, host)
51 | self._sent = []
52 |
53 | @command("ar-add", parser=CommandParser.shlex)
54 | async def add(self, msg, match, response):
55 | """
56 | Add a new trigger / response pair.
57 | """
58 | text = "Updated" if match in self.config["responses"] else "Added"
59 | self.config["responses"][match] = response
60 | await msg.channel.send(immp.Message(text="{} {}".format(TICK, text)))
61 |
62 | @command("ar-remove", parser=CommandParser.shlex)
63 | async def remove(self, msg, match):
64 | """
65 | Remove an existing trigger.
66 | """
67 | if match in self.config["responses"]:
68 | del self.config["responses"][match]
69 | text = "{} Removed".format(TICK)
70 | else:
71 | text = "{} No such response".format(CROSS)
72 | await msg.channel.send(immp.Message(text=text))
73 |
74 | async def on_receive(self, sent, source, primary):
75 | await super().on_receive(sent, source, primary)
76 | if not primary or not await self.group.has_channel(sent.channel):
77 | return
78 | # Skip our own response messages.
79 | if (sent.channel, sent.id) in self._sent:
80 | return
81 | text = str(source.text)
82 | for regex, response in self.config["responses"].items():
83 | match = re.search(regex, text, re.I)
84 | if match:
85 | log.debug("Matched regex %r in channel: %r", match, sent.channel)
86 | response = response.format(*match.groups())
87 | for receipt in await sent.channel.send(immp.Message(text=response)):
88 | self._sent.append((sent.channel, receipt.id))
89 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/base.j2:
--------------------------------------------------------------------------------
1 | {%- from ctx.module + "/macros.j2" import open_icon, open_colour -%}
2 |
3 |
4 |
5 |
6 | {% if title %}{{ title }} | {% endif %}IMMP
7 |
8 |
9 |
10 |
11 |
12 |
13 |
31 |
62 |
63 |
64 |
65 |
66 |
67 | {%- if title %}
68 |
{{ title }}
69 | {%- endif %}
70 | {%- if subtitle %}
71 | {{ subtitle }}
72 | {%- endif %}
73 |
74 |
75 |
76 |
77 | {%- if request.rel_url != ctx.url_for("main") %}
78 |
79 |
88 |
89 | {%- endif %}
90 | {%- block body %}{% endblock %}
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/hook.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% from ctx.module + "/macros.j2" import render_doc %}
3 | {% set title = ("Resource" if resource else "Hook") + ": " + hook.name %}
4 | {% set subtitle = hook.__class__.__module__ + "." + hook.__class__.__name__ %}
5 |
6 | {% block body -%}
7 |
8 |
9 |
10 | {% set colour = open_colour(hook.state) %}
11 |
12 |
13 | {{ hook.state.name|title }}
14 |
15 |
16 |
17 | {%- if hook.state == immp.OpenState.disabled %}
18 |
19 |
20 | Enable
21 |
22 |
23 | {%- elif hook.state in (immp.OpenState.inactive, immp.OpenState.failed) %}
24 |
25 |
26 | Start
27 |
28 |
29 |
30 |
31 |
32 |
33 | Disable
34 |
35 |
36 | {%- elif hook.state == immp.OpenState.active %}
37 |
38 |
39 | Stop
40 |
41 |
42 | {%- endif %}
43 |
44 |
45 | {%- if hook.virtual %}
46 |
Virtual
47 | {%- else %}
48 |
49 | Remove
50 |
51 | {%- endif %}
52 |
53 |
54 |
55 |
Edit
56 | {%- if hook.schema %}
57 |
58 |
59 |
Config
60 |
61 |
62 |
{{ hook.config|json(indent=2) }}
63 |
64 | {%- if runner and not runner.writeable %}
65 |
66 |
67 |
A config file is being used, but will not be written to. Changes will only apply to the current session.
68 |
69 |
70 | {%- endif %}
71 |
72 | {%- endif %}
73 |
74 |
Priority
75 |
76 |
77 |
78 |
Optional ordering constraint. If set, this hook will process messages ahead of unordered hooks, and relative to other ordered hooks.
79 |
80 |
81 |
82 | Save
83 |
84 |
85 | Revert
86 |
87 |
88 |
89 | {{ render_doc(doc, doc_html) }}
90 |
91 | {%- endblock %}
92 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/channel.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% set title = "Channel: " + channel.source %}
3 | {% if title_ %}
4 | {% set subtitle = title_ %}
5 | {% endif %}
6 | {% set nav = [("Plug: " + channel.plug.name, ctx.url_for("plug", name=channel.plug.name))] %}
7 |
8 | {% block body -%}
9 |
10 |
11 |
12 | {%- if private is not none %}
13 |
14 |
15 | {%- if private %}
16 | Private
17 | {%- else %}
18 | Group
19 | {%- endif %}
20 |
21 |
22 | {%- endif %}
23 | {%- if link %}
24 |
27 | {%- endif %}
28 |
29 |
30 |
Migrate
31 |
Transfer hook data from this channel to a replacement. Caution: this action is irreversible.
32 |
33 |
34 |
35 |
36 |
37 | Channels
38 | {% for option in host.channels %}
39 | {{ option }}
40 | {% endfor %}
41 |
42 |
43 |
44 |
45 |
46 | Migrate
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | Plugs
57 | {% for option in host.plugs %}
58 | {{ option }}
59 | {% endfor %}
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | Migrate
69 |
70 |
71 |
72 |
73 | {%- if members %}
74 |
Members ({{ members|length }})
75 |
130 | {%- endif %}
131 |
132 | {%- endblock %}
133 |
--------------------------------------------------------------------------------
/immp/core/stream.py:
--------------------------------------------------------------------------------
1 | from asyncio import FIRST_COMPLETED, CancelledError, Event, ensure_future, wait
2 | import logging
3 |
4 |
5 | log = logging.getLogger(__name__)
6 |
7 |
8 | class PlugStream:
9 | """
10 | Message multiplexer, to read messages from multiple asynchronous generators in parallel.
11 |
12 | Instances of this class are async-iterable -- for each incoming message, a tuple is produced:
13 | the physical message received by the plug, a source message if originating from within the
14 | system, and a primary flag to indicate supplementary messages created when a system-sourced
15 | message can't be represented in a single plug message.
16 |
17 | .. warning::
18 | As per :meth:`.Plug.stream`, only one iterator of this class should be used at once.
19 |
20 | Yields:
21 | (.SentMessage, .Message, bool) tuple:
22 | Messages received and processed by any connected plug.
23 | """
24 |
25 | __slots__ = ("_agens", "_tasks", "_sync", "_close")
26 |
27 | def __init__(self):
28 | # Mapping from plugs to their stream() generator coroutines.
29 | self._agens = {}
30 | # Mapping from coroutine task wrappers back to their tasks.
31 | self._tasks = {}
32 | # When a plug is added or removed, the stream wouldn't be able to update until an event
33 | # arrives. This is a signal used to recreate the task list without a message.
34 | self._sync = Event()
35 | # Generators can't be closed synchronously, schedule the plugs to be done on the next sync.
36 | self._close = set()
37 |
38 | def add(self, *plugs):
39 | """
40 | Connect plugs to the stream. When the stream is active, their :meth:`.Plug.stream`
41 | methods will be called to start collecting queued messages.
42 |
43 | Args:
44 | plugs (.Plug list):
45 | New plugs to merge in.
46 | """
47 | for plug in plugs:
48 | if plug not in self._agens:
49 | self._agens[plug] = plug.stream()
50 | self._sync.set()
51 |
52 | def remove(self, *plugs):
53 | """
54 | Disconnect plugs from the stream. Their :meth:`.Plug.stream` tasks will be cancelled, and
55 | any last messages will be collected before removing.
56 |
57 | Args:
58 | plugs (.Plug list):
59 | Active plugs to remove.
60 | """
61 | for plug in plugs:
62 | if plug in self._agens:
63 | self._close.add(plug)
64 | self._sync.set()
65 |
66 | async def _queue(self):
67 | for plug, coro in self._agens.items():
68 | if plug not in self._tasks.values():
69 | log.debug("Queueing receive task for plug %r", plug.name)
70 | # Poor man's async iteration -- there's no async equivalent to next(gen).
71 | self._tasks[ensure_future(coro.asend(None))] = plug
72 | for task, plug in list(self._tasks.items()):
73 | if plug not in self._agens and plug is not self._sync:
74 | task.cancel()
75 | self._close.add(plug)
76 | del self._tasks[task]
77 | for plug in self._close:
78 | log.debug("Cancelling receive task for plug %r", plug.name)
79 | await self._agens[plug].aclose()
80 | self._close.clear()
81 | if self._sync not in self._tasks.values():
82 | log.debug("Recreating sync task")
83 | self._sync.clear()
84 | self._tasks[ensure_future(self._sync.wait())] = self._sync
85 |
86 | async def __aiter__(self):
87 | log.info("Ready for first message")
88 | while True:
89 | try:
90 | await self._queue()
91 | done, _ = await wait(self._tasks, return_when=FIRST_COMPLETED)
92 | except (GeneratorExit, CancelledError):
93 | for task in self._tasks:
94 | task.cancel()
95 | return
96 | for task in done:
97 | plug = self._tasks.pop(task)
98 | if plug is self._sync:
99 | continue
100 | try:
101 | sent, source, primary = task.result()
102 | except CancelledError:
103 | del self._agens[plug]
104 | except Exception:
105 | log.warning("Generator for plug %r exited, recreating",
106 | plug.name, exc_info=True)
107 | self._agens[plug] = plug.stream()
108 | else:
109 | log.info("Received message ID %r in channel %r%s",
110 | sent.id, sent.channel, " (primary)" if primary else "")
111 | log.debug("Message content: %r", sent)
112 | if sent is not source:
113 | log.debug("Source message: %r", source)
114 | yield (sent, source, primary)
115 | log.debug("Waiting for next message")
116 |
117 | def __repr__(self):
118 | done = sum(1 for task in self._tasks if task.done())
119 | pending = len(self._tasks) - done
120 | return "<{}: {} done, {} pending>".format(self.__class__.__name__, done, pending)
121 |
--------------------------------------------------------------------------------
/immp/hook/database.py:
--------------------------------------------------------------------------------
1 | """
2 | Provider of database access to other hooks. All first-party hooks with database support will
3 | require the asynchronous database provider to be present.
4 |
5 | Asynchronous
6 | ~~~~~~~~~~~~
7 |
8 | Requirements:
9 | Extra name: ``db``
10 |
11 | `Tortoise `_
12 |
13 | Any database-specific libraries (e.g. asyncpg for PostgreSQL)
14 |
15 | Config:
16 | url (str):
17 | Database connection string, as defined by Tortoise's :ref:`tortoise:db_url`.
18 |
19 | Synchronous
20 | ~~~~~~~~~~~
21 |
22 | .. deprecated:: 0.10.0
23 | Use the asynchronous variant instead.
24 |
25 | Requirements:
26 | `Peewee `_
27 |
28 | Any database-specific libraries (e.g. Psycopg2 for PostgreSQL)
29 |
30 | Config:
31 | url (str):
32 | Database connection string, as defined by Peewee's :ref:`peewee:db_url`.
33 |
34 | .. warning::
35 | Database requests will block all other running tasks; notably, all plugs will be unable to make
36 | any progress whilst long-running queries are executing.
37 | """
38 |
39 | import logging
40 | from warnings import warn
41 |
42 | try:
43 | from peewee import DatabaseProxy, Model
44 | from playhouse.db_url import connect
45 | except ImportError:
46 | DatabaseProxy = Model = connect = None
47 |
48 | try:
49 | from tortoise import Tortoise
50 | except ImportError:
51 | Tortoise = None
52 |
53 | import immp
54 |
55 |
56 | log = logging.getLogger(__name__)
57 |
58 |
59 | class _ModelsMixin:
60 |
61 | def __init__(self, *args, **kwargs):
62 | super().__init__(*args, **kwargs)
63 | self.models = set()
64 |
65 | def add_models(self, *models):
66 | self.models.update(models)
67 |
68 |
69 | if Model:
70 |
71 | class BaseModel(Model):
72 | """
73 | Template model to be used by other hooks.
74 | """
75 |
76 | class Meta:
77 | database = DatabaseProxy()
78 |
79 |
80 | class DatabaseHook(immp.ResourceHook, _ModelsMixin):
81 | """
82 | Hook that provides generic data access to other hooks, backed by :ref:`Peewee `.
83 | Because models are in the global scope, they can only be attached to a single database,
84 | therefore this hook acts as the single source of truth for obtaining that "global" database.
85 |
86 | Hooks should subclass :class:`.BaseModel` for their data structures, in order to gain the
87 | database connection. At startup, they can register their models to the database connection by
88 | calling :meth:`add_models` (obtained from ``host.resources[DatabaseHook]``), which will create
89 | any needed tables the first time models are added.
90 |
91 | Any database types supported by Peewee can be used, though the usual caveats apply: if a hook
92 | requires fields specific to a single database type, the system as a whole is effectively
93 | locked-in to that type.
94 | """
95 |
96 | schema = immp.Schema({"url": str})
97 |
98 | def __init__(self, name, config, host):
99 | super().__init__(name, config, host)
100 | warn("DatabaseHook is deprecated, migrate to AsyncDatabaseHook", DeprecationWarning)
101 | if not Model:
102 | raise immp.PlugError("'peewee' module not installed")
103 | self.db = None
104 |
105 | async def start(self):
106 | await super().start()
107 | log.debug("Opening connection to database")
108 | self.db = connect(self.config["url"])
109 | BaseModel._meta.database.initialize(self.db)
110 | if self.models:
111 | names = sorted(cls.__name__ for cls in self.models)
112 | log.debug("Registering models: %s", ", ".join(names))
113 | self.db.create_tables(self.models, safe=True)
114 |
115 | async def stop(self):
116 | await super().stop()
117 | if self.db:
118 | log.debug("Closing connection to database")
119 | self.db.close()
120 | self.db = None
121 |
122 |
123 | class AsyncDatabaseHook(immp.ResourceHook, _ModelsMixin):
124 | """
125 | Hook that provides generic data access to other hooks, backed by :mod:`tortoise`. Because
126 | models are in the global scope, they can only be attached to a single database, therefore this
127 | hook acts as the single source of truth for obtaining that "global" database.
128 |
129 | Hooks should register their models to the database connection at startup by calling
130 | :meth:`add_models` (obtained from ``host.resources[AsyncDatabaseHook]``), which will create any
131 | needed tables the first time models are added.
132 | """
133 |
134 | schema = immp.Schema({"url": str})
135 |
136 | def __init__(self, name, config, host):
137 | super().__init__(name, config, host)
138 | if not Tortoise:
139 | raise immp.PlugError("'tortoise' module not installed")
140 |
141 | async def start(self):
142 | await super().start()
143 | log.debug("Opening connection to database")
144 | modules = sorted(set(model.__module__ for model in self.models))
145 | log.debug("Registering model modules: %s", ", ".join(modules))
146 | await Tortoise.init(db_url=self.config["url"], modules={"db": modules})
147 | await Tortoise.generate_schemas(safe=True)
148 |
149 | async def stop(self):
150 | await super().stop()
151 | log.debug("Closing connection to database")
152 | await Tortoise.close_connections()
153 |
--------------------------------------------------------------------------------
/immp/hook/alerts/mentions.py:
--------------------------------------------------------------------------------
1 | from asyncio import wait
2 | import logging
3 | import re
4 |
5 | import immp
6 |
7 | from .common import AlertHookBase, Skip
8 |
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | class MentionsHook(AlertHookBase):
14 | """
15 | Hook to send mention alerts via private channels.
16 | """
17 |
18 | schema = immp.Schema({immp.Optional("usernames", True): bool,
19 | immp.Optional("real-names", False): bool,
20 | immp.Optional("ambiguous", False): bool}, AlertHookBase.schema)
21 |
22 | @staticmethod
23 | def _clean(text):
24 | return re.sub(r"\W", "", text).lower() if text else None
25 |
26 | def match(self, mention, members):
27 | """
28 | Identify users relevant to a mention.
29 |
30 | Args:
31 | mention (str):
32 | Raw mention text, e.g. ``@fred``.
33 | members (.User list):
34 | List of members in the channel where the mention took place.
35 |
36 | Returns:
37 | .User set:
38 | All applicable members to be notified.
39 | """
40 | name = self._clean(mention)
41 | real_matches = set()
42 | real_partials = set()
43 | for member in members:
44 | if self.config["usernames"] and self._clean(member.username) == name:
45 | # Assume usernames are unique, only match the corresponding user.
46 | return {member}
47 | if self.config["real-names"]:
48 | real = self._clean(member.real_name)
49 | if real == name:
50 | real_matches.add(member)
51 | if real.startswith(name):
52 | real_partials.add(member)
53 | if real_matches:
54 | # Assume multiple identical real names is unlikely.
55 | # If it's the same person with two users, they both get mentioned.
56 | return real_matches
57 | elif len(real_partials) == 1 or self.config["ambiguous"]:
58 | # Return a single partial match if it exists.
59 | # Only allow multiple partials if enabled, else ignore the mention.
60 | return real_partials
61 | else:
62 | return set()
63 |
64 | async def before_receive(self, sent, source, primary):
65 | await super().on_receive(sent, source, primary)
66 | if not primary or not source.text or await sent.channel.is_private():
67 | return sent
68 | try:
69 | _, members = await self._get_members(sent)
70 | except Skip:
71 | return sent
72 | for match in re.finditer(r"@\S+", str(source.text)):
73 | mention = match.group(0)
74 | matches = self.match(mention, members)
75 | if len(matches) == 1:
76 | target = next(iter(matches))
77 | log.debug("Exact match for mention %r: %r", mention, target)
78 | text = sent.text[match.start():match.end():True]
79 | for segment in text:
80 | segment.mention = target
81 | sent.text = (sent.text[:match.start():True] + text +
82 | sent.text[match.end()::True])
83 | return sent
84 |
85 | async def on_receive(self, sent, source, primary):
86 | await super().on_receive(sent, source, primary)
87 | if not primary or not source.text or await sent.channel.is_private():
88 | return
89 | try:
90 | _, members = await self._get_members(sent)
91 | except Skip:
92 | return
93 | mentioned = set()
94 | for mention in re.findall(r"@\S+", str(source.text)):
95 | matches = self.match(mention, members)
96 | if matches:
97 | log.debug("Mention %r applies: %r", mention, matches)
98 | mentioned.update(matches)
99 | else:
100 | log.debug("Mention %r doesn't apply", mention)
101 | for segment in source.text:
102 | if segment.mention and segment.mention in members:
103 | log.debug("Segment mention %r applies: %r", segment.text, segment.mention)
104 | mentioned.add(segment.mention)
105 | if not mentioned:
106 | return
107 | text = immp.RichText()
108 | if source.user:
109 | text.append(immp.Segment(source.user.real_name or source.user.username, bold=True),
110 | immp.Segment(" mentioned you"))
111 | else:
112 | text.append(immp.Segment("You were mentioned"))
113 | title = await sent.channel.title()
114 | link = await sent.channel.link()
115 | if title:
116 | text.append(immp.Segment(" in "),
117 | immp.Segment(title, italic=True))
118 | text.append(immp.Segment(":\n"))
119 | text += source.text
120 | if source.user and source.user.link:
121 | text.append(immp.Segment("\n"),
122 | immp.Segment("Go to user", link=source.user.link))
123 | if link:
124 | text.append(immp.Segment("\n"),
125 | immp.Segment("Go to channel", link=link))
126 | tasks = []
127 | for member in mentioned:
128 | if member == source.user:
129 | continue
130 | private = await sent.channel.plug.channel_for_user(member)
131 | if private:
132 | tasks.append(private.send(immp.Message(text=text)))
133 | if tasks:
134 | await wait(tasks)
135 |
--------------------------------------------------------------------------------
/immp/core/hook.py:
--------------------------------------------------------------------------------
1 | from .util import Configurable, Openable, pretty_str
2 |
3 |
4 | @pretty_str
5 | class Hook(Configurable, Openable):
6 | """
7 | Base of all hook classes, performs any form of processing on messages from all connected
8 | plugs, via the provided host instance.
9 |
10 | Instantiation may raise :class:`.ConfigError` if the provided configuration is invalid.
11 |
12 | Attributes:
13 | virtual (bool):
14 | ``True`` if managed by another component (e.g. a hook that exposes plug functionality).
15 | """
16 |
17 | def __init__(self, name, config, host, virtual=False):
18 | super().__init__(name, config, host)
19 | self.virtual = virtual
20 |
21 | def on_load(self):
22 | """
23 | Perform any additional one-time setup that requires other plugs or hooks to be loaded.
24 | """
25 |
26 | def on_ready(self):
27 | """
28 | Perform any post-startup tasks once all hooks and plugs are ready.
29 | """
30 |
31 | async def channel_migrate(self, old, new):
32 | """
33 | Move any private data between channels on admin request. This is intended to cover data
34 | keyed by channel sources and plug network identifiers.
35 |
36 | Args:
37 | old (.Channel):
38 | Existing channel with local data.
39 | new (.Channel):
40 | Target replacement channel to migrate data to.
41 |
42 | Returns:
43 | bool:
44 | ``True`` if any data was migrated for the requested channel.
45 | """
46 | return False
47 |
48 | async def before_send(self, channel, msg):
49 | """
50 | Modify an outgoing message before it's pushed to the network. The ``(channel, msg)`` pair
51 | must be returned, so hooks may modify in-place or return a different pair. This method is
52 | called for each hook, one after another. If ``channel`` is modified, the sending will
53 | restart on the new channel, meaning this method will be called again for all hooks.
54 |
55 | Hooks may also suppress a message (e.g. if their actions caused it, but it bears no value
56 | to the network) by returning ``None``.
57 |
58 | Args:
59 | channel (.Channel):
60 | Original source of this message.
61 | msg (.Message):
62 | Raw message received from another plug.
63 |
64 | Returns:
65 | (.Channel, .Message) tuple:
66 | The augmented or replacement pair, or ``None`` to suppress this message.
67 | """
68 | return (channel, msg)
69 |
70 | async def before_receive(self, sent, source, primary):
71 | """
72 | Modify an incoming message before it's pushed to other hooks. The ``sent`` object must be
73 | returned, so hooks may modify in-place or return a different object. This method is called
74 | for each hook, one after another, so any time-consuming tasks should be deferred to
75 | :meth:`process` (which is run for all hooks in parallel).
76 |
77 | Hooks may also suppress a message (e.g. if their actions caused it, but it bears no value
78 | to the rest of the system) by returning ``None``.
79 |
80 | Args:
81 | sent (.SentMessage):
82 | Raw message received from another plug.
83 | source (.Message):
84 | Original message data used to generate the raw message, if sent via the plug (e.g.
85 | from another hook), equivalent to ``msg`` if the source is otherwise unknown.
86 | primary (bool):
87 | ``False`` for supplementary messages if the source message required multiple raw
88 | messages in order to represent it (e.g. messages with multiple attachments where
89 | the underlying network doesn't support it), otherwise ``True``.
90 |
91 | Returns:
92 | .SentMessage:
93 | The augmented or replacement message, or ``None`` to suppress this message.
94 | """
95 | return sent
96 |
97 | async def on_receive(self, sent, source, primary):
98 | """
99 | Handle an incoming message received by any plug.
100 |
101 | Args:
102 | sent (.SentMessage):
103 | Raw message received from another plug.
104 | source (.Message):
105 | Original message data used to generate the raw message, if sent via the plug (e.g.
106 | from another hook), equivalent to ``msg`` if the source is otherwise unknown.
107 | primary (bool):
108 | ``False`` for supplementary messages if the source message required multiple raw
109 | messages in order to represent it (e.g. messages with multiple attachments where
110 | the underlying network doesn't support it), otherwise ``True``.
111 | """
112 |
113 | def on_config_change(self, source):
114 | """
115 | Handle a configuration change from another plug or hook.
116 |
117 | Args:
118 | source (.Configurable):
119 | Source plug or hook that triggered the event.
120 | """
121 |
122 | def __repr__(self):
123 | return "<{}: {}>".format(self.__class__.__name__, self.name)
124 |
125 |
126 | class ResourceHook(Hook):
127 | """
128 | Variant of hooks that globally provide access to some resource.
129 |
130 | Only one of each class may be loaded, which happens before regular hooks, and such hooks are
131 | keyed by their class rather than a name, allowing for easier lookups.
132 | """
133 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/plug.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 | {% from ctx.module + "/macros.j2" import render_doc %}
3 | {% set title = "Plug: " + plug.name %}
4 | {% set subtitle = plug.network_name %}
5 | {% if plug.network_id %}
6 | {% set subtitle = subtitle + " (" + plug.network_id + ")" %}
7 | {% endif %}
8 |
9 | {% block body -%}
10 |
11 |
12 |
13 | {% set colour = open_colour(plug.state) %}
14 |
15 |
16 | {{ plug.state.name|title }}
17 |
18 |
19 |
20 | {%- if plug.state == immp.OpenState.disabled %}
21 |
22 |
23 | Enable
24 |
25 |
26 | {%- elif plug.state in (immp.OpenState.inactive, immp.OpenState.failed) %}
27 |
28 |
29 | Start
30 |
31 |
32 |
33 |
34 |
35 |
36 | Disable
37 |
38 |
39 | {%- elif plug.state == immp.OpenState.active %}
40 |
41 |
42 | Stop
43 |
44 |
45 | {%- endif %}
46 |
47 |
48 | {%- if plug.virtual %}
49 |
Virtual
50 | {%- else %}
51 |
52 | Remove
53 |
54 | {%- endif %}
55 |
56 |
57 |
58 | {%- if not plug.virtual %}
59 |
Channels
60 |
110 | {%- endif %}
111 | {%- if plug.schema %}
112 |
Edit
113 |
114 |
115 |
Config
116 |
117 |
118 |
{{ plug.config|json(indent=2) }}
119 |
120 | {%- if runner and not runner.writeable %}
121 |
122 |
123 |
A config file is being used, but will not be written to. Changes will only apply to the current session.
124 |
125 |
126 | {%- endif %}
127 |
128 |
129 |
130 | Save
131 |
132 |
133 | Revert
134 |
135 |
136 |
137 | {% endif %}
138 | {{ render_doc(doc, doc_html) }}
139 |
140 | {%- endblock %}
141 |
--------------------------------------------------------------------------------
/immp/hook/webui/templates/main.j2:
--------------------------------------------------------------------------------
1 | {% extends ctx.module + "/base.j2" %}
2 |
3 | {%- macro plural(count, template, one="", many="s") -%}
4 | {{- template.format(count, one if count == 1 else many) -}}
5 | {%- endmacro -%}
6 |
7 | {% set title = "Status" %}
8 | {% set subtitle = plural(host.plugs|length, "{} plug{}") + ", " +
9 | plural(host.resources|length, "{} resource{}") + ", " +
10 | plural(host.hooks|length, "{} hook{}") %}
11 |
12 | {% macro qualname(openable) -%}
13 | {{ openable.__class__.__module__ }}. {{ openable.__class__.__name__ }}
14 | {%- endmacro %}
15 |
16 | {% block body -%}
17 |
146 | {%- endblock %}
147 |
--------------------------------------------------------------------------------
/immp/hook/shell.py:
--------------------------------------------------------------------------------
1 | """
2 | Interact with and debug a running app in the console.
3 |
4 | Asynchronous
5 | ~~~~~~~~~~~~
6 |
7 | Requirements:
8 | Extra name: ``console``
9 |
10 | `aioconsole `_
11 |
12 | Config:
13 | bind (int or str):
14 | TCP port or UNIX socket path to bind the console on. See
15 | `aioconsole's docs `_
16 | for more info.
17 | buffer (int):
18 | Number of received messages to keep cached for later inspection. If unset (default), no
19 | buffer will be available. Otherwise, when a new message comes in and the queue is full,
20 | the oldest message will be discarded. Set to ``0`` for an unlimited buffer, not
21 | recommended on production deployments.
22 |
23 | At startup, a console will be launched on the given port or socket. You can connect to it from a
24 | separate terminal, for example with netcat for TCP::
25 |
26 | $ rlwrap nc localhost $PORT
27 |
28 | Or socat for sockets::
29 |
30 | $ rlwrap socat $PATH -
31 |
32 | .. tip::
33 | Use of ``rlwrap`` provides you with readline-style keybinds, such as ↑ and ↓ to navigate
34 | through previous commands, and Ctrl-R to search the command history.
35 |
36 | The variables :data:`shell` and :data:`host` are defined, refering to the shell hook and the
37 | running :class:`.Host` respectively. This hook also maintains a cache of messages as they're
38 | received, accessible via :attr:`.AsyncShellHook.buffer`.
39 |
40 | .. warning::
41 | The console will be accessible on a locally bound port without authentication. Do not use on
42 | shared or untrusted systems, as the host and all connected plugs are exposed.
43 |
44 | Synchronous
45 | ~~~~~~~~~~~
46 |
47 | .. deprecated:: 0.10.0
48 | Use the asynchronous shell with a buffer in order to interact with incoming messages.
49 |
50 | Requirements:
51 | `ptpython `_:
52 | Can be used with ``console: ptpython`` as described below.
53 |
54 | Config:
55 | all (bool):
56 | ``True`` to process any message, ``False`` (default) to restrict to defined channels.
57 | console (str):
58 | Use a different embedded console. By default, :meth:`code.interact` is used, but set this
59 | to ``ptpython`` for a more functional shell.
60 |
61 | When a new message is received, a console will launch in the terminal where your app is running.
62 | The variables :data:`channel` and :data:`msg` are defined in the local scope, whilst :data:`self`
63 | refers to the shell hook itself.
64 |
65 | .. warning::
66 | The console will block all other running tasks; notably, all plugs will be unable to make any
67 | progress whilst the console is open.
68 | """
69 |
70 | import code
71 | from collections import deque
72 | from functools import partial
73 | import logging
74 | from pprint import pformat
75 | from warnings import warn
76 |
77 | try:
78 | import ptpython.repl
79 | except ImportError:
80 | ptpython = None
81 |
82 | try:
83 | import aioconsole
84 | except ImportError:
85 | aioconsole = None
86 |
87 | import immp
88 |
89 |
90 | log = logging.getLogger(__name__)
91 |
92 |
93 | class ShellHook(immp.ResourceHook):
94 | """
95 | Hook to start a Python shell when a message is received.
96 | """
97 |
98 | schema = immp.Schema({immp.Optional("all", False): bool,
99 | immp.Optional("console"): immp.Nullable("ptpython")})
100 |
101 | def __init__(self, name, config, host):
102 | super().__init__(name, config, host)
103 | warn("ShellHook is deprecated, migrate to AsyncShellHook", DeprecationWarning)
104 | if self.config["console"] == "ptpython":
105 | if ptpython:
106 | log.debug("Using ptpython console")
107 | self.console = self._ptpython
108 | else:
109 | raise immp.PlugError("'ptpython' module not installed")
110 | else:
111 | log.debug("Using native console")
112 | self.console = self._code
113 |
114 | @staticmethod
115 | def _ptpython(local):
116 | ptpython.repl.embed(globals(), local)
117 |
118 | @staticmethod
119 | def _code(local):
120 | code.interact(local=dict(globals(), **local))
121 |
122 | async def on_receive(self, sent, source, primary):
123 | await super().on_receive(sent, source, primary)
124 | if sent.channel in self.host.channels or self.config["all"]:
125 | log.debug("Entering console: %r", sent)
126 | self.console(locals())
127 |
128 |
129 | class AsyncShellHook(immp.ResourceHook):
130 | """
131 | Hook to launch an asynchonous console alongside a :class:`.Host` instance.
132 |
133 | Attributes:
134 | buffer (collections.deque):
135 | Queue of recent messages, the length defined by the ``buffer`` config entry.
136 | last ((.SentMessage, .Message) tuple):
137 | Most recent message received from a connected plug.
138 | """
139 |
140 | schema = immp.Schema({"bind": immp.Any(str, int),
141 | immp.Optional("buffer"): immp.Nullable(int)})
142 |
143 | def __init__(self, name, config, host):
144 | super().__init__(name, config, host)
145 | if not aioconsole:
146 | raise immp.PlugError("'aioconsole' module not installed")
147 | self.buffer = None
148 | self._server = None
149 |
150 | @property
151 | def last(self):
152 | return self.buffer[-1] if self.buffer else None
153 |
154 | async def start(self):
155 | await super().start()
156 | if self.config["buffer"] is not None:
157 | self.buffer = deque(maxlen=self.config["buffer"] or None)
158 | if isinstance(self.config["bind"], str):
159 | log.debug("Launching console on socket %s", self.config["bind"])
160 | bind = {"path": self.config["bind"]}
161 | else:
162 | log.debug("Launching console on port %d", self.config["bind"])
163 | bind = {"port": self.config["bind"]}
164 | self._server = await aioconsole.start_interactive_server(factory=self._factory, **bind)
165 |
166 | async def stop(self):
167 | await super().stop()
168 | self.buffer = None
169 | if self._server:
170 | log.debug("Stopping console server")
171 | self._server.close()
172 | self._server = None
173 |
174 | @staticmethod
175 | def _pprint(console, obj):
176 | console.print(pformat(obj))
177 |
178 | def _factory(self, streams=None):
179 | context = {"host": self.host, "shell": self, "immp": immp}
180 | console = aioconsole.AsynchronousConsole(locals=context, streams=streams)
181 | context["pprint"] = partial(self._pprint, console)
182 | log.debug("Accepted console connection")
183 | return console
184 |
185 | async def on_receive(self, sent, source, primary):
186 | await super().on_receive(sent, source, primary)
187 | if self.buffer is not None:
188 | self.buffer.append((sent, source))
189 |
--------------------------------------------------------------------------------
/immp/hook/runner.py:
--------------------------------------------------------------------------------
1 | """
2 | Utility methods related to loading and saving config files, as used by the console entry point to
3 | save the config on exit. Creating and running :class:`.Host` via ``immp`` or ``python -m immp``
4 | will attach a :class:`RunnerHook` to its resources.
5 |
6 | Requirements:
7 | Extra name: ``runner``
8 |
9 | `anyconfig `_
10 |
11 | Any format-specific libraries for config files (e.g. PyYAML for YAML files)
12 |
13 | Commands:
14 | run-write:
15 | Force a write of the live config out to the configured file.
16 | """
17 |
18 | from asyncio import get_event_loop
19 | import json
20 | import logging
21 | import logging.config
22 | import signal
23 | import sys
24 |
25 | try:
26 | import anyconfig
27 | except ImportError:
28 | anyconfig = None
29 |
30 | import immp
31 |
32 |
33 | log = logging.getLogger(__name__)
34 |
35 |
36 | class _Schema:
37 |
38 | _openable = {"path": str,
39 | immp.Optional("enabled", True): bool,
40 | immp.Optional("config", dict): dict}
41 |
42 | _plugs = {str: _openable}
43 |
44 | _hooks = {str: immp.Schema({immp.Optional("priority"): immp.Nullable(int)}, _openable)}
45 |
46 | _channels = {str: {"plug": str, "source": str}}
47 |
48 | _logging = {immp.Optional("disable_existing_loggers", False): bool}
49 |
50 | config = immp.Schema({immp.Optional("path", list): [str],
51 | immp.Optional("plugs", dict): _plugs,
52 | immp.Optional("channels", dict): _channels,
53 | immp.Optional("groups", dict): {str: dict},
54 | immp.Optional("hooks", dict): _hooks,
55 | immp.Optional("logging"): immp.Nullable(_logging)})
56 |
57 |
58 | def _load_file(path):
59 | if anyconfig:
60 | # Note: anyconfig v0.11.0 parses ac_template=False as positive (fixed in v0.11.1).
61 | # https://github.com/ssato/python-anyconfig/pull/126
62 | return anyconfig.load(path, ac_template=False)
63 | else:
64 | with open(path, "r") as reader:
65 | return json.load(reader)
66 |
67 |
68 | def _save_file(data, path):
69 | if anyconfig:
70 | anyconfig.dump(data, path)
71 | else:
72 | with open(path, "w") as writer:
73 | json.dump(data, writer)
74 |
75 |
76 | def config_to_host(config, path, write):
77 | host = immp.Host()
78 | base = dict(config)
79 | for name, spec in base.pop("plugs").items():
80 | cls = immp.resolve_import(spec["path"])
81 | host.add_plug(cls(name, spec["config"], host), spec["enabled"])
82 | for name, spec in base.pop("channels").items():
83 | plug = host.plugs[spec["plug"]]
84 | host.add_channel(name, immp.Channel(plug, spec["source"]))
85 | for name, group in base.pop("groups").items():
86 | host.add_group(immp.Group(name, group, host))
87 | for name, spec in base.pop("hooks").items():
88 | cls = immp.resolve_import(spec["path"])
89 | host.add_hook(cls(name, spec["config"], host), spec["enabled"], spec["priority"])
90 | try:
91 | host.add_hook(RunnerHook("runner", {}, host))
92 | except immp.ConfigError:
93 | # Prefer existing hook defined within the config itself.
94 | pass
95 | host.resources[RunnerHook].load(base, path, write)
96 | host.loaded()
97 | return host
98 |
99 |
100 | def _handle_signal(signum, loop, task):
101 | # Gracefully accept a signal once, then revert to the default handler.
102 | def handler(_signum, _frame):
103 | log.info("Closing on signal")
104 | task.cancel()
105 | signal.signal(signum, original)
106 | original = signal.getsignal(signum)
107 | signal.signal(signum, handler)
108 |
109 |
110 | def main(path, write=False):
111 | config = _Schema.config(_load_file(path))
112 | for search in config["path"]:
113 | sys.path.append(search)
114 | if config["logging"]:
115 | logging.config.dictConfig(config["logging"])
116 | else:
117 | logging.basicConfig(level=logging.INFO)
118 | logging.getLogger().setLevel(logging.WARNING)
119 | logging.getLogger(immp.__name__).setLevel(logging.INFO)
120 | log.info("Creating plugs and hooks")
121 | host = config_to_host(config, path, write)
122 | loop = get_event_loop()
123 | task = loop.create_task(host.run())
124 | for signum in (signal.SIGINT, signal.SIGTERM):
125 | _handle_signal(signum, loop, task)
126 | try:
127 | log.info("Starting host")
128 | loop.run_until_complete(task)
129 | finally:
130 | loop.close()
131 | if write:
132 | host.resources[RunnerHook].write_config()
133 |
134 |
135 | class RunnerHook(immp.ResourceHook):
136 | """
137 | Virtual hook that handles reading and writing of config from/to a file.
138 |
139 | Attributes:
140 | writeable (bool):
141 | ``True`` if the file will be updated on exit, or ``False`` if being used read-only.
142 | """
143 |
144 | schema = None
145 |
146 | def __init__(self, name, config, host):
147 | super().__init__(name, config, host)
148 | self._base_config = None
149 | self._path = None
150 | self.writeable = None
151 |
152 | def load(self, base, path, writeable):
153 | """
154 | Initialise the runner with a full config and the file path.
155 |
156 | Args:
157 | base (dict):
158 | Parsed config file content, excluding object components.
159 | path (str):
160 | Target config file location.
161 | writeable (bool):
162 | ``True`` if changes to the live config may be written back to the file.
163 | """
164 | self._base_config = base
165 | self._path = path
166 | self.writeable = writeable
167 |
168 | @staticmethod
169 | def _config_feature(section, name, obj, priority=None):
170 | if obj.virtual:
171 | return
172 | feature = {"path": "{}.{}".format(obj.__class__.__module__, obj.__class__.__name__),
173 | "enabled": obj.state != immp.OpenState.disabled}
174 | if obj.schema and obj.config:
175 | feature["config"] = immp.Watchable.unwrap(obj.config)
176 | if priority:
177 | feature["priority"] = priority
178 | section[name] = feature
179 |
180 | @property
181 | def config_features(self):
182 | config = {"plugs": {}, "channels": {}, "groups": {}, "hooks": {}}
183 | for name, plug in self.host.plugs.items():
184 | self._config_feature(config["plugs"], name, plug)
185 | for name, channel in self.host.channels.items():
186 | if not channel.plug.virtual:
187 | config["channels"][name] = {"plug": channel.plug.name, "source": channel.source}
188 | for name, group in self.host.groups.items():
189 | config["groups"][name] = immp.Watchable.unwrap(group.config)
190 | for name, hook in self.host.hooks.items():
191 | self._config_feature(config["hooks"], name, hook, self.host._priority.get(name))
192 | return config
193 |
194 | @property
195 | def config_full(self):
196 | config = self._base_config.copy()
197 | config.update(self.config_features)
198 | return config
199 |
200 | def write_config(self):
201 | """
202 | Write the live config out to the target config file, if writing is enabled.
203 | """
204 | if not self.writeable:
205 | raise immp.PlugError("Writing not enabled")
206 | log.info("Writing config file: %r", self._path)
207 | _save_file(self.config_full, self._path)
208 |
209 | def on_config_change(self, source):
210 | if self.writeable:
211 | self.write_config()
212 |
--------------------------------------------------------------------------------
/immp/hook/notes.py:
--------------------------------------------------------------------------------
1 | """
2 | Recallable per-channel lists of text items.
3 |
4 | Dependencies:
5 | :class:`.AsyncDatabaseHook`
6 |
7 | Commands:
8 | note-add :
9 | Add a new note for this channel.
10 | note-edit :
11 | Update an existing note from this channel with new text.
12 | note-remove :
13 | Delete an existing note from this channel by its position.
14 | note-show :
15 | Recall a single note in this channel.
16 | note-list:
17 | Recall all notes for this channel.
18 | """
19 |
20 | import time
21 |
22 | from tortoise import Model
23 | from tortoise.exceptions import DoesNotExist
24 | from tortoise.fields import IntField, TextField
25 |
26 | import immp
27 | from immp.hook.command import BadUsage, CommandParser, command
28 | from immp.hook.database import AsyncDatabaseHook
29 |
30 |
31 | CROSS = "\N{CROSS MARK}"
32 | TICK = "\N{WHITE HEAVY CHECK MARK}"
33 |
34 |
35 | class Note(Model):
36 | """
37 | Representation of a single note.
38 |
39 | Attributes:
40 | timestamp (int):
41 | Creation time of the note.
42 | network (str):
43 | Network identifier for the channel's plug.
44 | channel (str):
45 | Channel identifier where the note was created.
46 | user (str):
47 | User identifier of the note's author.
48 | text (str):
49 | Note content.
50 | """
51 |
52 | timestamp = IntField(default=lambda: int(time.time()))
53 | network = TextField()
54 | channel = TextField()
55 | user = TextField(null=True)
56 | text = TextField()
57 |
58 | @classmethod
59 | def select_channel(cls, channel):
60 | return (cls.filter(network=channel.plug.network_id, channel=channel.source)
61 | .order_by("timestamp"))
62 |
63 | @classmethod
64 | async def select_position(cls, channel, num):
65 | if num < 1:
66 | raise ValueError
67 | note = await cls.select_channel(channel).offset(num - 1).first()
68 | if note:
69 | return note
70 | else:
71 | raise DoesNotExist
72 |
73 | @classmethod
74 | async def select_position_multi(cls, channel, *nums):
75 | if any(num < 1 for num in nums):
76 | raise ValueError
77 | notes = await cls.select_channel(channel)
78 | try:
79 | return [notes[num - 1] for num in nums]
80 | except IndexError:
81 | raise DoesNotExist from None
82 |
83 | @property
84 | def ago(self):
85 | diff = int(time.time()) - self.timestamp
86 | for step, unit in ((60, "s"), (60, "m"), (24, "h")):
87 | if diff < step:
88 | return "{}{}".format(diff, unit)
89 | diff //= step
90 | return "{}d".format(diff)
91 |
92 | def __repr__(self):
93 | return "<{}: #{} {} @ {}: {}>".format(self.__class__.__name__, self.id, self.ago,
94 | repr(self.channel), repr(self.text))
95 |
96 |
97 | class NotesHook(immp.Hook):
98 | """
99 | Hook for managing and recalling notes in channels.
100 | """
101 |
102 | schema = None
103 |
104 | def on_load(self):
105 | self.host.resources[AsyncDatabaseHook].add_models(Note)
106 |
107 | async def channel_migrate(self, old, new):
108 | count = await (Note.filter(network=old.plug.network_id, channel=old.source)
109 | .update(network=new.plug.network_id, channel=new.source))
110 | return count > 0
111 |
112 | @command("note-add", parser=CommandParser.none)
113 | async def add(self, msg, text):
114 | """
115 | Add a new note for this channel.
116 | """
117 | await Note.create(network=msg.channel.plug.network_id,
118 | channel=msg.channel.source,
119 | user=(msg.user.id or msg.user.username) if msg.user else None,
120 | text=text.raw())
121 | count = await Note.select_channel(msg.channel).count()
122 | await msg.channel.send(immp.Message(text="{} Added #{}".format(TICK, count)))
123 |
124 | @command("note-edit", parser=CommandParser.hybrid)
125 | async def edit(self, msg, num, text):
126 | """
127 | Update an existing note from this channel with new text.
128 | """
129 | try:
130 | note = await Note.select_position(msg.channel, int(num))
131 | except ValueError:
132 | raise BadUsage from None
133 | except DoesNotExist:
134 | text = "{} Does not exist".format(CROSS)
135 | else:
136 | note.text = text.raw()
137 | await note.save()
138 | text = "{} Edited".format(TICK)
139 | await msg.channel.send(immp.Message(text=text))
140 |
141 | @command("note-remove")
142 | async def remove(self, msg, *nums):
143 | """
144 | Delete one or more notes from this channel by their positions.
145 | """
146 | if not nums:
147 | raise BadUsage
148 | try:
149 | nums = [int(num) for num in nums]
150 | notes = await Note.select_position_multi(msg.channel, *nums)
151 | except ValueError:
152 | raise BadUsage from None
153 | except DoesNotExist:
154 | text = "{} Does not exist".format(CROSS)
155 | else:
156 | count = await Note.filter(id__in=tuple(note.id for note in notes)).delete()
157 | text = "{} Removed {} note{}".format(TICK, count, "" if count == 1 else "s")
158 | await msg.channel.send(immp.Message(text=text))
159 |
160 | @command("note-show")
161 | async def show(self, msg, num):
162 | """
163 | Recall a single note in this channel.
164 | """
165 | try:
166 | note = await Note.select_position(msg.channel, int(num))
167 | except ValueError:
168 | raise BadUsage from None
169 | except DoesNotExist:
170 | text = "{} Does not exist".format(CROSS)
171 | else:
172 | text = immp.RichText([immp.Segment("{}.".format(num), bold=True),
173 | immp.Segment("\t"),
174 | *immp.RichText.unraw(note.text, self.host),
175 | immp.Segment("\t"),
176 | immp.Segment(note.ago, italic=True)])
177 | await msg.channel.send(immp.Message(text=text))
178 |
179 | @command("note-list")
180 | async def list(self, msg, query=None):
181 | """
182 | Recall all notes for this channel, or search for text across all notes.
183 | """
184 | notes = await Note.select_channel(msg.channel)
185 | count = len(notes)
186 | if query:
187 | matches = [(num, note) for num, note in enumerate(notes, 1)
188 | if query.lower() in note.text.lower()]
189 | count = len(matches)
190 | else:
191 | matches = enumerate(notes, 1)
192 | if count:
193 | title = ("{}{} note{} in this channel{}"
194 | .format(count or "No", " matching" if query else "",
195 | "" if count == 1 else "s", ":" if count else "."))
196 | text = immp.RichText([immp.Segment(title, bold=True)])
197 | for num, note in matches:
198 | text.append(immp.Segment("\n"),
199 | immp.Segment("{}.".format(num), bold=True),
200 | immp.Segment("\t"),
201 | *immp.RichText.unraw(note.text, self.host),
202 | immp.Segment("\t"),
203 | immp.Segment(note.ago, italic=True))
204 | else:
205 | text = "{} No {}".format(CROSS, "matches" if query and notes else "notes")
206 | target = None
207 | if count and msg.user:
208 | target = await msg.user.private_channel()
209 | if target and msg.channel != target:
210 | await target.send(immp.Message(text=text))
211 | await msg.channel.send(immp.Message(text="{} Sent".format(TICK)))
212 | else:
213 | await msg.channel.send(immp.Message(text=text))
214 |
--------------------------------------------------------------------------------
/immp/core/channel.py:
--------------------------------------------------------------------------------
1 | from .schema import Optional, Schema
2 | from .util import ConfigProperty, Configurable, pretty_str
3 |
4 |
5 | _GROUP_FIELDS = ("channels", "exclude", "anywhere", "named", "private", "shared")
6 |
7 |
8 | @pretty_str
9 | class Channel:
10 | """
11 | Container class that holds a (:class:`.Plug`, :class:`str`) pair representing a room
12 | inside the plug's network.
13 |
14 | Attributes:
15 | plug (.Plug):
16 | Related plug instance where the channel resides.
17 | source (str):
18 | Plug-specific channel identifier.
19 | """
20 |
21 | __slots__ = ("plug", "source")
22 |
23 | def __init__(self, plug, source):
24 | self.plug = plug
25 | self.source = str(source)
26 |
27 | async def is_private(self):
28 | """
29 | Equivalent to :meth:`.Plug.channel_is_private`.
30 |
31 | Returns:
32 | bool:
33 | ``True`` if the channel is private; ``None`` if the service doesn't have a notion
34 | of private channels.
35 | """
36 | return await self.plug.channel_is_private(self)
37 |
38 | async def title(self):
39 | """
40 | Equivalent to :meth:`.Plug.channel_title`.
41 |
42 | Returns:
43 | str:
44 | Display name for the channel.
45 | """
46 | return await self.plug.channel_title(self)
47 |
48 | async def link(self):
49 | """
50 | Equivalent to :meth:`.Plug.channel_link`.
51 |
52 | Returns:
53 | str:
54 | Internal deep link to this channel.
55 | """
56 | return await self.plug.channel_link(self)
57 |
58 | async def rename(self, title):
59 | """
60 | Equivalent to :meth:`.Plug.channel_rename`.
61 |
62 | Args:
63 | title (str):
64 | New display name for the channel.
65 | """
66 | return await self.plug.channel_rename(self, title)
67 |
68 | async def members(self):
69 | """
70 | Equivalent to :meth:`.Plug.channel_members`.
71 |
72 | Returns:
73 | .User list:
74 | Members present in the channel.
75 | """
76 | return await self.plug.channel_members(self)
77 |
78 | async def admins(self):
79 | """
80 | Equivalent to :meth:`.Plug.channel_admins`.
81 |
82 | Returns:
83 | .User list:
84 | Members with admin privileges present in the channel.
85 | """
86 | return await self.plug.channel_admins(self)
87 |
88 | async def invite_multi(self, users):
89 | """
90 | Equivalent to :meth:`.Plug.channel_invite_multi`.
91 |
92 | Args:
93 | users (.User list):
94 | New users to invite.
95 | """
96 | return await self.plug.channel_invite_multi(self, users)
97 |
98 | async def invite(self, user):
99 | """
100 | Equivalent to :meth:`.Plug.channel_invite`.
101 |
102 | Args:
103 | user (.User):
104 | New user to invite.
105 | """
106 | return await self.plug.channel_invite(self, user)
107 |
108 | async def remove_multi(self, users):
109 | """
110 | Equivalent to :meth:`.Plug.channel_remove_multi`.
111 |
112 | Args:
113 | users (.User list):
114 | Existing users to kick.
115 | """
116 | return await self.plug.channel_remove_multi(self, users)
117 |
118 | async def remove(self, user):
119 | """
120 | Equivalent to :meth:`.Plug.channel_remove`.
121 |
122 | Args:
123 | user (.User):
124 | Existing user to kick.
125 | """
126 | return await self.plug.channel_remove(self, user)
127 |
128 | async def link_create(self, shared=True):
129 | """
130 | Equivalent to :meth:`.Plug.channel_link_create`.
131 |
132 | Args:
133 | shared (bool):
134 | ``True`` (default) for a common, unlimited-use, non-expiring link (subject to any
135 | limitations from the underlying network); ``False`` for a private, single-use link.
136 | """
137 | return await self.plug.channel_link_create(self, shared)
138 |
139 | async def link_revoke(self, link=None):
140 | """
141 | Equivalent to :meth:`.Plug.channel_link_revoke`.
142 |
143 | Args:
144 | link (str):
145 | Existing invite link to revoke.
146 | """
147 | return await self.plug.channel_link_revoke(self, link)
148 |
149 | async def history(self, before=None):
150 | """
151 | Equivalent to :meth:`.Plug.channel_history`.
152 |
153 | Args:
154 | before (.Receipt):
155 | Starting point message, or ``None`` to fetch the most recent.
156 |
157 | Returns:
158 | .Receipt list:
159 | Messages from the channel, oldest first.
160 | """
161 | return await self.plug.channel_history(self, before)
162 |
163 | async def send(self, msg):
164 | """
165 | Push a message to the related plug on this channel. Equivalent to :meth:`.Plug.send`.
166 |
167 | Args:
168 | msg (.Message):
169 | Original message received from another channel or plug.
170 | """
171 | return await self.plug.send(self, msg)
172 |
173 | def __eq__(self, other):
174 | return (isinstance(other, Channel) and
175 | self.plug == other.plug and self.source == other.source)
176 |
177 | def __hash__(self):
178 | return hash((self.plug.network_id, self.source))
179 |
180 | def __repr__(self):
181 | return "<{}: {} @ {}>".format(self.__class__.__name__, self.plug.name, self.source)
182 |
183 |
184 | @pretty_str
185 | class Group(Configurable):
186 | """
187 | Container of multiple channels.
188 |
189 | Groups cannot be iterated, as they may hold any possible channel from a :class:`.Plug`, but you
190 | can test for membership of a given :class:`.Channel` using :meth:`has`.
191 |
192 | A group is defined by a base list of channels, and/or lists of channel types from plugs. The
193 | latter may target **private** or **shared** (non-private) channels, **named** for host-defined
194 | channels, or **anywhere** as long as it belongs to the given plug.
195 | """
196 |
197 | class MergedProperty(ConfigProperty):
198 |
199 | def __init__(self, key=None):
200 | super().__init__([Group], key)
201 |
202 | def __get__(self, instance, owner):
203 | return Group.merge(instance.host, *super().__get__(instance, owner))
204 |
205 | schema = Schema({Optional(field, list): [str] for field in _GROUP_FIELDS})
206 |
207 | _channels = ConfigProperty([Channel])
208 | _exclude = ConfigProperty([Channel])
209 |
210 | @classmethod
211 | def merge(cls, host, *groups):
212 | config = {field: [] for field in _GROUP_FIELDS}
213 | for group in groups:
214 | for field in _GROUP_FIELDS:
215 | config[field].extend(item for item in group.config[field]
216 | if item not in config[field])
217 | return cls(None, config, host)
218 |
219 | async def has_channel(self, channel):
220 | if not isinstance(channel, Channel):
221 | raise TypeError
222 | elif channel in self._exclude:
223 | return False
224 | elif channel in self._channels:
225 | return True
226 | elif self.has_plug(channel.plug, "anywhere"):
227 | return True
228 | elif self.has_plug(channel.plug, "named") and channel in self.host.channels.values():
229 | return True
230 | private = await channel.is_private()
231 | if self.has_plug(channel.plug, "private") and private:
232 | return True
233 | elif self.has_plug(channel.plug, "shared") and not private:
234 | return True
235 | else:
236 | return False
237 |
238 | def has_plug(self, plug, *fields):
239 | return any(plug.name in self.config[field] for field in fields or _GROUP_FIELDS)
240 |
241 | def __repr__(self):
242 | return "<{}: {}>".format(self.__class__.__name__, self.name)
243 |
--------------------------------------------------------------------------------
/immp/hook/identity.py:
--------------------------------------------------------------------------------
1 | """
2 | Identity protocol backbone, and a generic user lookup command.
3 |
4 | Config:
5 | identities (str list):
6 | List of identity provider names from which to allow lookups.
7 | public (bool):
8 | ``True`` to allow anyone with access to the ``who`` command to do a lookup, without
9 | necessarily being identified themselves (defaults to ``False``).
10 |
11 | Commands:
12 | who :
13 | Recall a known identity and all of its links.
14 |
15 | This module defines a subclass for all hooks providing identity services -- no hook is needed from
16 | here if using an identity hook elsewhere. The :class:`.WhoIsHook` provides a command for users to
17 | query basic identity information.
18 | """
19 |
20 | from asyncio import gather
21 | from collections import defaultdict
22 | import logging
23 |
24 | import immp
25 | from immp.hook.command import command, CommandParser
26 |
27 |
28 | CROSS = "\N{CROSS MARK}"
29 | TICK = "\N{WHITE HEAVY CHECK MARK}"
30 |
31 |
32 | log = logging.getLogger(__name__)
33 |
34 |
35 | @immp.pretty_str
36 | class Identity:
37 | """
38 | Basic representation of an external identity.
39 |
40 | Attributes:
41 | name (str):
42 | Common name used across any linked platforms.
43 | provider (.IdentityProvider):
44 | Service hook where the identity information was acquired from.
45 | links (.User list):
46 | Physical platform users assigned to this identity.
47 | roles (str list):
48 | Optional set of role names, if applicable to the backend.
49 | profile (str):
50 | URL to the identity profile page.
51 | """
52 |
53 | @classmethod
54 | async def gather(cls, *tasks):
55 | """
56 | Helper for fetching users from plugs, filtering out calls with no matches::
57 |
58 | >>> await Identity.gather(plug1.user_from_id(id1), plug2.user_from_id(id2))
59 | []
60 |
61 | Args:
62 | tasks (coroutine list):
63 | Non-awaited coroutines or tasks.
64 |
65 | Returns:
66 | .User list:
67 | Gathered results of those tasks.
68 | """
69 | tasks = list(filter(None, tasks))
70 | if not tasks:
71 | return []
72 | users = []
73 | for result in await gather(*tasks, return_exceptions=True):
74 | if isinstance(result, BaseException):
75 | log.warning("Failed to retrieve user for identity", exc_info=result)
76 | elif result:
77 | users.append(result)
78 | return users
79 |
80 | def __init__(self, name, provider=None, links=(), roles=(), profile=None):
81 | self.name = name
82 | self.provider = provider
83 | self.links = links
84 | self.roles = roles
85 | self.profile = profile
86 |
87 | def __eq__(self, other):
88 | return (isinstance(other, Identity) and
89 | (self.name, self.provider) == (other.name, other.provider))
90 |
91 | def __hash__(self):
92 | return hash((self.name, self.provider))
93 |
94 | def __repr__(self):
95 | return "<{}: {} x{}{}>".format(self.__class__.__name__, repr(self.name), len(self.links),
96 | " ({})".format(" ".join(self.roles)) if self.roles else "")
97 |
98 |
99 | class IdentityProvider:
100 | """
101 | Interface for hooks to provide identity information from a backing source.
102 |
103 | Attributes:
104 | provider_name (str):
105 | Readable name of the underlying service, used when displaying info about this provider.
106 | """
107 |
108 | provider_name = None
109 |
110 | async def identity_from_name(self, name):
111 | """
112 | Look up an identity by the external provider's username for them.
113 |
114 | Args:
115 | name (str):
116 | External name to query.
117 |
118 | Returns:
119 | .Identity:
120 | Matching identity from the provider, or ``None`` if not found.
121 | """
122 | raise NotImplementedError
123 |
124 | async def identity_from_user(self, user):
125 | """
126 | Look up an identity by a linked network user.
127 |
128 | Args:
129 | user (.User):
130 | Plug user referenced by the identity.
131 |
132 | Returns:
133 | .Identity:
134 | Matching identity from the provider, or ``None`` if not found.
135 | """
136 | raise NotImplementedError
137 |
138 |
139 | class WhoIsHook(immp.Hook):
140 | """
141 | Hook to provide generic lookup of user profiles across one or more identity providers.
142 | """
143 |
144 | schema = immp.Schema({"identities": [str],
145 | immp.Optional("public", False): bool})
146 |
147 | _identities = immp.ConfigProperty([IdentityProvider])
148 |
149 | async def _query_all(self, query, providers=None):
150 | getter = "identity_from_{}".format("user" if isinstance(query, immp.User) else "name")
151 | providers = providers or self._identities
152 | tasks = (getattr(provider, getter)(query) for provider in providers)
153 | identities = []
154 | for provider, result in zip(providers, await gather(*tasks, return_exceptions=True)):
155 | if isinstance(result, Identity):
156 | identities.append(result)
157 | elif isinstance(result, Exception):
158 | log.warning("Failed to retrieve identity from %r (%r)",
159 | provider.name, provider.provider_name, exc_info=result)
160 | return identities
161 |
162 | @command("who", parser=CommandParser.none)
163 | async def who(self, msg, name):
164 | """
165 | Recall a known identity and all of its links.
166 | """
167 | if self.config["public"]:
168 | providers = self._identities
169 | else:
170 | providers = [ident.provider for ident in await self._query_all(msg.user)]
171 | if providers:
172 | identities = await self._query_all(name[0].mention or str(name), providers)
173 | if identities:
174 | identities.sort(key=lambda ident: ident.provider.provider_name)
175 | links = defaultdict(list)
176 | roles = []
177 | for ident in identities:
178 | for link in ident.links:
179 | links[link].append(ident)
180 | if ident.roles:
181 | roles.append(ident)
182 | text = name.clone()
183 | text.prepend(immp.Segment("Info for "))
184 | for segment in text:
185 | segment.bold = True
186 | text.append(immp.Segment("\nMatching providers:"))
187 | for i, ident in enumerate(identities):
188 | text.append(immp.Segment("\n{}.\t".format(i + 1)),
189 | immp.Segment(ident.provider.provider_name, link=ident.profile))
190 | if links:
191 | text.append(immp.Segment("\nIdentity links:"))
192 | for user in sorted(links, key=lambda user: user.plug.network_name):
193 | text.append(immp.Segment("\n({}) ".format(user.plug.network_name)))
194 | if user.link:
195 | text.append(immp.Segment(user.real_name or user.username,
196 | link=user.link))
197 | elif user.real_name and user.username:
198 | text.append(immp.Segment("{} [{}]".format(user.real_name,
199 | user.username)))
200 | else:
201 | text.append(immp.Segment(user.real_name or user.username))
202 | known = links[user]
203 | if known != identities:
204 | indexes = [identities.index(ident) + 1 for ident in known]
205 | text.append(immp.Segment(" {}".format(indexes)))
206 | if roles:
207 | text.append(immp.Segment("\nRoles:"))
208 | for ident in roles:
209 | text.append(immp.Segment("\n({}) {}".format(ident.provider.provider_name,
210 | ", ".join(ident.roles))))
211 | else:
212 | text = "{} Name not in use".format(CROSS)
213 | else:
214 | text = "{} Not identified".format(CROSS)
215 | await msg.channel.send(immp.Message(text=text))
216 |
--------------------------------------------------------------------------------
/immp/hook/web.py:
--------------------------------------------------------------------------------
1 | """
2 | Run a webserver for other plugs or hooks to accept incoming HTTP requests.
3 |
4 | Requirements:
5 | Extra name: ``web``
6 |
7 | `aiohttp `_
8 |
9 | `aiohttp_jinja2 `_:
10 | Required for page templating.
11 |
12 | Config:
13 | host (str):
14 | Hostname or IP address to bind to.
15 | port (int):
16 | Port number to bind to.
17 |
18 | As the server is unauthenticated, you will typically want to bind it to ``localhost``, and proxy it
19 | behind a full web server like nginx to separate out routes, lock down access and so on.
20 | """
21 |
22 | from functools import wraps
23 | from importlib.util import find_spec
24 | import json
25 | import logging
26 | import os.path
27 |
28 | from aiohttp import web
29 |
30 | import immp
31 |
32 |
33 | try:
34 | import aiohttp_jinja2
35 | from jinja2 import PackageLoader, PrefixLoader
36 | except ImportError:
37 | aiohttp_jinja2 = PackageLoader = PrefixLoader = None
38 |
39 |
40 | log = logging.getLogger(__name__)
41 |
42 |
43 | class WebContext:
44 | """
45 | Abstraction from :mod:`aiohttp` to provide routing and templating to other hooks.
46 |
47 | Attributes:
48 | hook (.WebHook):
49 | Parent hook instance providing the webserver.
50 | prefix (str):
51 | URL prefix acting as the base path.
52 | module (str):
53 | Dotted module name of the Python module using this context (``__name__``).
54 | path (str):
55 | Base path of the module (``os.path.dirname(__file__)``), needed for static routes.
56 | env (dict):
57 | Additional variables to make available in the Jinja context. The default context is:
58 |
59 | * :data:`immp`, the top-level :mod:`immp` module
60 | * :data:`host`, the running :class:`.Host` instance
61 | * :data:`ctx`, this :class:`.WebContext` instance
62 | * :data:`request`, the current :class:`aiohttp.web.Request` instance
63 | """
64 |
65 | def __init__(self, hook, prefix, module, path=None, env=None):
66 | self.hook = hook
67 | self.prefix = prefix
68 | self.module = module
69 | self.path = path
70 | self.env = {"ctx": self}
71 | if env:
72 | self.env.update(env)
73 | self._routes = {}
74 | # Add Jinja2 only if the module is a full package (i.e. a directory).
75 | spec = find_spec(self.module)
76 | if spec and spec.submodule_search_locations:
77 | self.hook.add_loader(self.module)
78 |
79 | def route(self, method, route, fn, template=None, name=None):
80 | """
81 | Add a new route to the webserver.
82 |
83 | Args:
84 | method (str):
85 | HTTP verb for the route (``GET``, ``POST`` etc.).
86 | route (str):
87 | URL pattern to match.
88 | fn (function):
89 | Callable to render the response, accepting a :class:`aiohttp.web.Request` argument.
90 | template (str):
91 | Optional template path, relative to the module path. If specified, the view
92 | callable should return a context :class:`dict` which is passed to the template.
93 | name (str):
94 | Custom name for the route, defaulting to the function name if not specified.
95 | """
96 | name = name or fn.__name__
97 | if name in self._routes:
98 | raise KeyError(name)
99 | if template:
100 | fn = self._jinja(fn, template)
101 | route = self.hook.add_route(method, "{}/{}".format(self.prefix, route), fn,
102 | name="{}:{}".format(self.module, name))
103 | self._routes[name] = route
104 |
105 | def static(self, route, path, name=None):
106 | """
107 | Add a new route to the webserver.
108 |
109 | Args:
110 | route (str):
111 | URL pattern to match.
112 | path (str):
113 | Filesystem location relative to the base path.
114 | name (str):
115 | Custom name for the route, defaulting to the function name if not specified.
116 | """
117 | name = name or path
118 | if name in self._routes:
119 | raise KeyError(name)
120 | route = self.hook.add_static("{}/{}".format(self.prefix, route),
121 | os.path.join(self.path, path),
122 | name="{}:{}".format(self.module, name))
123 | self._routes[name] = route
124 |
125 | def _jinja(self, fn, path):
126 | @wraps(fn)
127 | async def inner(request):
128 | env = dict(self.env)
129 | env["request"] = request
130 | env.update(await fn(request))
131 | return env
132 | if not aiohttp_jinja2:
133 | raise immp.HookError("Templating requires Jinja2 and aiohttp_jinja2")
134 | outer = aiohttp_jinja2.template("{}/{}".format(self.module, path))
135 | return outer(inner)
136 |
137 | def url_for(self, name_, **kwargs):
138 | """
139 | Generate an absolute URL for the named route.
140 |
141 | Args:
142 | name (str):
143 | Route name, either the function name or the custom name given during registration.
144 |
145 | Returns:
146 | str:
147 | Relative URL to the corresponding page.
148 | """
149 | return self._routes[name_].url_for(**{k: v.replace("/", "%2F") for k, v in kwargs.items()})
150 |
151 | def __repr__(self):
152 | return "<{}: {}>".format(self.__class__.__name__, self.prefix)
153 |
154 |
155 | class WebHook(immp.ResourceHook):
156 | """
157 | Hook that provides a generic webserver, which other hooks can bind routes to.
158 |
159 | Attributes:
160 | app (aiohttp.web.Application):
161 | Web application instance, used to add new routes.
162 | """
163 |
164 | schema = immp.Schema(immp.Any({immp.Optional("host"): immp.Nullable(str),
165 | "port": int},
166 | {"path": str}))
167 |
168 | def __init__(self, name, config, host):
169 | super().__init__(name, config, host)
170 | self.app = web.Application()
171 | if aiohttp_jinja2:
172 | # Empty mapping by default, other hooks can add to this via add_loader().
173 | self._loader = PrefixLoader({})
174 | self._jinja = aiohttp_jinja2.setup(self.app, loader=self._loader)
175 | self._jinja.filters["json"] = json.dumps
176 | self._jinja.globals["immp"] = immp
177 | self._jinja.globals["host"] = self.host
178 | self._runner = web.AppRunner(self.app)
179 | self._site = None
180 | self._contexts = {}
181 |
182 | def context(self, prefix, module, path=None, env=None):
183 | """
184 | Retrieve a context for the current module.
185 |
186 | Args:
187 | prefix (str):
188 | URL prefix acting as the base path.
189 | module (str):
190 | Dotted module name of the Python module using this context. Callers should use
191 | :data:`__name__` from the root of their module.
192 | path (str):
193 | Base path of the module, needed for static routes. Callers should use
194 | ``os.path.dirname(__file__)`` from the root of their module.
195 | env (dict):
196 | Additional variables to make available in the Jinja context. See
197 | :attr:`.WebContext.env` for details.
198 |
199 | Returns:
200 | .WebContext:
201 | Linked context instance for that module.
202 | """
203 | self._contexts[module] = WebContext(self, prefix, module, path, env)
204 | return self._contexts[module]
205 |
206 | def add_loader(self, module):
207 | """
208 | Register a Jinja2 package loader for the given module.
209 |
210 | Args:
211 | module (str):
212 | Module name to register.
213 | """
214 | if not aiohttp_jinja2:
215 | raise immp.HookError("Loaders require Jinja2 and aiohttp_jinja2")
216 | self._loader.mapping[module] = PackageLoader(module)
217 |
218 | def add_route(self, *args, **kwargs):
219 | """
220 | Equivalent to :meth:`aiohttp.web.UrlDispatcher.add_route`.
221 | """
222 | return self.app.router.add_route(*args, **kwargs)
223 |
224 | def add_static(self, *args, **kwargs):
225 | """
226 | Equivalent to :meth:`aiohttp.web.UrlDispatcher.add_static`.
227 | """
228 | return self.app.router.add_static(*args, **kwargs)
229 |
230 | async def start(self):
231 | await super().start()
232 | await self._runner.setup()
233 | if "path" in self.config:
234 | log.debug("Starting server on socket %s", self.config["path"])
235 | self._site = web.UnixSite(self._runner, self.config["path"])
236 | else:
237 | log.debug("Starting server on host %s:%d", self.config["host"], self.config["port"])
238 | self._site = web.TCPSite(self._runner, self.config["host"], self.config["port"])
239 | await self._site.start()
240 |
241 | async def stop(self):
242 | await super().stop()
243 | if self._site:
244 | log.debug("Stopping server")
245 | await self._runner.cleanup()
246 | self._site = None
247 |
--------------------------------------------------------------------------------
/immp/hook/alerts/subscriptions.py:
--------------------------------------------------------------------------------
1 | from asyncio import wait
2 | from collections import defaultdict
3 | import re
4 |
5 | try:
6 | from tortoise import Model
7 | from tortoise.exceptions import DoesNotExist
8 | from tortoise.fields import ForeignKeyField, TextField
9 | except ImportError:
10 | Model = None
11 |
12 | import immp
13 | from immp.hook.command import CommandScope, command
14 | from immp.hook.database import AsyncDatabaseHook
15 |
16 | from .common import AlertHookBase, Skip
17 |
18 |
19 | CROSS = "\N{CROSS MARK}"
20 | TICK = "\N{WHITE HEAVY CHECK MARK}"
21 |
22 |
23 | if Model:
24 |
25 | class SubTrigger(Model):
26 | """
27 | Individual subscription trigger phrase for an individual user.
28 |
29 | Attributes:
30 | network (str):
31 | Network identifier that the user belongs to.
32 | user (str):
33 | User identifier as given by the plug.
34 | text (str):
35 | Subscription text that they wish to be notified on.
36 | """
37 |
38 | network = TextField()
39 | user = TextField()
40 | text = TextField()
41 |
42 | def __repr__(self):
43 | return "<{}: #{} {} ({} @ {})>".format(self.__class__.__name__, self.id,
44 | repr(self.text), repr(self.user),
45 | repr(self.network))
46 |
47 | class SubExclude(Model):
48 | """
49 | Exclusion for a trigger in a specific channel.
50 |
51 | Attributes:
52 | trigger (.SubTrigger):
53 | Containing trigger instance.
54 | network (str):
55 | Network identifier that the channel belongs to.
56 | user (str):
57 | Channel's own identifier.
58 | """
59 |
60 | trigger = ForeignKeyField("db.SubTrigger", "excludes")
61 | network = TextField()
62 | channel = TextField()
63 |
64 | @classmethod
65 | def select_related(cls):
66 | return cls.all().prefetch_related("trigger")
67 |
68 | def __repr__(self):
69 | if isinstance(self.trigger, SubTrigger):
70 | trigger = repr(self.trigger)
71 | else:
72 | trigger = "<{}: #{}>".format(SubTrigger.__name__, self.trigger_id)
73 | return "<{}: #{} {} @ {} {}>".format(self.__class__.__name__, self.id,
74 | repr(self.network), repr(self.channel), trigger)
75 |
76 |
77 | class SubscriptionsHook(AlertHookBase):
78 | """
79 | Hook to send trigger word alerts via private channels.
80 | """
81 |
82 | def __init__(self, name, config, host):
83 | super().__init__(name, config, host)
84 | if not Model:
85 | raise immp.PlugError("'tortoise' module not installed")
86 |
87 | def on_load(self):
88 | self.host.resources[AsyncDatabaseHook].add_models(SubTrigger, SubExclude)
89 |
90 | @classmethod
91 | def _clean(cls, text):
92 | return re.sub(r"[^\w ]", "", text).lower()
93 |
94 | def _test(self, channel, user):
95 | return self.group.has_plug(channel.plug)
96 |
97 | @command("sub-add", scope=CommandScope.private, test=_test)
98 | async def add(self, msg, *words):
99 | """
100 | Add a subscription to your trigger list.
101 | """
102 | text = re.sub(r"[^\w ]", "", " ".join(words)).lower()
103 | _, created = await SubTrigger.get_or_create(network=msg.channel.plug.network_id,
104 | user=msg.user.id, text=text)
105 | resp = "{} {}".format(TICK, "Subscribed" if created else "Already subscribed")
106 | await msg.channel.send(immp.Message(text=resp))
107 |
108 | @command("sub-remove", scope=CommandScope.private, test=_test)
109 | async def remove(self, msg, *words):
110 | """
111 | Remove a subscription from your trigger list.
112 | """
113 | text = re.sub(r"[^\w ]", "", " ".join(words)).lower()
114 | count = await SubTrigger.filter(network=msg.channel.plug.network_id,
115 | user=msg.user.id,
116 | text=text).delete()
117 | resp = "{} {}".format(TICK, "Unsubscribed" if count else "Not subscribed")
118 | await msg.channel.send(immp.Message(text=resp))
119 |
120 | @command("sub-list", scope=CommandScope.private, test=_test)
121 | async def list(self, msg):
122 | """
123 | Show all active subscriptions.
124 | """
125 | subs = await SubTrigger.filter(network=msg.user.plug.network_id,
126 | user=msg.user.id).order_by("text")
127 | if subs:
128 | text = immp.RichText([immp.Segment("Your subscriptions:", bold=True)])
129 | for sub in subs:
130 | text.append(immp.Segment("\n- {}".format(sub.text)))
131 | else:
132 | text = "No active subscriptions."
133 | await msg.channel.send(immp.Message(text=text))
134 |
135 | @command("sub-exclude", scope=CommandScope.shared, test=_test)
136 | async def exclude(self, msg, *words):
137 | """
138 | Don't trigger a specific subscription in the current channel.
139 | """
140 | text = re.sub(r"[^\w ]", "", " ".join(words)).lower()
141 | try:
142 | trigger = await SubTrigger.get(network=msg.user.plug.network_id,
143 | user=msg.user.id, text=text)
144 | except DoesNotExist:
145 | resp = "{} Not subscribed".format(CROSS)
146 | else:
147 | exclude, created = await SubExclude.get_or_create(trigger=trigger,
148 | network=msg.channel.plug.network_id,
149 | channel=msg.channel.source)
150 | if not created:
151 | await exclude.delete()
152 | resp = "{} {}".format(TICK, "Excluded" if created else "No longer excluded")
153 | await msg.channel.send(immp.Message(text=resp))
154 |
155 | @staticmethod
156 | async def match(text, channel, present):
157 | """
158 | Identify users subscribed to text snippets in a message.
159 |
160 | Args:
161 | text (str):
162 | Cleaned message text.
163 | channel (.Channel):
164 | Channel where the subscriptions were triggered.
165 | present (((str, str), .User) dict):
166 | Mapping from network/user IDs to members of the source channel.
167 |
168 | Returns:
169 | (.User, str set) dict:
170 | Mapping from applicable users to their filtered triggers.
171 | """
172 | subs = set()
173 | for sub in await SubTrigger.all():
174 | key = (sub.network, sub.user)
175 | if key in present and sub.text in text:
176 | subs.add(sub)
177 | triggered = defaultdict(set)
178 | excludes = (await SubExclude.select_related()
179 | .filter(trigger__id__in=tuple(sub.id for sub in subs),
180 | network=channel.plug.network_id,
181 | channel=channel.source))
182 | excluded = set(exclude.trigger.text for exclude in excludes)
183 | for trigger in subs:
184 | if trigger.text not in excluded:
185 | triggered[present[(trigger.network, trigger.user)]].add(trigger.text)
186 | return triggered
187 |
188 | async def channel_migrate(self, old, new):
189 | count = (await SubExclude.filter(network=old.plug.network_id, channel=old.source)
190 | .update(network=new.plug.network_id, channel=new.source))
191 | return count > 0
192 |
193 | async def on_receive(self, sent, source, primary):
194 | await super().on_receive(sent, source, primary)
195 | if not primary or not source.text or await sent.channel.is_private():
196 | return
197 | try:
198 | lookup, members = await self._get_members(sent)
199 | except Skip:
200 | return
201 | present = {(member.plug.network_id, str(member.id)): member for member in members}
202 | triggered = await self.match(self._clean(str(source.text)), lookup, present)
203 | if not triggered:
204 | return
205 | tasks = []
206 | for member, triggers in triggered.items():
207 | if member == source.user:
208 | continue
209 | private = await member.private_channel()
210 | if not private:
211 | continue
212 | text = immp.RichText()
213 | mentioned = immp.Segment(", ".join(sorted(triggers)), italic=True)
214 | if source.user:
215 | text.append(immp.Segment(source.user.real_name or source.user.username, bold=True),
216 | immp.Segment(" mentioned "), mentioned)
217 | else:
218 | text.append(mentioned, immp.Segment(" mentioned"))
219 | title = await sent.channel.title()
220 | link = await sent.channel.link()
221 | if title:
222 | text.append(immp.Segment(" in "),
223 | immp.Segment(title, italic=True))
224 | text.append(immp.Segment(":\n"))
225 | text += source.text
226 | if source.user and source.user.link:
227 | text.append(immp.Segment("\n"),
228 | immp.Segment("Go to user", link=source.user.link))
229 | if link:
230 | text.append(immp.Segment("\n"),
231 | immp.Segment("Go to channel", link=link))
232 | tasks.append(private.send(immp.Message(text=text)))
233 | if tasks:
234 | await wait(tasks)
235 |
--------------------------------------------------------------------------------
/immp/hook/access.py:
--------------------------------------------------------------------------------
1 | """
2 | Channel join control, extended by other hooks.
3 |
4 | Config:
5 | hooks ((str, str list) dict):
6 | Mapping of access-aware hooks to a list of channels they manage. If a list is ``None``,
7 | the included hook will just manage channels it declares ownership of.
8 | exclude ((str, str list) dict):
9 | Mapping of plugs to user IDs who should be ignored during checks.
10 | joins (bool):
11 | ``True`` to check each join as it happens.
12 | startup (bool):
13 | ``True`` to run a full check of all named channels on load.
14 | passive (bool):
15 | ``True`` to log violations without actually following through with removals.
16 | default (bool):
17 | ``True`` (default) to implicitly grant access if no predicates provide a decision, or
18 | ``False`` to treat all abstains as an implicit deny.
19 |
20 | This hook implements its own protocol for test purposes, by rejecting all joins and members of a
21 | channel. To make full use of it, other hooks with support for channel access can determine if a
22 | user satisfies membership of an external group or application.
23 | """
24 |
25 | from asyncio import ensure_future, gather
26 | from collections import defaultdict
27 | from itertools import product
28 | import logging
29 |
30 | import immp
31 |
32 |
33 | log = logging.getLogger(__name__)
34 |
35 |
36 | class AccessPredicate:
37 | """
38 | Interface for hooks to provide channel access control from a backing source.
39 | """
40 |
41 | async def access_channels(self):
42 | """
43 | Request a specific set of channels to be assessed. If a separate set of channels is given
44 | by the controlling :class:`ChannelAccessHook`, the intersection will be taken.
45 |
46 | Returns:
47 | .Channel set:
48 | Channels this hook is interested in providing access control for, or ``None`` to
49 | just take all channels configured upstream.
50 | """
51 | return None
52 |
53 | async def channel_access_multi(self, members):
54 | """
55 | Bulk-verify if a set of users are allowed access to all given channels.
56 |
57 | By default, calls :meth:`channel_access` for each channel-user pair, but can be overridden
58 | in order to optimise any necessary work.
59 |
60 | Args:
61 | members ((.Channel, .User set) dict):
62 | Mapping from target channels to members awaiting verification. If ``None`` is given
63 | for a channel's set of users, all members of the channel will be verified.
64 |
65 | Returns:
66 | ((.Channel, .User) set, (.Channel, .User) set):
67 | Two sets of channel-user pairs, the first for users who are allowed, the second
68 | for those who are denied. Each pair should appear in at most one of the two lists;
69 | conflicts will be resolved to deny access.
70 | """
71 | allowed = set()
72 | denied = set()
73 | for channel, users in members.items():
74 | for user in users:
75 | try:
76 | decision = await self.channel_access(channel, user)
77 | except Exception:
78 | log.warning("Failed to process channel %r access for user %r",
79 | channel, user.id, exc_info=True)
80 | continue
81 | if decision is not None:
82 | (allowed if decision else denied).add((channel, user))
83 | return allowed, denied
84 |
85 | async def channel_access(self, channel, user):
86 | """
87 | Verify if a user is allowed access to a channel.
88 |
89 | Args:
90 | channel (.Channel):
91 | Target channel.
92 | user (.User):
93 | User to be verified.
94 |
95 | Returns:
96 | bool:
97 | ``True`` to grant access for this user to the given channel, ``False`` to deny
98 | access, or ``None`` to abstain from a decision.
99 | """
100 | raise NotImplementedError
101 |
102 |
103 | class ChannelAccessHook(immp.Hook, AccessPredicate):
104 | """
105 | Hook for controlling membership of, and joins to, secure channels.
106 | """
107 |
108 | schema = immp.Schema({immp.Optional("hooks", dict): {str: immp.Nullable([str])},
109 | immp.Optional("exclude", dict): {str: [str]},
110 | immp.Optional("joins", True): bool,
111 | immp.Optional("startup", False): bool,
112 | immp.Optional("passive", False): bool,
113 | immp.Optional("default", True): bool})
114 |
115 | hooks = immp.ConfigProperty({AccessPredicate: [immp.Channel]})
116 |
117 | @property
118 | def channels(self):
119 | inverse = defaultdict(list)
120 | for hook, channels in self.hooks.items():
121 | if not channels:
122 | continue
123 | for channel in channels:
124 | inverse[channel].append(hook)
125 | return inverse
126 |
127 | # This hook acts as an example predicate to block all access.
128 |
129 | async def channel_access_multi(self, channels, users):
130 | return [], list(product(channels, users))
131 |
132 | async def channel_access(self, channel, user):
133 | return False
134 |
135 | async def verify(self, members=None):
136 | """
137 | Perform verification of each user in each channel, for all configured access predicates.
138 | Users who are denied access by any predicate will be removed, unless passive mode is set.
139 |
140 | Args:
141 | members ((.Channel, .User set) dict):
142 | Mapping from target channels to a subset of users pending verification.
143 |
144 | If ``None`` is given for a channel's set of users, all members present in the
145 | channel will be verified. If ``members`` itself is ``None``, access checks will be
146 | run against all configured channels.
147 | """
148 | everywhere = set()
149 | grouped = {}
150 | for hook, scope in self.hooks.items():
151 | interested = await hook.access_channels()
152 | if scope and interested:
153 | log.debug("Hook %r using scope and own list", hook)
154 | wanted = set(interested).intersection(scope)
155 | elif scope or interested:
156 | log.debug("Hook %r using %s", hook, "scope" if scope else "own list")
157 | wanted = set(scope or interested)
158 | else:
159 | log.warning("Hook %r has no declared channels for access control", hook)
160 | continue
161 | if members is not None:
162 | wanted.intersection_update(members)
163 | if wanted:
164 | everywhere.update(wanted)
165 | grouped[hook] = wanted
166 | else:
167 | log.debug("Skipping hook %r as member filter doesn't overlap", hook)
168 | targets = defaultdict(set)
169 | members = members or {}
170 | for channel in everywhere:
171 | users = members.get(channel)
172 | try:
173 | current = await channel.members()
174 | except Exception:
175 | log.warning("Failed to retrieve members for channel %r", channel, exc_info=True)
176 | continue
177 | for user in users or current or ():
178 | if current and user not in current:
179 | log.debug("Skipping non-member user %r", user)
180 | elif user.id in self.config["exclude"].get(user.plug.name, []):
181 | log.debug("Skipping excluded user %r", user)
182 | elif await user.is_system():
183 | log.debug("Skipping system user %r", user)
184 | else:
185 | targets[channel].add(user)
186 | hooks = []
187 | tasks = []
188 | for hook, channels in grouped.items():
189 | known = {channel: users for channel, users in targets.items() if users}
190 | log.debug("Requesting decisions from %r: %r", hook, set(known))
191 | hooks.append(hook)
192 | tasks.append(ensure_future(hook.channel_access_multi(known)))
193 | allowed = set()
194 | denied = set()
195 | for hook, result in zip(hooks, await gather(*tasks, return_exceptions=True)):
196 | if isinstance(result, Exception):
197 | log.warning("Failed to verify channel access with hook %r",
198 | hook.name, exc_info=result)
199 | continue
200 | hook_allowed, hook_denied = result
201 | allowed.update(hook_allowed)
202 | if hook_denied:
203 | log.debug("Hook %r denied %d user-channel pair(s)", hook.name, len(hook_denied))
204 | denied.update(hook_denied)
205 | removals = defaultdict(set)
206 | for channel, users in targets.items():
207 | for user in users:
208 | pair = (channel, user)
209 | if pair in denied:
210 | allow = False
211 | elif pair in allowed:
212 | allow = True
213 | else:
214 | allow = self.config["default"]
215 | if allow:
216 | log.debug("Allowing access to %r for %r", channel, user)
217 | else:
218 | log.debug("Denying access to %r for %r", channel, user)
219 | removals[channel].add(user)
220 | active = not self.config["passive"]
221 | for channel, refused in removals.items():
222 | log.debug("%s %d user(s) from %r: %r", "Removing" if active else "Would remove",
223 | len(refused), channel, refused)
224 | if active:
225 | await channel.remove_multi(refused)
226 |
227 | async def _startup_check(self):
228 | log.debug("Running startup access checks")
229 | await self.verify()
230 | log.debug("Finished startup access checks")
231 |
232 | def on_ready(self):
233 | if self.config["startup"]:
234 | ensure_future(self._startup_check())
235 |
236 | async def on_receive(self, sent, source, primary):
237 | await super().on_receive(sent, source, primary)
238 | if self.config["joins"] and primary and sent == source and source.joined:
239 | await self.verify({sent.channel: source.joined})
240 |
--------------------------------------------------------------------------------
/immp/hook/identitylocal.py:
--------------------------------------------------------------------------------
1 | """
2 | Basic identity management for users in different networks.
3 |
4 | Dependencies:
5 | :class:`.AsyncDatabaseHook`
6 |
7 | Config:
8 | instance (int):
9 | Unique instance code.
10 | plugs (str list):
11 | List of plug names to accept identities for.
12 | multiple (bool):
13 | ``True`` (default) to allow linking multiple accounts from the same network.
14 | admins ((str, str list) dict):
15 | Mapping from plug names to user identifiers that can use the ``id-role`` command.
16 |
17 | Commands:
18 | id-add :
19 | Create a new identity, or link to an existing one from a second user.
20 | id-rename :
21 | Rename the current identity.
22 | id-password :
23 | Update the password for the current identity.
24 | id-reset:
25 | Delete the current identity and all linked users.
26 | id-role [role]:
27 | List roles assigned to an identity, or add/remove a given role.
28 |
29 | This is a local implementation of the identity protocol, providing self-serve identity linking to
30 | users where a user management backend doesn't otherwise exist.
31 |
32 | In order to support multiple copies of this hook with overlapping plugs (e.g. including a private
33 | network in some groups), each hook has an instance code. If a code isn't defined in the config, a
34 | new one will be assigned at startup. If multiple hooks are in use, it's important to define these
35 | yourself, so that identities remained assigned to the correct instance.
36 | """
37 |
38 | from hashlib import sha256
39 | import logging
40 |
41 | from tortoise import Model
42 | from tortoise.exceptions import DoesNotExist
43 | from tortoise.fields import ForeignKeyField, IntField, TextField
44 |
45 | import immp
46 | from immp.hook.access import AccessPredicate
47 | from immp.hook.command import CommandScope, command
48 | from immp.hook.database import AsyncDatabaseHook
49 | from immp.hook.identity import Identity, IdentityProvider
50 |
51 |
52 | CROSS = "\N{CROSS MARK}"
53 | TICK = "\N{WHITE HEAVY CHECK MARK}"
54 |
55 |
56 | log = logging.getLogger(__name__)
57 |
58 |
59 | class IdentityGroup(Model):
60 | """
61 | Representation of a single identity.
62 |
63 | Attributes:
64 | instance (int):
65 | :class:`IdentityHook` instance code.
66 | name (str):
67 | Unique display name.
68 | pwd (str):
69 | Hashed password, used by the user to authenticate when linking identities.
70 | links (.IdentityLink iterable):
71 | All links contained by this group.
72 | """
73 |
74 | instance = IntField()
75 | name = TextField()
76 | pwd = TextField()
77 |
78 | class Meta:
79 | # Uniqueness constraint for each name in each identity instance.
80 | unique_together = (("instance", "name"),)
81 |
82 | @classmethod
83 | def hash(cls, pwd):
84 | return sha256(pwd.encode("utf-8")).hexdigest()
85 |
86 | @classmethod
87 | def select_related(cls):
88 | return cls.all().prefetch_related("links", "roles")
89 |
90 | async def to_identity(self, host, provider):
91 | tasks = []
92 | plugs = {plug.network_id: plug for plug in host.plugs.values()
93 | if plug.state == immp.OpenState.active}
94 | for link in self.links:
95 | if link.network in plugs:
96 | tasks.append(plugs[link.network].user_from_id(link.user))
97 | else:
98 | log.debug("Ignoring identity link for unavailable plug: %r", link)
99 | users = await Identity.gather(*tasks)
100 | roles = [role.role for role in self.roles]
101 | return Identity(self.name, provider, users, roles)
102 |
103 | def __repr__(self):
104 | return "<{}: #{} {}>".format(self.__class__.__name__, self.id, repr(self.name))
105 |
106 |
107 | class IdentityLink(Model):
108 | """
109 | Single link between an identity and a user.
110 |
111 | Attributes:
112 | group (.IdentityGroup):
113 | Containing group instance.
114 | network (str):
115 | Network identifier that the user belongs to.
116 | user (str):
117 | User identifier as given by the plug.
118 | """
119 |
120 | group = ForeignKeyField("db.IdentityGroup", "links")
121 | network = TextField()
122 | user = TextField()
123 |
124 | def __repr__(self):
125 | if isinstance(self.group, IdentityGroup):
126 | group = repr(self.group)
127 | else:
128 | group = "<{}: #{}>".format(IdentityGroup.__name__, self.group_id)
129 | return "<{}: #{} {} @ {} {}>".format(self.__class__.__name__, self.id, repr(self.user),
130 | repr(self.network), group)
131 |
132 |
133 | class IdentityRole(Model):
134 | """
135 | Assignment of a role to an identity.
136 |
137 | Attributes:
138 | group (.IdentityGroup):
139 | Containing group instance.
140 | role (str):
141 | Plain role identifier.
142 | """
143 |
144 | group = ForeignKeyField("db.IdentityGroup", "roles")
145 | role = TextField()
146 |
147 | def __repr__(self):
148 | if isinstance(self.group, IdentityGroup):
149 | group = repr(self.group)
150 | else:
151 | group = "<{}: #{}>".format(IdentityGroup.__name__, self.group_id)
152 | return "<{}: #{} {} {}>".format(self.__class__.__name__, self.id, repr(self.role), group)
153 |
154 |
155 | class LocalIdentityHook(immp.Hook, AccessPredicate, IdentityProvider):
156 | """
157 | Hook for managing physical users with multiple logical links across different plugs. This
158 | effectively provides self-service identities, as opposed to being provided externally.
159 | """
160 |
161 | schema = immp.Schema({immp.Optional("instance"): immp.Nullable(int),
162 | "plugs": [str],
163 | immp.Optional("multiple", True): bool,
164 | immp.Optional("admins", dict): {str: [str]}})
165 |
166 | provider_name = "Local"
167 |
168 | _plugs = immp.ConfigProperty([immp.Plug])
169 |
170 | def on_load(self):
171 | self.host.resources[AsyncDatabaseHook].add_models(IdentityGroup, IdentityLink,
172 | IdentityRole)
173 |
174 | async def start(self):
175 | await super().start()
176 | if not self.config["instance"]:
177 | # Find a non-conflicting number and assign it.
178 | codes = {hook.config["instance"] for hook in self.host.hooks.values()
179 | if isinstance(hook, self.__class__)}
180 | code = 1
181 | while code in codes:
182 | code += 1
183 | log.debug("Assigning instance code %d to hook %r", code, self.name)
184 | self.config["instance"] = code
185 |
186 | async def get(self, name):
187 | """
188 | Retrieve the identity group using the given name.
189 |
190 | Args:
191 | name (str):
192 | Existing name to query.
193 |
194 | Returns:
195 | .IdentityGroup:
196 | Linked identity, or ``None`` if not linked.
197 | """
198 | try:
199 | return (await IdentityGroup.select_related()
200 | .get(instance=self.config["instance"], name=name))
201 | except DoesNotExist:
202 | return None
203 |
204 | async def find(self, user):
205 | """
206 | Retrieve the identity that contains the given user, if one exists.
207 |
208 | Args:
209 | user (.User):
210 | Existing user to query.
211 |
212 | Returns:
213 | .IdentityGroup:
214 | Linked identity, or ``None`` if not linked.
215 | """
216 | if not user or user.plug not in self._plugs:
217 | return None
218 | try:
219 | return (await IdentityGroup.select_related()
220 | .get(instance=self.config["instance"],
221 | links__network=user.plug.network_id,
222 | links__user=user.id))
223 | except DoesNotExist:
224 | return None
225 |
226 | async def channel_access(self, channel, user):
227 | return bool(await self.find(user))
228 |
229 | async def identity_from_name(self, name):
230 | group = await self.get(name)
231 | return await group.to_identity(self.host, self) if group else None
232 |
233 | async def identity_from_user(self, user):
234 | group = await self.find(user)
235 | return await group.to_identity(self.host, self) if group else None
236 |
237 | def _test(self, channel, user):
238 | return channel.plug in self._plugs
239 |
240 | def _test_admin(self, channel, user):
241 | if not self._test(channel, user):
242 | return False
243 | elif not user.plug:
244 | return False
245 | else:
246 | return user.id in self.config["admins"].get(user.plug.name, ())
247 |
248 | @command("id-add", scope=CommandScope.private, test=_test)
249 | async def add(self, msg, name, pwd):
250 | """
251 | Create a new identity, or link to an existing one from a second user.
252 | """
253 | if not msg.user or msg.user.plug not in self._plugs:
254 | return
255 | if await self.find(msg.user):
256 | text = "{} Already identified".format(CROSS)
257 | else:
258 | pwd = IdentityGroup.hash(pwd)
259 | exists = False
260 | try:
261 | group = await IdentityGroup.get(instance=self.config["instance"], name=name)
262 | exists = True
263 | except DoesNotExist:
264 | group = await IdentityGroup.create(instance=self.config["instance"],
265 | name=name, pwd=pwd)
266 | if exists and not group.pwd == pwd:
267 | text = "{} Password incorrect".format(CROSS)
268 | elif not self.config["multiple"] and any(link.network == msg.user.plug.network_id
269 | for link in group.links):
270 | text = "{} Already identified on {}".format(CROSS, msg.user.plug.network_name)
271 | else:
272 | await IdentityLink.create(group=group, network=msg.user.plug.network_id,
273 | user=msg.user.id)
274 | text = "{} {}".format(TICK, "Added" if exists else "Claimed")
275 | await msg.channel.send(immp.Message(text=text))
276 |
277 | @command("id-rename", scope=CommandScope.private, test=_test)
278 | async def rename(self, msg, name):
279 | """
280 | Rename the current identity.
281 | """
282 | if not msg.user:
283 | return
284 | group = await self.find(msg.user)
285 | if not group:
286 | text = "{} Not identified".format(CROSS)
287 | elif group.name == name:
288 | text = "{} No change".format(TICK)
289 | elif await IdentityGroup.filter(instance=self.config["instance"], name=name).exists():
290 | text = "{} Name already in use".format(CROSS)
291 | else:
292 | group.name = name
293 | await group.save()
294 | text = "{} Claimed".format(TICK)
295 | await msg.channel.send(immp.Message(text=text))
296 |
297 | @command("id-password", scope=CommandScope.private, test=_test)
298 | async def password(self, msg, pwd):
299 | """
300 | Update the password for the current identity.
301 | """
302 | if not msg.user:
303 | return
304 | group = await self.find(msg.user)
305 | if not group:
306 | text = "{} Not identified".format(CROSS)
307 | else:
308 | group.pwd = IdentityGroup.hash(pwd)
309 | await group.save()
310 | text = "{} Changed".format(TICK)
311 | await msg.channel.send(immp.Message(text=text))
312 |
313 | @command("id-reset", scope=CommandScope.private, test=_test)
314 | async def reset(self, msg):
315 | """
316 | Delete the current identity and all linked users.
317 | """
318 | if not msg.user:
319 | return
320 | group = await self.find(msg.user)
321 | if not group:
322 | text = "{} Not identified".format(CROSS)
323 | else:
324 | await group.delete()
325 | text = "{} Reset".format(TICK)
326 | await msg.channel.send(immp.Message(text=text))
327 |
328 | @command("id-role", scope=CommandScope.private, test=_test_admin)
329 | async def role(self, msg, name, role=None):
330 | """
331 | List roles assigned to an identity, or add/remove a given role.
332 | """
333 | try:
334 | group = await IdentityGroup.get(instance=self.config["instance"], name=name)
335 | except DoesNotExist:
336 | text = "{} Name not registered".format(CROSS)
337 | else:
338 | if role:
339 | if await IdentityRole.filter(group=group, role=role).delete():
340 | text = "{} Removed".format(TICK)
341 | else:
342 | await IdentityRole.create(group=group, role=role)
343 | text = "{} Added".format(TICK)
344 | else:
345 | roles = await IdentityRole.filter(group=group)
346 | if roles:
347 | labels = [role.role for role in roles]
348 | text = "Roles for {}: {}".format(name, ", ".join(labels))
349 | else:
350 | text = "No roles for {}.".format(name)
351 | await msg.channel.send(immp.Message(text=text))
352 |
--------------------------------------------------------------------------------
/immp/plug/github.py:
--------------------------------------------------------------------------------
1 | """
2 | Listen for incoming GitHub webhooks.
3 |
4 | Dependencies:
5 | :class:`.WebHook`
6 |
7 | Config:
8 | route (str):
9 | Path to expose the webhook request handler.
10 | secret (str):
11 | Shared string between the plug and GitHub's servers. Optional but recommended, must be
12 | configured to match the webhook on GitHub.
13 |
14 | Go to your repository > Settings > Webhooks > Add webhook, set the URL to match the configured
15 | route, and choose the events you wish to handle. Message summaries for each event will be emitted
16 | on channels matching the full name of each repository (e.g. ``user/repo``).
17 | """
18 |
19 | import hashlib
20 | import hmac
21 | import logging
22 |
23 | from aiohttp import web
24 |
25 | import immp
26 | from immp.hook.web import WebHook
27 |
28 |
29 | log = logging.getLogger(__name__)
30 |
31 |
32 | class _Schema:
33 |
34 | config = immp.Schema({"route": str,
35 | immp.Optional("secret"): immp.Nullable(str),
36 | immp.Optional("ignore", list): [str]})
37 |
38 | _linked = {"html_url": str}
39 |
40 | _sender = {"id": int, "login": str, "avatar_url": str}
41 |
42 | _repo = {"full_name": str}
43 |
44 | _project = {"name": str, "number": int, **_linked}
45 | _card = {"note": str, "url": str}
46 |
47 | _release = {"tag": str, immp.Optional("name"): immp.Nullable(str), **_linked}
48 |
49 | _issue = {"number": int, "title": str, **_linked}
50 | _pull = {immp.Optional("merged", False): bool, **_issue}
51 |
52 | _fork = {"full_name": str, **_linked}
53 |
54 | _page = {"action": str, "title": str, **_linked}
55 |
56 | push = immp.Schema({"ref": str,
57 | "after": str,
58 | "compare": str,
59 | immp.Optional("created", False): bool,
60 | immp.Optional("deleted", False): bool,
61 | immp.Optional("commits", list): [{"id": str, "message": str}]})
62 |
63 | event = immp.Schema({"sender": _sender,
64 | immp.Optional("organization"): immp.Nullable(_sender),
65 | immp.Optional("repository"): immp.Nullable(_repo),
66 | immp.Optional("project"): immp.Nullable(_project),
67 | immp.Optional("project_card"): immp.Nullable(_card),
68 | immp.Optional("release"): immp.Nullable(_release),
69 | immp.Optional("issue"): immp.Nullable(_issue),
70 | immp.Optional("pull_request"): immp.Nullable(_pull),
71 | immp.Optional("review"): immp.Nullable(_linked),
72 | immp.Optional("forkee"): immp.Nullable(_fork),
73 | immp.Optional("pages"): immp.Nullable([_page])})
74 |
75 |
76 | class GitHubUser(immp.User):
77 | """
78 | User present in GitHub.
79 | """
80 |
81 | @classmethod
82 | def from_sender(cls, github, sender):
83 | return cls(id_=sender["id"],
84 | plug=github,
85 | username=sender["login"],
86 | avatar=sender["avatar_url"],
87 | raw=sender)
88 |
89 | @property
90 | def link(self):
91 | return "https://github.com/{}".format(self.username)
92 |
93 | @link.setter
94 | def link(self, value):
95 | pass
96 |
97 |
98 | class GitHubMessage(immp.Message):
99 | """
100 | Repository event originating from GitHub.
101 | """
102 |
103 | _ACTIONS = {"converted_to_draft": "drafted",
104 | "prereleased": "pre-released",
105 | "ready_for_review": "readied",
106 | "review_requested": "requested review of",
107 | "review_request_removed": "removed review request of",
108 | "synchronize": "updated"}
109 |
110 | @classmethod
111 | def _action_text(cls, github, action):
112 | if not action:
113 | return None
114 | elif action in github.config["ignore"]:
115 | raise NotImplementedError
116 | else:
117 | return cls._ACTIONS.get(action, action)
118 |
119 | @classmethod
120 | def _repo_text(cls, github, type_, event):
121 | text = None
122 | repo = event["repository"]
123 | name = repo["full_name"]
124 | name_seg = immp.Segment(name, link=repo["html_url"])
125 | action = cls._action_text(github, event.get("action"))
126 | issue = event.get("issue")
127 | pull = event.get("pull_request")
128 | if type_ == "repository":
129 | text = immp.RichText([immp.Segment("{} repository ".format(action)), name_seg])
130 | elif type_ == "push":
131 | push = _Schema.push(event)
132 | count = len(push["commits"])
133 | desc = "{} commits".format(count) if count > 1 else push["after"][:7]
134 | root, target = push["ref"].split("/", 2)[1:]
135 | join = None
136 | if root == "tags":
137 | tag = True
138 | elif root == "heads":
139 | tag = False
140 | else:
141 | raise NotImplementedError
142 | if push["deleted"]:
143 | action = "deleted {}".format("tag" if tag else "branch")
144 | elif tag:
145 | action, join = "tagged", "as"
146 | else:
147 | action, join = "pushed", ("to new branch" if push["created"] else "to")
148 | text = immp.RichText([immp.Segment("{} ".format(action))])
149 | if join:
150 | text.append(immp.Segment(desc, link=push["compare"]),
151 | immp.Segment(" {} ".format(join)))
152 | text.append(immp.Segment("{} of {}".format(target, name)))
153 | for commit in push["commits"]:
154 | text.append(immp.Segment("\n\N{BULLET} {}: {}"
155 | .format(commit["id"][:7],
156 | commit["message"].split("\n")[0])))
157 | elif type_ == "release":
158 | release = event["release"]
159 | desc = ("{} ({} {})".format(release["name"], name, release["tag_name"])
160 | if release["name"] else release["tag_name"])
161 | text = immp.RichText([immp.Segment("{} release ".format(action)),
162 | immp.Segment(desc, link=release["html_url"])])
163 | elif type_ == "issues":
164 | desc = "{} ({}#{})".format(issue["title"], name, issue["number"])
165 | text = immp.RichText([immp.Segment("{} issue ".format(action)),
166 | immp.Segment(desc, link=issue["html_url"])])
167 | elif type_ == "issue_comment":
168 | comment = event["comment"]
169 | desc = "{} ({}#{})".format(issue["title"], name, issue["number"])
170 | text = immp.RichText([immp.Segment("{} a ".format(action)),
171 | immp.Segment("comment", link=comment["html_url"]),
172 | immp.Segment(" on issue "),
173 | immp.Segment(desc, link=issue["html_url"])])
174 | elif type_ == "pull_request":
175 | if action == "closed" and pull["merged"]:
176 | action = "merged"
177 | desc = "{} ({}#{})".format(pull["title"], name, pull["number"])
178 | text = immp.RichText([immp.Segment("{} pull request ".format(action)),
179 | immp.Segment(desc, link=pull["html_url"])])
180 | elif type_ == "pull_request_review":
181 | review = event["review"]
182 | desc = "{} ({}#{})".format(pull["title"], name, pull["number"])
183 | text = immp.RichText([immp.Segment("{} a ".format(action)),
184 | immp.Segment("review", link=review["html_url"]),
185 | immp.Segment(" on pull request "),
186 | immp.Segment(desc, link=pull["html_url"])])
187 | elif type_ == "pull_request_review_comment":
188 | comment = event["comment"]
189 | desc = "{} ({}#{})".format(pull["title"], name, pull["number"])
190 | text = immp.RichText([immp.Segment("{} a ".format(action)),
191 | immp.Segment("comment", link=comment["html_url"]),
192 | immp.Segment(" on pull request "),
193 | immp.Segment(desc, link=pull["html_url"])])
194 | elif type_ == "project":
195 | project = event["project"]
196 | desc = "{} ({}#{})".format(project["name"], name, project["number"])
197 | text = immp.RichText([immp.Segment("{} project ".format(action)),
198 | immp.Segment(desc, link=project["html_url"])])
199 | elif type_ == "project_card":
200 | card = event["project_card"]
201 | text = immp.RichText([immp.Segment("{} ".format(action)),
202 | immp.Segment("card", link=card["url"]),
203 | immp.Segment(" in project:\n"),
204 | immp.Segment(card["note"])])
205 | elif type_ == "gollum":
206 | text = immp.RichText()
207 | for i, page in enumerate(event["pages"]):
208 | if i:
209 | text.append(immp.Segment(", "))
210 | text.append(immp.Segment("{} {} wiki page ".format(page["action"], name)),
211 | immp.Segment(page["title"], link=page["html_url"]))
212 | elif type_ == "fork":
213 | fork = event["forkee"]
214 | text = immp.RichText([immp.Segment("forked {} to ".format(name)),
215 | immp.Segment(fork["full_name"], link=fork["html_url"])])
216 | elif type_ == "watch":
217 | text = immp.RichText([immp.Segment("starred "), name_seg])
218 | elif type_ == "public":
219 | text = immp.RichText([immp.Segment("made "), name_seg, immp.Segment(" public")])
220 | return text
221 |
222 | @classmethod
223 | def from_event(cls, github, type_, id_, event):
224 | """
225 | Convert a `GitHub webhook `_ payload to a
226 | :class:`.Message`.
227 |
228 | Args:
229 | github (.GitHubPlug):
230 | Related plug instance that provides the event.
231 | type (str):
232 | Event type name from the ``X-GitHub-Event`` header.
233 | id (str):
234 | GUID of the event delivery from the ``X-GitHub-Delivery`` header.
235 | event (dict):
236 | GitHub webhook payload.
237 |
238 | Returns:
239 | .GitHubMessage:
240 | Parsed message object.
241 | """
242 | text = None
243 | if event["repository"]:
244 | channel = immp.Channel(github, event["repository"]["full_name"])
245 | text = cls._repo_text(github, type_, event)
246 | if not text:
247 | raise NotImplementedError
248 | user = GitHubUser.from_sender(github, event["sender"])
249 | return immp.SentMessage(id_=id_,
250 | channel=channel,
251 | text=text,
252 | user=user,
253 | action=True,
254 | raw=event)
255 |
256 |
257 | class GitHubPlug(immp.Plug):
258 | """
259 | Plug for incoming `GitHub `_ notifications.
260 | """
261 |
262 | schema = _Schema.config
263 |
264 | network_name = "GitHub"
265 | network_id = "github"
266 |
267 | def __init__(self, name, config, host):
268 | super().__init__(name, config, host)
269 | self.ctx = None
270 |
271 | def on_load(self):
272 | log.debug("Registering webhook route")
273 | self.ctx = self.host.resources[WebHook].context(self.config["route"], __name__)
274 | self.ctx.route("POST", "", self.handle)
275 |
276 | async def channel_title(self, channel):
277 | return channel.source
278 |
279 | async def handle(self, request):
280 | if self.config["secret"]:
281 | try:
282 | body = await request.read()
283 | except ValueError:
284 | raise web.HTTPBadRequest
285 | try:
286 | alg, sig = request.headers["X-Hub-Signature"].split("=", 1)
287 | except (KeyError, ValueError):
288 | log.warning("No signature on event, secret needs configuring on webhook")
289 | raise web.HTTPUnauthorized
290 | match = hmac.new(self.config["secret"].encode("utf-8"), body, hashlib.sha1).hexdigest()
291 | if alg != "sha1" or sig != match:
292 | log.warning("Bad signature on event")
293 | raise web.HTTPUnauthorized
294 | try:
295 | data = await request.json()
296 | except ValueError:
297 | log.warning("Bad content type, webhook needs configuring as JSON")
298 | raise web.HTTPBadRequest
299 | try:
300 | type_ = request.headers["X-GitHub-Event"]
301 | id_ = request.headers["X-GitHub-Delivery"]
302 | event = _Schema.event(data)
303 | except (KeyError, ValueError):
304 | raise web.HTTPBadRequest
305 | if type_ == "ping":
306 | target = None
307 | if event["repository"]:
308 | target = "repository", event["repository"]["full_name"]
309 | elif event["organization"]:
310 | target = "organisation", event["organization"]["login"]
311 | if target:
312 | log.debug("Received ping event for %s %r", *target)
313 | else:
314 | log.warning("Received ping event for unknown target")
315 | else:
316 | try:
317 | self.queue(GitHubMessage.from_event(self, type_, id_, event))
318 | except NotImplementedError:
319 | log.debug("Ignoring unrecognised event type %r", type_)
320 | return web.Response()
321 |
--------------------------------------------------------------------------------
/immp/core/plug.py:
--------------------------------------------------------------------------------
1 | from asyncio import BoundedSemaphore, Queue
2 | import logging
3 |
4 | from .error import PlugError
5 | from .message import Message, Receipt
6 | from .util import Configurable, Openable, OpenState, pretty_str
7 |
8 |
9 | log = logging.getLogger(__name__)
10 |
11 |
12 | @pretty_str
13 | class Plug(Configurable, Openable):
14 | """
15 | Base of all plug classes, handles communication with an external network by converting
16 | outside data into standardised message objects, and pushing new messages into the network.
17 |
18 | Instantiation may raise :class:`.ConfigError` if the provided configuration is invalid.
19 |
20 | Attributes:
21 | name (str):
22 | User-provided, unique name of the plug, used for config references.
23 | config (dict):
24 | Reference to the user-provided configuration.
25 | host (.Host):
26 | Controlling host instance, providing access to plugs.
27 | virtual (bool):
28 | ``True`` if managed by another component (e.g. a hook that exposes plug functionality).
29 | network_name (str):
30 | Readable name of the underlying network, for use when displaying info about this plug.
31 | network_id (str):
32 | Unique and stable identifier for this plug.
33 |
34 | This should usually vary by the account or keys being used to connect, but persistent
35 | with later connections. If a network provides multiple distinct spaces, this should
36 | also vary by space.
37 | """
38 |
39 | network_name = network_id = None
40 |
41 | def __init__(self, name, config, host, virtual=False):
42 | super().__init__(name, config, host)
43 | self.virtual = virtual
44 | # Active generator created from get(), referenced to cancel on disconnect.
45 | self._getter = None
46 | # Message queue, to move processing from the event stream to the generator.
47 | self._queue = Queue()
48 | # Message history, to match up received messages with their sent sources.
49 | # Mapping from (channel, message ID) to (source message, all IDs).
50 | self._sent = {}
51 | # Hook lock, to put a hold on retrieving messages whilst a send is in progress.
52 | self._lock = BoundedSemaphore()
53 |
54 | def on_load(self):
55 | """
56 | Perform any additional one-time setup that requires other plugs or hooks to be loaded.
57 | """
58 |
59 | def on_ready(self):
60 | """
61 | Perform any post-startup tasks once all hooks and plugs are ready.
62 | """
63 |
64 | async def user_from_id(self, id_):
65 | """
66 | Retrieve a :class:`.User` based on the underlying network's identifier.
67 |
68 | Args:
69 | id (str):
70 | Network identifier of the user.
71 |
72 | Returns:
73 | .User:
74 | Corresponding user instance.
75 | """
76 | return None
77 |
78 | async def user_from_username(self, username):
79 | """
80 | Retrieve a :class:`.User` based on the underlying network's username.
81 |
82 | Args:
83 | username (str):
84 | Network username of the user.
85 |
86 | Returns:
87 | .User:
88 | Corresponding user instance.
89 | """
90 | return None
91 |
92 | async def user_is_system(self, user):
93 | """
94 | Check if a given user is automated by the plug (for example a bot user from which the plug
95 | operates). Hooks may exclude system users from certain operations.
96 |
97 | Returns:
98 | bool:
99 | ``True`` if the user relates to the plug itself.
100 | """
101 | return False
102 |
103 | async def public_channels(self):
104 | """
105 | Retrieve all shared channels known to this plug, either public or otherwise accessible.
106 | May return ``None`` if the network doesn't support channel discovery.
107 |
108 | Returns:
109 | .Channel list:
110 | All available non-private channels.
111 | """
112 | return None
113 |
114 | async def private_channels(self):
115 | """
116 | Retrieve all private (one-to-one) channels known to this plug. May return ``None`` if the
117 | network doesn't support channel discovery.
118 |
119 | Returns:
120 | .Channel list:
121 | All available private channels.
122 | """
123 | return None
124 |
125 | async def channel_for_user(self, user):
126 | """
127 | Retrieve a :class:`.Channel` representing a private (one-to-one) conversation between a
128 | given user and the service. Returns ``None`` if the user does not have a private channel.
129 |
130 | Args:
131 | user (.User):
132 | Requested user instance.
133 |
134 | Returns:
135 | .Channel:
136 | Private channel for this user.
137 | """
138 | return None
139 |
140 | async def channel_is_private(self, channel):
141 | """
142 | Test if a given channel represents a private (one-to-one) conversation between a given user
143 | and the service. May return ``None`` if the network doesn't have a notion of public/shared
144 | and private channels.
145 |
146 | Args:
147 | channel (.Channel):
148 | Requested channel instance.
149 |
150 | Returns:
151 | bool:
152 | ``True`` if the channel is private.
153 | """
154 | return None
155 |
156 | async def channel_title(self, channel):
157 | """
158 | Retrieve the friendly name of this channel, as used in the underlying network. May return
159 | ``None`` if the service doesn't have a notion of titles.
160 |
161 | Returns:
162 | str:
163 | Display name for the channel.
164 | """
165 | return None
166 |
167 | async def channel_link(self, channel):
168 | """
169 | Return a URL that acts as a direct link to the given channel. This is not a join link,
170 | rather one that opens a conversation in the client (it may e.g. use a custom protocol).
171 |
172 | Args:
173 | channel (.Channel):
174 | Requested channel instance.
175 |
176 | Returns:
177 | str:
178 | Internal deep link to this channel.
179 | """
180 | return None
181 |
182 | async def channel_rename(self, channel, title):
183 | """
184 | Update the friendly name of this conversation.
185 |
186 | Args:
187 | channel (.Channel):
188 | Requested channel instance.
189 | title (str):
190 | New display name for the channel.
191 | """
192 |
193 | async def channel_members(self, channel):
194 | """
195 | Retrieve a :class:`.User` list representing all members of the given channel. May return
196 | ``None`` if the plug doesn't recognise the channel, or is unable to query members.
197 |
198 | Args:
199 | channel (.Channel):
200 | Requested channel instance.
201 |
202 | Returns:
203 | .User list:
204 | Members present in the channel.
205 | """
206 | return None
207 |
208 | async def channel_admins(self, channel):
209 | """
210 | Retrieve a :class:`.User` list representing members of the given channel with the ability
211 | to manage the channel or its members. May return ``None`` if the plug doesn't recognise
212 | the channel, is unable to query members, or has no concept of admins.
213 |
214 | Args:
215 | channel (.Channel):
216 | Requested channel instance.
217 |
218 | Returns:
219 | .User list:
220 | Members with admin privileges present in the channel.
221 | """
222 | return None
223 |
224 | async def channel_invite_multi(self, channel, users):
225 | """
226 | Add multiple users to the channel's list of members.
227 |
228 | By default, calls :meth:`channel_invite` for each channel-user pair, but can be overridden
229 | in order to optimise any necessary work.
230 |
231 | Args:
232 | channel (.Channel):
233 | Requested channel instance.
234 | users (.User list):
235 | New users to invite.
236 | """
237 | for user in users:
238 | await self.channel_invite(channel, user)
239 |
240 | async def channel_invite(self, channel, user):
241 | """
242 | Add the given user to the channel's list of members.
243 |
244 | Args:
245 | channel (.Channel):
246 | Requested channel instance.
247 | user (.User):
248 | New user to invite.
249 | """
250 |
251 | async def channel_remove_multi(self, channel, users):
252 | """
253 | Remove multiple users from the channel's list of members.
254 |
255 | By default, calls :meth:`channel_remove` for each channel-user pair, but can be overridden
256 | in order to optimise any necessary work.
257 |
258 | Args:
259 | channel (.Channel):
260 | Requested channel instance.
261 | user (.User):
262 | Existing users to kick.
263 | """
264 | for user in users:
265 | await self.channel_remove(channel, user)
266 |
267 | async def channel_remove(self, channel, user):
268 | """
269 | Remove the given user from the channel's list of members.
270 |
271 | Args:
272 | channel (.Channel):
273 | Requested channel instance.
274 | user (.User):
275 | Existing user to kick.
276 | """
277 |
278 | async def channel_link_create(self, channel, shared=True):
279 | """
280 | Create a URL that can be used by non-members to join the given channel. To link existing
281 | participants to the channel without invite authorisation, see :meth:`channel_link`.
282 |
283 | Args:
284 | channel (.Channel):
285 | Requested channel instance.
286 | shared (bool):
287 | ``True`` (default) for a common, unlimited-use, non-expiring link (subject to any
288 | limitations from the underlying network); ``False`` for a private, single-use link.
289 |
290 | Networks may only support one of these two modes of link creation; if ``None`` is
291 | returned, but the caller can make do with the other type, they may retry the call
292 | and flip this option.
293 |
294 | Returns:
295 | str:
296 | Shareable invite link, or ``None`` if one couldn't be created.
297 | """
298 | return None
299 |
300 | async def channel_link_revoke(self, channel, link=None):
301 | """
302 | Revoke an invite link, or otherwise prevent its use in the future.
303 |
304 | Args:
305 | channel (.Channel):
306 | Requested channel instance.
307 | link (str):
308 | Existing invite link to revoke. This should be provided when known; if ``None``,
309 | the plug will revoke the default invite link, if such a link exists for the channel
310 | in the underlying network.
311 | """
312 |
313 | async def channel_history(self, channel, before=None):
314 | """
315 | Retrieve the most recent messages sent or received in the given channel. May return an
316 | empty list if the plug is unable to query history.
317 |
318 | Args:
319 | channel (.Channel):
320 | Requested channel instance.
321 | before (.Receipt):
322 | Starting point message, or ``None`` to fetch the most recent.
323 |
324 | Returns:
325 | .Receipt list:
326 | Messages from the channel, oldest first.
327 | """
328 | return []
329 |
330 | async def get_message(self, receipt):
331 | """
332 | Lookup a :class:`.Receipt` and fetch the corresponding :class:`.SentMessage`.
333 |
334 | Args:
335 | receipt (.Receipt):
336 | Existing message reference to retrieve.
337 |
338 | Returns:
339 | .SentMessage:
340 | Full message.
341 | """
342 | return None
343 |
344 | async def resolve_message(self, msg):
345 | """
346 | Lookup a :class:`.Receipt` if no :class:`.Message` data is present, and fetch the
347 | corresponding :class:`.SentMessage`.
348 |
349 | Args:
350 | msg (.Message | .Receipt):
351 | Existing message reference to retrieve.
352 |
353 | Returns:
354 | .SentMessage:
355 | Full message.
356 | """
357 | if msg is None:
358 | return None
359 | elif isinstance(msg, Message):
360 | return msg
361 | elif isinstance(msg, Receipt):
362 | return await self.get_message(msg)
363 | else:
364 | raise TypeError
365 |
366 | def queue(self, sent):
367 | """
368 | Add a new message to the queue, picked up from :meth:`get` by default.
369 |
370 | Args:
371 | sent (.SentMessage):
372 | Message received and processed by the plug.
373 | """
374 | self._queue.put_nowait(sent)
375 |
376 | def _lookup(self, sent):
377 | try:
378 | # If this message was sent by us, retrieve the canonical version. For source
379 | # messages split into multiple parts, only make the first raw message primary.
380 | source, ids = self._sent[(sent.channel, sent.id)]
381 | primary = ids.index(sent.id) == 0
382 | except KeyError:
383 | # The message came from elsewhere, consider it the canonical copy.
384 | source = sent
385 | primary = True
386 | return (sent, source, primary)
387 |
388 | async def stream(self):
389 | """
390 | Wrapper method to receive messages from the network. Plugs should implement their own
391 | retrieval of messages, scheduled as a background task or managed by another async client,
392 | then call :meth:`queue` for each received message to have it yielded here.
393 |
394 | This method is called by the :class:`.PlugStream`, in parallel with other plugs to produce
395 | a single stream of messages across all sources.
396 |
397 | Yields:
398 | (.SentMessage, .Message, bool) tuple:
399 | Messages received and processed by the plug, paired with a source message if one
400 | exists (from a call to :meth:`send`).
401 |
402 | .. warning::
403 | Because the message buffer is backed by an :class:`asyncio.Queue`, only one generator
404 | from this method should be used at once -- each message will only be retrieved from the
405 | queue once, by the first instance that asks for it.
406 | """
407 | try:
408 | while True:
409 | sent = await self._queue.get()
410 | async with self._lock:
411 | # No critical section here, just wait for any pending messages to be sent.
412 | pass
413 | yield self._lookup(sent)
414 | except GeneratorExit:
415 | # Caused by gen.aclose(), in this state we can't yield any further messages.
416 | log.debug("Immediate exit from plug %r getter", self.name)
417 | except Exception:
418 | if not self._queue.empty():
419 | log.debug("Retrieving queued messages for %r", self.name)
420 | while not self._queue.empty():
421 | yield self._lookup(self._queue.get_nowait())
422 | raise
423 |
424 | async def send(self, channel, msg):
425 | """
426 | Wrapper method to send a message to the network. Plugs should implement :meth:`put`
427 | to convert the framework message into a native representation and submit it.
428 |
429 | Args:
430 | channel (.Channel):
431 | Target channel for the new message.
432 | msg (.Message):
433 | Original message received from another channel or plug.
434 |
435 | Returns:
436 | .Receipt list:
437 | References to new messages sent to the plug.
438 | """
439 | if not self.state == OpenState.active:
440 | raise PlugError("Can't send messages when not active")
441 | # Allow any hooks to modify the outgoing message before sending.
442 | original = channel
443 | ordered = self.host.ordered_hooks()
444 | for hooks in ordered:
445 | for hook in hooks:
446 | try:
447 | result = await hook.before_send(channel, msg)
448 | except Exception:
449 | log.exception("Hook %r failed before-send event", hook.name)
450 | continue
451 | if result:
452 | channel, msg = result
453 | else:
454 | # Message has been suppressed by a hook.
455 | return []
456 | if not original == channel:
457 | log.debug("Redirecting message to new channel: %r", channel)
458 | # Restart sending with the new channel, including a new round of before-send.
459 | return await channel.send(msg)
460 | # When sending messages asynchronously, the network will likely return the new message
461 | # before the send request returns with confirmation. Use the lock when sending in order
462 | # return the new message ID(s) in advance of them appearing in the receive queue.
463 | async with self._lock:
464 | receipts = await self.put(channel, msg)
465 | ids = [receipt.id for receipt in receipts]
466 | for id_ in ids:
467 | self._sent[(channel, id_)] = (msg, ids)
468 | return receipts
469 |
470 | async def put(self, channel, msg):
471 | """
472 | Take a :class:`.Message` object, and push it to the underlying network.
473 |
474 | Because some plugs may not support combinations of message components (such as text
475 | and an accompanying image), this method may send more than one physical message.
476 |
477 | Args:
478 | channel (.Channel):
479 | Target channel for the new message.
480 | msg (.Message):
481 | Original message received from another channel or plug.
482 |
483 | Returns:
484 | .Receipt list:
485 | References to new messages sent to the plug.
486 | """
487 | return []
488 |
489 | async def delete(self, sent):
490 | """
491 | Request deletion of this message, if supported by the network.
492 |
493 | Args:
494 | sent (.SentMessage):
495 | Existing message to be removed.
496 | """
497 |
498 | def __repr__(self):
499 | return "<{}{}>".format(self.__class__.__name__,
500 | ": {}".format(self.network_id) if self.network_id else "")
501 |
--------------------------------------------------------------------------------
/immp/core/util.py:
--------------------------------------------------------------------------------
1 | from asyncio import Condition
2 | from collections.abc import MutableMapping, MutableSequence
3 | from enum import Enum
4 | from functools import reduce, wraps
5 | from importlib import import_module
6 | import logging
7 | import re
8 | import time
9 | from warnings import warn
10 |
11 | try:
12 | from pkg_resources import get_distribution
13 | except ImportError:
14 | # Setuptools might not be available.
15 | get_distribution = None
16 |
17 | try:
18 | from aiohttp import ClientSession
19 | except ImportError:
20 | ClientSession = None
21 |
22 | from .error import ConfigError
23 | from .schema import Schema
24 |
25 |
26 | log = logging.getLogger(__name__)
27 |
28 |
29 | def resolve_import(path):
30 | """
31 | Take the qualified name of a Python class, and return the physical class object.
32 |
33 | Args:
34 | path (str):
35 | Dotted Python class name, e.g. ``.``.
36 |
37 | Returns:
38 | type:
39 | Class object imported from module.
40 | """
41 | module, class_ = path.rsplit(".", 1)
42 | return getattr(import_module(module), class_)
43 |
44 |
45 | def pretty_str(cls):
46 | """
47 | Class decorator to provide a default :meth:`__str__` based on the contents of :attr:`__dict__`.
48 | """
49 |
50 | def nest_str(obj):
51 | if isinstance(obj, dict):
52 | return "{{...{}...}}".format(len(obj)) if obj else "{}"
53 | elif isinstance(obj, list):
54 | return "[...{}...]".format(len(obj)) if obj else "[]"
55 | else:
56 | return str(obj)
57 |
58 | def __str__(self):
59 | if hasattr(self, "__dict__"):
60 | data = self.__dict__
61 | elif hasattr(self, "__slots__"):
62 | data = {attr: getattr(self, attr) for attr in self.__slots__}
63 | else:
64 | raise TypeError("No __dict__ or __slots__ to collect attributes")
65 | args = "\n".join("{}: {}".format(k, nest_str(v).replace("\n", "\n" + " " * (len(k) + 2)))
66 | for k, v in data.items() if not k.startswith("_"))
67 | return "[{}]\n{}".format(self.__class__.__name__, args)
68 |
69 | cls.__str__ = __str__
70 | return cls
71 |
72 |
73 | def _no_escape(char):
74 | # Fail a match if the next character is escaped by a backslash.
75 | return r"(?".format(self.__class__.__name__, super().__repr__())
207 |
208 | def __setitem__(self, key, value):
209 | return super().__setitem__(key, self._wrap(value))
210 |
211 | def update(self, other=None, **kwargs):
212 | self._wrap_inline(kwargs)
213 | return super().update(self._wrap(other) if other else (), **kwargs)
214 |
215 |
216 | @Watchable.watch("__setitem__", "__delitem__", "__iadd__", "__imul__",
217 | "insert", "append", "extend", "pop", "remove", "clear", "reverse", "sort")
218 | class WatchedList(list, Watchable):
219 | """
220 | Watchable-enabled :class:`list` subclass. Lists or dictionaries added as items in this
221 | container will be wrapped automatically.
222 | """
223 |
224 | def __init__(self, watch, initial):
225 | super().__init__(initial)
226 | self._wrap_inline(self)
227 | Watchable.__init__(self, watch)
228 |
229 | def __repr__(self):
230 | return "<{}: {}>".format(self.__class__.__name__, super().__repr__())
231 |
232 | def __setitem__(self, key, value):
233 | return super().__setitem__(key, self._wrap(value))
234 |
235 | def insert(self, index, value):
236 | return super().insert(index, self._wrap(value))
237 |
238 | def append(self, value):
239 | return super().append(self._wrap(value))
240 |
241 | def extend(self, other):
242 | return super().extend([self._wrap(item) for item in other])
243 |
244 |
245 | class ConfigProperty:
246 | """
247 | Data descriptor to present config from :class:`.Openable` instances using the actual objects
248 | stored in a :class:`.Host`.
249 | """
250 |
251 | __slots__ = ("_cls", "_key")
252 |
253 | def __init__(self, cls=None, key=None):
254 | if isinstance(cls, (list, dict)) and len(cls) != 1:
255 | raise TypeError("Config property list/dict must contain single type or tuple")
256 | self._cls = cls
257 | self._key = key
258 |
259 | def __set_name__(self, owner, name):
260 | if not self._key:
261 | self._key = name.lstrip("_")
262 |
263 | @classmethod
264 | def _describe(cls, spec):
265 | if spec is None:
266 | return "?"
267 | elif isinstance(spec, list):
268 | return "[{}]".format(", ".join(cls._describe(inner) for inner in spec))
269 | elif isinstance(spec, dict):
270 | return "{{{}}}".format(", ".join("{}: {}".format(cls._describe(key),
271 | cls._describe(value))
272 | for key, value in spec.items()))
273 | elif isinstance(spec, tuple):
274 | return "{{{}}}".format(", ".join(inner.__name__ for inner in spec))
275 | else:
276 | return spec.__name__
277 |
278 | def _from_host(self, instance, name, spec=None):
279 | if spec is None or name is None:
280 | return name
281 | elif isinstance(spec, list):
282 | subspec = spec[0]
283 | return [self._from_host(instance, value, subspec) for value in name]
284 | elif isinstance(spec, dict):
285 | kspec, vspec = next(iter(spec.items()))
286 | return {self._from_host(instance, key, kspec): self._from_host(instance, value, vspec)
287 | for key, value in name.items()}
288 | try:
289 | obj = instance.host[name]
290 | except KeyError:
291 | raise ConfigError("No object {} on host".format(repr(name))) from None
292 | if spec and not isinstance(obj, spec):
293 | raise ConfigError("Reference {} not instance of {}"
294 | .format(repr(name), self._describe(spec)))
295 | else:
296 | return obj
297 |
298 | def __get__(self, instance, owner):
299 | if not instance:
300 | return self
301 | name = instance.config.get(self._key)
302 | return self._from_host(instance, name, self._cls)
303 |
304 | def __repr__(self):
305 | return "<{}: {}{}>".format(self.__class__.__name__, repr(self._key),
306 | " {}".format(self._describe(self._cls)) if self._cls else "")
307 |
308 |
309 | class IDGen:
310 | """
311 | Generator of generic timestamp-based identifiers.
312 |
313 | IDs are guaranteed unique for the lifetime of the application -- two successive calls will
314 | yield two different identifiers.
315 | """
316 |
317 | __slots__ = ("last",)
318 |
319 | def __init__(self):
320 | self.last = 0
321 |
322 | def __call__(self):
323 | """
324 | Make a new identifier.
325 |
326 | Returns:
327 | str:
328 | Newly generated identifier.
329 | """
330 | new = max(self.last + 1, int(time.time()))
331 | self.last = new
332 | return str(new)
333 |
334 | def __repr__(self):
335 | return "<{}: {} -> {}>".format(self.__class__.__name__, self.last, self())
336 |
337 |
338 | class LocalFilter(logging.Filter):
339 | """
340 | Logging filter that restricts capture to loggers within the ``immp`` namespace.
341 |
342 | .. deprecated:: 0.10.0
343 | Pure logging config offers a cleaner solution to using this filter::
344 |
345 | root:
346 | level: WARNING
347 | loggers:
348 | immp:
349 | level: DEBUG
350 | """
351 |
352 | def __init__(self, name=""):
353 | super().__init__(name)
354 | warn("LocalFilter is deprecated, use `loggers` in logging config", DeprecationWarning)
355 |
356 | def filter(self, record):
357 | return record.name == "__main__" or record.name.split(".", 1)[0] == "immp"
358 |
359 |
360 | class OpenState(Enum):
361 | """
362 | Readiness status for instances of :class:`Openable`.
363 |
364 | Attributes:
365 | disabled:
366 | Not currently in use, and won't be started by the host.
367 | inactive:
368 | Hasn't been started yet.
369 | starting:
370 | Currently starting up (during :meth:`.Openable.start`).
371 | active:
372 | Currently running.
373 | stopping:
374 | Currently closing down (during :meth:`.Openable.stop`).
375 | failed:
376 | Exception occurred during starting or stopping phases.
377 | """
378 | disabled = -1
379 | inactive = 0
380 | starting = 1
381 | active = 2
382 | stopping = 3
383 | failed = 4
384 |
385 |
386 | class Configurable:
387 | """
388 | Superclass for objects managed by a :class:`.Host` and created using configuration.
389 |
390 | Attributes:
391 | schema (.Schema):
392 | Structure of the config expected by this configurable. If not customised by the
393 | subclass, it defaults to ``dict`` (that is, any :class:`dict` structure is valid).
394 |
395 | It may also be set to :data:`None`, to declare that no configuration is accepted.
396 | name (str):
397 | User-provided, unique name of the hook, used for config references.
398 | config (dict):
399 | Reference to the user-provided configuration. Assigning to this field will validate
400 | the data against the class schema, which may raise exceptions on failure as defined by
401 | :meth:`.Schema.validate`.
402 | host (.Host):
403 | Controlling host instance, providing access to plugs.
404 | """
405 |
406 | schema = Schema(dict)
407 |
408 | __slots__ = ("name", "_config", "host")
409 |
410 | def __init__(self, name, config, host):
411 | super().__init__()
412 | self.name = name
413 | self._config = None
414 | self.config = config
415 | self.host = host
416 |
417 | def _callback(self):
418 | log.debug("Triggering config change event for %r", self)
419 | self.host.config_change(self)
420 |
421 | @property
422 | def config(self):
423 | return self._config
424 |
425 | @config.setter
426 | def config(self, value):
427 | if self.schema:
428 | old = self._config
429 | self._config = WatchedDict(self._callback, self.schema(value))
430 | if old:
431 | self._callback()
432 | elif value:
433 | raise TypeError("{} doesn't accept configuration".format(self.__class__.__name__))
434 | else:
435 | self._config = None
436 |
437 |
438 | class Openable:
439 | """
440 | Abstract class to provide open and close hooks. Subclasses should implement :meth:`start`
441 | and :meth:`stop`, whilst users should make use of :meth:`open` and :meth:`close`.
442 |
443 | Attributes:
444 | state (.OpenState):
445 | Current status of this resource.
446 | """
447 |
448 | state = property(lambda self: self._state)
449 |
450 | def __init__(self):
451 | super().__init__()
452 | self._state = OpenState.inactive
453 | self._changing = Condition()
454 |
455 | async def open(self):
456 | """
457 | Open this resource ready for use. Does nothing if already open, but raises
458 | :class:`RuntimeError` if currently changing state.
459 | """
460 | if self._state == OpenState.active:
461 | return
462 | elif self._state not in (OpenState.inactive, OpenState.failed):
463 | raise RuntimeError("Can't open when already opening/closing")
464 | self._state = OpenState.starting
465 | try:
466 | await self.start()
467 | except Exception:
468 | self._state = OpenState.failed
469 | raise
470 | else:
471 | self._state = OpenState.active
472 |
473 | async def start(self):
474 | """
475 | Perform any underlying operations needed to ready this resource for use, such as opening
476 | connections to an external network or API.
477 |
478 | If using an event-driven framework that yields and runs in the background, you should use
479 | a signal of some form (e.g. :class:`asyncio.Condition`) to block this method until the
480 | framework is ready for use.
481 | """
482 |
483 | async def close(self):
484 | """
485 | Close this resource after it's used. Does nothing if already closed, but raises
486 | :class:`RuntimeError` if currently changing state.
487 | """
488 | if self._state == OpenState.inactive:
489 | return
490 | elif self._state != OpenState.active:
491 | raise RuntimeError("Can't close when already opening/closing")
492 | self._state = OpenState.stopping
493 | try:
494 | await self.stop()
495 | except Exception:
496 | self._state = OpenState.failed
497 | raise
498 | else:
499 | self._state = OpenState.inactive
500 |
501 | async def stop(self):
502 | """
503 | Perform any underlying operations needed to stop using this resource and tidy up, such as
504 | terminating open network connections.
505 |
506 | Like with :meth:`start`, this should block as needed to wait for other frameworks -- this
507 | method should return only when ready to be started again.
508 | """
509 |
510 | def disable(self):
511 | """
512 | Prevent this openable from being run by the host.
513 | """
514 | if self._state == OpenState.disabled:
515 | return
516 | elif self._state in (OpenState.inactive, OpenState.failed):
517 | self._state = OpenState.disabled
518 | else:
519 | raise RuntimeError("Can't disable when currently running")
520 |
521 | def enable(self):
522 | """
523 | Restore normal operation of this openable.
524 | """
525 | if self._state == OpenState.disabled:
526 | self._state = OpenState.inactive
527 |
528 |
529 | class HTTPOpenable(Openable):
530 | """
531 | Template openable including a :class:`aiohttp.ClientSession` instance for networking.
532 |
533 | Attributes:
534 | session (aiohttp.ClientSession):
535 | Managed session object.
536 | """
537 |
538 | def __init__(self):
539 | super().__init__()
540 | self.session = None
541 |
542 | async def start(self):
543 | if not ClientSession:
544 | raise ConfigError("'aiohttp' module not installed")
545 | if get_distribution:
546 | dist = get_distribution(Openable.__module__.split(".", 1)[0])
547 | agent = "{}/{}".format(dist.project_name, dist.version)
548 | else:
549 | agent = "IMMP"
550 | self.session = ClientSession(headers={"User-Agent": agent})
551 | await super().start()
552 |
553 | async def stop(self):
554 | await super().stop()
555 | if self.session:
556 | log.debug("Closing session")
557 | await self.session.close()
558 | self.session = None
559 |
--------------------------------------------------------------------------------