mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 12:16:20 +00:00
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:
1 parent
744cfde565
commit
c3e83ff9dd
8 files changed
+253
-26
No files matched your search
@@ -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"]
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user