8000 feat(slack): built-in Slack app over Socket Mode by adeelahmad · Pull Request #287 · 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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Slack, built in.** Settings › Slack creates a Slack app from Lens's manifest in one click; paste its two tokens
and the assistant answers direct messages, what's said to it in channels and `/lens …`, over Socket Mode: no
Matterbridge, no public address. Voice clips are heard, files are held for the assistant to import, and everything
Matterbridge rooms do works there ([Chat rooms](docs/chat-rooms.md#slack-built-in)).

- **The file tree over NFS.** Settings → NFS share serves the archive's folders and files (namespaces, collections,
topics and entities as folders, assets as files) read-only over NFS, to the networks you allow, showing what one
chosen account sees. Off until turned on; published on port 12049 of the host ([File shares](docs/file-shares.md)).
Expand Down
22 changes: 22 additions & 0 deletions docs/chat-rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ Give Lens a bot of its own: a bot that Matterbridge also reads answers neither r
Telegram is one adapter of the chat-channel layer (`app/domain/channels.py`) that Matterbridge is another of; a new
chat app adds an adapter (reading, sending, files) and gets everything in a room for free.

## Slack, built in

Slack needs no Matterbridge and no public address either: Lens connects to a Slack app of your own over Socket Mode
and answers there.

1. Open **Settings → Slack** and click **Create the Slack app**. Slack opens with the app already described (Lens's
manifest: its permissions, the `/lens` command, Socket Mode); pick the workspace and create it.
2. In the app's page, **Install to Workspace**, then copy the **Bot User OAuth Token** (`xoxb-…`, under OAuth &
Permissions). Under **Basic Information → App-Level Tokens**, generate one with `connections:write` and copy it
(`xapp-…`).
3. Paste both tokens in Settings → Slack and turn on **Answer in Slack**. **Check the app** says whether both work.

Message the app directly (always for Lens), invite it to a channel with `/invite @Lens` and say "Lens, …" or
@mention it, or type `/lens what did we decide about the launch?` anywhere (the answer shows in that channel).
`/lens clear` and `/lens compact` are the [chat commands](assistant.md). In a thread it answers in the thread. Voice
clips are transcribed by the server's speech-to-text and files posted with a message are held for the assistant to
import, as with Telegram. **People it talks to** (Slack usernames or member ids) limits who it answers, and lets them
approve by replying "yes"; **Channels for a namespace's assistant** gives a channel to a namespace (`slack/general =
pods`). Slack is an adapter of the same chat-channel layer as Telegram and Matterbridge (`app/domain/slack.py`).

Give Lens an app of its own: an app whose bot token Matterbridge also uses answers twice (**Check the app** says so).

## Set it up in the web app

Lens can run Matterbridge for you, so adding Slack or WhatsApp takes no config file:
Expand Down
17 changes: 16 additions & 1 deletion fastapi_backend/app/api/v1/routes/admin.py
8000
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
nfs_share,
semantic,
settings,
slack,
smb_share,
sources,
speech,
Expand Down Expand Up @@ -59,6 +60,7 @@
NfsStatus,
Removed,
SemanticStatus,
SlackStatus,
SmbStatus,
SpeechTestResult,
Started,
Expand Down Expand Up @@ -89,7 +91,7 @@ def update_settings(section: str, body: dict[str, Any], user: AdminWriter, reque
if "*" not in hosts and here not in hosts:
raise HTTPException(400, f"that list leaves out {here}, the address you're using, and would lock you out")
if (
section in ("bridge", "telegram")
section in ("bridge", "telegram", "slack")
and body.get("enabled")
and "account" not in body
and not (request.app.state.archive.current().get(section) or {}).get("account")
Expand Down Expand Up @@ -237,6 +239,19 @@ def test_telegram(user: AdminWriter, cfg: Cfg, db: Db) -> BridgeTestResult:
return BridgeTestResult(ok=error is None, error=error)


@router.get("/settings/slack")
def slack_status(user: AdminReader, cfg: Cfg, db: Db) -> SlackStatus:
"""How the assistant's Slack app is doing, its slash command, and the link that creates the app from its manifest."""
return SlackStatus(**slack.status(db, cfg))


@router.post("/settings/slack/test")
def test_slack(user: AdminWriter, cfg: Cfg, db: Db) -> BridgeTestResult:
"""Check both Slack tokens, and that the account to answer as exists."""
error = slack.SLACK.check(db, cfg)
return BridgeTestResult(ok=error is None, error=error)


@router.post("/settings/documents/node/test")
def test_conversion_node(user: AdminWriter, cfg: Cfg) -> ConversionNodeTestResult:
"""Check that the conversion node (documents.anytopdf_url) answers at its address and takes Lens's token."""
Expand Down
6 changes: 3 additions & 3 deletions fastapi_backend/app/domain/channels.py
8BD0
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
An adapter (a Channel) knows only its own connection: whether it's set up (ready), checking it (check), what was said
since the last look (fetch), posting a reply (send) and fetching a voice note or file sent with a message
(download). Its settings section holds the room settings bridge.py reads: account, name, answer, users, rooms,
approve. Matterbridge (bridge.MATTERBRIDGE) and Telegram (telegram.py) are adapters; each has its own thread, lease
approve. Matterbridge (bridge.MATTERBRIDGE), Telegram (telegram.py) and Slack (slack.py) are adapters; each has its own thread, lease
and status, so one being down doesn't stop the other.

Messages are dicts in Matterbridge's shape (gateway, channel, account, username, userid, text, event), and may say:
Expand Down Expand Up @@ -226,9 +226,9 @@ def loop():

def every():
"""Every channel Lens has."""
from . import bridge, telegram
from . import bridge, slack, telegram

return [bridge.MATTERBRIDGE, telegram.TELEGRAM]
return [bridge.MATTERBRIDGE, telegram.TELEGRAM, slack.SLACK]


def start_all(db, cfg_fn, stop, base_fn=None, name=None, log_fn=None):
Expand Down
24 changes: 23 additions & 1 deletion fastapi_backend/app/domain/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"mail": None,
"bridge": None,
"telegram": None,
"slack": None,
"matterbridge": None,
"notifications": None,
"telemetry": None,
Expand Down Expand Up @@ -125,6 +126,7 @@
"mail": ("password",),
"bridge": ("token",),
"telegram": ("token",),
"slack": ("bot_token", "app_token"),
"matterbridge": ("slack_token", "discord_token", "telegram_token", "matrix_password"),
"telemetry": ("headers", "prometheus_token"),
"fedora": ("password",),
Expand Down Expand Up @@ -155,6 +157,8 @@
("telegram", "answer"): {"mention", "all"},
("telegram", "approve"): {"off", "low_risk"},
("ai", "injection_check"): {"patterns", "classifier", "model", "off"},
("slack", "answer"): {"mention", "all"},
("slack", "approve"): {"off", "low_risk"},
("tunnel", "mode"): {"off", "quick", "token", "managed"},
("https", "ca"): {"letsencrypt", "zerossl", "both", "custom"},
("https", "challenge"): {"http", "tls-alpn", "dns"},
Expand Down Expand Up @@ -451,6 +455,8 @@ def _check(section, key, value, default):
return _bridge_setting(key, value)
if section == "telegram" and key not in ("answer", "enabled", "approve"):
return _telegram_setting(key, value)
if section == "slack" and key not in ("answer", "enabled", "approve"):
return _slack_setting(key, value)
if section == "matterbridge" and key != "run":
return _matterbridge_setting(key, value)
if (section, key) == ("voice", "tts_base_url"):
Expand Down Expand Up @@ -720,6 +726,18 @@ def _telegram_setting(key, value):
return _bridge_setting(key, value, "telegram")


def _slack_setting(key, value):
prefix = {"bot_token": "xoxb-", "app_token": "xapp-"}.get(key)
if prefix:
value = (value or "").strip() if isinstance(value, str) or value is None else value
if value and not (isinstance(value, str) and value.startswith(prefix) and len(value) <= 500 and " " not in value):
raise ValueError(f"slack.{key} is the Slack app's token starting {prefix}")
return value or None
if key in ("url", "token", "gateway", "poll_seconds"): # Matterbridge's own; Slack keeps a socket open instead
raise ValueError(f"unknown setting slack.{key}")
return _bridge_setting(key, value, "slack")


def _matterbridge_setting(key, value):
if key.endswith(("_channels", "_chats", "_rooms", "_groups")):
if not (isinstance(value, list) and len(value) <= 100 and all(isinstance(v, str) and len(v) <= 200 for v in value)):
Expand Down Expand Up @@ -1155,7 +1173,11 @@ def save(db, base, section, changes, user=None):
telemetry.parse_headers(v) # ValueError when malformed
if section == "matterbridge" and (len(v) > 500 or "\n" in v.strip()):
raise ValueError(f"matterbridge.{k} is one line of text")
if (section, k) in (("documents", "anytopdf_token"), ("telemetry", "prometheus_token"), ("telegram", "token")):
if (section, k) in (
("documents", "anytopdf_token"),
("telemetry", "prometheus_token"),
("telegram", "token"),
) or section == "slack":
v = _check(section, k, v, None)
if (section, k) == ("https", "dns_credentials"):
from . import https
Expand Down
Loading
Loading
0