├── 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 |
14 |
15 |
16 | 19 |
20 | 25 |
26 |
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 |
17 |
18 |
19 | 22 |
23 | 28 |
29 |
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 | 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 |
27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |
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 | 22 | 23 | 32 | 33 | {%- endfor %} 34 | 35 |
20 | {{ channel.source }} 21 | {% if name %}{{ name }}{% endif %} 24 | {% if channels[channel] %} 25 | {{ channels[channel] | join("
") }} 26 | {% else %} 27 | 28 | Add 29 | 30 | {% endif %} 31 |
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 | 52 | 53 | 62 | 63 | {%- endfor %} 64 | 65 |
50 | {{ channel.source }} 51 | {% for user in users %}{% if not loop.first %}, {% endif %}{{ user.real_name or user.username }}{% endfor %} 54 | {% if channels[channel] %} 55 | {{ channels[channel] | join("
") }} 56 | {% else %} 57 | 58 | Add 59 | 60 | {% endif %} 61 |
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 | 11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 | 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 | 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 | 39 | {%- if class.schema %} 40 |
41 |
42 | 43 |
44 | {%- else %} 45 |

No config required.

46 | {%- endif %} 47 |
48 |
49 |
50 | 51 |
52 |
53 | {%- else %} 54 |
55 | 56 |
57 |
58 | 59 |
60 |
61 | 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 | 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 | 55 |

Edit

56 | {%- if hook.schema %} 57 |
58 |
59 | 60 |
61 |
62 | 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 | 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 | 83 |
84 |
85 | 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 | 30 |

Migrate

31 |

Transfer hook data from this channel to a replacement. Caution: this action is irreversible.

32 |
33 |
34 |
35 |
36 | 42 |
43 |
44 |
45 | 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | 61 |
62 |
63 |
64 | 65 |
66 |
67 | 70 |
71 |
72 |
73 | {%- if members %} 74 |

Members ({{ members|length }})

75 | 76 | 77 | {%- for member in members %} 78 | 79 | 86 | 95 | 101 | {%- if not channel.plug.virtual %} 102 | 107 | {%- endif %} 108 | 109 | {%- endfor %} 110 | 111 | {%- if not channel.plug.virtual %} 112 | 113 | 114 | 115 | 118 | 125 | 126 | 127 | 128 | {%- endif %} 129 |
80 | {%- if member.avatar %} 81 |
82 | 83 |
84 | {%- endif %} 85 |
87 | {%- if member.link -%}{%- endif %} 88 | {{- member.id }} 89 | {%- if member.link %}{% endif %} 90 | {%- if member.plug.name != channel.plug.name %} 91 |
92 | {{ member.plug.name }} 93 | {%- endif %} 94 |
96 | {{ member.real_name or member.username or "" }} 97 | {%- if member.real_name and member.username -%} 98 |
{{ member.username }} 99 | {%- endif %} 100 |
103 |
104 | 105 |
106 |
116 | 117 | 119 |
120 | 123 |
124 |
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 | 58 | {%- if not plug.virtual %} 59 |

Channels

60 | 61 | 62 | {%- for name, channel in channels.items() %} 63 | 64 | 65 | 66 | 78 | 79 | {%- endfor %} 80 | {%- if not channels %} 81 | 82 | 83 | 84 | {%- endif %} 85 | 86 | 87 | 88 | 89 | 90 | 93 | 96 | 106 | 107 | 108 | 109 |
{{ name }}{{ channel.source }} 67 |
68 | 69 | View 70 | 71 |
72 | 75 |
76 |
77 |
No channels defined.
91 | 92 | 94 | 95 | 97 |
98 | 101 | 102 | Choose 103 | 104 |
105 |
110 | {%- endif %} 111 | {%- if plug.schema %} 112 |

Edit

113 |
114 |
115 | 116 |
117 |
118 | 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 | 131 |
132 |
133 | 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 |
18 | 21 |
22 |
23 |

Plugs

24 | {%- if host.plugs %} 25 | 36 | {%- else %} 37 |

None loaded.

38 | {%- endif %} 39 |

Channels

40 | {%- if host.channels %} 41 | 51 | {%- else %} 52 |

None defined.

53 | {%- endif %} 54 |

Groups

55 | {%- if host.groups %} 56 | 65 | {%- else %} 66 |

None defined.

67 | {%- endif %} 68 |
69 |
70 |
71 | 72 |
73 |
74 | 77 |
78 |
79 |
80 |
81 |
82 |

Resources

83 | {%- if host.resources %} 84 | 95 | {%- else %} 96 |

None loaded.

97 | {%- endif %} 98 |

Hooks

99 | {%- if host.hooks %} 100 | 111 | {%- else %} 112 |

None loaded.

113 | {%- endif %} 114 |

Uptime

115 | 127 |

Logging

128 | 135 |

Versions

136 | 143 |
144 |
145 |
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 | --------------------------------------------------------------------------------