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.
This commit is contained in:
arc53-machine committed 2026-09-29 10:41:48 +01:00
1 parent 744cfde565
commit c3e83ff9dd
8 files changed
+253 -26

No files matched your search

+34 -2
View File
@@ -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"]
+19 -3
View File
@@ -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}
+4 -3
View File
@@ -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)
+38 -2
View File
@@ -35,15 +35,30 @@ class CredentialField:
``settings.connectors.fields.<key>`` 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.<connector>_<key>``.
"""
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<token>/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.
+56 -16
View File
@@ -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,
+14
View File
@@ -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:
+25
View File
@@ -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):
+63
View File
@@ -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