From c3e83ff9dd5b75d876365905a470e40325043375 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:22:17 +0100 Subject: [PATCH] Add a default chat to the Telegram connector A Telegram connection can now hold an optional default chat ID. When it is set, both Telegram actions send there, the model is no longer asked for a chat, and a chat it names anyway is ignored. In member mode each member's own connection supplies their own chat. The same bot with a different chat is a separate connection. --- docsgpt/agents/tool_executor.py | 36 +++++++++++- docsgpt/agents/tools/telegram.py | 22 +++++++- docsgpt/api/connector/connections.py | 7 ++- docsgpt/connectors/catalog.py | 40 ++++++++++++- docsgpt/connectors/service.py | 72 ++++++++++++++++++------ tests/agents/test_telegram_tool.py | 14 +++++ tests/connectors/test_connections_api.py | 25 ++++++++ tests/connectors/test_runtime.py | 63 +++++++++++++++++++++ 8 files changed, 253 insertions(+), 26 deletions(-) diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py index 7bbde1f4..5acab4ca 100644 --- a/docsgpt/agents/tool_executor.py +++ b/docsgpt/agents/tool_executor.py @@ -496,6 +496,8 @@ class ToolExecutor: # Per-turn connection resolution for connection-backed tools, keyed # by tool row id, so check_pause and execute share one lookup. self._connections: Dict[str, Any] = {} + # Tool parameters those connections set (Telegram's default chat). + self._connection_params: Dict[str, Dict] = {} def get_tools(self) -> Dict[str, Dict]: """Load tool configs from DB based on user context. @@ -748,7 +750,9 @@ class ToolExecutor: if is_client: params = action.get("parameters", {}) else: - params = self._build_tool_parameters(action) + params = self._build_tool_parameters( + action, hidden=set(self._connection_parameters(tools_dict[tool_id])), + ) result.append( { @@ -816,6 +820,34 @@ class ToolExecutor: self._connections[key] = None return self._connections[key] + def _connection_parameters(self, tool_data: Dict) -> Dict: + """Tool parameters the connection a call runs with sets, e.g. Telegram's default chat. + + Resolved like the credentials: in member mode each member's own + connection (and so their own chat) applies. Only connectors with + such fields are looked up. + """ + if not tool_data.get("connection_id") or tool_data.get("client_side"): + return {} + from docsgpt.connectors import catalog + + definition = catalog.definition_for_tool(tool_data.get("name") or "") + if definition is None or not catalog.parameter_fields(definition.key): + return {} + key = str(tool_data.get("id") or tool_data.get("connection_id")) + if key not in self._connection_params: + resolved = self._resolve_connection(tool_data) + params: Dict = {} + if resolved is not None and resolved.available: + from docsgpt.connectors.service import connection_parameters + + try: + params = connection_parameters(resolved.row) + except Exception: + logger.exception("connection parameters failed for tool %s", key) + self._connection_params[key] = params + return self._connection_params[key] + @staticmethod def _connection_payload(resolved) -> Dict: """What the chat's Connect card needs; never an account or a secret. @@ -1400,7 +1432,7 @@ class ToolExecutor: tool_call_data["access"] = action_access(tool_data.get("name"), action_data) # Fixed values win over whatever the model sent for the same key. - sections = resolve_arguments(action_data, call_args) + sections = resolve_arguments(action_data, call_args, self._connection_parameters(tool_data)) query_params, headers = sections["query_params"], sections["headers"] body, parameters = sections["body"], sections["parameters"] diff --git a/docsgpt/agents/tools/telegram.py b/docsgpt/agents/tools/telegram.py index 138a0240..2dcca301 100644 --- a/docsgpt/agents/tools/telegram.py +++ b/docsgpt/agents/tools/telegram.py @@ -11,12 +11,22 @@ class TelegramTool(Tool): """ Telegram Bot A flexible Telegram tool for performing various actions (e.g., sending messages, images). - Requires a bot token and chat ID for configuration + Requires a bot token; a default chat ID set on the connection is used when no chat is named """ def __init__(self, config): self.config = config self.token = config.get("token", "") + self.default_chat_id = config.get("chat_id") or None + + def _no_chat(self): + return { + "status": "error", + "error": ( + "No chat to send to. Name a chat_id, or set a default chat ID on the Telegram " + "connection in Settings > Connectors." + ), + } def execute_action(self, action_name, **kwargs): actions = { @@ -27,14 +37,20 @@ class TelegramTool(Tool): raise ValueError(f"Unknown action: {action_name}") return actions[action_name](**kwargs) - def _send_message(self, text, chat_id): + def _send_message(self, text, chat_id=None): + chat_id = chat_id or self.default_chat_id + if not chat_id: + return self._no_chat() logger.debug("Sending Telegram message to chat_id=%s", chat_id) url = f"https://api.telegram.org/bot{self.token}/sendMessage" payload = {"chat_id": chat_id, "text": text} response = requests.post(url, data=payload, timeout=100) return {"status_code": response.status_code, "message": "Message sent"} - def _send_image(self, image_url, chat_id): + def _send_image(self, image_url, chat_id=None): + chat_id = chat_id or self.default_chat_id + if not chat_id: + return self._no_chat() logger.debug("Sending Telegram image to chat_id=%s", chat_id) url = f"https://api.telegram.org/bot{self.token}/sendPhoto" payload = {"chat_id": chat_id, "photo": image_url} diff --git a/docsgpt/api/connector/connections.py b/docsgpt/api/connector/connections.py index 6e145928..a51270e9 100644 --- a/docsgpt/api/connector/connections.py +++ b/docsgpt/api/connector/connections.py @@ -256,7 +256,8 @@ class ConnectionSetup(Resource): conn, user_id, row, permissions=body.get("tool_permissions") or None, mcp_actions=mcp_actions, ) - tool_payload = [service.serialize_tool(tool) for tool in tools] + account_parameters = service.connection_parameters(row) + tool_payload = [service.serialize_tool(tool, account_parameters) for tool in tools] sources = [] if body.get("sync"): started = _start_sync(user_id, row, body["sync"]) @@ -514,7 +515,7 @@ class ConnectionToolPermissions(Resource): tool = service.set_tool_permissions(conn, user_id, connection_id, tool_id, permissions) if tool is None: return _not_found() - payload = service.serialize_tool(tool) + payload = service.serialize_tool(tool, service.connection_parameters(row)) return make_response(jsonify({"success": True, "tool": payload}), 200) @@ -544,7 +545,7 @@ class ConnectionToolParameters(Resource): return _error(str(err), 400) if tool is None: return _not_found() - payload = service.serialize_tool(tool) + payload = service.serialize_tool(tool, service.connection_parameters(row)) return make_response(jsonify({"success": True, "tool": payload}), 200) diff --git a/docsgpt/connectors/catalog.py b/docsgpt/connectors/catalog.py index 4fa7df5c..54f0585a 100644 --- a/docsgpt/connectors/catalog.py +++ b/docsgpt/connectors/catalog.py @@ -35,15 +35,30 @@ class CredentialField: ``settings.connectors.fields.`` when it has one. secret: Masked in the form and never returned by the API. required: Whether connecting fails without it. + parameter: A tool parameter this field sets. When the connection has + a value for it, every call through the connection uses that + value and the model is not asked for the parameter (Telegram's + default chat). + hint: English help shown under the field; the frontend shows the + translated ``settings.connectors.fieldHints._``. """ key: str label: str secret: bool = True required: bool = True + parameter: Optional[str] = None + hint: Optional[str] = None def to_dict(self) -> dict: - return {"key": self.key, "label": self.label, "secret": self.secret, "required": self.required} + return { + "key": self.key, + "label": self.label, + "secret": self.secret, + "required": self.required, + "parameter": self.parameter, + "hint": self.hint, + } @dataclass(frozen=True) @@ -292,7 +307,20 @@ _BUILT_IN: tuple[ConnectorDefinition, ...] = ( category="messaging", auth_kind="api_key", capabilities=("write",), - credential_fields=(CredentialField("token", "Bot token"),), + credential_fields=( + CredentialField("token", "Bot token"), + CredentialField( + "chat_id", + "Default chat ID", + secret=False, + required=False, + parameter="chat_id", + hint=( + "Messages go to this chat, and the AI cannot pick another. Add the bot to the chat, send " + "it a message, then find the chat's id in https://api.telegram.org/bot/getUpdates." + ), + ), + ), tool_templates=("telegram",), setup={"tools": "auto", "sync": "off"}, ), @@ -422,6 +450,14 @@ def definition_for_tool(tool_name: str) -> Optional[ConnectorDefinition]: return None +def parameter_fields(key: Optional[str]) -> tuple[CredentialField, ...]: + """The credential fields of connector ``key`` that set a tool parameter.""" + definition = get_definition(key) + if definition is None: + return () + return tuple(f for f in definition.credential_fields if f.parameter) + + def connector_key_for_row(row: dict) -> Optional[str]: """Catalog key for a ``connector_sessions`` row. diff --git a/docsgpt/connectors/service.py b/docsgpt/connectors/service.py index dc82f000..060165f0 100644 --- a/docsgpt/connectors/service.py +++ b/docsgpt/connectors/service.py @@ -151,12 +151,14 @@ def serialize_source(row: dict, connection_status: Optional[str] = None) -> dict } -def serialize_parameters(action: dict) -> list[dict]: +def serialize_parameters(action: dict, account_parameters: Optional[dict] = None) -> list[dict]: """An action's parameters as the connection drawer shows them. ``fixed`` parameters are sent with ``value`` on every call and hidden - from the model; the others are left to it. + from the model; the others are left to it. A parameter the connection + itself sets (Telegram's default chat) is fixed with ``set_by: account``. """ + account_parameters = account_parameters or {} from docsgpt.agents.tool_pins import is_pinned, iter_parameters schema = action.get("parameters") if isinstance(action.get("parameters"), dict) else {} @@ -164,21 +166,29 @@ def serialize_parameters(action: dict) -> list[dict]: parameters = [] for _section, name, details in iter_parameters(action): fixed = is_pinned(details) - parameters.append( - { - "name": name, - "description": details.get("description") or "", - "type": details.get("type") if isinstance(details.get("type"), str) else "string", - "required": bool(details.get("required")) or name in required, - "fixed": fixed, - "value": details.get("value") if fixed else None, - } - ) + entry = { + "name": name, + "description": details.get("description") or "", + "type": details.get("type") if isinstance(details.get("type"), str) else "string", + "required": bool(details.get("required")) or name in required, + "fixed": fixed, + "value": details.get("value") if fixed else None, + "set_by": "tool" if fixed else None, + } + if name in account_parameters: + entry.update(fixed=True, value=account_parameters[name], set_by="account") + parameters.append(entry) return parameters -def serialize_tool(row: dict) -> dict: - """A tool linked to a connection, with its actions, permissions and parameters.""" +def serialize_tool(row: dict, account_parameters: Optional[dict] = None) -> dict: + """A tool linked to a connection, with its actions, permissions and parameters. + + Args: + row: The ``user_tools`` row. + account_parameters: Parameters its connection sets, from + :func:`connection_parameters`. + """ from docsgpt.connectors.permissions import action_access, action_permission actions = [] @@ -192,7 +202,7 @@ def serialize_tool(row: dict) -> dict: "description": action.get("description", ""), "access": access, "permission": action_permission(action), - "parameters": serialize_parameters(action), + "parameters": serialize_parameters(action, account_parameters), } ) return { @@ -210,7 +220,8 @@ def connection_detail(conn, row: dict) -> dict: repo = ConnectorSessionsRepository(conn) status = normalize_status(row) sources = [serialize_source(s, status) for s in repo.list_sources(str(row["id"]))] - tools = [serialize_tool(t) for t in repo.list_tools(str(row["id"]))] + account_parameters = connection_parameters(row) if status == STATUS_CONNECTED else {} + tools = [serialize_tool(t, account_parameters) for t in repo.list_tools(str(row["id"]))] detail = serialize_connection(row, {"sources": len(sources), "tools": len(tools)}) detail["sources"] = sources detail["tools"] = tools @@ -702,6 +713,35 @@ def access_credentials(row: dict) -> dict: return get_credentials(row) +def connection_parameters(row: Optional[dict]) -> dict: + """Tool parameters a connection sets, from its credential fields. + + Only fields the catalog marks with ``parameter`` count (Telegram's + default chat); an empty value sets nothing. A connection that cannot be + read sets nothing either: the call then fails on its credentials. + + Args: + row: The connection row, or None. + + Returns: + Parameter name to value. + """ + if row is None: + return {} + fields = catalog.parameter_fields(catalog.connector_key_for_row(row)) + if not fields: + return {} + try: + credentials = get_credentials(row) + except ConnectionUnavailable: + return {} + return { + f.parameter: credentials[f.key] + for f in fields + if credentials.get(f.key) not in (None, "") + } + + def _api_key_account( repo: ConnectorSessionsRepository, user_id: str, diff --git a/tests/agents/test_telegram_tool.py b/tests/agents/test_telegram_tool.py index acc92d44..c4209b99 100644 --- a/tests/agents/test_telegram_tool.py +++ b/tests/agents/test_telegram_tool.py @@ -63,6 +63,20 @@ class TestTelegramExecuteAction: assert result["status_code"] == 403 + @patch("docsgpt.agents.tools.telegram.requests.post") + def test_sends_to_the_default_chat_when_none_is_named(self, mock_post): + mock_post.return_value = MagicMock(status_code=200) + tool = TelegramTool(config={"token": "bot123:ABC", "chat_id": "-1001"}) + tool.execute_action("telegram_send_message", text="Hello") + assert mock_post.call_args[1]["data"]["chat_id"] == "-1001" + + @patch("docsgpt.agents.tools.telegram.requests.post") + def test_without_any_chat_it_says_how_to_set_one(self, mock_post, tool): + result = tool.execute_action("telegram_send_message", text="Hello") + mock_post.assert_not_called() + assert result["status"] == "error" + assert "chat" in result["error"].lower() + @pytest.mark.unit class TestTelegramMetadata: diff --git a/tests/connectors/test_connections_api.py b/tests/connectors/test_connections_api.py index a4b3413b..c3289ce8 100644 --- a/tests/connectors/test_connections_api.py +++ b/tests/connectors/test_connections_api.py @@ -75,6 +75,18 @@ class TestCreate: assert actions["telegram_send_message"]["access"] == "write" assert actions["telegram_send_message"]["permission"] == "ask" + def test_same_bot_with_another_default_chat_is_another_connection(self, app, pg_conn): + from docsgpt.api.connector.connections import ConnectionsList + + with _db(pg_conn): + first = _call(app, ConnectionsList, "post", "/api/connections", + body={"connector_key": "telegram", "credentials": {"token": "123:abcdefgh"}}) + second = _call(app, ConnectionsList, "post", "/api/connections", + body={"connector_key": "telegram", + "credentials": {"token": "123:abcdefgh", "chat_id": "-1001"}}) + assert first.status_code == 201 and second.status_code == 201 + assert first.get_json()["connection"]["id"] != second.get_json()["connection"]["id"] + def test_setup_is_idempotent_for_tools(self, app, pg_conn): from docsgpt.api.connector.connections import ConnectionSetup @@ -379,6 +391,19 @@ class TestParameters: resp = self._put(app, cid, tool_id, body) assert resp.status_code == 400 + def test_a_chat_set_on_the_account_shows_as_set_there(self, app, pg_conn): + cid = _connection(pg_conn, secrets={"credentials": {"token": "t", "chat_id": "-1001"}}) + row = service.ConnectorSessionsRepository(pg_conn).get(cid) + tool_id = str(service.ensure_connection_tools(pg_conn, "alice", row)[0]["id"]) + detail = service.connection_detail(pg_conn, row) + action = next(a for a in detail["tools"][0]["actions"] if a["name"] == "telegram_send_message") + chat_id = next(p for p in action["parameters"] if p["name"] == "chat_id") + assert chat_id == {**chat_id, "fixed": True, "value": "-1001", "set_by": "account"} + with _db(pg_conn): + resp = self._put(app, cid, tool_id, {"action": "telegram_send_message", "parameters": {"text": "hi"}}) + chat_id = self._parameters(resp.get_json())["chat_id"] + assert chat_id["set_by"] == "account" + def test_another_user_cannot_fix_values(self, app, pg_conn): cid, tool_id = self._tool(pg_conn) with _db(pg_conn): diff --git a/tests/connectors/test_runtime.py b/tests/connectors/test_runtime.py index 3c53e415..c0b11094 100644 --- a/tests/connectors/test_runtime.py +++ b/tests/connectors/test_runtime.py @@ -213,6 +213,69 @@ class TestExecutor: assert "connection_id" not in config +def _telegram_tool(connection_id, *, user="alice", mode="owner"): + from docsgpt.agents.tools.telegram import TelegramTool + from docsgpt.connectors.service import _transform_actions + + return { + **_tool(connection_id, user=user, mode=mode), + "actions": _transform_actions(TelegramTool({}).get_actions_metadata()), + } + + +def _run_send(executor, tool, arguments): + with patch("docsgpt.agents.tool_executor.ToolActionParser") as parser, \ + patch("docsgpt.agents.tool_executor.ToolManager") as manager: + parser.return_value.parse_args.return_value = ("t1", "telegram_send_message", arguments) + gen = executor.execute({"t1": tool}, _call(), "OpenAILLM") + while True: + try: + next(gen) + except StopIteration: + break + return manager.return_value.load_tool.return_value.execute_action.call_args + + +class TestTelegramDefaultChat: + def test_the_connection_offers_a_default_chat_field(self): + from docsgpt.connectors import catalog + + fields = {f.key: f for f in catalog.get_definition("telegram").credential_fields} + chat = fields["chat_id"] + assert chat.secret is False and chat.required is False + assert chat.parameter == "chat_id" + assert chat.hint + assert chat.to_dict()["hint"] == chat.hint + + def test_the_model_is_not_asked_for_a_chat_the_connection_sets(self, pg_conn): + cid = _connection(pg_conn, secrets={"credentials": {"token": "t", "chat_id": "-1001"}}) + with _service_db(pg_conn): + functions = _executor().prepare_tools_for_llm({"t1": _telegram_tool(cid)}) + by_name = {f["function"]["name"]: f["function"]["parameters"] for f in functions} + assert "chat_id" not in by_name["telegram_send_message"]["properties"] + assert "chat_id" not in by_name["telegram_send_image"]["properties"] + + def test_without_a_default_chat_the_model_still_names_one(self, pg_conn): + cid = _connection(pg_conn, secrets={"credentials": {"token": "t"}}) + with _service_db(pg_conn): + functions = _executor().prepare_tools_for_llm({"t1": _telegram_tool(cid)}) + params = {f["function"]["name"]: f["function"]["parameters"] for f in functions} + assert "chat_id" in params["telegram_send_message"]["properties"] + + def test_the_default_chat_wins_over_what_the_model_sends(self, pg_conn): + cid = _connection(pg_conn, secrets={"credentials": {"token": "t", "chat_id": "-1001"}}) + with _service_db(pg_conn): + call = _run_send(_executor(), _telegram_tool(cid), {"text": "hi", "chat_id": "666"}) + assert call.kwargs == {"text": "hi", "chat_id": "-1001"} + + def test_each_member_uses_their_own_chat(self, pg_conn): + owner = _connection(pg_conn, secrets={"credentials": {"token": "t", "chat_id": "-1001"}}) + _connection(pg_conn, user="bob", secrets={"credentials": {"token": "b", "chat_id": "-2002"}}) + with _service_db(pg_conn): + call = _run_send(_executor(user="bob"), _telegram_tool(owner, mode="member"), {"text": "hi"}) + assert call.kwargs == {"text": "hi", "chat_id": "-2002"} + + class TestScheduledSync: def test_connector_sources_with_a_connection_are_dispatched(self, pg_conn): from docsgpt import worker