diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index a1aecae6..39240299 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -1126,15 +1126,24 @@ async fn dispatch_message( // Prepend sender context so the agent knows who is speaking. // In group spaces this is essential for multi-user conversations. + // + // For Telegram we also inject the numeric `tg_id` because display names are + // not unique and can change — agents that key per-user state (RBAC, per-user + // workspaces) need a stable identifier. See issue #915. let sender_name = &message.sender.display_name; let sender_email = message .metadata .get("sender_email") .and_then(|v| v.as_str()); + let telegram_user_id = message + .metadata + .get("telegram_user_id") + .and_then(|v| v.as_str()); let prefixed_text = if !sender_name.is_empty() { - match sender_email { - Some(email) => format!("[From: {sender_name} <{email}>] {text}"), - None => format!("[From: {sender_name}] {text}"), + match (sender_email, telegram_user_id) { + (Some(email), _) => format!("[From: {sender_name} <{email}>] {text}"), + (None, Some(tg_id)) => format!("[From: {sender_name} (tg_id:{tg_id})] {text}"), + (None, None) => format!("[From: {sender_name}] {text}"), } } else { text.clone() diff --git a/crates/openfang-channels/src/telegram.rs b/crates/openfang-channels/src/telegram.rs index cb4a5b01..472fa684 100644 --- a/crates/openfang-channels/src/telegram.rs +++ b/crates/openfang-channels/src/telegram.rs @@ -999,6 +999,16 @@ async fn parse_telegram_update( // Detect @mention of the bot in entities / caption_entities for MentionOnly group policy. let mut metadata = HashMap::new(); + // Always expose the Telegram numeric user_id in metadata. Display names are not + // unique and can change, so agents that need stable per-user keys (RBAC, per-user + // workspaces, deterministic routing) must rely on this id. The id originates from + // `message.from.id` for normal users or `message.sender_chat.id` for messages sent + // on behalf of a channel/group. See issue #915. + metadata.insert( + "telegram_user_id".to_string(), + serde_json::json!(user_id_str), + ); + // Store reply_to_message_id in metadata for downstream consumers. if let Some(reply_msg) = message.get("reply_to_message") { if let Some(reply_id) = reply_msg["message_id"].as_i64() { @@ -1179,6 +1189,83 @@ mod tests { assert!(matches!(msg.content, ChannelContent::Text(ref t) if t == "Hello, agent!")); } + #[tokio::test] + async fn test_parse_injects_telegram_user_id_metadata() { + // Issue #915 — agents need a stable per-user identifier. The numeric + // Telegram user_id from `message.from.id` must land in metadata as a + // string so downstream consumers (bridge prompt builder, tools, etc.) + // can key per-user state on it. + let update = serde_json::json!({ + "update_id": 555, + "message": { + "message_id": 1, + "from": { + "id": 554772934_i64, + "first_name": "Alena" + }, + "chat": { + "id": -1009876543210_i64, + "type": "group" + }, + "date": 1700000000, + "text": "Hello" + } + }); + + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); + + // The chat_id (used for replies) stays on sender.platform_id. + assert_eq!(msg.sender.platform_id, "-1009876543210"); + assert_eq!(msg.sender.display_name, "Alena"); + + // The numeric Telegram user_id is exposed in metadata as a string. + let tg_id = msg + .metadata + .get("telegram_user_id") + .and_then(|v| v.as_str()) + .expect("telegram_user_id should be present in metadata"); + assert_eq!(tg_id, "554772934"); + } + + #[tokio::test] + async fn test_parse_sender_chat_user_id_metadata() { + // When a message arrives via `sender_chat` (channel/group posting on + // its own behalf), the chat id is what we have — surface it under + // `telegram_user_id` so the metadata key is always present. + let update = serde_json::json!({ + "update_id": 556, + "message": { + "message_id": 2, + "sender_chat": { + "id": -1001234567890_i64, + "type": "channel", + "title": "My Channel" + }, + "chat": { + "id": -1001234567890_i64, + "type": "channel" + }, + "date": 1700000001, + "text": "Broadcast" + } + }); + + let client = test_client(); + let msg = parse_telegram_update(&update, &[], "fake:token", &client, DEFAULT_API_URL, None) + .await + .unwrap(); + + let tg_id = msg + .metadata + .get("telegram_user_id") + .and_then(|v| v.as_str()) + .expect("telegram_user_id should be present in metadata"); + assert_eq!(tg_id, "-1001234567890"); + } + #[tokio::test] async fn test_parse_telegram_command() { let update = serde_json::json!({ diff --git a/docs/channel-adapters.md b/docs/channel-adapters.md index cd44652e..de6e4801 100644 --- a/docs/channel-adapters.md +++ b/docs/channel-adapters.md @@ -284,6 +284,20 @@ The Telegram adapter uses long-polling via the `getUpdates` API. It polls every Messages from authorized users are converted to `ChannelMessage` events and routed to the configured agent. Responses are sent back via the `sendMessage` API. Long responses are automatically split into multiple messages to respect Telegram's 4096-character limit using the shared `split_message()` utility. +### Per-User Identity (`telegram_user_id`) + +Every inbound Telegram message exposes the sender's numeric Telegram user_id under `message.metadata["telegram_user_id"]` as a string. This is the stable, permanent identifier from `message.from.id` (or `message.sender_chat.id` for channel/group posts). + +Display names are not unique and can change, so agents that need deterministic per-user behavior — RBAC, per-user workspaces, family-assistant style memory keyed by person — should key on `telegram_user_id`, not `sender.display_name`. + +The bridge also injects the id into the prompt prefix when no `sender_email` is set: + +``` +[From: Alena (tg_id:554772934)] Hello! +``` + +Agents can read the raw metadata field via tool calls that expose `ChannelMessage.metadata` (the prompt prefix is a convenience for plain LLM context). `sender.platform_id` continues to hold the **chat_id** (not user_id), since replies are addressed to the chat, not the user. + ### Interactive Setup ```bash diff --git a/docs/configuration.md b/docs/configuration.md index 99a9b936..a233c99d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -301,10 +301,11 @@ volumes: If you prefer mounting `config.toml`, the file must live at `/data/config.toml` inside the container (because of `OPENFANG_HOME=/data`). Placing it at `/opt/openfang/config.toml` or any other path will not be picked up. The port in `api_listen` must also match the port published in `ports:` — the example config in `openfang.toml.example` ships with port `50051` to be safe; change it to `4200` (or whatever port you publish) when running in Docker. -**Security warning.** Once you bind to a non-loopback address, anyone reachable at that address can talk to the API. OpenFang's middleware enforces a fail-closed default: +**Security warning.** Once you bind to a non-loopback address, anyone reachable at that address can talk to the API. OpenFang's middleware enforces a fail-closed default on authenticated routes: -- If `api_key` is empty AND dashboard auth is disabled AND the bind address is not loopback, all non-loopback requests are rejected with `401 Unauthorized`. -- To run in this configuration anyway (not recommended), set `OPENFANG_ALLOW_NO_AUTH=1`. This will be loudly logged. +- If `api_key` is empty AND dashboard auth is disabled AND the bind address is not loopback, authenticated routes reject non-loopback requests with `401 Unauthorized`. +- A small set of public routes (health check, static assets, OAuth callback) remain reachable so the dashboard can render its login page. They do not expose agent data or accept commands. +- To run with full open access anyway (not recommended), set `OPENFANG_ALLOW_NO_AUTH=1`. This will be loudly logged. The supported ways to expose the dashboard safely: