8000 feat(automations): tell a person in their own chat app, the notify action by adeelahmad · Pull Request #433 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ merged is called built.
| The timeline API (`GET /api/v1/activities`), the MCP tools `timeline` and `event_kinds`, the assistant's `timeline` | built |
| The shapes of triggers, joins, an If, policy and intents, checked (`app/domain/automation_spec.py`) | built |
| The action registry with risk from each action's effect, and one action, `log_line` (`app/domain/actions.py`) | built |
| Telling a person: the `notify` action and `channels.tell` (their own chat, a notification target, a Matterbridge room) | built |
| Settings → Automations: how often followers look and how long the event log is kept | built |
| Storing and running automations, schedules and timers, joins, the real actions, approvals, variables, the timeline page, the studio | planned |
| Storing and running automations, schedules and timers, joins, the other actions, approvals, variables, the timeline page, the studio | planned |

## Contract

Expand Down Expand Up @@ -247,9 +248,29 @@ policy with the node in `ctx["node"]`):
An effect may be worked out from the params (`notify` to "me" is `self`, to a room is `out`); when it can't be
worked out, it counts as `out`. An action's own `risk` function may make it stricter, never looser.

An action acts for the automation's person (`run_as`), with their permissions as they are when it runs
(`actions.person`): an automation whose person was switched off or removed does nothing.

| Action | Does | Effect | Output |
| --- | --- | --- | --- |
| `log_line` | puts a line (`text`, `level` info or warning) in the run's log | read | `{line, level}` |
| `notify` | sends `text` (and a `link`; Lens's own paths become full links) `to` `me` (your own chat app), `target:<id>` (one of the namespace's [notification targets](notifications.md)) or `gateway:<name>` (a Matterbridge room) | self for `me`, else out | `{to, text, sent, failed}`: where it went |

#### Telling a person

`notify` to `me` goes through `channels.tell(db, cfg, account, text, links=())`: every chat channel that answers as
your account (the Telegram bot, the Slack app) keeps the private chat it last had with someone on its list of people
who may talk to Lens (`chat_route:<channel>`, updated as it answers), and the message goes there. A group or a room is
never used, since it reaches other people, and neither is Matterbridge, where a gateway is a room. With no such chat
the action fails with "no chat app linked", saying how to link one. The result names each channel and chat it used,
so a reply there can be matched to what was sent.

Chat apps fetch what a message links to show a preview, so a web address built from what the run read would carry it
out before anyone clicks. When the run read text from outside Lens (the intent's `untrusted`), web addresses in the
message are broken up (`hxxps://evil[.]example/...`), except Lens's own; when it read none, the person wrote them and
they stay. The same goes for notification targets and Matterbridge rooms. A notification target must be in one of the
automation's namespaces, switched on, and the automation's person must be able to edit that namespace; a Matterbridge
room needs the bridge on and answering as the automation's person (or the person to be an admin).

## Refine later

Expand All @@ -260,3 +281,7 @@ worked out, it counts as `out`. An action's own `risk` function may make it stri
no row records; pushing instead of polling.
- People given only some collections of a namespace see that namespace's activities about those collections.
- Name an automation actor by its automation's name once automations are stored.
- Telling a person: buttons on chat approvals (Telegram inline keyboards, Slack Block Kit); email as a `notify`
channel; web push; private chats through Matterbridge (it can't tell them from rooms); with several people let in,
"me" is whoever last wrote privately, until each chat user has a Lens account of their own; telling which links in a
message the person's template wrote, so they stay when the run read outside text.
6 changes: 6 additions & 0 deletions docs/chat-rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ That's all. Without an account given, it answers as the admin who turned it on.
answer looks there and says so once ("Looked in calls"); when it isn't, it looks everywhere and names the likeliest
namespaces. **use pods** keeps the conversation in pods until **use everything**. The conversation's scope in Chat
shows where the last question looked.
- **Telling you things.** An automation that tells you something ([Automations](automations.md#telling-a-person))
sends it, as the account the bot or app answers as, to the private chat in Telegram or Slack it last had with
someone on its list of people who may talk to Lens. Nothing goes to a room or a group, which reaches other people
too, and nothing goes through Matterbridge, where Lens can't tell a private chat from a room. Message the bot or app
privately once and messages for you go there; until then an automation's run says "no chat app linked". Web
addresses in them are broken up as answers' are.
- **One reader.** Matterbridge hands out each message once, so only one server process reads it: the API and the
workers all run the bridge thread, and whichever holds the lease (renewed every look, given up when it stops or the
bridge is turned off) does the reading. Settings → Chat rooms says whether it is listening and how many messages it answered.
Expand Down
24 changes: 22 additions & 2 deletions fastapi_backend/app/domain/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
- `out` sends outside Lens, reaches other people, or deletes: ask, unless the person set that node to run on its own.

When an action node runs, it renders its params and makes an intent (automation_spec.check_intent); `perform` carries
an intent out, or with `dry` says what it would do and changes nothing. One action ships here, `log_line`; the real
ones come with the actions module.
an intent out, or with `dry` says what it would do and changes nothing. An action acts for the automation's person
(`person`), with their permissions as they are when it runs. `log_line` ships here; the other actions are registered by
a module per group, loaded with this one: actions_messages.py (notify).
"""

from __future__ import annotations
Expand All @@ -20,6 +21,7 @@
from collections.abc import Callable
from dataclasses import dataclass

from . import auth
from . import automation_spec as spec_

NAME_RX = re.compile(r"^[a-z][a-z0-9_]{0,40}$")
Expand Down Expand Up @@ -198,6 +200,21 @@ def perform(db, cfg, intent, user, dry=False):
return out


def person(db, intent, user=None):
"""The account an intent acts for, as it is now ({id, email, name, admin}): perform's `user`, else the intent's
run_as. ValueError when there's none, or it has been switched off or removed: an automation never acts for nobody."""
uid = user.get("id") if isinstance(user, dict) else user
if uid is None:
uid = intent.get("run_as")
try:
a = auth.get_account(db, int(uid)) if uid is not None else None
except (TypeError, ValueError):
a = None
if not a or a.get("disabled") or a.get("removed_at"):
raise ValueError("this automation acts for nobody who can sign in: give it a person to run as")
return {"id": a["id"], "email": a["email"], "name": a.get("name") or a["email"], "admin": bool(a.get("admin"))}


def describe(name, params):
"""What an action would do, in words."""
spec = get(name)
Expand Down Expand Up @@ -238,3 +255,6 @@ def _log_line(db, cfg, intent, user, dry):
describe=lambda p: f"Write “{p.get('text', '')}” to the run log",
)
)


from . import actions_messages # noqa: E402, F401 - registers notify; loaded with the catalog
204 changes: 204 additions & 0 deletions fastapi_backend/app/domain/actions_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
"""Automation actions that tell people something (docs/automations.md, Actions): `notify`.

`notify` sends a message to one of three places, named by its `to`:

- `me`: the automation's own person, in their chat apps (channels.tell: the private chat each Telegram bot or Slack app
that answers as their account last had with them). Only they read it, so it runs without asking (effect `self`).
- `target:<id>`: one of the namespace's notification targets (notify.py: a signed webhook, Matterbridge, Slack or
Discord), sent the way notifications are.
- `gateway:<name>`: a Matterbridge gateway (a room) through the chat-room bridge (bridge.py).

The last two reach other people, so they ask first unless the person set that node to run on its own (effect `out`).
Web addresses in the message are broken up (untrusted.defang) unless Lens wrote them or the run read nothing from
outside Lens, since chat apps fetch what they link before anyone clicks: an address built from an email's text would
carry it out.
"""

from __future__ import annotations

import re

from . import actions, activity, auth, bridge, channels, notify, store, untrusted

TARGET_RX = re.compile(r"^(?:target:)?(\d{1,12})$")
GATEWAY_RX = re.compile(r"^gateway:(\S{1,120})$")
MAX_TEXT = 4000


def _to(p):
return str(p.get("to") or "me").strip()


def _effect(p):
return "self" if _to(p) == "me" else "out"


def _link(db, cfg, link):
"""A link as people open it: Lens's own paths (/resources/7) on the web app's address."""
link = str(link or "").strip()
if link.startswith("/") and not link.startswith("//"):
from app.email import app_url

return app_url(cfg, db) + link
return link


def _own(cfg, db):
from app.email import app_url

return [app_url(cfg, db)]


def _text(db, cfg, p):
"""(the message, its link), as sent."""
text = str(p.get("text") or "").strip()[:MAX_TEXT]
if not text:
raise ValueError("notify: the message is empty")
return text, _link(db, cfg, p.get("link"))


def _keep(intent, text):
"""The web addresses that stay as written: all of them when the run read nothing from outside Lens (the person
and Lens wrote the message), else only Lens's own (left to defang)."""
return () if intent.get("untrusted") else untrusted.links(text)


def _me(db, cfg, intent, user, dry, text):
acc = actions.person(db, intent, user)
if dry:
found = [channels.where(ch, r) for ch, _, r in channels.routes(db, cfg, acc)]
return {"to": "me", "text": text, "sent": [], "would": found, "error": None if found else channels.NOT_LINKED}
got = channels.tell(db, cfg, acc, text, _keep(intent, text))
if not got["sent"]:
raise ValueError(f"couldn't tell {acc['name']}: {got['error']}")
return {"to": "me", "text": text, "sent": got["sent"], "failed": got["failed"]}


def _target(db, cfg, intent, user, dry, tid, text, link):
try:
t = notify.get(db, tid)
except KeyError:
raise ValueError(f"there's no notification target {tid}") from None
if intent["spaces"] and t["space"] not in intent["spaces"]:
raise ValueError(f"notification target {t['name']} isn't in this automation's namespaces")
acc = actions.person(db, intent, user)
if not auth.allows(auth.roles(db, acc), t["space"], "editor"):
raise ValueError(f"{acc['name']} can't send to {t['name']}: it needs someone who can edit its namespace")
if not t.get("enabled"):
raise ValueError(f"notification target {t['name']} is switched off")
out = {"to": f"target:{tid}", "text": text}
if dry:
return {**out, "sent": [], "would": [{"target": tid, "name": t["name"], "kind": t["kind"]}]}
safe = untrusted.defang(text, _keep(intent, text), _own(cfg, db))
ns = store.space_names(db).get(t["space"])
event = notify._event(
"automation.message",
f"automation-{intent['key']}",
t["space"],
ns,
safe,
link or None,
{"automation": intent.get("automation"), "run": intent.get("run"), "node": intent["node"]},
)
got = notify.send(db, cfg, t, event, event["id"])
if not got["ok"]:
raise ValueError(f"notification target {t['name']}: {got['error']}")
return {**out, "sent": [{"target": tid, "name": t["name"], "kind": t["kind"], "code": got["code"]}]}


def _gateway(db, cfg, intent, user, dry, gateway, text):
why = bridge.ready(cfg)
if why:
raise ValueError("the chat-room bridge isn't on" if why == "off" else f"the chat-room bridge isn't set up: {why}")
acc = actions.person(db, intent, user)
answers_as = (cfg["bridge"].get("account") or "").strip().lower()
if not acc["admin"] and answers_as != acc["email"].lower():
raise ValueError("Matterbridge answers as someone else: only they or an admin can post to its gateways")
out = {"to": f"gateway:{gateway}", "text": text}
if dry:
return {**out, "sent": [], "would": [{"gateway": gateway}]}
error = None
try:
bridge.post(cfg, gateway, bridge.safe_links(cfg, db, text, _keep(intent, text)))
except (RuntimeError, OSError, ValueError) as e:
error = str(e) or type(e).__name__
activity.record("out", "chat.bridge", [], cfg, db, ok=error is None, error="unreachable" if error else None)
if error:
raise ValueError(f"Matterbridge gateway {gateway}: {error}")
return {**out, "sent": [{"channel": "bridge", "label": "Matterbridge", "gateway": gateway}]}


def _notify(db, cfg, intent, user, dry):
p = intent["params"]
to = _to(p)
text, link = _text(db, cfg, p)
if to == "me":
return _me(db, cfg, intent, user, dry, text + (f"\n{link}" if link else ""))
m = TARGET_RX.match(to)
if m:
return _target(db, cfg, intent, user, dry, int(m.group(1)), text, link)
m = GATEWAY_RX.match(to)
if m:
return _gateway(db, cfg, intent, user, dry, m.group(1), text + (f"\n{link}" if link else ""))
raise ValueError(f"notify: send to me, target:<id> or gateway:<name>, not {to}")


def _short(text, n=80):
text = " ".join(str(text or "").split())
return text if len(text) <= n else text[: n - 1] + "…"


def _describe(p):
to, said = _to(p), _short(p.get("text"))
if to == "me":
return f"Tell me in my chat app: “{said}”"
m = GATEWAY_RX.match(to)
if m:
return f"Post in the Matterbridge gateway {m.group(1)}: “{said}”"
m = TARGET_RX.match(to)
return f"Send to notification target {m.group(1)}: “{said}”" if m else f"Send to {to}: “{said}”"


actions.register(
actions.ActionSpec(
name="notify",
label="Send a message",
group="Messages",
description=(
"Tells you in your own chat app (Telegram or Slack, where you last talked to Lens privately), or sends to a "
"notification target or a Matterbridge room, which asks first."
),
params={
"type": "object",
"properties": {
"to": {
"type": "string",
"title": "To",
"default": "me",
"maxLength": 200,
"description": "me, target:<notification target id> or gateway:<Matterbridge gateway>",
},
"text": {"type": "string", "title": "Message", "maxLength": MAX_TEXT},
"link": {
"type": "string",
"title": "Link",
"maxLength": 2000,
"description": "Lens's own paths (/resources/7) become full links",
},
},
},
required=("text",),
effect=_effect,
output={
"type": "object",
"properties": {
"to": {"type": "string"},
"text": {"type": "string"},
"sent": {"type": "array", "items": {"type": "object"}},
"failed": {"type": "array", "items": {"type": "object"}},
},
},
perform=_notify,
describe=_describe,
)
)
4 changes: 4 additions & 0 deletions fastapi_backend/app/domain/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,10 @@ def handle(db, cfg, base, m, ch=None):
return False
account = _account(db, cfg)
cid = conversation(db, account, m, ns if sid is not None else None, ch.label)
try: # a private chat with someone the channel vouches for is where the account's person is told things
channels.remember(db, cfg, ch, account, m, cid)
except Exception: # noqa: BLE001 - answering comes first
log.warning("%s: couldn't keep where to tell %s things", ch.key, account["email"], exc_info=True)
note = None
cmd = chat.command(q)
if cmd: # /clear or /compact, for this room's conversation
Expand Down
Loading
Loading
0