8000 feat(chat): built-in Telegram bot, on a shared chat-channel layer by adeelahmad · Pull Request #293 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version
passwords or card numbers, and showing each action as it happens. Once it has read a page, opening an address or
typing text you didn't write asks you first too ([The assistant in your browser](docs/browser-assistant.md)).

- **Telegram, built in.** Paste a bot token from @BotFather in Settings › Telegram and the assistant answers there:
no Matterbridge, no public address. It talks only to people you let in (one click on the settings page), hears
voice notes, takes files for the assistant to import, and does everything it does in Matterbridge rooms
([Chat rooms](docs/chat-rooms.md#telegram-built-in)). Chat apps now share one channel layer, so the next one is an
adapter.

## 0.7.0 <small>October 10, 2026</small> {id="0.7.0"}

- **Prometheus metrics.** Lens can serve `/metrics` for Prometheus and Grafana: jobs by status and pipeline, imports,
Expand Down
22 changes: 22 additions & 0 deletions docs/chat-rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Lens's assistant can answer in Slack, Discord, Telegram, Matrix, Mattermost, IRC
[Matterbridge](https://github.com/42wim/matterbridge) bridges. Say "Lens, what did we decide about the launch?" (or
"@Lens …") in a bridged room and it answers there, as it would in Chat.

## Telegram, built in

Telegram needs no Matterbridge and no public address: Lens reads its own bot's messages (long polling) and answers.

1. In Telegram, message **@BotFather**, send `/newbot` and copy the token it gives you.
2. Open **Settings → Telegram**, paste the token and turn on **Answer in Telegram**.
3. Message your bot. You show on the settings page under **Wrote to the bot, not let in**: click **Let in**.

Anyone can find a bot, so it answers only the people in **People it talks to** (Telegram user ids or usernames);
anyone else who writes to it is told once that they need to be let in, and shows on the page. A private chat with
the bot is always for Lens; in a group it answers what names it ("Lens, …"), mentions the bot (`@your_bot`) or
replies to it. Voice notes are transcribed by the server's speech-to-text (the answer starts with what it heard), and
files sent to it (up to Telegram's 20 MB for bots) are held for the assistant, which imports them into a namespace as
a file dropped into Chat is. The page lists the chats it has seen with their ids, so a group can be given to a
namespace's assistant with **Chats for a namespace's assistant** (`-1001234567890 = pods`). Approving by replying
"yes", the account it answers as and routing between namespaces work as below.

Give Lens a bot of its own: a bot that Matterbridge also reads answers neither reliably (**Check the bot** says so).

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.

## Set it up in the web app

Lens can run Matterbridge for you, so adding Slack or WhatsApp takes no config file:
Expand Down
19 changes: 17 additions & 2 deletions fastapi_backend/app/api/v1/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
sources,
speech,
store,
telegram,
telemetry,
tunnel,
)
Expand Down Expand Up @@ -61,6 +62,7 @@
SmbStatus,
SpeechTestResult,
Started,
TelegramStatus,
TelemetryStatus,
TelemetryTestResult,
TunnelStatus,
Expand All @@ -87,10 + 10BC8 89,10 @@ 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 == "bridge"
section in ("bridge", "telegram")
and body.get("enabled")
and "account" not in body
and not request.app.state.archive.current()["bridge"].get("account")
and not (request.app.state.archive.current().get(section) or {}).get("account")
):
body = {**body, "account": user.email} # turned on without saying who it answers as: the admin who turned it on
files_were = request.app.state.settings.current()["encryption"].get("files") if section == "encryption" else None
Expand Down Expand Up @@ -222,6 +224,19 @@ def test_bridge(user: AdminWriter, cfg: Cfg, db: Db) -> BridgeTestResult:
return BridgeTestResult(ok=error is None, error=error)


@router.get("/settings/telegram")
def telegram_status(user: AdminReader, cfg: Cfg, db: Db) -> TelegramStatus:
"""How the assistant's Telegram bot is doing, and the chats it has seen (their ids are what telegram.rooms names)."""
return TelegramStatus(**telegram.status(db, cfg))


@router.post("/settings/telegram/test")
def test_telegram(user: AdminWriter, cfg: Cfg, db: Db) -> BridgeTestResult:
"""Check that Telegram knows the bot's token, and that the account to answer as exists."""
error = telegram.TELEGRAM.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
4 changes: 2 additions & 2 deletions fastapi_backend/app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def platform_main(argv, config):

from .domain import (
auth,
bridge,
channels,
https,
jobs,
local_llm,
Expand Down Expand Up @@ -384,7 +384,7 @@ def password():
sensors.start(db, C, stop, log=print, name=wk.name) # and the sensor hub, while sensors are on
smb_share.start(db, C, stop, name=wk.name, log=print) # and the SMB share of the file tree, while it's on
shares.start(db, C, stop, log=print, name=wk.name) # and the file shares (S3, WebDAV), while they're on
bridge.start(db, C, stop, lambda: cfg, name=wk.name, log_fn=print) # and the chat-room bridge, while it's on
channels.start_all(db, C, stop, lambda: cfg, name=wk.name, log_fn=print) # and the chat channels, while they're on
tunnel.start(db, C, stop, name=wk.name, log=print) # and the Cloudflare tunnel, while it's on
https.start(db, C, stop, name=wk.name, log=print) # and HTTPS, where the ports are (LENS_HTTPS_LISTEN)
local_llm.start(db, C, stop, name=wk.name, log=print) # and the local chat model, while it's on
Expand Down
6 changes: 3 additions & 3 deletions fastapi_backend/app/core/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from app.domain import (
activity,
auth,
bridge,
channels,
components,
content_types,
https,
Expand Down Expand Up @@ -83,15 +83,15 @@ def prepare(self) -> None:

def start_background(self) -> None:
"""Inline workers, the watched-folder poller, the notifier, the routine scheduler, the sensor hub (idle while
sensors are off) and the chat-room bridge (idle while it's off). Production runs these as separate processes (`lens worker`)."""
sensors are off) and the chat channels (each idle while it's off). Production runs these as separate processes (`lens worker`)."""
for i in range(max(0, int(self.current()["workers"]["inline"]))):
w = jobs.Worker(self.db, self.current, name=f"api-{os.getpid()}-{i}", log=log.info)
threading.Thread(target=w.loop, args=(self.stop,), daemon=True, name=f"worker-{i}").start()

notify.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.warning)
routines.start(self.db, self.current, self.stop, log=log.info)
sensors.start(self.db, self.current, self.stop, log=log.info, name=f"api-{os.getpid()}")
bridge.start(self.db, self.current, self.stop, lambda: self.base, name=f"api-{os.getpid()}", log_fn=log.warning)
channels.start_all(self.db, self.current, self.stop, lambda: self.base, name=f"api-{os.getpid()}", log_fn=log.warning)
tunnel.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.info)
https.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.info)
local_llm.start(self.db, self.current, self.stop, name=f"api-{os.getpid()}", log=log.info)
Expand Down
Loading
Loading
0