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
20 changes: 20 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,23 @@ Octop ACP runner
https://github.com/TencentCloud/Octop
Copyright (c) 2026 Octop
Licensed under the MIT License.

Jonathan Ai chat channels and LAN device control follow Octop's gateway:
one inbound message is normalized, checked against an allow-list, and
answered on the same channel. Jonathan Ai implements Telegram, Discord,
and Slack only. Bot tokens are stored in the OS keyring when it is
available, otherwise in an encrypted file on this machine, and they are
not written into settings or logs. Device control is limited to MQTT,
Home Assistant REST, generic HTTP, and SSH targets the user registers.
Discovery reads the ARP table and can send one mDNS query and one SSDP
search. Every device action asks for approval, and each device has its
own allow-list of actions. Only loopback, private, and link-local
addresses are accepted. Jonathan Ai does not include the upstream
channels for other regions, does not import the octop-gateway package,
and does not call a third-party host for the protocol itself.

Octop gateway
https://github.com/TencentCloud/Octop
https://github.com/TencentCloud/octop-gateway
Copyright (c) 2026 Octop
Licensed under the MIT License.
10 changes: 10 additions & 0 deletions src/channels/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Local chat-channel bridge for Telegram, Discord, and Slack.

The shape follows Octop's gateway: one inbound message, an allow-list, and a
reply sent back on the same channel. Jonathan Ai does not include the
upstream CN channels and does not import octop-gateway.
"""

from src.channels.service import format_channels_command, route_inbound

__all__ = ["format_channels_command", "route_inbound"]
69 changes: 69 additions & 0 deletions src/channels/parse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Turn Telegram, Discord, and Slack payloads into plain inbound messages.

Only these three channels are supported. Payloads are data, not instructions.
"""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Inbound:
channel: str
user_id: str
text: str
reply_to: str


def parse_telegram_updates(payload: object) -> list[Inbound]:
if not isinstance(payload, dict):
return []
found: list[Inbound] = []
for update in payload.get("result") or []:
if not isinstance(update, dict):
continue
message = update.get("message") or update.get("edited_message")
if not isinstance(message, dict):
continue
sender = message.get("from") if isinstance(message.get("from"), dict) else {}
chat = message.get("chat") if isinstance(message.get("chat"), dict) else {}
user_id = str(sender.get("id") or "").strip()
reply_to = str(chat.get("id") or user_id).strip()
text = str(message.get("text") or message.get("caption") or "").strip()
if user_id and text:
found.append(Inbound("telegram", user_id, text, reply_to))
return found


def parse_discord_message(payload: object) -> Inbound | None:
if not isinstance(payload, dict):
return None
event = payload.get("d") if payload.get("t") == "MESSAGE_CREATE" and isinstance(payload.get("d"), dict) else payload
if not isinstance(event, dict):
return None
author = event.get("author") if isinstance(event.get("author"), dict) else {}
user_id = str(author.get("id") or "").strip()
text = str(event.get("content") or "").strip()
reply_to = str(event.get("channel_id") or "").strip()
if not user_id or not text or not reply_to:
return None
if author.get("bot") is True:
return None
return Inbound("discord", user_id, text, reply_to)


def parse_slack_event(payload: object) -> Inbound | None:
if not isinstance(payload, dict):
return None
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
if not isinstance(event, dict):
return None
if event.get("bot_id") or event.get("subtype"):
return None
user_id = str(event.get("user") or "").strip()
text = str(event.get("text") or "").strip()
reply_to = str(event.get("channel") or "").strip()
if not user_id or not text or not reply_to:
return None
return Inbound("slack", user_id, text, reply_to)
283 changes: 283 additions & 0 deletions src/channels/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
"""Per-user chat channels: Telegram, Discord, and Slack.

Inbound messages reach Jonathan only when the channel is enabled and the
sender is on that channel's allow-list. An empty allow-list drops everyone.
Bot tokens stay in the OS keyring or an encrypted file.
"""

from __future__ import annotations

import json
import logging
import os
import re
from pathlib import Path

from src.agent.memory import current_user_id
from src.channels.parse import Inbound, parse_discord_message, parse_slack_event, parse_telegram_updates
from src.channels.transport import OFFICIAL_HOSTS, Transport, official_url, urllib_transport
from src.secret_mask import mask_secrets
from src.secret_store import get_secret, has_secret, put_secret

logger = logging.getLogger(__name__)

CHANNEL_IDS = ("telegram", "discord", "slack")
CHANNEL_LABELS = {
"telegram": "Telegram",
"discord": "Discord",
"slack": "Slack",
}
_USAGE = (
"Usage: /channels list\n"
"Enable Telegram, Discord, or Slack in settings, store your own bot token, "
"and list the user IDs allowed to message Jonathan. An empty list drops everyone."
)


def channels_root(directory: Path | None = None) -> Path:
if directory is not None:
path = directory
else:
raw = os.environ.get("JONATHAN_CHANNELS_DIR", "").strip()
path = Path(raw) if raw else Path.home() / ".clawd" / "channels"
path.mkdir(parents=True, exist_ok=True)
try:
path.chmod(0o700)
except OSError:
pass
return path


def _settings_path(directory: Path | None, user_id: str | None) -> Path:
user = user_id or current_user_id()
return channels_root(directory) / f"settings-{user}.json"


def _split_ids(value: object) -> list[str]:
if isinstance(value, str):
parts = re.split(r"[,\s]+", value.strip()) if value.strip() else []
elif isinstance(value, list):
parts = [str(item).strip() for item in value]
else:
raise ValueError("allow list must be user IDs")
found: list[str] = []
for part in parts:
if not part:
continue
if any(char in part for char in "\n\r\x00"):
raise ValueError("user id is not valid")
if part not in found:
found.append(part)
return found


def load_channels(directory: Path | None = None, *, user_id: str | None = None) -> dict[str, dict]:
user = user_id or current_user_id()
stored: dict = {}
path = _settings_path(directory, user)
if path.is_file():
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
if isinstance(raw, dict) and isinstance(raw.get("channels"), dict):
stored = raw["channels"]
channels: dict[str, dict] = {}
for name in CHANNEL_IDS:
item = stored.get(name) if isinstance(stored.get(name), dict) else {}
channels[name] = {
"enabled": bool(item.get("enabled")),
"allow_user_ids": _split_ids(item.get("allow_user_ids") or []),
}
return channels


def _write_channels(channels: dict[str, dict], directory: Path | None, user_id: str) -> None:
path = _settings_path(directory, user_id)
payload = {
"channels": {
name: {
"enabled": bool(channels[name]["enabled"]),
"allow_user_ids": list(channels[name]["allow_user_ids"]),
}
for name in CHANNEL_IDS
}
}
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
return


def public_channels(
directory: Path | None = None,
*,
user_id: str | None = None,
backend: object | None = None,
) -> dict[str, object]:
user = user_id or current_user_id()
root = channels_root(directory)
channels = load_channels(root, user_id=user)
return {
"channels": [
{
"id": name,
"label": CHANNEL_LABELS[name],
"enabled": channels[name]["enabled"],
"allow_user_ids": list(channels[name]["allow_user_ids"]),
"token_set": has_secret(root, user, f"channel-{name}", backend=backend),
}
for name in CHANNEL_IDS
]
}


def update_channels(
changes: object,
directory: Path | None = None,
*,
user_id: str | None = None,
backend: object | None = None,
) -> dict[str, object]:
rows = changes
if isinstance(changes, dict):
rows = changes.get("channels", changes)
if isinstance(rows, dict):
rows = [{"id": key, **value} if isinstance(value, dict) else {"id": key} for key, value in rows.items()]
if not isinstance(rows, list):
raise ValueError("channels must be a list")
user = user_id or current_user_id()
root = channels_root(directory)
channels = load_channels(root, user_id=user)
for item in rows:
if not isinstance(item, dict):
raise ValueError("each channel must be an object")
name = str(item.get("id") or "").strip()
if name not in channels:
raise ValueError(f"unknown channel: {name}")
if "enabled" in item:
channels[name]["enabled"] = bool(item.get("enabled"))
if "allow_user_ids" in item or "allow" in item:
channels[name]["allow_user_ids"] = _split_ids(item.get("allow_user_ids", item.get("allow")))
token = item.get("token")
if isinstance(token, str) and token.strip():
put_secret(root, user, f"channel-{name}", token, backend=backend)
_write_channels(channels, root, user)
logger.info("Updated chat channel settings for %s channel(s)", len(rows))
return public_channels(root, user_id=user, backend=backend)


def sender_allowed(channel: str, user_id: str, directory: Path | None = None, *, user_id_owner: str | None = None) -> bool:
name = channel.strip().lower()
channels = load_channels(directory, user_id=user_id_owner)
row = channels.get(name)
if row is None or not row["enabled"]:
return False
sender = str(user_id).strip()
return bool(sender) and sender in row["allow_user_ids"]


def route_inbound(
inbound: Inbound,
reply_fn,
*,
directory: Path | None = None,
user_id: str | None = None,
backend: object | None = None,
transport: Transport | None = None,
) -> dict[str, object]:
"""Route one allow-listed message to Jonathan and send the reply back."""
owner = user_id or current_user_id()
root = channels_root(directory)
if not sender_allowed(inbound.channel, inbound.user_id, root, user_id_owner=owner):
logger.info("Dropped %s message from a sender who is not allow-listed", inbound.channel)
return {"accepted": False, "reply": ""}
reply = mask_secrets(str(reply_fn(inbound.text) or ""))
token = get_secret(root, owner, f"channel-{inbound.channel}", backend=backend)
sent = False
if token and inbound.reply_to:
poster = transport or urllib_transport
_send(inbound.channel, token, inbound.reply_to, reply, poster)
sent = True
logger.info("Sent %s reply", inbound.channel)
return {"accepted": True, "reply": reply, "sent": sent}


def poll_telegram(
token: str,
*,
offset: int = 0,
transport: Transport | None = None,
) -> list[Inbound]:
poster = transport or urllib_transport
url = official_url("telegram", f"https://api.telegram.org/bot{token}/getUpdates")
payload = poster("POST", url, {}, {"offset": offset, "timeout": 0})
return parse_telegram_updates(payload)


def _send(channel: str, token: str, reply_to: str, text: str, transport: Transport) -> None:
if channel == "telegram":
url = official_url("telegram", f"https://api.telegram.org/bot{token}/sendMessage")
transport("POST", url, {}, {"chat_id": reply_to, "text": text})
return
if channel == "discord":
url = official_url("discord", f"https://discord.com/api/v10/channels/{reply_to}/messages")
transport("POST", url, {"Authorization": f"Bot {token}"}, {"content": text})
return
if channel == "slack":
url = official_url("slack", "https://slack.com/api/chat.postMessage")
transport("POST", url, {"Authorization": f"Bearer {token}"}, {"channel": reply_to, "text": text})
return
raise ValueError(f"unknown channel: {channel}")


def format_channels_command(args: str, *, directory: Path | None = None, user_id: str | None = None) -> str:
parts = args.split()
if parts and parts[0] not in {"list", "help"}:
return _USAGE
view = public_channels(directory, user_id=user_id)
lines = ["Chat channels:"]
for row in view["channels"]:
state = "enabled" if row["enabled"] else "disabled"
token = "token set" if row["token_set"] else "no token"
allow = ", ".join(row["allow_user_ids"]) or "allow-list empty"
lines.append(f"{row['id']} {row['label']} {state} {token} {allow}")
lines.append("Empty allow-lists drop every inbound message.")
return "\n".join(lines)


def ingest_payload(
channel: str,
payload: object,
reply_fn,
*,
directory: Path | None = None,
user_id: str | None = None,
backend: object | None = None,
transport: Transport | None = None,
) -> list[dict[str, object]]:
"""Parse a platform payload and route each message. Used by tests and polls."""
name = channel.strip().lower()
messages: list[Inbound] = []
if name == "telegram":
messages = parse_telegram_updates(payload)
elif name == "discord":
one = parse_discord_message(payload)
messages = [one] if one else []
elif name == "slack":
one = parse_slack_event(payload)
messages = [one] if one else []
else:
raise ValueError(f"unknown channel: {name}")
return [
route_inbound(
message,
reply_fn,
directory=directory,
user_id=user_id,
backend=backend,
transport=transport,
)
for message in messages
]
Loading
Loading